diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2b893b4..5d084aa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + captureFullResolutionYuv(result) + "scanFile" -> { + val path = call.argument("path") + if (path != null) { + MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf("image/png")) { _, _ -> + result.success(null) + } + } else { + result.error("INVALID_PATH", "パスが指定されていません", null) + } + } + else -> result.notImplemented() + } + } + + private fun captureFullResolutionYuv(result: MethodChannel.Result) { + startBackgroundThread() + + val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + val cameraId = cameraManager.cameraIdList.firstOrNull { id -> + val characteristics = cameraManager.getCameraCharacteristics(id) + characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT + } + + if (cameraId == null) { + returnError(result, "NO_CAMERA", "フロントカメラが見つかりません") + return + } + + val characteristics = cameraManager.getCameraCharacteristics(cameraId) + val streamConfigMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) + + if (streamConfigMap == null) { + returnError(result, "NO_CONFIG", "カメラの設定を取得できません") + return + } + + val yuvSizes = streamConfigMap.getOutputSizes(ImageFormat.YUV_420_888) + val maxSize = yuvSizes.maxByOrNull { it.width * it.height } + + if (maxSize == null) { + returnError(result, "NO_SIZE", "YUV の解像度を取得できません") + return + } + + val imageReader = ImageReader.newInstance( + maxSize.width, maxSize.height, ImageFormat.YUV_420_888, 2 + ) + + var resultSent = false + + try { + cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() { + override fun onOpened(camera: CameraDevice) { + cameraDevice = camera + try { + camera.createCaptureSession( + listOf(imageReader.surface), + object : CameraCaptureSession.StateCallback() { + override fun onConfigured(session: CameraCaptureSession) { + try { + // Phase 1: プレビューを流して AE/AF を安定させる + // この間のフレームは捨てる(リスナー未設定) + val previewRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply { + addTarget(imageReader.surface) + set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) + set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) + } + session.setRepeatingRequest(previewRequest.build(), null, backgroundHandler) + + // Phase 2: 1 秒後にリスナーを設定してから本番キャプチャ + backgroundHandler?.postDelayed({ + try { + session.stopRepeating() + + // プレビュー中のバッファを捨てる + while (true) { + val stale = imageReader.acquireLatestImage() + if (stale != null) { + stale.close() + } else { + break + } + } + + // ここでリスナーを設定(本番フレームのみ受信) + imageReader.setOnImageAvailableListener({ reader -> + if (resultSent) return@setOnImageAvailableListener + val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener + resultSent = true + + try { + val yPlane = image.planes[0] + val uPlane = image.planes[1] + val vPlane = image.planes[2] + + val yBytes = ByteArray(yPlane.buffer.remaining()) + val uBytes = ByteArray(uPlane.buffer.remaining()) + val vBytes = ByteArray(vPlane.buffer.remaining()) + + yPlane.buffer.get(yBytes) + uPlane.buffer.get(uBytes) + vPlane.buffer.get(vBytes) + + val data = mapOf( + "width" to image.width, + "height" to image.height, + "yPlane" to yBytes, + "uPlane" to uBytes, + "vPlane" to vBytes, + "yRowStride" to yPlane.rowStride, + "uvRowStride" to uPlane.rowStride, + "uvPixelStride" to uPlane.pixelStride + ) + + image.close() + reader.close() + camera.close() + cameraDevice = null + + Handler(context.mainLooper).post { + result.success(data) + stopBackgroundThread() + } + } catch (e: Exception) { + image.close() + reader.close() + cleanup() + Handler(context.mainLooper).post { + result.error("PROCESS_ERROR", "画像データの処理に失敗: ${e.message}", null) + } + } + }, backgroundHandler) + + // 本番キャプチャ実行 + val captureRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply { + addTarget(imageReader.surface) + set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) + set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) + } + session.capture(captureRequest.build(), null, backgroundHandler) + } catch (e: Exception) { + cleanup() + imageReader.close() + Handler(context.mainLooper).post { + result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null) + } + } + }, 1000) + } catch (e: Exception) { + cleanup() + imageReader.close() + Handler(context.mainLooper).post { + result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null) + } + } + } + + override fun onConfigureFailed(session: CameraCaptureSession) { + cleanup() + imageReader.close() + Handler(context.mainLooper).post { + result.error("SESSION_ERROR", "カメラセッションの設定に失敗", null) + } + } + }, + backgroundHandler + ) + } catch (e: Exception) { + cleanup() + imageReader.close() + Handler(context.mainLooper).post { + result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null) + } + } + } + + override fun onDisconnected(camera: CameraDevice) { + camera.close() + cameraDevice = null + imageReader.close() + stopBackgroundThread() + } + + override fun onError(camera: CameraDevice, error: Int) { + camera.close() + cameraDevice = null + imageReader.close() + Handler(context.mainLooper).post { + result.error("CAMERA_ERROR", "カメラエラー: $error", null) + stopBackgroundThread() + } + } + }, backgroundHandler) + } catch (e: SecurityException) { + imageReader.close() + returnError(result, "PERMISSION_ERROR", "カメラの権限がありません") + } + } + + private fun cleanup() { + cameraDevice?.close() + cameraDevice = null + stopBackgroundThread() + } + + private fun returnError(result: MethodChannel.Result, code: String, message: String) { + result.error(code, message, null) + stopBackgroundThread() + } + + private fun startBackgroundThread() { + backgroundThread = HandlerThread("RawCaptureThread").also { it.start() } + backgroundHandler = Handler(backgroundThread!!.looper) + } + + private fun stopBackgroundThread() { + backgroundThread?.quitSafely() + try { + backgroundThread?.join(3000) + } catch (_: InterruptedException) { + } + backgroundThread = null + backgroundHandler = null + } +} diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index b8fffdc..73163b7 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -27,10 +27,11 @@ | パッケージ | 用途 | 備考 | | --- | --- | --- | | `camera` | インカメラのライブプレビュー・撮影 | Flutter 公式プラグイン | -| `image` | 撮影画像の PNG エンコード | JPEG → PNG 変換に使用 | +| `image` | 撮影画像の PNG エンコード | YUV → RGB → PNG 変換に使用 | | `path_provider` | アプリ内ファイルパスの取得 | 保存先ディレクトリの参照に使用 | | `permission_handler` | カメラ・ストレージの権限管理 | Android の実行時権限リクエスト | | `provider` | 状態管理 | Flutter 公式推奨.シンプルかつ拡張しやすい | +| `screen_brightness` | 画面の輝度制御 | アタッチメント装着時の明るさ調整に使用 | ※ パッケージバージョンは環境構築時に最新 stable を採用し,`pubspec.yaml` で管理する. diff --git "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index edaa94c..29a8bdd 100644 --- "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -125,6 +125,24 @@ 接続した端末名が表示されれば OK. +### 環境変数の設定 (Environment Variables) + +新しいターミナルを開くたびに以下の設定が必要になる場合がある.ビルドエラーが出た際に確認すること. + +**JAVA_HOME(Gradle ビルドに必要):** + +```powershell +$env:JAVA_HOME = "$env:USERPROFILE\scoop\apps\android-studio\current\jbr" +``` + +**ADB パス(実機デバッグに必要):** + +```powershell +$env:Path += ";$env:LOCALAPPDATA\Android\Sdk\platform-tools" +``` + +※ 上記は現在のターミナルのみ有効な一時設定.恒久化するにはシステムの環境変数に追加する. + ### アプリの実行確認 (Run App) ```bash @@ -154,3 +172,12 @@ - USB ケーブルが「データ転送対応」であることを確認する(充電専用ケーブルでは認識されない) - スマートフォンの USB 接続モードが「ファイル転送(MTP)」になっていることを確認する - 端末のメーカー固有の USB ドライバが必要な場合がある(AQUOS の場合は SHARP 公式サイトからダウンロード) +- `adb` コマンドが見つからない場合は,ADB パスの環境変数設定(上記「環境変数の設定」セクション)を確認する + +### Gradle ビルドで `JAVA_HOME is not set` エラーが出る場合 + +JAVA_HOME の環境変数設定(上記「環境変数の設定」セクション)を確認する.Scoop 経由で Android Studio をインストールした場合,JDK は以下にある. + +```text +%USERPROFILE%\scoop\apps\android-studio\current\jbr +``` 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 b68984e..951f432 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" @@ -38,30 +38,33 @@ ### レイアウト +アプリ全体を `app.dart` の `MaterialApp` ごと `Transform.rotate(angle: pi)` で 180° 回転する.これにより画面・ダイアログ・スナックバー等すべての UI が自動的に回転されるため,個々のウィジェットで回転を意識する必要がない.以下は**コード上の配置順**(回転前)を示す.操作者が逆さに置いた端末を見ると,上下が反転して表示される. + ```text -┌─────────────────────────┐ -│ BottomNavBar │ ← 画面下部(通常位置) -├─────────────────────────┤ -│ │ -│ │ -│ カメラプレビュー │ ← 上下反転表示(180°回転) -│ (画面全体) │ -│ │ -│ │ -├─────────────────────────┤ -│ [シャッターボタン] │ ← 画面上部(端末を逆さに置くため) -└─────────────────────────┘ +操作者の視点(回転後) コード上の配置(回転前) +┌─────────────────────┐ ┌─────────────────────┐ +│ (黒い空白) │ │ BottomNavBar │ +│ アタッチメントで隠れる │ │ 明るさスライダー │ +├─────────────────────┤ │ │ +│ │ │ カメラプレビュー │ +│ カメラプレビュー │ │ (中央クロップ) │ +│ (中央クロップ) │ │ │ +│ │ ├─────────────────────┤ +│ 明るさスライダー │ │ (黒い空白) │ +│ BottomNavBar │ │ アタッチメントで隠れる │ +└─────────────────────┘ └─────────────────────┘ ``` -※ 端末を逆さに置いて使用するため,UI 全体を 180° 回転する.操作者から見ると通常の上下配置に見える. +※ アタッチメント(SmTIAS)により画面の約 1/3 が隠れるため,その領域は黒い空白とし,UI 要素は見える領域に配置する. ### UI 要素 | 要素 | 仕様 | | --- | --- | -| カメラプレビュー | インカメラ(前面カメラ)のライブ映像.UI 全体を 180° 回転して表示 | -| シャッターボタン | 丸型ボタン.タップで撮影実行.連続タップ可(連写対応) | -| BottomNavigationBar | 撮影タブ(アクティブ)/ 一覧タブ | +| カメラプレビュー | インカメラのライブ映像.鏡像(左右反転)+上下反転で表示.画面いっぱいに拡大し中央をクロップ | +| シャッターボタン | 丸型ボタン.タップで撮影実行.連続タップ可(連写対応).※ Step 3 で実装 | +| 明るさスライダー | ナビバーの直下に配置.画面の輝度を手動で調整する(デフォルト: 0.8) | +| BottomNavigationBar | 撮影タブ(アクティブ)/ 一覧タブ.背景色は黒系で統一 | ### 動作仕様 @@ -132,13 +135,16 @@ | 解像度 | `ResolutionPreset.max`(カメラが対応する最大解像度) | | フォーカス | オートフォーカス(デフォルト動作) | | フラッシュ | OFF(LED ライトはアタッチメント側で制御) | -| 画像フォーマット | カメラ出力は JPEG → アプリ内で PNG に変換して保存 | +| 画像フォーマット | Camera2 API で YUV_420_888 生データを取得し,PNG に直接変換して保存(JPEG 非経由) | ### プレビュー表示 - `camera` パッケージの `CameraPreview` ウィジェットを使用する - UI 全体を `Transform.rotate(angle: pi)` で 180° 回転する -- プレビューのアスペクト比はカメラのネイティブ比率を維持する +- カメラ映像に `Matrix4.diagonal3Values(-scale, -scale, 1.0)` を適用する + - X 軸反転: 鏡像表示(操作者が自然に見える向き) + - Y 軸反転: 端末が逆さのため上下を補正 +- プレビューは画面いっぱいに拡大し,はみ出す部分はクロップする(`ClipRect` + スケール計算) ### 撮影フロー @@ -146,13 +152,22 @@ シャッターボタンタップ │ ▼ -CameraController.takePicture() +プレビュー停止(CameraController.dispose()) │ ▼ -一時ファイル(XFile)取得 +Camera2 API でフロントカメラを開く(YUV_420_888,フル解像度) │ ▼ -PNG エンコード(image パッケージ) +AE/AF 安定のため 1 秒間プレビューフレームを流す + │ + ▼ +STILL_CAPTURE リクエストで YUV フレームを 1 枚取得 + │ + ▼ +YUV → RGB 変換(BT.601 係数,isolate で実行) + │ + ▼ +PNG エンコード(image パッケージ,ロスレス) │ ▼ ファイル名生成(命名規則に従う) @@ -161,7 +176,10 @@ Pictures/MiniTIAS/ に保存 │ ▼ -MediaStore 通知(ギャラリーアプリへの反映) +MediaStore 通知(MediaScannerConnection でスキャン) + │ + ▼ +Camera2 を閉じ,プレビューを再開 │ ▼ スナックバーで保存完了を通知 @@ -300,6 +318,13 @@ ## 非機能要件 (Non-functional Requirements) +### 画面輝度 + +- アプリ起動時に画面の輝度をデフォルト値(0.8)に設定する +- ユーザーが明るさスライダーで任意に調整できる +- アプリ終了時にシステムの輝度設定に復帰する +- アタッチメント装着時に光センサーで画面が暗くなることへの対策 + ### パフォーマンス - 撮影から保存完了まで 2 秒以内を目標とする diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..35b393c --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,33 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'package:mini_tias/screens/home_screen.dart'; + +/// MiniTIAS アプリのルートウィジェット. +/// +/// 端末を逆さに置いて使用するため,アプリ全体を 180° 回転する. +/// これにより画面・ダイアログ・スナックバー等すべての UI が +/// 操作者から見て正しい向きで表示される. +class MiniTiasApp extends StatelessWidget { + const MiniTiasApp({super.key}); + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Transform.rotate( + angle: math.pi, + child: MaterialApp( + title: 'MiniTIAS', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey), + useMaterial3: true, + ), + home: const HomeScreen(), + debugShowCheckedModeBanner: false, + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..87089bf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,122 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import 'package:mini_tias/app.dart'; +import 'package:mini_tias/providers/camera_provider.dart'; +import 'package:mini_tias/providers/gallery_provider.dart'; void main() { - runApp(const MyApp()); -} + WidgetsFlutterBinding.ensureInitialized(); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), - ); - } + runApp( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => CameraProvider()), + ChangeNotifierProvider(create: (_) => GalleryProvider()), + ], + child: const MiniTiasApp(), + ), + ); } diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart new file mode 100644 index 0000000..4c5fddb --- /dev/null +++ b/lib/providers/camera_provider.dart @@ -0,0 +1,137 @@ +import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:mini_tias/services/file_service.dart'; +import 'package:mini_tias/services/permission_service.dart'; +import 'package:mini_tias/services/raw_capture_service.dart'; + +/// カメラの初期化・プレビュー制御・撮影実行・ライフサイクルを管理する. +class CameraProvider extends ChangeNotifier { + final PermissionService _permissionService = PermissionService(); + final FileService _fileService = FileService(); + final RawCaptureService _rawCaptureService = RawCaptureService(); + + CameraController? _controller; + bool _isInitialized = false; + String? _errorMessage; + bool _permissionDenied = false; + bool _permissionPermanentlyDenied = false; + String? _lastSavedFileName; + bool _isSaving = false; + + CameraController? get controller => _controller; + bool get isInitialized => _isInitialized; + String? get errorMessage => _errorMessage; + bool get permissionDenied => _permissionDenied; + bool get permissionPermanentlyDenied => _permissionPermanentlyDenied; + String? get lastSavedFileName => _lastSavedFileName; + bool get isSaving => _isSaving; + + /// カメラを初期化する. + Future initialize() async { + final granted = await _permissionService.requestCamera(); + if (!granted) { + _permissionDenied = true; + _permissionPermanentlyDenied = await _permissionService + .isCameraPermanentlyDenied(); + _errorMessage = 'カメラの権限が必要です'; + notifyListeners(); + return; + } + + _permissionDenied = false; + _permissionPermanentlyDenied = false; + + try { + final cameras = await availableCameras(); + final frontCamera = cameras.firstWhere( + (c) => c.lensDirection == CameraLensDirection.front, + ); + + _controller = CameraController( + frontCamera, + ResolutionPreset.max, + enableAudio: false, + ); + + await _controller!.initialize(); + await _controller!.setFlashMode(FlashMode.off); + _isInitialized = true; + _errorMessage = null; + } catch (e) { + _errorMessage = 'カメラの初期化に失敗しました: $e'; + _isInitialized = false; + } + + notifyListeners(); + } + + /// Camera2 API でフル解像度 YUV キャプチャし,PNG で保存する. + /// + /// プレビューを一時停止 → Camera2 でキャプチャ → プレビュー再開. + Future takePicture() async { + if (!_isInitialized || _isSaving) return; + + _isSaving = true; + _isInitialized = false; + notifyListeners(); + + try { + // ストレージ権限の確認 + final storageGranted = await _permissionService.requestStorage(); + if (!storageGranted) { + _errorMessage = 'ストレージの権限が必要です'; + _isSaving = false; + notifyListeners(); + return; + } + + // プレビューを停止してカメラを解放 + await _controller?.dispose(); + _controller = null; + + // カメラが完全に解放されるまで待つ + await Future.delayed(const Duration(milliseconds: 500)); + + // Camera2 API でフル解像度 YUV キャプチャ + final yuvData = await _rawCaptureService.captureFullResolutionYuv(); + + // PNG に変換して保存 + final savedPath = await _fileService.saveImageFromYuvData(yuvData); + + // MediaStore に登録(PC から MTP で見えるようにする) + await _rawCaptureService.scanFile(savedPath); + + _lastSavedFileName = savedPath.split('/').last; + _errorMessage = null; + } catch (e) { + _errorMessage = '撮影に失敗しました: $e'; + _lastSavedFileName = null; + } + + _isSaving = false; + notifyListeners(); + + // プレビューを再開 + await initialize(); + } + + /// カメラリソースを解放する. + void disposeCamera() { + _controller?.dispose(); + _controller = null; + _isInitialized = false; + notifyListeners(); + } + + /// アプリの設定画面を開く(権限が永久拒否された場合). + Future openSettings() async { + await _permissionService.openSettings(); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } +} diff --git a/lib/providers/gallery_provider.dart b/lib/providers/gallery_provider.dart new file mode 100644 index 0000000..35fb41b --- /dev/null +++ b/lib/providers/gallery_provider.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +/// 画像一覧の取得・キャッシュ・削除操作を管理する. +class GalleryProvider extends ChangeNotifier { + static const _basePath = '/storage/emulated/0/Pictures/MiniTIAS'; + + List _images = []; + bool _isLoading = false; + + List get images => _images; + bool get isLoading => _isLoading; + + /// Pictures/MiniTIAS/ 内の PNG ファイルを取得し,新しい順に並べる. + Future loadImages() async { + _isLoading = true; + notifyListeners(); + + try { + final directory = Directory(_basePath); + if (!await directory.exists()) { + _images = []; + } else { + final files = await directory + .list() + .where( + (entity) => + entity is File && entity.path.toLowerCase().endsWith('.png'), + ) + .cast() + .toList(); + + // 更新日時の新しい順にソート + files.sort((a, b) { + final aStat = a.statSync(); + final bStat = b.statSync(); + return bStat.modified.compareTo(aStat.modified); + }); + + _images = files; + } + } catch (e) { + debugPrint('画像一覧の取得に失敗: $e'); + _images = []; + } + + _isLoading = false; + notifyListeners(); + } + + /// 指定したファイルを削除し,一覧から除去する. + Future deleteImage(File file) async { + try { + if (await file.exists()) { + await file.delete(); + } + // 削除成功後にのみ一覧から除去 + if (!await file.exists()) { + _images.remove(file); + notifyListeners(); + return true; + } + return false; + } catch (e) { + debugPrint('画像の削除に失敗: $e'); + return false; + } + } +} diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart new file mode 100644 index 0000000..965caeb --- /dev/null +++ b/lib/screens/capture_screen.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:mini_tias/providers/camera_provider.dart'; +import 'package:mini_tias/widgets/camera_preview.dart'; +import 'package:mini_tias/widgets/shutter_button.dart'; + +/// 撮影画面.カメラプレビューとシャッターボタンを表示する. +class CaptureScreen extends StatefulWidget { + const CaptureScreen({super.key}); + + @override + State createState() => _CaptureScreenState(); +} + +class _CaptureScreenState extends State + with WidgetsBindingObserver { + String? _previousFileName; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().initialize(); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final cameraProvider = context.read(); + if (state == AppLifecycleState.paused) { + cameraProvider.disposeCamera(); + } else if (state == AppLifecycleState.resumed) { + cameraProvider.initialize(); + } + } + + void _showSaveResult(CameraProvider cameraProvider) { + final fileName = cameraProvider.lastSavedFileName; + if (fileName != null && fileName != _previousFileName) { + _previousFileName = fileName; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('保存しました: $fileName'), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only( + bottom: MediaQuery.of(context).size.height / 2, + left: 16, + right: 16, + ), + ), + ); + }); + } + + final error = cameraProvider.errorMessage; + if (error != null && !cameraProvider.isInitialized) return; + if (error != null && !cameraProvider.isSaving) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error), backgroundColor: Colors.red), + ); + }); + } + } + + @override + Widget build(BuildContext context) { + final cameraProvider = context.watch(); + + _showSaveResult(cameraProvider); + + if (cameraProvider.permissionDenied) { + return _buildPermissionDenied(cameraProvider); + } + + if (cameraProvider.errorMessage != null && !cameraProvider.isInitialized) { + return Center(child: Text(cameraProvider.errorMessage!)); + } + + if (!cameraProvider.isInitialized) { + return const Center(child: CircularProgressIndicator()); + } + + return Stack( + children: [ + Positioned.fill( + child: CameraPreviewWidget(controller: cameraProvider.controller!), + ), + // 保存中インジケーター + if (cameraProvider.isSaving) + const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Colors.white), + SizedBox(height: 12), + Text( + '保存中...', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ], + ), + ), + // シャッターボタン(画面下部中央) + Positioned( + left: 0, + right: 0, + bottom: 24, + child: Center( + child: ShutterButton( + onPressed: cameraProvider.isSaving + ? () {} + : () => cameraProvider.takePicture(), + ), + ), + ), + ], + ); + } + + Widget _buildPermissionDenied(CameraProvider cameraProvider) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(cameraProvider.errorMessage ?? 'カメラの権限が必要です'), + const SizedBox(height: 16), + if (cameraProvider.permissionPermanentlyDenied) + ElevatedButton( + onPressed: () => cameraProvider.openSettings(), + child: const Text('設定画面を開く'), + ) + else + ElevatedButton( + onPressed: () => cameraProvider.initialize(), + child: const Text('権限を許可する'), + ), + ], + ), + ); + } +} diff --git a/lib/screens/gallery_screen.dart b/lib/screens/gallery_screen.dart new file mode 100644 index 0000000..5e08a72 --- /dev/null +++ b/lib/screens/gallery_screen.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:mini_tias/providers/gallery_provider.dart'; +import 'package:mini_tias/widgets/image_detail_dialog.dart'; +import 'package:mini_tias/widgets/image_grid.dart'; + +/// 一覧画面.撮影済み画像のサムネイルグリッドを表示する. +class GalleryScreen extends StatefulWidget { + const GalleryScreen({super.key}); + + @override + State createState() => _GalleryScreenState(); +} + +class _GalleryScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadImages(); + }); + } + + @override + Widget build(BuildContext context) { + final galleryProvider = context.watch(); + + if (galleryProvider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (galleryProvider.images.isEmpty) { + return const Center(child: Text('撮影した画像がありません')); + } + + return ImageGrid( + images: galleryProvider.images, + onImageTap: (file) { + showDialog( + context: context, + builder: (_) => ImageDetailDialog( + file: file, + onDelete: () { + galleryProvider.deleteImage(file); + }, + ), + ); + }, + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..fdce83d --- /dev/null +++ b/lib/screens/home_screen.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:screen_brightness/screen_brightness.dart'; + +import 'package:mini_tias/providers/gallery_provider.dart'; +import 'package:mini_tias/screens/capture_screen.dart'; +import 'package:mini_tias/screens/gallery_screen.dart'; + +/// BottomNavigationBar で撮影画面と一覧画面を切り替えるホーム画面. +/// +/// アタッチメントで隠れる下部領域を避け,上部の見える領域に UI を配置する. +/// ※ 180° 回転は app.dart で一括適用しているため,ここでは不要. +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + int _currentIndex = 0; + double _brightness = 0.8; + + @override + void initState() { + super.initState(); + _setBrightness(_brightness); + } + + @override + void dispose() { + ScreenBrightness.instance.resetApplicationScreenBrightness(); + super.dispose(); + } + + Future _setBrightness(double value) async { + await ScreenBrightness.instance.setApplicationScreenBrightness(value); + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + final attachmentPadding = screenHeight / 3; + + return Scaffold( + backgroundColor: Colors.black, + body: Column( + children: [ + // ナビゲーションバー + BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (index) { + setState(() => _currentIndex = index); + if (index == 1) { + context.read().loadImages(); + } + }, + backgroundColor: Colors.black87, + selectedItemColor: Colors.white, + unselectedItemColor: Colors.white60, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.camera_alt), + label: '撮影', + ), + BottomNavigationBarItem( + icon: Icon(Icons.photo_library), + label: '一覧', + ), + ], + ), + + // 明るさスライダー + Container( + color: Colors.black54, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon(Icons.brightness_low, size: 20, color: Colors.white), + Expanded( + child: Slider( + value: _brightness, + onChanged: (value) { + setState(() => _brightness = value); + _setBrightness(value); + }, + ), + ), + const Icon( + Icons.brightness_high, + size: 20, + color: Colors.white, + ), + ], + ), + ), + + // メインコンテンツ(カメラプレビュー or 一覧) + Expanded( + child: _currentIndex == 0 + ? const CaptureScreen() + : const GalleryScreen(), + ), + + // アタッチメントで隠れる領域(空白) + SizedBox(height: attachmentPadding), + ], + ), + ); + } +} diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart new file mode 100644 index 0000000..8495733 --- /dev/null +++ b/lib/services/file_service.dart @@ -0,0 +1,163 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; + +/// 撮影画像の保存・命名・削除を行うサービス. +class FileService { + static const _basePath = '/storage/emulated/0/Pictures/MiniTIAS'; + + /// 保存先ディレクトリのパスを返す. + String get directoryPath => _basePath; + + /// Platform Channel から受け取った YUV データを PNG に変換して保存する. + /// + /// Camera2 API でフル解像度キャプチャした生データを直接変換するため, + /// JPEG 圧縮を経由せず画質劣化がない. + Future saveImageFromYuvData(Map yuvData) async { + final directory = Directory(_basePath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + + final yRaw = yuvData['yPlane']; + final uRaw = yuvData['uPlane']; + final vRaw = yuvData['vPlane']; + + final Uint8List yPlane; + final Uint8List uPlane; + final Uint8List vPlane; + + if (yRaw is Uint8List) { + yPlane = yRaw; + uPlane = uRaw as Uint8List; + vPlane = vRaw as Uint8List; + } else { + yPlane = Uint8List.fromList((yRaw as List).cast()); + uPlane = Uint8List.fromList((uRaw as List).cast()); + vPlane = Uint8List.fromList((vRaw as List).cast()); + } + + debugPrint( + 'YUV data: ${yuvData['width']}x${yuvData['height']}, ' + 'yPlane: ${yPlane.length} bytes, ' + 'uPlane: ${uPlane.length} bytes, ' + 'vPlane: ${vPlane.length} bytes', + ); + + if (yPlane.isEmpty) { + throw Exception('YUV データが空です'); + } + + final params = { + 'width': yuvData['width'] as int, + 'height': yuvData['height'] as int, + 'yPlane': yPlane, + 'uPlane': uPlane, + 'vPlane': vPlane, + 'yRowStride': yuvData['yRowStride'] as int, + 'uvRowStride': yuvData['uvRowStride'] as int, + 'uvPixelStride': yuvData['uvPixelStride'] as int, + }; + + debugPrint('PNG 変換開始...'); + final stopwatch = Stopwatch()..start(); + final pngBytes = await compute(_convertYuvToPng, params); + stopwatch.stop(); + debugPrint( + 'PNG 変換完了: ${pngBytes.length} bytes, ' + '${stopwatch.elapsedMilliseconds}ms', + ); + + final fileName = await generateFileName(); + final filePath = '$_basePath/$fileName'; + final file = File(filePath); + await file.writeAsBytes(pngBytes); + debugPrint('ファイル保存完了: $filePath'); + + return filePath; + } + + /// ファイル名を生成する. + /// + /// 形式: MiniTIAS_YYYYMMDD_HHmmss.png + /// 同秒の重複がある場合は _1, _2, ... と連番を付与する. + Future generateFileName() async { + final now = DateTime.now(); + final timestamp = + '${now.year}' + '${now.month.toString().padLeft(2, '0')}' + '${now.day.toString().padLeft(2, '0')}' + '_' + '${now.hour.toString().padLeft(2, '0')}' + '${now.minute.toString().padLeft(2, '0')}' + '${now.second.toString().padLeft(2, '0')}'; + + final baseName = 'MiniTIAS_$timestamp'; + final candidate = '$baseName.png'; + + if (!await File('$_basePath/$candidate').exists()) { + return candidate; + } + + var suffix = 1; + while (await File('$_basePath/${baseName}_$suffix.png').exists()) { + suffix++; + } + return '${baseName}_$suffix.png'; + } + + /// テスト用: タイムスタンプとファイル名一覧からファイル名を生成する. + static String generateFileNameSync( + String timestamp, + List existingFiles, + ) { + final baseName = 'MiniTIAS_$timestamp'; + final candidate = '$baseName.png'; + + if (!existingFiles.contains(candidate)) { + return candidate; + } + + var suffix = 1; + while (existingFiles.contains('${baseName}_$suffix.png')) { + suffix++; + } + return '${baseName}_$suffix.png'; + } + + /// YUV420 を RGB に変換し PNG エンコードする(isolate 内で実行). + static Uint8List _convertYuvToPng(Map params) { + final width = params['width'] as int; + final height = params['height'] as int; + final yPlane = params['yPlane'] as Uint8List; + final uPlane = params['uPlane'] as Uint8List; + final vPlane = params['vPlane'] as Uint8List; + final yRowStride = params['yRowStride'] as int; + final uvRowStride = params['uvRowStride'] as int; + final uvPixelStride = params['uvPixelStride'] as int; + + final image = img.Image(width: width, height: height); + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + final yIndex = y * yRowStride + x; + final uvIndex = (y ~/ 2) * uvRowStride + (x ~/ 2) * uvPixelStride; + + final yValue = yPlane[yIndex]; + final uValue = uPlane[uvIndex]; + final vValue = vPlane[uvIndex]; + + final r = (yValue + 1.402 * (vValue - 128)).round().clamp(0, 255); + final g = (yValue - 0.344 * (uValue - 128) - 0.714 * (vValue - 128)) + .round() + .clamp(0, 255); + final b = (yValue + 1.772 * (uValue - 128)).round().clamp(0, 255); + + image.setPixelRgb(x, y, r, g, b); + } + } + + return Uint8List.fromList(img.encodePng(image)); + } +} diff --git a/lib/services/permission_service.dart b/lib/services/permission_service.dart new file mode 100644 index 0000000..65a4072 --- /dev/null +++ b/lib/services/permission_service.dart @@ -0,0 +1,26 @@ +import 'package:permission_handler/permission_handler.dart'; + +/// カメラ・ストレージのパーミッション確認・要求を行うサービス. +class PermissionService { + /// カメラ権限を確認し,未許可なら要求する. + Future requestCamera() async { + final status = await Permission.camera.request(); + return status.isGranted; + } + + /// ストレージ権限を確認し,未許可なら要求する. + Future requestStorage() async { + final status = await Permission.storage.request(); + return status.isGranted; + } + + /// カメラ権限が永久に拒否されているかを返す. + Future isCameraPermanentlyDenied() async { + return await Permission.camera.isPermanentlyDenied; + } + + /// アプリの設定画面を開く. + Future openSettings() async { + await openAppSettings(); + } +} diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart new file mode 100644 index 0000000..8a92d39 --- /dev/null +++ b/lib/services/raw_capture_service.dart @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; + +/// Camera2 API を使用してフル解像度の YUV 画像をキャプチャするサービス. +class RawCaptureService { + static const _channel = MethodChannel('com.example.mini_tias/raw_capture'); + + /// フロントカメラからフル解像度の YUV データをキャプチャする. + /// + /// 返り値は YUV データを含む Map: + /// - width, height: 画像サイズ + /// - yPlane, uPlane, vPlane: YUV プレーンのバイトデータ + /// - yRowStride, uvRowStride, uvPixelStride: ストライド情報 + Future> captureFullResolutionYuv() async { + final result = await _channel + .invokeMethod('captureFullResolutionYuv') + .timeout( + const Duration(seconds: 30), + onTimeout: () => throw Exception('カメラキャプチャがタイムアウトしました'), + ); + return Map.from(result as Map); + } + + /// MediaStore にファイルを登録し,PC から MTP で見えるようにする. + Future scanFile(String path) async { + await _channel.invokeMethod('scanFile', {'path': path}); + } +} diff --git a/lib/widgets/camera_preview.dart b/lib/widgets/camera_preview.dart new file mode 100644 index 0000000..c0b04e7 --- /dev/null +++ b/lib/widgets/camera_preview.dart @@ -0,0 +1,50 @@ +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; + +/// カメラのライブプレビューを全画面で表示するウィジェット. +/// +/// フロントカメラの映像を鏡像(左右反転)で表示する. +class CameraPreviewWidget extends StatelessWidget { + const CameraPreviewWidget({super.key, required this.controller}); + + final CameraController controller; + + @override + Widget build(BuildContext context) { + if (!controller.value.isInitialized) { + return const Center(child: CircularProgressIndicator()); + } + + return LayoutBuilder( + builder: (context, constraints) { + final previewSize = controller.value.previewSize; + if (previewSize == null) { + return const Center(child: CircularProgressIndicator()); + } + + // カメラのアスペクト比(プレビューは横長で返るので反転) + final cameraAspect = previewSize.height / previewSize.width; + final screenAspect = constraints.maxWidth / constraints.maxHeight; + + // 画面いっぱいに表示するためのスケール + final scale = cameraAspect > screenAspect + ? constraints.maxHeight / (constraints.maxWidth / cameraAspect) + : constraints.maxWidth / (constraints.maxHeight * cameraAspect); + + return ClipRect( + child: Transform( + alignment: Alignment.center, + // X 軸を反転(鏡像),Y 軸を反転(上下逆転) + transform: Matrix4.diagonal3Values(-scale, -scale, 1.0), + child: Center( + child: AspectRatio( + aspectRatio: cameraAspect, + child: CameraPreview(controller), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/image_detail_dialog.dart b/lib/widgets/image_detail_dialog.dart new file mode 100644 index 0000000..c014875 --- /dev/null +++ b/lib/widgets/image_detail_dialog.dart @@ -0,0 +1,90 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +/// 画像の拡大表示ダイアログ.ピンチズームと削除ボタンを備える. +class ImageDetailDialog extends StatelessWidget { + const ImageDetailDialog({ + super.key, + required this.file, + required this.onDelete, + }); + + final File file; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final fileName = file.path.split('/').last; + + return Dialog( + backgroundColor: Colors.black, + insetPadding: const EdgeInsets.all(8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ヘッダー(ファイル名 + 閉じるボタン) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + Expanded( + child: Text( + fileName, + style: const TextStyle(color: Colors.white, fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + // 画像(ピンチズーム対応) + Flexible( + child: InteractiveViewer( + minScale: 1.0, + maxScale: 5.0, + child: Image.file(file), + ), + ), + // 削除ボタン + Padding( + padding: const EdgeInsets.all(8), + child: TextButton.icon( + onPressed: () => _confirmDelete(context), + icon: const Icon(Icons.delete, color: Colors.red), + label: const Text('削除', style: TextStyle(color: Colors.red)), + ), + ), + ], + ), + ); + } + + void _confirmDelete(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('画像を削除'), + content: const Text('この画像を削除しますか?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('キャンセル'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); // 確認ダイアログを閉じる + Navigator.of(context).pop(); // 詳細ダイアログを閉じる + onDelete(); + }, + child: const Text('削除', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/image_grid.dart b/lib/widgets/image_grid.dart new file mode 100644 index 0000000..2581448 --- /dev/null +++ b/lib/widgets/image_grid.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +/// 撮影済み画像のサムネイルを 3 列のグリッドで表示するウィジェット. +class ImageGrid extends StatelessWidget { + const ImageGrid({super.key, required this.images, required this.onImageTap}); + + final List images; + final void Function(File file) onImageTap; + + @override + Widget build(BuildContext context) { + return GridView.builder( + padding: const EdgeInsets.all(4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + itemCount: images.length, + itemBuilder: (context, index) { + final file = images[index]; + return GestureDetector( + onTap: () => onImageTap(file), + child: Image.file(file, fit: BoxFit.cover, cacheWidth: 200), + ); + }, + ); + } +} diff --git a/lib/widgets/shutter_button.dart b/lib/widgets/shutter_button.dart new file mode 100644 index 0000000..0a7c1a2 --- /dev/null +++ b/lib/widgets/shutter_button.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +/// 撮影用のシャッターボタン. +class ShutterButton extends StatelessWidget { + const ShutterButton({super.key, required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 4), + ), + child: Center( + child: Container( + width: 58, + height: 58, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + ), + ), + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..e93ef1f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import screen_brightness_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index ec64d1f..c6ebf24 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" async: dependency: transitive description: @@ -17,6 +25,46 @@ url: "https://pub.dev" source: hosted version: "2.1.2" + camera: + dependency: "direct main" + description: + name: camera + sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777" + url: "https://pub.dev" + source: hosted + version: "0.12.0+1" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "2c178975759aac0f0ef7ce1ec698b6e2acd792127ea7f38fa79a424fbebeae7f" + url: "https://pub.dev" + source: hosted + version: "0.7.1+2" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "90e4cc3fde331581a3b2d35d83be41dbb7393af0ab857eb27b732174289cb96d" + url: "https://pub.dev" + source: hosted + version: "0.10.1" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" + url: "https://pub.dev" + source: hosted + version: "2.12.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7" + url: "https://pub.dev" + source: hosted + version: "0.3.5+3" characters: dependency: transitive description: @@ -41,6 +89,14 @@ url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" cupertino_icons: dependency: "direct main" description: @@ -57,6 +113,14 @@ url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" flutter: dependency: "direct main" description: flutter @@ -70,11 +134,32 @@ url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + image: + dependency: "direct main" + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" leak_tracker: dependency: transitive description: @@ -131,6 +216,14 @@ url: "https://pub.dev" source: hosted version: "1.17.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" path: dependency: transitive description: @@ -139,6 +232,142 @@ url: "https://pub.dev" source: hosted version: "1.9.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + screen_brightness: + dependency: "direct main" + description: + name: screen_brightness + sha256: "5f70754028f169f059fdc61112a19dcbee152f8b293c42c848317854d650cba3" + url: "https://pub.dev" + source: hosted + version: "2.1.7" + screen_brightness_android: + dependency: transitive + description: + name: screen_brightness_android + sha256: d34f5321abd03bc3474f4c381f53d189117eba0b039eac1916aa92cca5fd0a96 + url: "https://pub.dev" + source: hosted + version: "2.1.3" + screen_brightness_ios: + dependency: transitive + description: + name: screen_brightness_ios + sha256: "2493953340ecfe8f4f13f61db50ce72533a55b0bbd58ba1402893feecf3727f5" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + screen_brightness_macos: + dependency: transitive + description: + name: screen_brightness_macos + sha256: "4edf330ad21078686d8bfaf89413325fbaf571dcebe1e89254d675a3f288b5b9" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + screen_brightness_ohos: + dependency: transitive + description: + name: screen_brightness_ohos + sha256: a93a263dcd39b5c56e589eb495bcd001ce65cdd96ff12ab1350683559d5c5bb7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + screen_brightness_platform_interface: + dependency: transitive + description: + name: screen_brightness_platform_interface + sha256: "737bd47b57746bc4291cab1b8a5843ee881af499514881b0247ec77447ee769c" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + screen_brightness_windows: + dependency: transitive + description: + name: screen_brightness_windows + sha256: d3518bf0f5d7a884cee2c14449ae0b36803802866de09f7ef74077874b6b2448 + url: "https://pub.dev" + source: hosted + version: "2.1.0" sky_engine: dependency: transitive description: flutter @@ -168,6 +397,14 @@ url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -208,6 +445,22 @@ url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" sdks: dart: ">=3.11.4 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index da41d0c..05d7385 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,11 @@ # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + provider: ^6.1.5+1 + screen_brightness: ^2.1.7 + camera: ^0.12.0+1 + permission_handler: ^12.0.1 + image: ^4.8.0 dev_dependencies: flutter_test: diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart new file mode 100644 index 0000000..4d577a7 --- /dev/null +++ b/test/services/file_service_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mini_tias/services/file_service.dart'; + +void main() { + group('FileService.generateFileNameSync', () { + test('同秒のファイルが存在しない場合,サフィックスなしのファイル名を返す', () { + final result = FileService.generateFileNameSync('20260404_120000', []); + expect(result, 'MiniTIAS_20260404_120000.png'); + }); + + test('同秒のファイルが存在する場合,_1 サフィックスを付与する', () { + final result = FileService.generateFileNameSync('20260404_120000', [ + 'MiniTIAS_20260404_120000.png', + ]); + expect(result, 'MiniTIAS_20260404_120000_1.png'); + }); + + test('_1 も存在する場合,_2 サフィックスを付与する', () { + final result = FileService.generateFileNameSync('20260404_120000', [ + 'MiniTIAS_20260404_120000.png', + 'MiniTIAS_20260404_120000_1.png', + ]); + expect(result, 'MiniTIAS_20260404_120000_2.png'); + }); + + test('異なるタイムスタンプのファイルが存在しても影響しない', () { + final result = FileService.generateFileNameSync('20260404_120000', [ + 'MiniTIAS_20260404_120001.png', + ]); + expect(result, 'MiniTIAS_20260404_120000.png'); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 6b796bd..cff4d50 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,45 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; -import 'package:mini_tias/main.dart'; +import 'package:mini_tias/app.dart'; +import 'package:mini_tias/providers/camera_provider.dart'; +import 'package:mini_tias/providers/gallery_provider.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('アプリ起動時にナビゲーションバーが表示される', (WidgetTester tester) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => CameraProvider()), + ChangeNotifierProvider(create: (_) => GalleryProvider()), + ], + child: const MiniTiasApp(), + ), + ); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + expect(find.text('撮影'), findsOneWidget); + expect(find.text('一覧'), findsOneWidget); + }); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); + testWidgets('一覧タブをタップすると一覧画面に切り替わる', (WidgetTester tester) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => CameraProvider()), + ChangeNotifierProvider(create: (_) => GalleryProvider()), + ], + child: const MiniTiasApp(), + ), + ); + + await tester.tap(find.text('一覧')); await tester.pump(); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + // 一覧タブがアクティブになっていることを確認 + final navBar = tester.widget( + find.byType(BottomNavigationBar), + ); + expect(navBar.currentIndex, 1); }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..d06af2e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + ScreenBrightnessWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..a4456cf 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + permission_handler_windows + screen_brightness_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST