Compare commits

..

13 Commits

Author SHA1 Message Date
Ponshu Developer 5d8689b7ee merge: claude/sync-cursor-history-cSlsP — SakeItem setter廃止 + セマンティックカラー置換
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 13:09:56 +09:00
Claude a62bcd1d11
refactor: ハードコード色をAppColorsセマンティックカラーに置換
意図的な箇所(カメラUI・AppBar白テキスト・ハート色・Proバッジ等)はKEEP。
以下のUIロジックに影響しない7ファイルのみ変更:

- Colors.grey.shade400/[400] → appColors.iconSubtle (アイコン、無効状態)
- Colors.grey.shade300 → appColors.divider (プレースホルダー背景)
- Colors.grey → appColors.textSecondary / iconSubtle / divider (文脈別)
- Colors.grey[200] → appColors.surfaceSubtle (プログレスバー背景)
- Colors.orange → appColors.warning (警告スナックバー)
- Colors.green / Colors.red → appColors.success / error (完了・失敗スナックバー)

_createBackup()にappColorsをasync前にキャプチャするFlutterベストプラクティスを適用。
コメント化されていたデッドコメントも同時削除。

https://claude.ai/code/session_01DWQpnqrQWwxVKKWSL9kDPp
2026-04-16 23:50:39 +00:00
Claude 8ebd233305
refactor: SakeItemのdisplayData setter危険性を排除し、テストを追加
- SakeItem.applyUpdates()を追加(displayData/hiddenSpecsを1回のsave()でアトミックに更新)
- displayData/hiddenSpecs setterに@Deprecatedを付与(save()忘れによるデータ消失防止)
- sakenowa_auto_matching_service.dartをapplyUpdates()に移行(setterの直接使用を撲滅)
- SakeAnalysisResult.fromJson()のユニットテストを新規追加(tasteStatsクランプ・欠損補完等)
- SakeItem.ensureMigrated()のユニットテストを追加

https://claude.ai/code/session_01DWQpnqrQWwxVKKWSL9kDPp
2026-04-16 23:40:48 +00:00
Ponshu Developer 05c27d9cdf chore: update download page to v1.0.42
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 21:12:18 +09:00
Ponshu Developer cc5175ebae refactor: ExposureSliderPainter を別ファイルに切り出し
camera_screen.dart の末尾にあった _ExposureSliderPainter (60行) を
lib/screens/camera_exposure_painter.dart に分離。
動作変更なし。718行 → 659行。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 21:08:30 +09:00
Ponshu Developer b7f5edf9a9 chore: update download page to v1.0.41
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 18:01:03 +09:00
Ponshu Developer ac2a54d07a feat: AI使用回数トラッキング + クォータ上限時ドラフト保存
- ApiUsageService: SharedPreferences で Gemini 日次使用回数を追跡
  - UTC 08:00(=日本時間 17:00)でリセット
  - 上限 20回/日(プロジェクトあたりの無料枠)
- DraftReason enum: offline / quotaLimit / congestion を区別
- camera_analysis_mixin: 解析前にクォータを事前チェック
  - 上限到達時は Draft 保存してカメラを閉じる(写真は失われない)
  - 429 エラー時も同様に Draft 保存(従来はエラー表示のみで写真消失)
  - API 呼び出し成功時(キャッシュ除く)にカウントアップ
- pending_analysis_screen: ドラフト理由を各アイテムに表示
  - クォータ: リセット時刻つきの警告(オレンジ)
  - 混雑 / オフライン: 理由別メッセージ
- ActivityStats: AI 使用状況 bento カードを追加
  - 今日のAI解析 X / 20回 + プログレスバー
  - 残り5回以下でオレンジ、上限到達で赤

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:58:00 +09:00
Ponshu Developer d39db78c80 chore: update download page to v1.0.40
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:04:01 +09:00
Ponshu Developer 4e6ff6d6e9 chore: bump version to 1.0.40+47
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:01:27 +09:00
Ponshu Developer 68723a884e feat: 記録日時を一覧カードと詳細画面に表示
- sake_list_item: カード末尾に相対日付を追加
  今日 / 昨日 / N日前 / M/D / YYYY/M/D
- sake_basic_info_section: 蔵元行直下にカレンダーアイコン + 「YYYY年M月D日に記録」を追加
  セット商品は非表示
- metadata.createdAt(非null保証)を使用、追加フィールド不要

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:01:17 +09:00
Ponshu Developer fad896e817 chore: update download page to v1.0.39 (actual APK sizes)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:40:23 +09:00
Ponshu Developer 26183e458e chore: update download page to v1.0.39
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:27:01 +09:00
Ponshu Developer cad2855b6e chore: bump version to 1.0.39+46
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:24:58 +09:00
21 changed files with 666 additions and 115 deletions

View File

@ -157,12 +157,20 @@ class SakeItem extends HiveObject {
); );
} }
// Allow setting for UI updates ( await sakeItem.save() ) /// displayData/hiddenSpecs Hiveへ保存する
/// setterを直接使わずこのメソッドを使うこと
Future<void> 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) { set displayData(DisplayData val) {
_displayData = val; _displayData = val;
// save() setter await
// unawaited save()
// sakenowa_auto_matching_service.dart await save()
} }
HiddenSpecs get hiddenSpecs { 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) { set hiddenSpecs(HiddenSpecs val) {
_hiddenSpecs = val; _hiddenSpecs = val;
} }

View File

@ -8,6 +8,7 @@ import '../models/sake_item.dart';
import '../providers/gemini_provider.dart'; import '../providers/gemini_provider.dart';
import '../providers/sakenowa_providers.dart'; import '../providers/sakenowa_providers.dart';
import '../providers/theme_provider.dart'; import '../providers/theme_provider.dart';
import '../services/api_usage_service.dart';
import '../services/draft_service.dart'; import '../services/draft_service.dart';
import '../services/gamification_service.dart'; import '../services/gamification_service.dart';
import '../services/gemini_exceptions.dart'; import '../services/gemini_exceptions.dart';
@ -38,7 +39,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
debugPrint('Offline detected: Saving as draft...'); debugPrint('Offline detected: Saving as draft...');
try { try {
await DraftService.saveDraft(capturedImages); await DraftService.saveDraft(capturedImages, reason: DraftReason.offline);
if (!mounted) return; if (!mounted) return;
@ -77,6 +78,49 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
} }
} }
// 20/UTC 08:00
final isQuotaExhausted = await ApiUsageService.isExhausted();
if (isQuotaExhausted) {
debugPrint('Quota exhausted: Saving as draft...');
try {
await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit);
if (!mounted) return;
final resetTime = ApiUsageService.getNextResetTime();
final resetStr = '${resetTime.hour}:${resetTime.minute.toString().padLeft(2, '0')}';
messenger.showSnackBar(
SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(LucideIcons.zap, color: Colors.orange, size: 16),
SizedBox(width: 8),
Text('本日のAI解析上限20回に達しました',
style: TextStyle(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 4),
const Text('写真を「解析待ち」として保存しました。'),
Text('$resetStr 以降にホーム画面から解析できます。'),
],
),
duration: const Duration(seconds: 6),
backgroundColor: Colors.orange,
),
);
navigator.pop(); //
} catch (e) {
debugPrint('Draft save error (quota): $e');
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('保存エラー: $e')),
);
}
return;
}
// : // :
if (!mounted) return; if (!mounted) return;
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
@ -125,6 +169,12 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
await box.add(sakeItem); await box.add(sakeItem);
// API 使 API
if (!result.isFromCache) {
await ApiUsageService.increment();
ref.invalidate(apiUsageCountProvider);
}
// //
// //
_performSakenowaMatching(sakeItem).catchError((error) { _performSakenowaMatching(sakeItem).catchError((error) {
@ -243,7 +293,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
// AIサーバー混雑503 // AIサーバー混雑503
if (e is GeminiCongestionException) { if (e is GeminiCongestionException) {
try { try {
await DraftService.saveDraft(capturedImages); await DraftService.saveDraft(capturedImages, reason: DraftReason.congestion);
if (!mounted) return; if (!mounted) return;
navigator.pop(); // Close camera screen navigator.pop(); // Close camera screen
messenger.showSnackBar( messenger.showSnackBar(
@ -278,12 +328,46 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
return; return;
} }
// Quota 429 // Quota 429
final errStr = e.toString(); final errStr = e.toString();
if (errStr.contains('Quota') || errStr.contains('429')) { if (errStr.contains('Quota') || errStr.contains('429')) {
setState(() { try {
quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1)); await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit);
}); if (!mounted) return;
navigator.pop(); //
final resetTime = ApiUsageService.getNextResetTime();
final resetStr = '${resetTime.hour}:${resetTime.minute.toString().padLeft(2, '0')}';
messenger.showSnackBar(
SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(LucideIcons.zap, color: Colors.orange, size: 16),
SizedBox(width: 8),
Text('本日のAI解析上限20回に達しました',
style: TextStyle(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 4),
const Text('写真を「解析待ち」として保存しました。'),
Text('$resetStr 以降にホーム画面から解析できます。'),
],
),
duration: const Duration(seconds: 6),
backgroundColor: Colors.orange,
),
);
} catch (draftError) {
debugPrint('Draft save failed after 429: $draftError');
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),
);
}
return;
} }
final appColors = Theme.of(context).extension<AppColors>()!; final appColors = Theme.of(context).extension<AppColors>()!;

View File

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
/// CustomPainter
///
/// -
/// - 0 EV
/// -
class ExposureSliderPainter extends CustomPainter {
final double currentValue;
final double minValue;
final double maxValue;
const ExposureSliderPainter({
required this.currentValue,
required this.minValue,
required this.maxValue,
});
@override
void paint(Canvas canvas, Size size) {
final trackPaint = Paint()
..color = Colors.white.withValues(alpha: 0.3)
..strokeWidth = 4
..strokeCap = StrokeCap.round;
final centerLinePaint = Paint()
..color = Colors.white54
..strokeWidth = 2;
final knobPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
final knobShadowPaint = Paint()
..color = Colors.black26
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
//
final trackX = size.width / 2;
canvas.drawLine(
Offset(trackX, 10),
Offset(trackX, size.height - 10),
trackPaint,
);
// 0 EV
canvas.drawLine(
Offset(trackX - 6, size.height / 2),
Offset(trackX + 6, size.height / 2),
centerLinePaint,
);
//
final range = maxValue - minValue;
if (range > 0) {
// minValue() 0.0maxValue() 1.0 Y座標に変換
final normalized = (currentValue - minValue) / range;
final knobY = (size.height - 20) * (1.0 - normalized) + 10;
canvas.drawCircle(Offset(trackX, knobY), 7, knobShadowPaint);
canvas.drawCircle(Offset(trackX, knobY), 6, knobPaint);
}
}
@override
bool shouldRepaint(ExposureSliderPainter oldDelegate) {
return oldDelegate.currentValue != currentValue ||
oldDelegate.minValue != minValue ||
oldDelegate.maxValue != maxValue;
}
}

View File

@ -13,6 +13,7 @@ import 'package:image_picker/image_picker.dart'; // Gallery & Camera
import '../services/image_compression_service.dart'; import '../services/image_compression_service.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'camera_analysis_mixin.dart'; import 'camera_analysis_mixin.dart';
import 'camera_exposure_painter.dart';
enum CameraMode { enum CameraMode {
@ -475,8 +476,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
height: 180, height: 180,
width: 48, // Wider for easier tapping width: 48, // Wider for easier tapping
child: CustomPaint( child: CustomPaint(
key: ValueKey(_currentExposureOffset), // Force repaint on value change key: ValueKey(_currentExposureOffset),
painter: _ExposureSliderPainter( painter: ExposureSliderPainter(
currentValue: _currentExposureOffset, currentValue: _currentExposureOffset,
minValue: _minExposure, minValue: _minExposure,
maxValue: _maxExposure, maxValue: _maxExposure,
@ -656,63 +657,3 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
} }
// Custom Painter for Exposure Slider
class _ExposureSliderPainter extends CustomPainter {
final double currentValue;
final double minValue;
final double maxValue;
_ExposureSliderPainter({
required this.currentValue,
required this.minValue,
required this.maxValue,
});
@override
void paint(Canvas canvas, Size size) {
final trackPaint = Paint()
..color = Colors.white.withValues(alpha: 0.3)
..strokeWidth = 4
..strokeCap = StrokeCap.round;
final centerLinePaint = Paint()
..color = Colors.white54
..strokeWidth = 2;
final knobPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill; final knobShadowPaint = Paint()
..color = Colors.black26
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); // Draw vertical track (centered)
final trackX = size.width / 2;
canvas.drawLine(
Offset(trackX, 10),
Offset(trackX, size.height - 10),
trackPaint,
); // Draw center marker
canvas.drawLine(
Offset(trackX - 6, size.height / 2),
Offset(trackX + 6, size.height / 2),
centerLinePaint,
); // Calculate knob position
final range = maxValue - minValue;
if (range > 0) {
// Normalize currentValue to 0.0-1.0 range
// minValue (e.g., -4.0) -> 0.0 (bottom)
// 0.0 (center) -> 0.5 (middle)
// maxValue (e.g., +4.0) -> 1.0 (top)
final normalized = (currentValue - minValue) / range;
// Map to Y coordinate: 0.0 (normalized) -> bottom, 1.0 (normalized) -> top
final knobY = (size.height - 20) * (1.0 - normalized) + 10;
// Draw knob shadow
canvas.drawCircle(Offset(trackX, knobY), 7, knobShadowPaint);
// Draw knob
canvas.drawCircle(Offset(trackX, knobY), 6, knobPaint);
}
} @override
bool shouldRepaint(_ExposureSliderPainter oldDelegate) {
return oldDelegate.currentValue != currentValue ||
oldDelegate.minValue != minValue ||
oldDelegate.maxValue != maxValue;
}
}

View File

@ -184,11 +184,11 @@ class HomeScreen extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(LucideIcons.clipboardCheck, size: 60, color: Colors.grey[400]), Icon(LucideIcons.clipboardCheck, size: 60, color: appColors.iconSubtle),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(t['noMenuItems'], style: const TextStyle(fontWeight: FontWeight.bold)), Text(t['noMenuItems'], style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8), 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)),
], ],
), ),
); );

View File

@ -65,7 +65,7 @@ class _MainScreenState extends ConsumerState<MainScreen> {
clipBehavior: Clip.none, clipBehavior: Clip.none,
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
Icon(featureIcon, size: 48, color: Colors.grey.shade400), Icon(featureIcon, size: 48, color: appColors.iconSubtle),
Positioned( Positioned(
right: -8, right: -8,
top: -8, top: -8,

View File

@ -123,7 +123,7 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
preferredSize: const Size.fromHeight(2), preferredSize: const Size.fromHeight(2),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: 2 / 3, // Step 2 of 3 = 66% value: 2 / 3, // Step 2 of 3 = 66%
backgroundColor: Colors.grey[200], backgroundColor: Theme.of(context).extension<AppColors>()!.surfaceSubtle,
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor), valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
minHeight: 2, minHeight: 2,
), ),
@ -320,9 +320,9 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
// Drag Handle // Drag Handle
ReorderableDragStartListener( ReorderableDragStartListener(
index: index, index: index,
child: const Padding( child: Padding(
padding: EdgeInsets.only(right: 12, top: 4, bottom: 4), padding: const EdgeInsets.only(right: 12, top: 4, bottom: 4),
child: Icon(Icons.drag_indicator, color: Colors.grey), child: Icon(Icons.drag_indicator, color: appColors.iconSubtle),
), ),
), ),
Expanded( Expanded(

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../models/sake_item.dart'; import '../models/sake_item.dart';
import '../providers/gemini_provider.dart'; import '../providers/gemini_provider.dart';
import '../services/api_usage_service.dart';
import '../services/draft_service.dart'; import '../services/draft_service.dart';
import '../services/network_service.dart'; import '../services/network_service.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
@ -54,6 +55,24 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
} }
} }
///
String _getDraftNote(SakeItem draft) {
final desc = draft.hiddenSpecs.description ?? '';
switch (desc) {
case 'quota':
final resetTime = ApiUsageService.getNextResetTime();
final resetStr = '${resetTime.hour}:${resetTime.minute.toString().padLeft(2, '0')}';
return 'AI上限20回/日)に達したため保留中。次のリセット $resetStr 以降に解析可';
case 'congestion':
return 'AIサーバー混雑のため保留中。「すべて解析」で再試行できます';
default:
return 'オフライン時に保存。オンライン接続後に解析できます';
}
}
bool _isQuotaDraft(SakeItem draft) =>
draft.hiddenSpecs.description == 'quota';
Future<void> _analyzeAllDrafts() async { Future<void> _analyzeAllDrafts() async {
final appColors = Theme.of(context).extension<AppColors>()!; final appColors = Theme.of(context).extension<AppColors>()!;
@ -344,18 +363,33 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
width: 60, width: 60,
height: 60, height: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: appColors.divider,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: const Icon(LucideIcons.image, color: Colors.grey), child: Icon(LucideIcons.image, color: appColors.iconSubtle),
), ),
title: const Text( title: const Text(
'解析待ち', '解析待ち',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: Text( subtitle: Column(
'撮影日時: ${draft.metadata.createdAt.toString().substring(0, 16)}', crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle(fontSize: 12, color: appColors.textSecondary), children: [
Text(
'撮影日時: ${draft.metadata.createdAt.toString().substring(0, 16)}',
style: TextStyle(fontSize: 12, color: appColors.textSecondary),
),
const SizedBox(height: 2),
Text(
_getDraftNote(draft),
style: TextStyle(
fontSize: 11,
color: _isQuotaDraft(draft)
? Colors.orange
: appColors.textTertiary,
),
),
],
), ),
trailing: IconButton( trailing: IconButton(
icon: Icon(LucideIcons.trash2, color: appColors.error), icon: Icon(LucideIcons.trash2, color: appColors.error),
@ -390,7 +424,7 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: appColors.brandPrimary, backgroundColor: appColors.brandPrimary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey.shade400, disabledBackgroundColor: appColors.textTertiary,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),

View File

@ -8,6 +8,10 @@ import '../../../constants/app_constants.dart';
import '../../../providers/theme_provider.dart'; import '../../../providers/theme_provider.dart';
import '../../../services/mbti_compatibility_service.dart'; import '../../../services/mbti_compatibility_service.dart';
String _formatRecordedDate(DateTime date) {
return '${date.year}${date.month}${date.day}日に記録';
}
/// /AI確信度バッジMBTI相性バッジ /// /AI確信度バッジMBTI相性バッジ
class SakeBasicInfoSection extends ConsumerWidget { class SakeBasicInfoSection extends ConsumerWidget {
final SakeItem sake; final SakeItem sake;
@ -161,6 +165,24 @@ class SakeBasicInfoSection extends ConsumerWidget {
), ),
), ),
), ),
const SizedBox(height: 8),
//
if (sake.itemType != ItemType.set)
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.calendarDays, size: 11, color: appColors.textTertiary),
const SizedBox(width: 4),
Text(
_formatRecordedDate(sake.metadata.createdAt),
style: TextStyle(fontSize: 11, color: appColors.textTertiary),
),
],
),
),
const SizedBox(height: 20), const SizedBox(height: 20),
// 調 // 調

View File

@ -0,0 +1,75 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Gemini API 使
///
/// - : 1 20/
/// - : UTC 08:00= 17:00 JST / 16:00 JST
/// Gemini API Pacific Time UTC+9
/// (PST=UTC-8) 17:00 JST(PDT=UTC-7) 16:00 JST
class ApiUsageService {
static const int dailyLimit = 20;
static const _keyCount = 'gemini_usage_count';
static const _keyWindowStart = 'gemini_window_start';
/// UTC 08:00
static DateTime getCurrentWindowStart() {
final now = DateTime.now().toUtc();
final todayReset = DateTime.utc(now.year, now.month, now.day, 8, 0, 0);
return now.isBefore(todayReset)
? todayReset.subtract(const Duration(days: 1))
: todayReset;
}
///
static DateTime getNextResetTime() {
return getCurrentWindowStart().add(const Duration(days: 1)).toLocal();
}
/// 使
static Future<int> getCount() async {
final prefs = await SharedPreferences.getInstance();
final windowStart = getCurrentWindowStart();
final storedStr = prefs.getString(_keyWindowStart);
if (storedStr != null) {
final storedWindow = DateTime.parse(storedStr);
if (storedWindow.isBefore(windowStart)) {
//
await prefs.setInt(_keyCount, 0);
await prefs.setString(_keyWindowStart, windowStart.toIso8601String());
return 0;
}
} else {
//
await prefs.setString(_keyWindowStart, windowStart.toIso8601String());
}
return prefs.getInt(_keyCount) ?? 0;
}
/// 使 1
static Future<void> increment() async {
final prefs = await SharedPreferences.getInstance();
final current = await getCount();
await prefs.setInt(_keyCount, current + 1);
}
/// 0
static Future<int> getRemaining() async {
final count = await getCount();
return (dailyLimit - count).clamp(0, dailyLimit);
}
/// 使
static Future<bool> isExhausted() async {
return await getCount() >= dailyLimit;
}
}
/// ActivityStats / 使 Riverpod
/// increment() ref.invalidate(apiUsageCountProvider) UI
final apiUsageCountProvider = FutureProvider.autoDispose<int>((ref) async {
return ApiUsageService.getCount();
});

