fix(stability): エラーハンドリング強化・クラッシュ防止 (v1.0.45)

- backup_service: 復元ループを per-item try-catch に変更
  一件のJSONパース失敗でも他のアイテムが正常に復元されるよう修正
  rating/markup/score の num→double キャスト安全化も同時適用

- camera_analysis_mixin: cast<String>() を whereType<String>() に変更
  旧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 <noreply@anthropic.com>
This commit is contained in:
Ponshu Developer 2026-04-23 07:31:21 +09:00
parent 0fb4f6ea8b
commit 1bf59e02cc
11 changed files with 90 additions and 47 deletions

View File

@ -184,8 +184,10 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
// Prepend new item to sort order so it appears at the top // Prepend new item to sort order so it appears at the top
final settingsBox = Hive.box('settings'); final settingsBox = Hive.box('settings');
final List<String> currentOrder = (settingsBox.get('sake_sort_order') as List<dynamic>?) final rawOrder = settingsBox.get('sake_sort_order');
?.cast<String>() ?? []; final List<String> currentOrder = (rawOrder is List)
? rawOrder.whereType<String>().toList()
: [];
currentOrder.insert(0, sakeItem.id); currentOrder.insert(0, sakeItem.id);
await settingsBox.put('sake_sort_order', currentOrder); await settingsBox.put('sake_sort_order', currentOrder);

View File

