diff --git "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" index 951f432..397c3a2 100644 --- "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" +++ "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" @@ -62,15 +62,17 @@ | 要素 | 仕様 | | --- | --- | | カメラプレビュー | インカメラのライブ映像.鏡像(左右反転)+上下反転で表示.画面いっぱいに拡大し中央をクロップ | -| シャッターボタン | 丸型ボタン.タップで撮影実行.連続タップ可(連写対応).※ Step 3 で実装 | +| シャッターボタン | 丸型ボタン(画面下部中央).タップで撮影実行.連続撮影可 | +| タイマーボタン | 画面左下に配置.ON(黄色)/ OFF(グレー)を切り替える.ON 時はシャッター押下後 3 秒のカウントダウン表示の後に撮影 | | 明るさスライダー | ナビバーの直下に配置.画面の輝度を手動で調整する(デフォルト: 0.8) | | BottomNavigationBar | 撮影タブ(アクティブ)/ 一覧タブ.背景色は黒系で統一 | ### 動作仕様 - **起動時**: カメラの初期化を行い,プレビューを開始する -- **撮影**: シャッターボタンタップで静止画をキャプチャし,PNG 形式で保存する -- **連続撮影**: 前回の保存完了を待たずに次の撮影が可能.撮影中はシャッターボタンの操作を受け付ける +- **即時撮影**: タイマー OFF 時,シャッターボタンタップで即座に撮影する +- **タイマー撮影**: タイマー ON 時,シャッターボタンタップで 3, 2, 1 のカウントダウン後に撮影する(手ブレ・振動防止用) +- **連続撮影**: 前回の保存完了を待ってから次の撮影が可能 - **フィードバック**: 撮影成功時にスナックバーで「保存しました: [ファイル名]」を表示する - **エラー時**: カメラ初期化失敗やストレージ書き込み失敗時は,エラーメッセージをスナックバーで表示する diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index 965caeb..7fa4a0b 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -16,6 +18,9 @@ class _CaptureScreenState extends State with WidgetsBindingObserver { String? _previousFileName; + bool _timerEnabled = false; + int? _countdown; + Timer? _countdownTimer; @override void initState() { @@ -28,6 +33,7 @@ @override void dispose() { + _countdownTimer?.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -36,12 +42,49 @@ void didChangeAppLifecycleState(AppLifecycleState state) { final cameraProvider = context.read(); if (state == AppLifecycleState.paused) { + _cancelCountdown(); cameraProvider.disposeCamera(); } else if (state == AppLifecycleState.resumed) { cameraProvider.initialize(); } } + void _onShutterPressed() { + final cameraProvider = context.read(); + if (cameraProvider.isSaving || _countdown != null) return; + + if (_timerEnabled) { + _startCountdown(); + } else { + cameraProvider.takePicture(); + } + } + + void _startCountdown() { + setState(() => _countdown = 3); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_countdown == null) { + timer.cancel(); + return; + } + if (_countdown! <= 1) { + timer.cancel(); + setState(() => _countdown = null); + context.read().takePicture(); + } else { + setState(() => _countdown = _countdown! - 1); + } + }); + } + + void _cancelCountdown() { + _countdownTimer?.cancel(); + _countdownTimer = null; + if (mounted) { + setState(() => _countdown = null); + } + } + void _showSaveResult(CameraProvider cameraProvider) { final fileName = cameraProvider.lastSavedFileName; if (fileName != null && fileName != _previousFileName) { @@ -113,16 +156,71 @@ ], ), ), - // シャッターボタン(画面下部中央) + // カウントダウン表示 + if (_countdown != null) + Center( + child: Stack( + children: [ + // 黒い縁取り + Text( + '$_countdown', + style: TextStyle( + fontSize: 96, + fontWeight: FontWeight.bold, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 6 + ..color = Colors.black, + ), + ), + // 白いテキスト本体 + Text( + '$_countdown', + style: TextStyle( + color: Colors.white, + fontSize: 96, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + // タイマー切り替えボタン(左側) + Positioned( + left: 24, + bottom: 36, + child: GestureDetector( + onTap: () { + _cancelCountdown(); + setState(() => _timerEnabled = !_timerEnabled); + }, + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + _timerEnabled ? Icons.timer : Icons.timer_off, + color: Colors.black, + size: 40, + ), + Icon( + _timerEnabled ? Icons.timer : Icons.timer_off, + color: _timerEnabled ? Colors.yellow : Colors.grey, + size: 32, + ), + ], + ), + ), + ), + // シャッターボタン(中央) Positioned( left: 0, right: 0, bottom: 24, child: Center( child: ShutterButton( - onPressed: cameraProvider.isSaving + onPressed: cameraProvider.isSaving || _countdown != null ? () {} - : () => cameraProvider.takePicture(), + : _onShutterPressed, ), ), ),