View File

@ -4,6 +4,13 @@ import 'package:uuid/uuid.dart';
import '../models/sake_item.dart'; import '../models/sake_item.dart';
import 'gemini_service.dart'; import 'gemini_service.dart';
/// Draft
enum DraftReason {
offline, //
quotaLimit, // AI 使20/
congestion, // AI 503
}
/// Draft /// Draft
/// ///
/// ///
@ -29,13 +36,23 @@ class DraftService {
/// ```dart /// ```dart
/// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]); /// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]);
/// ``` /// ```
static Future<String> saveDraft(List<String> photoPaths) async { static Future<String> saveDraft(
List<String> photoPaths, {
DraftReason reason = DraftReason.offline,
}) async {
try { try {
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
// FIX: draftPhotoPathにimagePathsに保存 // FIX: draftPhotoPathにimagePathsに保存
final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : ''; final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : '';
// reason description
final reasonKey = switch (reason) {
DraftReason.offline => 'offline',
DraftReason.quotaLimit => 'quota',
DraftReason.congestion => 'congestion',
};
// Draft用の仮データを作成 // Draft用の仮データを作成
final draftItem = SakeItem( final draftItem = SakeItem(
id: _uuid.v4(), id: _uuid.v4(),
@ -49,7 +66,7 @@ class DraftService {
rating: null, rating: null,
), ),
hiddenSpecs: HiddenSpecs( hiddenSpecs: HiddenSpecs(
description: 'オフライン時に撮影された写真です。オンライン復帰後に自動解析されます。', description: reasonKey,
tasteStats: {}, tasteStats: {},
flavorTags: [], flavorTags: [],
), ),

View File

@ -159,12 +159,10 @@ class SakenowaAutoMatchingService {
sakenowaFlavorChart: flavorChartMap, sakenowaFlavorChart: flavorChartMap,
); );
// SakeItem更新 await sakeItem.applyUpdates(
sakeItem.displayData = updatedDisplayData; displayData: updatedDisplayData,
sakeItem.hiddenSpecs = updatedHiddenSpecs; hiddenSpecs: updatedHiddenSpecs,
);
// Hiveに保存
await sakeItem.save();
debugPrint(' [SakenowaAutoMatching] 適用完了!'); debugPrint(' [SakenowaAutoMatching] 適用完了!');
debugPrint(' displayName: ${sakeItem.displayData.displayName}'); debugPrint(' displayName: ${sakeItem.displayData.displayName}');
@ -226,10 +224,10 @@ class SakenowaAutoMatchingService {
sakenowaFlavorChart: null, sakenowaFlavorChart: null,
); );
sakeItem.displayData = clearedDisplayData; await sakeItem.applyUpdates(
sakeItem.hiddenSpecs = clearedHiddenSpecs; displayData: clearedDisplayData,
hiddenSpecs: clearedHiddenSpecs,
await sakeItem.save(); );
debugPrint(' [SakenowaAutoMatching] クリア完了'); debugPrint(' [SakenowaAutoMatching] クリア完了');
} }

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../../providers/sake_list_provider.dart'; import '../../providers/sake_list_provider.dart';
import '../../models/schema/item_type.dart'; import '../../models/schema/item_type.dart';
import '../../services/api_usage_service.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
class ActivityStats extends ConsumerWidget { class ActivityStats extends ConsumerWidget {
@ -12,6 +13,8 @@ class ActivityStats extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final allSakeAsync = ref.watch(allSakeItemsProvider); final allSakeAsync = ref.watch(allSakeItemsProvider);
final apiUsageAsync = ref.watch(apiUsageCountProvider);
return allSakeAsync.when( return allSakeAsync.when(
data: (sakes) { data: (sakes) {
final individualSakes = sakes.where((s) => s.itemType == ItemType.sake).toList(); final individualSakes = sakes.where((s) => s.itemType == ItemType.sake).toList();
@ -25,7 +28,17 @@ class ActivityStats extends ConsumerWidget {
}).toSet(); }).toSet();
final recordingDays = dates.length; final recordingDays = dates.length;
final apiCount = apiUsageAsync.asData?.value ?? 0;
final remaining = (ApiUsageService.dailyLimit - apiCount).clamp(0, ApiUsageService.dailyLimit);
final isExhausted = remaining == 0;
final isLow = remaining <= 5 && !isExhausted;
final appColors = Theme.of(context).extension<AppColors>()!; final appColors = Theme.of(context).extension<AppColors>()!;
final apiColor = isExhausted
? appColors.error
: isLow
? Colors.orange
: appColors.brandPrimary;
// Bento Grid: 2 // Bento Grid: 2
return Column( return Column(
@ -134,6 +147,57 @@ class ActivityStats extends ConsumerWidget {
), ),
], ],
), ),
const SizedBox(height: 8),
// AI 使
_BentoCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.sparkles, size: 14, color: apiColor),
const SizedBox(width: 6),
Text(
'今日のAI解析',
style: TextStyle(fontSize: 11, color: appColors.textSecondary),
),
const Spacer(),
Text(
'$apiCount / ${ApiUsageService.dailyLimit}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: apiColor,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: apiCount / ApiUsageService.dailyLimit,
backgroundColor: appColors.surfaceSubtle,
valueColor: AlwaysStoppedAnimation<Color>(apiColor),
minHeight: 5,
),
),
if (isExhausted) ...[
const SizedBox(height: 6),
Text(
'本日の上限に達しました。写真は保存されています。',
style: TextStyle(fontSize: 10, color: appColors.error),
),
] else if (isLow) ...[
const SizedBox(height: 6),
Text(
'残り$remaining回です',
style: const TextStyle(fontSize: 10, color: Colors.orange),
),
],
],
),
),
], ],
); );
}, },

