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 f10978e..4be528b 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" @@ -114,8 +114,7 @@ | 要素 | 仕様 | | --- | --- | | サムネイルグリッド | 3 列のグリッド表示.新しい画像が先頭(降順) | -| サムネイル | 正方形クロップ.タップで拡大表示 | -| ファイル名ラベル | 各サムネイル下部にファイル名を表示 | +| サムネイル | 正方形クロップ.タップで拡大表示(ファイル名はタップ後の詳細表示で確認する) | | BottomNavigationBar | 撮影タブ / 一覧タブ(アクティブ) | ### 詳細表示(インラインオーバーレイ) diff --git "a/docs/04_SPEC/SPEC_03_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\343\201\250\351\235\236\346\251\237\350\203\275.md" "b/docs/04_SPEC/SPEC_03_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\343\201\250\351\235\236\346\251\237\350\203\275.md" index 5e69362..833d74f 100644 --- "a/docs/04_SPEC/SPEC_03_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\343\201\250\351\235\236\346\251\237\350\203\275.md" +++ "b/docs/04_SPEC/SPEC_03_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\343\201\250\351\235\236\346\251\237\350\203\275.md" @@ -24,25 +24,33 @@ ```text lib/ -├── main.dart # アプリエントリポイント,Provider 登録 -├── app.dart # MaterialApp 定義,テーマ設定,180° 回転 +├── main.dart # アプリエントリポイント,Provider 登録 +├── app.dart # MaterialApp 定義,テーマ設定,180° 回転 +├── models/ +│ ├── display_orientation.dart # 表示向き設定(ライブ/スナップショット両系統の単一の真実) +│ ├── preview_frame.dart # プレビューストリームの YUV フレーム保持(不変) +│ └── preview_region.dart # プレビュークロップ領域(正規化矩形・不変) ├── providers/ -│ ├── camera_provider.dart # カメラ制御の状態管理 -│ ├── gallery_provider.dart # 画像一覧の状態管理 -│ └── diagnostics_provider.dart # カメラ診断(インフラのみ) +│ ├── camera_provider.dart # カメラ制御の状態管理 +│ ├── gallery_provider.dart # 画像一覧の状態管理 +│ └── diagnostics_provider.dart # カメラ診断(インフラのみ) ├── screens/ -│ ├── home_screen.dart # BottomNavigationBar による画面切替 -│ ├── capture_screen.dart # 撮影画面 (S-01) -│ └── gallery_screen.dart # 一覧画面 (S-02) +│ ├── home_screen.dart # BottomNavigationBar による画面切替 +│ ├── capture_screen.dart # 撮影画面 (S-01) +│ └── gallery_screen.dart # 一覧画面 (S-02) ├── widgets/ -│ ├── camera_preview.dart # カメラプレビューウィジェット -│ ├── shutter_button.dart # シャッターボタン -│ └── image_grid.dart # サムネイルグリッド +│ ├── camera_preview.dart # カメラプレビューウィジェット +│ ├── oriented_preview.dart # 表示向き変換の適用(クロップ元) +│ ├── region_crop.dart # 領域クロップ拡大表示(ライブ/スナップショット共通) +│ ├── cropped_snapshot.dart # 保存中スナップショットのクロップ表示 +│ ├── shutter_button.dart # シャッターボタン +│ └── image_grid.dart # サムネイルグリッド └── services/ - ├── file_service.dart # ファイル保存・命名・削除 - ├── permission_service.dart # パーミッション管理 - ├── raw_capture_service.dart # ネイティブ撮影(DNG/PNG)・診断の MethodChannel - └── sound_service.dart # シャッター音・保存完了音の制御 + ├── file_service.dart # ファイル保存・命名・削除 + ├── permission_service.dart # パーミッション管理 + ├── preview_region_service.dart # プレビュークロップ領域の config 読み込み + ├── raw_capture_service.dart # ネイティブ撮影(DNG/PNG)・診断の MethodChannel + └── sound_service.dart # シャッター音・保存完了音の制御 ``` ※ 撮影・画像変換・診断の実処理は Android ネイティブ diff --git a/lib/providers/gallery_provider.dart b/lib/providers/gallery_provider.dart index bb4e1d8..215dd62 100644 --- a/lib/providers/gallery_provider.dart +++ b/lib/providers/gallery_provider.dart @@ -2,10 +2,18 @@ import 'package:flutter/foundation.dart'; +import 'package:smtias_capture/services/raw_capture_service.dart'; + /// 画像一覧の取得・キャッシュ・削除操作を管理する. class GalleryProvider extends ChangeNotifier { + /// MediaStore 通知に使うサービスはコンストラクタで差し替え可能(省略時は既定実装). + GalleryProvider({RawCaptureService? rawCaptureService}) + : _rawCaptureService = rawCaptureService ?? RawCaptureService(); + static const _basePath = '/storage/emulated/0/Pictures/MiniTIAS'; + final RawCaptureService _rawCaptureService; + List _images = []; bool _isLoading = false; @@ -68,15 +76,20 @@ } try { + final deletedPaths = []; for (final f in filesToDelete) { if (await f.exists()) { await f.delete(); + deletedPaths.add(f.path); } } // 元ファイルが消えたら成功とみなす if (!await file.exists()) { _images.removeWhere((f) => f.path == file.path); notifyListeners(); + // 物理削除したファイルを MediaStore に再スキャンさせ,削除済みエントリ + // (MTP や他アプリから見える幽霊エントリ)を索引から除去する. + await _notifyMediaStoreDeleted(deletedPaths); return true; } return false; @@ -85,4 +98,18 @@ return false; } } + + /// 物理削除済みのパスを MediaStore に再スキャンさせ,索引から除去する. + /// + /// 存在しなくなったパスへの `scanFile` は,MediaStore の該当エントリを削除する + /// (Android の挙動).通知の失敗は削除自体の成否に影響させない(握りつぶす). + Future _notifyMediaStoreDeleted(List deletedPaths) async { + for (final path in deletedPaths) { + try { + await _rawCaptureService.scanFile(path); + } catch (e) { + debugPrint('MediaStore 更新の通知に失敗 ($path): $e'); + } + } + } } diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index f1ea177..911d013 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -4,10 +4,18 @@ /// 撮影画像の保存・命名・削除を行うサービス. class FileService { - static const _basePath = '/storage/emulated/0/Pictures/MiniTIAS'; + /// 保存先ディレクトリは省略可能(省略時は本番の共有ストレージパス). + /// + /// テストでは一時ディレクトリを渡して実メソッドを直接検証できる. + FileService({String? directoryPath}) + : _directoryPath = directoryPath ?? _defaultBasePath; + + static const _defaultBasePath = '/storage/emulated/0/Pictures/MiniTIAS'; + + final String _directoryPath; /// 保存先ディレクトリのパスを返す. - String get directoryPath => _basePath; + String get directoryPath => _directoryPath; /// [directoryPath] が存在しない場合,再帰的に作成する. Future _ensureDirectory() async { diff --git a/test/providers/gallery_provider_test.dart b/test/providers/gallery_provider_test.dart index 338abcc..fa8d92a 100644 --- a/test/providers/gallery_provider_test.dart +++ b/test/providers/gallery_provider_test.dart @@ -3,6 +3,23 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:smtias_capture/providers/gallery_provider.dart'; +import 'package:smtias_capture/services/raw_capture_service.dart'; + +/// テスト用 RawCaptureService.`scanFile` の呼び出しパスを記録する. +class _FakeRawCaptureService extends RawCaptureService { + final List scannedPaths = []; + bool scanShouldThrow = false; + + @override + Future scanFile(String path) async { + if (scanShouldThrow) { + throw Exception('テスト用 scanFile 失敗'); + } + scannedPaths.add(path); + } +} + /// テスト専用の GalleryProvider 実装. /// /// プロダクションの GalleryProvider はパスがハードコードされており @@ -340,4 +357,89 @@ expect(provider.images, isEmpty); }); }); + + // 本番の GalleryProvider を直接検証する.deleteImage は引数のファイルパスで + // 動作し _basePath に依存しないため,一時ファイル + フェイクで実コードを exercise できる. + group('GalleryProvider.deleteImage(本番実装・MediaStore 通知)', () { + late Directory tempDir; + late _FakeRawCaptureService fakeRawCapture; + late GalleryProvider provider; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'smtias_capture_del_real_test_', + ); + fakeRawCapture = _FakeRawCaptureService(); + provider = GalleryProvider(rawCaptureService: fakeRawCapture); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test( + '.preview.jpg 削除後,削除した preview/.dng/.meta.json すべてのパスで scanFile が呼ばれる', + () async { + final previewPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg'; + final dngPath = '${tempDir.path}/MiniTIAS_QM_20260101_120000.dng'; + final metaPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json'; + await File(previewPath).create(); + await File(dngPath).create(); + await File(metaPath).create(); + + final result = await provider.deleteImage(File(previewPath)); + + expect(result, isTrue); + expect( + fakeRawCapture.scannedPaths, + containsAll([previewPath, dngPath, metaPath]), + ); + expect(fakeRawCapture.scannedPaths, hasLength(3)); + }, + ); + + test('存在しない連動ファイル(.dng なし)は scanFile 対象に含まれない', () async { + final previewPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg'; + final metaPath = '${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json'; + // .dng は作成しない + await File(previewPath).create(); + await File(metaPath).create(); + + final result = await provider.deleteImage(File(previewPath)); + + expect(result, isTrue); + expect(fakeRawCapture.scannedPaths, containsAll([previewPath, metaPath])); + expect( + fakeRawCapture.scannedPaths, + isNot(contains('${tempDir.path}/MiniTIAS_QM_20260101_120000.dng')), + ); + expect(fakeRawCapture.scannedPaths, hasLength(2)); + }); + + test('.png 削除後,その .png パスで scanFile が呼ばれる(連動なし)', () async { + final pngPath = '${tempDir.path}/MiniTIAS_20260101_120000.png'; + await File(pngPath).create(); + + final result = await provider.deleteImage(File(pngPath)); + + expect(result, isTrue); + expect(fakeRawCapture.scannedPaths, [pngPath]); + }); + + test('scanFile が例外を投げても削除は成功扱いになる', () async { + final pngPath = '${tempDir.path}/MiniTIAS_20260101_120000.png'; + await File(pngPath).create(); + fakeRawCapture.scanShouldThrow = true; + + final result = await provider.deleteImage(File(pngPath)); + + expect(result, isTrue); + expect(await File(pngPath).exists(), isFalse); + }); + }); } diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart index bfef402..00d5f5d 100644 --- a/test/services/file_service_test.dart +++ b/test/services/file_service_test.dart @@ -79,11 +79,11 @@ group('FileService.saveImage', () { late Directory tempDir; - late _FileServiceTestable service; + late FileService service; setUp(() async { tempDir = await Directory.systemTemp.createTemp('smtias_capture_test_'); - service = _FileServiceTestable(tempDir.path); + service = FileService(directoryPath: tempDir.path); }); tearDown(() async { @@ -151,7 +151,9 @@ test('ディレクトリが存在しない場合でも自動作成してファイルを保存する', () async { final nonExistentSubDir = Directory('${tempDir.path}/sub/dir'); - final serviceWithSubDir = _FileServiceTestable(nonExistentSubDir.path); + final serviceWithSubDir = FileService( + directoryPath: nonExistentSubDir.path, + ); expect(await nonExistentSubDir.exists(), isFalse); final pngBytes = Uint8List.fromList([0, 1, 2, 3]); @@ -169,13 +171,13 @@ group('FileService.savePreviewJpeg', () { late Directory tempDir; - late _FileServiceTestablePreview service; + late FileService service; setUp(() async { tempDir = await Directory.systemTemp.createTemp( 'smtias_capture_preview_test_', ); - service = _FileServiceTestablePreview(tempDir.path); + service = FileService(directoryPath: tempDir.path); }); tearDown(() async { @@ -221,8 +223,8 @@ test('ディレクトリが存在しない場合でも自動作成してファイルを保存する', () async { final nonExistentSubDir = Directory('${tempDir.path}/sub/dir'); - final serviceWithSubDir = _FileServiceTestablePreview( - nonExistentSubDir.path, + final serviceWithSubDir = FileService( + directoryPath: nonExistentSubDir.path, ); expect(await nonExistentSubDir.exists(), isFalse); @@ -332,13 +334,13 @@ group('FileService.saveQuantitativeMetadata', () { late Directory tempDir; - late _FileServiceTestableQuantitative service; + late FileService service; setUp(() async { tempDir = await Directory.systemTemp.createTemp( 'smtias_capture_qm_test_', ); - service = _FileServiceTestableQuantitative(tempDir.path); + service = FileService(directoryPath: tempDir.path); }); tearDown(() async { @@ -438,8 +440,8 @@ test('ディレクトリが存在しない場合でも自動作成してファイルを保存する', () async { final nonExistentSubDir = Directory('${tempDir.path}/sub/dir'); - final serviceWithSubDir = _FileServiceTestableQuantitative( - nonExistentSubDir.path, + final serviceWithSubDir = FileService( + directoryPath: nonExistentSubDir.path, ); expect(await nonExistentSubDir.exists(), isFalse); @@ -451,111 +453,73 @@ expect(await File(path).exists(), isTrue); }); }); -} -/// テスト用: 保存先ディレクトリをオーバーライドできる FileService サブクラス. -class _FileServiceTestable extends FileService { - _FileServiceTestable(this._testBasePath); + group('FileService.saveDiagnosticsJson', () { + late Directory tempDir; + late FileService service; - final String _testBasePath; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'smtias_capture_diag_test_', + ); + service = FileService(directoryPath: tempDir.path); + }); - @override - String get directoryPath => _testBasePath; + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); - @override - Future saveImage(Uint8List pngBytes) async { - final directory = Directory(_testBasePath); - if (!await directory.exists()) { - await directory.create(recursive: true); - } - final fileName = await _generateFileNameForTest(); - final filePath = '$_testBasePath/$fileName'; - await File(filePath).writeAsBytes(pngBytes); - return filePath; - } + test('camera_diagnostics_ プレフィックスの .json ファイルが生成される', () async { + final diagnostics = {'make': 'TestMake'}; + final path = await service.saveDiagnosticsJson(diagnostics); - Future _generateFileNameForTest() 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 fileName = path.split('/').last; + expect(fileName, startsWith('camera_diagnostics_')); + expect(fileName, endsWith('.json')); + expect(await File(path).exists(), isTrue); + }); - final baseName = 'MiniTIAS_$timestamp'; - final candidate = '$baseName.png'; + test('診断内容が JSON として保持される', () async { + final diagnostics = { + 'make': 'TestMake', + 'model': 'TestModel', + 'sensor': { + 'iso_range': [40, 800], + }, + }; + final path = await service.saveDiagnosticsJson(diagnostics); - if (!await File('$_testBasePath/$candidate').exists()) { - return candidate; - } + final jsonStr = await File(path).readAsString(); + final decoded = jsonDecode(jsonStr) as Map; + expect(decoded['make'], 'TestMake'); + expect(decoded['model'], 'TestModel'); + expect((decoded['sensor'] as Map)['iso_range'], [ + 40, + 800, + ]); + }); - var suffix = 1; - while (await File('$_testBasePath/${baseName}_$suffix.png').exists()) { - suffix++; - } - return '${baseName}_$suffix.png'; - } -} + test('インデント付き(pretty print)で書き込まれる', () async { + final diagnostics = {'make': 'TestMake'}; + final path = await service.saveDiagnosticsJson(diagnostics); -/// テスト用: savePreviewJpeg の保存先をオーバーライドする FileService サブクラス. -class _FileServiceTestablePreview extends FileService { - _FileServiceTestablePreview(this._testBasePath); + final jsonStr = await File(path).readAsString(); + // JsonEncoder.withIndent(' ') により改行とインデントを含む + expect(jsonStr, contains('\n')); + expect(jsonStr, contains(' "make"')); + }); - final String _testBasePath; + test('ディレクトリが存在しない場合でも自動作成して保存する', () async { + final nonExistentSubDir = Directory('${tempDir.path}/sub/dir'); + final serviceWithSubDir = FileService( + directoryPath: nonExistentSubDir.path, + ); + expect(await nonExistentSubDir.exists(), isFalse); - @override - String get directoryPath => _testBasePath; - - @override - Future savePreviewJpeg( - String baseName, - Uint8List previewBytes, - ) async { - final directory = Directory(_testBasePath); - if (!await directory.exists()) { - await directory.create(recursive: true); - } - final filePath = '$_testBasePath/$baseName.preview.jpg'; - await File(filePath).writeAsBytes(previewBytes); - return filePath; - } -} - -/// テスト用: saveQuantitativeMetadata の保存先をオーバーライドする FileService サブクラス. -class _FileServiceTestableQuantitative extends FileService { - _FileServiceTestableQuantitative(this._testBasePath); - - final String _testBasePath; - - @override - String get directoryPath => _testBasePath; - - @override - Future saveQuantitativeMetadata( - String baseName, - Map metadata, { - List>? lscMap, - int? lscMapRowCount, - int? lscMapColumnCount, - }) async { - final directory = Directory(_testBasePath); - if (!await directory.exists()) { - await directory.create(recursive: true); - } - - final Map jsonMap = Map.from(metadata); - if (lscMap != null) { - jsonMap['lscMap'] = lscMap; - jsonMap['lscMapRowCount'] = lscMapRowCount; - jsonMap['lscMapColumnCount'] = lscMapColumnCount; - } - - const encoder = JsonEncoder.withIndent(' '); - final filePath = '$_testBasePath/$baseName.meta.json'; - await File(filePath).writeAsString(encoder.convert(jsonMap)); - return filePath; - } + final path = await serviceWithSubDir.saveDiagnosticsJson({'make': 'X'}); + expect(await File(path).exists(), isTrue); + }); + }); }