@ -70,6 +70,10 @@ class SakeDetailSliverAppBar extends StatelessWidget {
final imageWidget = Image.file( final imageWidget = Image.file(
File(sake.displayData.imagePaths[index]), File(sake.displayData.imagePaths[index]),
fit: BoxFit.cover, 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 // Apply Hero only to the first image for smooth transition from Grid/List
@ -121,6 +125,10 @@ class SakeDetailSliverAppBar extends StatelessWidget {
? Image.file( ? Image.file(
File(sake.displayData.imagePaths.first), File(sake.displayData.imagePaths.first),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: appColors.surfaceSubtle,
child: Icon(LucideIcons.imageOff, size: 80, color: appColors.iconSubtle),
),
) )
: Container( : Container(
color: appColors.surfaceSubtle, color: appColors.surfaceSubtle,

View File

@ -112,6 +112,12 @@ class _SakePhotoEditModalState extends State<SakePhotoEditModal> {
width: 60, width: 60,
height: 60, height: 60,
fit: BoxFit.cover, 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( title: Text(

View File

@ -455,7 +455,10 @@ class BackupService {
final sakeBox = Hive.box<SakeItem>('sake_items'); final sakeBox = Hive.box<SakeItem>('sake_items');
await sakeBox.clear(); await sakeBox.clear();
int restoredCount = 0;
int skippedCount = 0;
for (var itemData in sakeItemsJson) { for (var itemData in sakeItemsJson) {
try {
final data = itemData as Map<String, dynamic>; final data = itemData as Map<String, dynamic>;
// JSONからSakeItemオブジェクトを再構築 // JSONからSakeItemオブジェクトを再構築
@ -467,21 +470,21 @@ class BackupService {
prefecture: data['displayData']['prefecture'] as String, prefecture: data['displayData']['prefecture'] as String,
catchCopy: data['displayData']['catchCopy'] as String?, catchCopy: data['displayData']['catchCopy'] as String?,
imagePaths: List<String>.from(data['displayData']['imagePaths'] as List), imagePaths: List<String>.from(data['displayData']['imagePaths'] as List),
rating: data['displayData']['rating'] as double?, rating: (data['displayData']['rating'] as num?)?.toDouble(),
), ),
hiddenSpecs: HiddenSpecs( hiddenSpecs: HiddenSpecs(
description: data['hiddenSpecs']['description'] as String?, description: data['hiddenSpecs']['description'] as String?,
tasteStats: Map<String, int>.from(data['hiddenSpecs']['tasteStats'] as Map), tasteStats: Map<String, int>.from(data['hiddenSpecs']['tasteStats'] as Map),
flavorTags: List<String>.from(data['hiddenSpecs']['flavorTags'] as List), flavorTags: List<String>.from(data['hiddenSpecs']['flavorTags'] as List),
sweetnessScore: data['hiddenSpecs']['sweetnessScore'] as double?, sweetnessScore: (data['hiddenSpecs']['sweetnessScore'] as num?)?.toDouble(),
bodyScore: data['hiddenSpecs']['bodyScore'] as double?, bodyScore: (data['hiddenSpecs']['bodyScore'] as num?)?.toDouble(),
), ),
userData: UserData( userData: UserData(
isFavorite: data['userData']['isFavorite'] as bool, isFavorite: data['userData']['isFavorite'] as bool,
isUserEdited: data['userData']['isUserEdited'] as bool, isUserEdited: data['userData']['isUserEdited'] as bool,
price: data['userData']['price'] as int?, price: data['userData']['price'] as int?,
costPrice: data['userData']['costPrice'] as int?, costPrice: data['userData']['costPrice'] as int?,
markup: data['userData']['markup'] as double, markup: (data['userData']['markup'] as num).toDouble(),
priceVariants: data['userData']['priceVariants'] != null priceVariants: data['userData']['priceVariants'] != null
? Map<String, int>.from(data['userData']['priceVariants'] as Map) ? Map<String, int>.from(data['userData']['priceVariants'] as Map)
: null, : null,
@ -498,8 +501,13 @@ class BackupService {
// IDを保持するためにput()使add() // IDを保持するためにput()使add()
await sakeBox.put(item.id, item); 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更新のためにわずかに待機 // UI更新のためにわずかに待機
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
} }

View File

@ -137,7 +137,7 @@ name・brand を出力する直前に以下を確認してください:
// 3. ID取得 // 3. ID取得
final deviceId = await DeviceService.getDeviceId(); final deviceId = await DeviceService.getDeviceId();
debugPrint('Device ID: $deviceId'); if (kDebugMode) debugPrint('Device ID: $deviceId');
// 4. // 4.
final requestBody = jsonEncode({ final requestBody = jsonEncode({

View File

@ -52,7 +52,12 @@ class _AddSetItemDialogState extends ConsumerState<AddSetItemDialog> {
}); });
} }
} catch (e) { } catch (e) {
// Handle error debugPrint('Image pick error: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('画像の選択に失敗しました。再度お試しください。')),
);
}
} }
} }

View File

@ -109,6 +109,10 @@ class _Sake3DCarouselState extends State<Sake3DCarousel> {
? Image.file( ? Image.file(
File(item.displayData.imagePaths.first), File(item.displayData.imagePaths.first),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.grey[300],
child: const Icon(Icons.broken_image, size: 50, color: Colors.grey),
),
) )
: Container( : Container(
color: Colors.grey[300], color: Colors.grey[300],

View File

@ -164,6 +164,10 @@ class _Sake3DCarouselWithReasonState extends State<Sake3DCarouselWithReason> {
? Image.file( ? Image.file(
File(rec.item.displayData.imagePaths.first), File(rec.item.displayData.imagePaths.first),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.grey[300],
child: Icon(LucideIcons.imageOff, size: 50, color: Colors.grey[600]),
),
) )
: Container( : Container(
color: Colors.grey[300], color: Colors.grey[300],

View File

@ -70,7 +70,12 @@ class SakeSearchDelegate extends SearchDelegate {
width: 40, width: 40,
height: 40, height: 40,
child: sake.displayData.imagePaths.isNotEmpty 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), : const Icon(LucideIcons.image),
), ),
title: Text(sake.displayData.displayName), title: Text(sake.displayData.displayName),

View File

@ -286,6 +286,7 @@ class _SakenowaRankingSectionState extends ConsumerState<SakenowaRankingSection>
Image.file( Image.file(
File(item.userImagePath!), File(item.userImagePath!),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => const SizedBox.shrink(),
) )
else else
Container( Container(

View File

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.10.1 sdk: ^3.10.1