diff --git a/lib/models/sake_item.dart b/lib/models/sake_item.dart index c3d6c98..49113d9 100644 --- a/lib/models/sake_item.dart +++ b/lib/models/sake_item.dart @@ -157,12 +157,20 @@ class SakeItem extends HiveObject { ); } - // Allow setting for UI updates (呼び出し元で必ず await sakeItem.save() すること) + /// displayData/hiddenSpecs をまとめて更新して即座にHiveへ保存する。 + /// setterを直接使わずこのメソッドを使うこと。 + Future applyUpdates({ + DisplayData? displayData, + HiddenSpecs? hiddenSpecs, + }) async { + if (displayData != null) _displayData = displayData; + if (hiddenSpecs != null) _hiddenSpecs = hiddenSpecs; + await save(); + } + + @Deprecated('Use applyUpdates() instead to ensure save() is always called.') set displayData(DisplayData val) { _displayData = val; - // save() はここで呼ばない。setter は同期のため await できず、 - // unawaited save() はデータ消失リスクがある。 - // 呼び出し元(sakenowa_auto_matching_service.dart)で明示的に await save() する。 } HiddenSpecs get hiddenSpecs { @@ -176,7 +184,7 @@ class SakeItem extends HiveObject { ); } - // Allow setting for さけのわ auto-matching + @Deprecated('Use applyUpdates() instead to ensure save() is always called.') set hiddenSpecs(HiddenSpecs val) { _hiddenSpecs = val; } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 1fb79b4..94ce523 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -184,11 +184,11 @@ class HomeScreen extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(LucideIcons.clipboardCheck, size: 60, color: Colors.grey[400]), + Icon(LucideIcons.clipboardCheck, size: 60, color: appColors.iconSubtle), const SizedBox(height: 16), Text(t['noMenuItems'], style: const TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), - Text(t['goBackToList'], textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey)), + Text(t['goBackToList'], textAlign: TextAlign.center, style: TextStyle(color: appColors.textSecondary)), ], ), ); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index d239d85..5c61f7d 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -65,7 +65,7 @@ class _MainScreenState extends ConsumerState { clipBehavior: Clip.none, alignment: Alignment.center, children: [ - Icon(featureIcon, size: 48, color: Colors.grey.shade400), + Icon(featureIcon, size: 48, color: appColors.iconSubtle), Positioned( right: -8, top: -8, diff --git a/lib/screens/menu_pricing_screen.dart b/lib/screens/menu_pricing_screen.dart index acec108..7009a07 100644 --- a/lib/screens/menu_pricing_screen.dart +++ b/lib/screens/menu_pricing_screen.dart @@ -123,7 +123,7 @@ class _MenuPricingScreenState extends ConsumerState { preferredSize: const Size.fromHeight(2), child: LinearProgressIndicator( value: 2 / 3, // Step 2 of 3 = 66% - backgroundColor: Colors.grey[200], + backgroundColor: Theme.of(context).extension()!.surfaceSubtle, valueColor: AlwaysStoppedAnimation(Theme.of(context).primaryColor), minHeight: 2, ), @@ -320,9 +320,9 @@ class _MenuPricingScreenState extends ConsumerState { // Drag Handle ReorderableDragStartListener( index: index, - child: const Padding( - padding: EdgeInsets.only(right: 12, top: 4, bottom: 4), - child: Icon(Icons.drag_indicator, color: Colors.grey), + child: Padding( + padding: const EdgeInsets.only(right: 12, top: 4, bottom: 4), + child: Icon(Icons.drag_indicator, color: appColors.iconSubtle), ), ), Expanded( diff --git a/lib/screens/pending_analysis_screen.dart b/lib/screens/pending_analysis_screen.dart index 5b5b6e8..b2ad4e2 100644 --- a/lib/screens/pending_analysis_screen.dart +++ b/lib/screens/pending_analysis_screen.dart @@ -363,10 +363,10 @@ class _PendingAnalysisScreenState extends ConsumerState { width: 60, height: 60, decoration: BoxDecoration( - color: Colors.grey.shade300, + color: appColors.divider, borderRadius: BorderRadius.circular(8), ), - child: const Icon(LucideIcons.image, color: Colors.grey), + child: Icon(LucideIcons.image, color: appColors.iconSubtle), ), title: const Text( '解析待ち', @@ -424,7 +424,7 @@ class _PendingAnalysisScreenState extends ConsumerState { style: ElevatedButton.styleFrom( backgroundColor: appColors.brandPrimary, foregroundColor: Colors.white, - disabledBackgroundColor: Colors.grey.shade400, + disabledBackgroundColor: appColors.textTertiary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), diff --git a/lib/services/sakenowa_auto_matching_service.dart b/lib/services/sakenowa_auto_matching_service.dart index c7074f6..a9cc664 100644 --- a/lib/services/sakenowa_auto_matching_service.dart +++ b/lib/services/sakenowa_auto_matching_service.dart @@ -159,12 +159,10 @@ class SakenowaAutoMatchingService { sakenowaFlavorChart: flavorChartMap, ); - // SakeItem更新 - sakeItem.displayData = updatedDisplayData; - sakeItem.hiddenSpecs = updatedHiddenSpecs; - - // Hiveに保存 - await sakeItem.save(); + await sakeItem.applyUpdates( + displayData: updatedDisplayData, + hiddenSpecs: updatedHiddenSpecs, + ); debugPrint(' [SakenowaAutoMatching] 適用完了!'); debugPrint(' displayName: ${sakeItem.displayData.displayName}'); @@ -226,10 +224,10 @@ class SakenowaAutoMatchingService { sakenowaFlavorChart: null, ); - sakeItem.displayData = clearedDisplayData; - sakeItem.hiddenSpecs = clearedHiddenSpecs; - - await sakeItem.save(); + await sakeItem.applyUpdates( + displayData: clearedDisplayData, + hiddenSpecs: clearedHiddenSpecs, + ); debugPrint(' [SakenowaAutoMatching] クリア完了'); } diff --git a/lib/widgets/map/prefecture_tile_map.dart b/lib/widgets/map/prefecture_tile_map.dart index 8a54627..a4c8e78 100644 --- a/lib/widgets/map/prefecture_tile_map.dart +++ b/lib/widgets/map/prefecture_tile_map.dart @@ -76,7 +76,7 @@ class PrefectureTileMap extends ConsumerWidget { if (v.contains(prefName)) prefId = k; }); final regionId = JapanMapData.getRegionId(prefId); - final regionColor = regionColors[regionId] ?? Colors.grey; + final regionColor = regionColors[regionId] ?? appColors.divider; Color baseColor; Color textColor; diff --git a/lib/widgets/sake_detail/sake_detail_specs.dart b/lib/widgets/sake_detail/sake_detail_specs.dart index 5b0c9ff..fc69194 100644 --- a/lib/widgets/sake_detail/sake_detail_specs.dart +++ b/lib/widgets/sake_detail/sake_detail_specs.dart @@ -75,9 +75,9 @@ class _SakeDetailSpecsState extends State { if (_isEditing) { // Warn user about external update while editing ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'), - backgroundColor: Colors.orange, + SnackBar( + content: const Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'), + backgroundColor: Theme.of(context).extension()!.warning, ), ); _cancel(); // Force exit edit mode diff --git a/lib/widgets/settings/backup_settings_section.dart b/lib/widgets/settings/backup_settings_section.dart index c8c25bb..e19f079 100644 --- a/lib/widgets/settings/backup_settings_section.dart +++ b/lib/widgets/settings/backup_settings_section.dart @@ -86,6 +86,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring } Future _createBackup() async { final messenger = ScaffoldMessenger.of(context); + final appColors = Theme.of(context).extension()!; setState(() => _state = _BackupState.backingUp); final success = await _backupService.createBackup(); if (mounted) { @@ -93,11 +94,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring } messenger.showSnackBar( SnackBar( content: Text(success ? 'バックアップが完了しました' : 'バックアップに失敗しました'), - // Snackbars can keep Green/Red for semantic clarity, or be neutral. - // User asked to remove Green/Red icons from the UI, but feedback (Snackbar) usually stays semantic. - // However, to be safe and "Washi", let's use Sumi (Black) for success? - // Or just leave snackbars as they are ephemeral. The request was likely about the visible static UI. - backgroundColor: success ? Colors.green : Colors.red, + backgroundColor: success ? appColors.success : appColors.error, ), ); } @@ -184,7 +181,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring } messenger.showSnackBar( SnackBar( content: Text(success ? '復元が完了しました' : '復元に失敗しました'), - backgroundColor: success ? Colors.green : Colors.red, + backgroundColor: success ? appColors.success : appColors.error, ), ); } diff --git a/test/models/sake_item_test.dart b/test/models/sake_item_test.dart index 17c5a89..a73df8e 100644 --- a/test/models/sake_item_test.dart +++ b/test/models/sake_item_test.dart @@ -137,6 +137,57 @@ void main() { }); }); + group('SakeItem - ensureMigrated', () { + test('レガシーフィールドからdisplayDataを構築する', () { + final sake = SakeItem( + id: 'legacy-001', + legacyName: '出羽桜', + legacyBrand: '出羽桜酒造', + legacyPrefecture: '山形県', + legacyCatchCopy: '花と夢と', + legacyImagePaths: ['/path/legacy.jpg'], + ); + + // displayData未設定の状態でgetterがlegacyから返す + expect(sake.displayData.name, '出羽桜'); + expect(sake.displayData.brewery, '出羽桜酒造'); + expect(sake.displayData.prefecture, '山形県'); + + // ensureMigratedで新構造に昇格 + final migrated = sake.ensureMigrated(); + expect(migrated, true); // 移行が実行された + + // 2回目はfalseを返す(既に移行済み) + final again = sake.ensureMigrated(); + expect(again, false); + }); + + test('displayDataが既に設定されている場合はfalseを返す', () { + final sake = SakeItem( + id: 'new-001', + displayData: DisplayData( + name: '新政', + brewery: '新政酒造', + prefecture: '秋田県', + imagePaths: [], + ), + ); + + final migrated = sake.ensureMigrated(); + expect(migrated, false); + }); + + test('legacyNameがnullの場合はUnknownにフォールバックする', () { + final sake = SakeItem(id: 'legacy-002'); + + sake.ensureMigrated(); + + expect(sake.displayData.name, 'Unknown'); + expect(sake.displayData.brewery, 'Unknown'); + expect(sake.displayData.prefecture, 'Unknown'); + }); + }); + group('SakeItem - HiddenSpecs / TasteStats', () { test('should return SakeTasteStats from tasteStats map', () { final sake = SakeItem( diff --git a/test/services/gemini_service_test.dart b/test/services/gemini_service_test.dart new file mode 100644 index 0000000..f867169 --- /dev/null +++ b/test/services/gemini_service_test.dart @@ -0,0 +1,158 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ponshu_room_lite/services/gemini_service.dart'; + +void main() { + group('SakeAnalysisResult.fromJson', () { + test('正常なJSONから全フィールドを正しくパースする', () { + final json = { + 'name': '獺祭', + 'brand': '旭酒造', + 'prefecture': '山口県', + 'type': '純米大吟醸', + 'description': 'フルーティーで華やかな香り', + 'catchCopy': '磨きその先へ', + 'confidenceScore': 92, + 'flavorTags': ['フルーティー', '辛口'], + 'tasteStats': {'aroma': 5, 'sweetness': 2, 'acidity': 3, 'bitterness': 2, 'body': 3}, + 'alcoholContent': 16.0, + 'polishingRatio': 23, + 'sakeMeterValue': 3.0, + 'riceVariety': '山田錦', + 'yeast': 'きょうかい9号', + 'manufacturingYearMonth': '2024.01', + }; + + final result = SakeAnalysisResult.fromJson(json); + + expect(result.name, '獺祭'); + expect(result.brand, '旭酒造'); + expect(result.prefecture, '山口県'); + expect(result.type, '純米大吟醸'); + expect(result.confidenceScore, 92); + expect(result.flavorTags, ['フルーティー', '辛口']); + expect(result.alcoholContent, 16.0); + expect(result.polishingRatio, 23); + expect(result.riceVariety, '山田錦'); + expect(result.manufacturingYearMonth, '2024.01'); + expect(result.isFromCache, false); + }); + + test('フィールドが全てnullの場合にデフォルト値が設定される', () { + final result = SakeAnalysisResult.fromJson({}); + + expect(result.name, isNull); + expect(result.brand, isNull); + expect(result.prefecture, isNull); + expect(result.flavorTags, isEmpty); + expect(result.isFromCache, false); + }); + + test('tasteStats: 範囲外の値(0, 6)が1〜5にクランプされる', () { + final json = { + 'tasteStats': { + 'aroma': 0, + 'sweetness': 6, + 'acidity': -1, + 'bitterness': 10, + 'body': 3, + }, + }; + + final result = SakeAnalysisResult.fromJson(json); + + expect(result.tasteStats['aroma'], 1); + expect(result.tasteStats['sweetness'], 5); + expect(result.tasteStats['acidity'], 1); + expect(result.tasteStats['bitterness'], 5); + expect(result.tasteStats['body'], 3); + }); + + test('tasteStats: 一部キーが欠損していると3で補完される', () { + final json = { + 'tasteStats': { + 'aroma': 5, + // sweetness, acidity, bitterness, body が欠損 + }, + }; + + final result = SakeAnalysisResult.fromJson(json); + + expect(result.tasteStats['aroma'], 5); + expect(result.tasteStats['sweetness'], 3); + expect(result.tasteStats['acidity'], 3); + expect(result.tasteStats['bitterness'], 3); + expect(result.tasteStats['body'], 3); + }); + + test('tasteStats: nullまたは不正な型の場合は全キーが3になる', () { + final json = {'tasteStats': null}; + final result = SakeAnalysisResult.fromJson(json); + + expect(result.tasteStats['aroma'], 3); + expect(result.tasteStats['sweetness'], 3); + expect(result.tasteStats['body'], 3); + }); + + test('alcoholContent: intで渡された場合もdoubleとして取得できる', () { + final json = {'alcoholContent': 15}; + final result = SakeAnalysisResult.fromJson(json); + expect(result.alcoholContent, 15.0); + }); + + test('flavorTags: nullの場合は空リストになる', () { + final json = {'flavorTags': null}; + final result = SakeAnalysisResult.fromJson(json); + expect(result.flavorTags, isEmpty); + }); + }); + + group('SakeAnalysisResult.asCached', () { + test('asCached()はisFromCache=trueを返す', () { + final original = SakeAnalysisResult(name: '獺祭', brand: '旭酒造'); + final cached = original.asCached(); + + expect(cached.isFromCache, true); + expect(cached.name, '獺祭'); + expect(cached.brand, '旭酒造'); + }); + + test('元のインスタンスはisFromCache=falseを維持する', () { + final original = SakeAnalysisResult(name: '久保田'); + original.asCached(); + expect(original.isFromCache, false); + }); + }); + + group('SakeAnalysisResult.toJson / fromJson 往復', () { + test('toJson → fromJson で値が保持される', () { + final original = SakeAnalysisResult( + name: '八海山', + brand: '八海醸造', + prefecture: '新潟県', + type: '特別本醸造', + confidenceScore: 80, + flavorTags: ['辛口', 'すっきり'], + tasteStats: {'aroma': 2, 'sweetness': 2, 'acidity': 3, 'bitterness': 3, 'body': 3}, + alcoholContent: 15.5, + polishingRatio: 55, + ); + + final json = original.toJson(); + final restored = SakeAnalysisResult.fromJson(json); + + expect(restored.name, original.name); + expect(restored.brand, original.brand); + expect(restored.prefecture, original.prefecture); + expect(restored.confidenceScore, original.confidenceScore); + expect(restored.flavorTags, original.flavorTags); + expect(restored.alcoholContent, original.alcoholContent); + expect(restored.polishingRatio, original.polishingRatio); + }); + + test('toJson に isFromCache は含まれない', () { + final result = SakeAnalysisResult(name: 'テスト').asCached(); + final json = result.toJson(); + expect(json.containsKey('isFromCache'), false); + }); + }); +}