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/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/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); + }); + }); }