From 1bf59e02ccc73d7ccc2980b2f19b1d5714ce59c9 Mon Sep 17 00:00:00 2001 From: Ponshu Developer Date: Thu, 23 Apr 2026 07:31:21 +0900 Subject: [PATCH] =?UTF-8?q?fix(stability):=20=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E3=83=8F=E3=83=B3=E3=83=89=E3=83=AA=E3=83=B3=E3=82=B0=E5=BC=B7?= =?UTF-8?q?=E5=8C=96=E3=83=BB=E3=82=AF=E3=83=A9=E3=83=83=E3=82=B7=E3=83=A5?= =?UTF-8?q?=E9=98=B2=E6=AD=A2=20(v1.0.45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backup_service: 復元ループを per-item try-catch に変更 一件のJSONパース失敗でも他のアイテムが正常に復元されるよう修正 rating/markup/score の num→double キャスト安全化も同時適用 - camera_analysis_mixin: cast() を whereType() に変更 旧Hiveデータや型不一致でも新規登録がクラッシュしなくなる - add_set_item_dialog: 空catchブロックにSnackBar通知を追加 画像選択失敗時にユーザーへフィードバックを表示するよう修正 - Image.file() errorBuilder を6ファイルに追加 sake_3d_carousel / sake_3d_carousel_with_reason / sake_search_delegate / sake_detail_sliver_app_bar (×2) / sake_photo_edit_modal / sakenowa_ranking_section — 画像ファイルが削除済みでも黒画面/クラッシュなし - gemini_service: Device ID の debugPrint を kDebugMode ガードに変更 Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/camera_analysis_mixin.dart | 6 +- .../widgets/sake_detail_sliver_app_bar.dart | 8 ++ .../widgets/sake_photo_edit_modal.dart | 6 ++ lib/services/backup_service.dart | 90 ++++++++++--------- lib/services/gemini_service.dart | 2 +- lib/widgets/add_set_item_dialog.dart | 7 +- lib/widgets/sake_3d_carousel.dart | 4 + lib/widgets/sake_3d_carousel_with_reason.dart | 4 + lib/widgets/sake_search_delegate.dart | 7 +- .../sakenowa/sakenowa_ranking_section.dart | 1 + pubspec.yaml | 2 +- 11 files changed, 90 insertions(+), 47 deletions(-) diff --git a/lib/screens/camera_analysis_mixin.dart b/lib/screens/camera_analysis_mixin.dart index 5b7611b..12ad421 100644 --- a/lib/screens/camera_analysis_mixin.dart +++ b/lib/screens/camera_analysis_mixin.dart @@ -184,8 +184,10 @@ mixin CameraAnalysisMixin on ConsumerState // Prepend new item to sort order so it appears at the top final settingsBox = Hive.box('settings'); - final List currentOrder = (settingsBox.get('sake_sort_order') as List?) - ?.cast() ?? []; + final rawOrder = settingsBox.get('sake_sort_order'); + final List currentOrder = (rawOrder is List) + ? rawOrder.whereType().toList() + : []; currentOrder.insert(0, sakeItem.id); await settingsBox.put('sake_sort_order', currentOrder); diff --git a/lib/screens/sake_detail/widgets/sake_detail_sliver_app_bar.dart b/lib/screens/sake_detail/widgets/sake_detail_sliver_app_bar.dart index b19ea15..89602fa 100644 --- a/lib/screens/sake_detail/widgets/sake_detail_sliver_app_bar.dart +++ b/lib/screens/sake_detail/widgets/sake_detail_sliver_app_bar.dart @@ -70,6 +70,10 @@ class SakeDetailSliverAppBar extends StatelessWidget { final imageWidget = Image.file( File(sake.displayData.imagePaths[index]), fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.grey[300], + child: const Icon(Icons.broken_image, size: 60, color: Colors.grey), + ), ); // Apply Hero only to the first image for smooth transition from Grid/List @@ -121,6 +125,10 @@ class SakeDetailSliverAppBar extends StatelessWidget { ? Image.file( File(sake.displayData.imagePaths.first), fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: appColors.surfaceSubtle, + child: Icon(LucideIcons.imageOff, size: 80, color: appColors.iconSubtle), + ), ) : Container( color: appColors.surfaceSubtle, diff --git a/lib/screens/sake_detail/widgets/sake_photo_edit_modal.dart b/lib/screens/sake_detail/widgets/sake_photo_edit_modal.dart index 30bf091..63e1d01 100644 --- a/lib/screens/sake_detail/widgets/sake_photo_edit_modal.dart +++ b/lib/screens/sake_detail/widgets/sake_photo_edit_modal.dart @@ -112,6 +112,12 @@ class _SakePhotoEditModalState extends State { width: 60, height: 60, fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + width: 60, + height: 60, + color: Colors.grey[200], + child: const Icon(Icons.broken_image, size: 24, color: Colors.grey), + ), ), ), title: Text( diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index b7f919d..7b2c5ad 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -455,51 +455,59 @@ class BackupService { final sakeBox = Hive.box('sake_items'); await sakeBox.clear(); + int restoredCount = 0; + int skippedCount = 0; for (var itemData in sakeItemsJson) { - final data = itemData as Map; + try { + final data = itemData as Map; - // JSONからSakeItemオブジェクトを再構築 - final item = SakeItem( - id: data['id'] as String, - displayData: DisplayData( - name: data['displayData']['name'] as String, - brewery: data['displayData']['brewery'] as String, - prefecture: data['displayData']['prefecture'] as String, - catchCopy: data['displayData']['catchCopy'] as String?, - imagePaths: List.from(data['displayData']['imagePaths'] as List), - rating: data['displayData']['rating'] as double?, - ), - hiddenSpecs: HiddenSpecs( - description: data['hiddenSpecs']['description'] as String?, - tasteStats: Map.from(data['hiddenSpecs']['tasteStats'] as Map), - flavorTags: List.from(data['hiddenSpecs']['flavorTags'] as List), - sweetnessScore: data['hiddenSpecs']['sweetnessScore'] as double?, - bodyScore: data['hiddenSpecs']['bodyScore'] as double?, - ), - userData: UserData( - isFavorite: data['userData']['isFavorite'] as bool, - isUserEdited: data['userData']['isUserEdited'] as bool, - price: data['userData']['price'] as int?, - costPrice: data['userData']['costPrice'] as int?, - markup: data['userData']['markup'] as double, - priceVariants: data['userData']['priceVariants'] != null - ? Map.from(data['userData']['priceVariants'] as Map) - : null, - ), - gamification: Gamification( - ponPoints: data['gamification']['ponPoints'] as int, - ), - metadata: Metadata( - createdAt: DateTime.parse(data['metadata']['createdAt'] as String), - aiConfidence: data['metadata']['aiConfidence'] as int?, - ), - itemType: data['itemType'] == 'set' ? ItemType.set : ItemType.sake, - ); + // JSONからSakeItemオブジェクトを再構築 + final item = SakeItem( + id: data['id'] as String, + displayData: DisplayData( + name: data['displayData']['name'] as String, + brewery: data['displayData']['brewery'] as String, + prefecture: data['displayData']['prefecture'] as String, + catchCopy: data['displayData']['catchCopy'] as String?, + imagePaths: List.from(data['displayData']['imagePaths'] as List), + rating: (data['displayData']['rating'] as num?)?.toDouble(), + ), + hiddenSpecs: HiddenSpecs( + description: data['hiddenSpecs']['description'] as String?, + tasteStats: Map.from(data['hiddenSpecs']['tasteStats'] as Map), + flavorTags: List.from(data['hiddenSpecs']['flavorTags'] as List), + sweetnessScore: (data['hiddenSpecs']['sweetnessScore'] as num?)?.toDouble(), + bodyScore: (data['hiddenSpecs']['bodyScore'] as num?)?.toDouble(), + ), + userData: UserData( + isFavorite: data['userData']['isFavorite'] as bool, + isUserEdited: data['userData']['isUserEdited'] as bool, + price: data['userData']['price'] as int?, + costPrice: data['userData']['costPrice'] as int?, + markup: (data['userData']['markup'] as num).toDouble(), + priceVariants: data['userData']['priceVariants'] != null + ? Map.from(data['userData']['priceVariants'] as Map) + : null, + ), + gamification: Gamification( + ponPoints: data['gamification']['ponPoints'] as int, + ), + metadata: Metadata( + createdAt: DateTime.parse(data['metadata']['createdAt'] as String), + aiConfidence: data['metadata']['aiConfidence'] as int?, + ), + itemType: data['itemType'] == 'set' ? ItemType.set : ItemType.sake, + ); - // IDを保持するためにput()を使用(add()は新しいキーを生成してしまう) - await sakeBox.put(item.id, item); + // IDを保持するためにput()を使用(add()は新しいキーを生成してしまう) + await sakeBox.put(item.id, item); + restoredCount++; + } catch (e) { + skippedCount++; + debugPrint('[RESTORE] Skipped malformed item: $e'); + } } - debugPrint('[RESTORE] SakeItems restored (${sakeItemsJson.length} items)'); + debugPrint('[RESTORE] SakeItems restored ($restoredCount items, $skippedCount skipped)'); // UI更新のためにわずかに待機 await Future.delayed(const Duration(milliseconds: 500)); } diff --git a/lib/services/gemini_service.dart b/lib/services/gemini_service.dart index 45ece3f..7d7870b 100644 --- a/lib/services/gemini_service.dart +++ b/lib/services/gemini_service.dart @@ -137,7 +137,7 @@ name・brand を出力する直前に以下を確認してください: // 3. デバイスID取得 final deviceId = await DeviceService.getDeviceId(); - debugPrint('Device ID: $deviceId'); + if (kDebugMode) debugPrint('Device ID: $deviceId'); // 4. リクエスト作成 final requestBody = jsonEncode({ diff --git a/lib/widgets/add_set_item_dialog.dart b/lib/widgets/add_set_item_dialog.dart index ea79b35..f9bdaf0 100644 --- a/lib/widgets/add_set_item_dialog.dart +++ b/lib/widgets/add_set_item_dialog.dart @@ -52,7 +52,12 @@ class _AddSetItemDialogState extends ConsumerState { }); } } catch (e) { - // Handle error + debugPrint('Image pick error: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('画像の選択に失敗しました。再度お試しください。')), + ); + } } } diff --git a/lib/widgets/sake_3d_carousel.dart b/lib/widgets/sake_3d_carousel.dart index 804217f..799a46c 100644 --- a/lib/widgets/sake_3d_carousel.dart +++ b/lib/widgets/sake_3d_carousel.dart @@ -109,6 +109,10 @@ class _Sake3DCarouselState extends State { ? Image.file( File(item.displayData.imagePaths.first), fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.grey[300], + child: const Icon(Icons.broken_image, size: 50, color: Colors.grey), + ), ) : Container( color: Colors.grey[300], diff --git a/lib/widgets/sake_3d_carousel_with_reason.dart b/lib/widgets/sake_3d_carousel_with_reason.dart index 951c8b6..76f4ef6 100644 --- a/lib/widgets/sake_3d_carousel_with_reason.dart +++ b/lib/widgets/sake_3d_carousel_with_reason.dart @@ -164,6 +164,10 @@ class _Sake3DCarouselWithReasonState extends State { ? Image.file( File(rec.item.displayData.imagePaths.first), fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.grey[300], + child: Icon(LucideIcons.imageOff, size: 50, color: Colors.grey[600]), + ), ) : Container( color: Colors.grey[300], diff --git a/lib/widgets/sake_search_delegate.dart b/lib/widgets/sake_search_delegate.dart index 24ab3bc..58a5c83 100644 --- a/lib/widgets/sake_search_delegate.dart +++ b/lib/widgets/sake_search_delegate.dart @@ -70,7 +70,12 @@ class SakeSearchDelegate extends SearchDelegate { width: 40, height: 40, child: sake.displayData.imagePaths.isNotEmpty - ? Image.file(File(sake.displayData.imagePaths.first), fit: BoxFit.cover) + ? Image.file( + File(sake.displayData.imagePaths.first), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(LucideIcons.imageOff), + ) : const Icon(LucideIcons.image), ), title: Text(sake.displayData.displayName), diff --git a/lib/widgets/sakenowa/sakenowa_ranking_section.dart b/lib/widgets/sakenowa/sakenowa_ranking_section.dart index 3d9c4e3..7b35edb 100644 --- a/lib/widgets/sakenowa/sakenowa_ranking_section.dart +++ b/lib/widgets/sakenowa/sakenowa_ranking_section.dart @@ -286,6 +286,7 @@ class _SakenowaRankingSectionState extends ConsumerState Image.file( File(item.userImagePath!), fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => const SizedBox.shrink(), ) else Container( diff --git a/pubspec.yaml b/pubspec.yaml index eb4255a..daabc62 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.44+51 +version: 1.0.45+52 environment: sdk: ^3.10.1