View File

@ -188,6 +188,8 @@ class SakeListItem extends ConsumerWidget {
if (!isMenuMode && sake.itemType != ItemType.set) ...[ if (!isMenuMode && sake.itemType != ItemType.set) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
_buildSpecLine(context, appColors), _buildSpecLine(context, appColors),
const SizedBox(height: 4),
_buildRecordedDate(appColors),
], ],
], ],
), ),
@ -200,6 +202,36 @@ class SakeListItem extends ConsumerWidget {
); // Pressable ); // Pressable
} }
///
Widget _buildRecordedDate(AppColors appColors) {
final date = sake.metadata.createdAt;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final itemDay = DateTime(date.year, date.month, date.day);
final diff = today.difference(itemDay).inDays;
final String label;
if (diff == 0) {
label = '今日';
} else if (diff == 1) {
label = '昨日';
} else if (diff < 7) {
label = '$diff日前';
} else if (date.year == now.year) {
label = '${date.month}/${date.day}';
} else {
label = '${date.year}/${date.month}/${date.day}';
}
return Text(
label,
style: TextStyle(
fontSize: 10,
color: appColors.textTertiary,
),
);
}
/// + /// +
Widget _buildSpecLine(BuildContext context, AppColors appColors) { Widget _buildSpecLine(BuildContext context, AppColors appColors) {
final type = sake.hiddenSpecs.type; final type = sake.hiddenSpecs.type;

View File

@ -76,7 +76,7 @@ class PrefectureTileMap extends ConsumerWidget {
if (v.contains(prefName)) prefId = k; if (v.contains(prefName)) prefId = k;
}); });
final regionId = JapanMapData.getRegionId(prefId); final regionId = JapanMapData.getRegionId(prefId);
final regionColor = regionColors[regionId] ?? Colors.grey; final regionColor = regionColors[regionId] ?? appColors.divider;
Color baseColor; Color baseColor;
Color textColor; Color textColor;

View File

@ -75,9 +75,9 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
if (_isEditing) { if (_isEditing) {
// Warn user about external update while editing // Warn user about external update while editing
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'), content: const Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'),
backgroundColor: Colors.orange, backgroundColor: Theme.of(context).extension<AppColors>()!.warning,
), ),
); );
_cancel(); // Force exit edit mode _cancel(); // Force exit edit mode

View File

@ -86,6 +86,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
Future<void> _createBackup() async { Future<void> _createBackup() async {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final appColors = Theme.of(context).extension<AppColors>()!;
setState(() => _state = _BackupState.backingUp); setState(() => _state = _BackupState.backingUp);
final success = await _backupService.createBackup(); final success = await _backupService.createBackup();
if (mounted) { if (mounted) {
@ -93,11 +94,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text(success ? 'バックアップが完了しました' : 'バックアップに失敗しました'), content: Text(success ? 'バックアップが完了しました' : 'バックアップに失敗しました'),
// Snackbars can keep Green/Red for semantic clarity, or be neutral. backgroundColor: success ? appColors.success : appColors.error,
// 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,
), ),
); );
} }
@ -184,7 +181,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text(success ? '復元が完了しました' : '復元に失敗しました'), content: Text(success ? '復元が完了しました' : '復元に失敗しました'),
backgroundColor: success ? Colors.green : Colors.red, backgroundColor: success ? appColors.success : appColors.error,
), ),
); );
} }

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.38+45 version: 1.0.42+49
environment: environment:
sdk: ^3.10.1 sdk: ^3.10.1

View File

@ -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); //
// 2falseを返す
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', () { group('SakeItem - HiddenSpecs / TasteStats', () {
test('should return SakeTasteStats from tasteStats map', () { test('should return SakeTasteStats from tasteStats map', () {
final sake = SakeItem( final sake = SakeItem(

View File

@ -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);
});
});
}

View File

@ -1,19 +1,19 @@
{ {
"date": "2026-04-16", "date": "2026-04-16",
"name": "Ponshu Room 1.0.37 (2026-04-16)", "name": "Ponshu Room 1.0.42 (2026-04-16)",
"version": "v1.0.37", "version": "v1.0.42",
"apks": { "apks": {
"eiji": { "eiji": {
"lite": { "lite": {
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.37/ponshu_room_consumer_eiji.apk", "url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.42/ponshu_room_consumer_eiji.apk",
"size_mb": 89.1, "size_mb": 89.2,
"filename": "ponshu_room_consumer_eiji.apk" "filename": "ponshu_room_consumer_eiji.apk"
} }
}, },
"maita": { "maita": {
"lite": { "lite": {
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.37/ponshu_room_consumer_maita.apk", "url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.42/ponshu_room_consumer_maita.apk",
"size_mb": 89.1, "size_mb": 89.2,
"filename": "ponshu_room_consumer_maita.apk" "filename": "ponshu_room_consumer_maita.apk"
} }
} }