Compare commits
13 Commits
aa933cf1e3
...
5d8689b7ee
| Author | SHA1 | Date |
|---|---|---|
|
|
5d8689b7ee | |
|
|
a62bcd1d11 | |
|
|
8ebd233305 | |
|
|
05c27d9cdf | |
|
|
cc5175ebae | |
|
|
b7f5edf9a9 | |
|
|
ac2a54d07a | |
|
|
d39db78c80 | |
|
|
4e6ff6d6e9 | |
|
|
68723a884e | |
|
|
fad896e817 | |
|
|
26183e458e | |
|
|
cad2855b6e |
|
|
@ -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) {
|
||||
_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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import '../models/sake_item.dart';
|
|||
import '../providers/gemini_provider.dart';
|
||||
import '../providers/sakenowa_providers.dart';
|
||||
import '../providers/theme_provider.dart';
|
||||
import '../services/api_usage_service.dart';
|
||||
import '../services/draft_service.dart';
|
||||
import '../services/gamification_service.dart';
|
||||
import '../services/gemini_exceptions.dart';
|
||||
|
|
@ -38,7 +39,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
debugPrint('Offline detected: Saving as draft...');
|
||||
|
||||
try {
|
||||
await DraftService.saveDraft(capturedImages);
|
||||
await DraftService.saveDraft(capturedImages, reason: DraftReason.offline);
|
||||
|
||||
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;
|
||||
// ignore: use_build_context_synchronously
|
||||
|
|
@ -125,6 +169,12 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
final box = Hive.box<SakeItem>('sake_items');
|
||||
await box.add(sakeItem);
|
||||
|
||||
// API 使用回数をカウントアップ(キャッシュヒット時は実際の API 呼び出しなし)
|
||||
if (!result.isFromCache) {
|
||||
await ApiUsageService.increment();
|
||||
ref.invalidate(apiUsageCountProvider);
|
||||
}
|
||||
|
||||
// さけのわ自動マッチング(非同期・バックグラウンド)
|
||||
// エラーが発生しても登録フローを中断しない
|
||||
_performSakenowaMatching(sakeItem).catchError((error) {
|
||||
|
|
@ -243,7 +293,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
// AIサーバー混雑(503)→ ドラフト保存してオフライン時と同じ扱いに
|
||||
if (e is GeminiCongestionException) {
|
||||
try {
|
||||
await DraftService.saveDraft(capturedImages);
|
||||
await DraftService.saveDraft(capturedImages, reason: DraftReason.congestion);
|
||||
if (!mounted) return;
|
||||
navigator.pop(); // Close camera screen
|
||||
messenger.showSnackBar(
|
||||
|
|
@ -278,12 +328,46 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
return;
|
||||
}
|
||||
|
||||
// Quota エラー(429)→ ロックアウト
|
||||
// Quota エラー(429)→ ドラフト保存してカメラを閉じる
|
||||
final errStr = e.toString();
|
||||
if (errStr.contains('Quota') || errStr.contains('429')) {
|
||||
setState(() {
|
||||
quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
|
||||
});
|
||||
try {
|
||||
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>()!;
|
||||
|
|
|
|||
|
|
@ -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.0、maxValue(上端) → 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import 'package:image_picker/image_picker.dart'; // Gallery & Camera
|
|||
import '../services/image_compression_service.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'camera_analysis_mixin.dart';
|
||||
import 'camera_exposure_painter.dart';
|
||||
|
||||
|
||||
enum CameraMode {
|
||||
|
|
@ -475,8 +476,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
height: 180,
|
||||
width: 48, // Wider for easier tapping
|
||||
child: CustomPaint(
|
||||
key: ValueKey(_currentExposureOffset), // Force repaint on value change
|
||||
painter: _ExposureSliderPainter(
|
||||
key: ValueKey(_currentExposureOffset),
|
||||
painter: ExposureSliderPainter(
|
||||
currentValue: _currentExposureOffset,
|
||||
minValue: _minExposure,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
|
|||
preferredSize: const Size.fromHeight(2),
|
||||
child: LinearProgressIndicator(
|
||||
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),
|
||||
minHeight: 2,
|
||||
),
|
||||
|
|
@ -320,9 +320,9 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
|
|||
// 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(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../models/sake_item.dart';
|
||||
import '../providers/gemini_provider.dart';
|
||||
import '../services/api_usage_service.dart';
|
||||
import '../services/draft_service.dart';
|
||||
import '../services/network_service.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 {
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
|
|
@ -344,19 +363,34 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
|||
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(
|
||||
'解析待ち',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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(
|
||||
icon: Icon(LucideIcons.trash2, color: appColors.error),
|
||||
onPressed: () => _deleteDraft(draft),
|
||||
|
|
@ -390,7 +424,7 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
|||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: appColors.brandPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.grey.shade400,
|
||||
disabledBackgroundColor: appColors.textTertiary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import '../../../constants/app_constants.dart';
|
|||
import '../../../providers/theme_provider.dart';
|
||||
import '../../../services/mbti_compatibility_service.dart';
|
||||
|
||||
String _formatRecordedDate(DateTime date) {
|
||||
return '${date.year}年${date.month}月${date.day}日に記録';
|
||||
}
|
||||
|
||||
/// 銘柄名・蔵元/都道府県・タグ・AI確信度バッジ・MBTI相性バッジ・キャッチコピーを表示するセクション
|
||||
class SakeBasicInfoSection extends ConsumerWidget {
|
||||
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),
|
||||
|
||||
// キャッチコピー(位置を銘柄名直下に移動して存在感を強調)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -4,6 +4,13 @@ import 'package:uuid/uuid.dart';
|
|||
import '../models/sake_item.dart';
|
||||
import 'gemini_service.dart';
|
||||
|
||||
/// 解析待ち(Draft)になった理由
|
||||
enum DraftReason {
|
||||
offline, // オフライン時に撮影
|
||||
quotaLimit, // AI 使用回数が上限(20回/日)に達した
|
||||
congestion, // AI サーバー混雑(503)
|
||||
}
|
||||
|
||||
/// Draft(解析待ちアイテム)管理サービス
|
||||
///
|
||||
/// オフライン時に撮影した写真を一時保存し、
|
||||
|
|
@ -29,13 +36,23 @@ class DraftService {
|
|||
/// ```dart
|
||||
/// 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 {
|
||||
final box = Hive.box<SakeItem>('sake_items');
|
||||
|
||||
// FIX: 最初の画像をdraftPhotoPathに、全画像をimagePathsに保存
|
||||
final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : '';
|
||||
|
||||
// reason を description フィールドに格納(解析時に上書きされる)
|
||||
final reasonKey = switch (reason) {
|
||||
DraftReason.offline => 'offline',
|
||||
DraftReason.quotaLimit => 'quota',
|
||||
DraftReason.congestion => 'congestion',
|
||||
};
|
||||
|
||||
// Draft用の仮データを作成
|
||||
final draftItem = SakeItem(
|
||||
id: _uuid.v4(),
|
||||
|
|
@ -49,7 +66,7 @@ class DraftService {
|
|||
rating: null,
|
||||
),
|
||||
hiddenSpecs: HiddenSpecs(
|
||||
description: 'オフライン時に撮影された写真です。オンライン復帰後に自動解析されます。',
|
||||
description: reasonKey,
|
||||
tasteStats: {},
|
||||
flavorTags: [],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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] クリア完了');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../providers/sake_list_provider.dart';
|
||||
import '../../models/schema/item_type.dart';
|
||||
import '../../services/api_usage_service.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
|
||||
class ActivityStats extends ConsumerWidget {
|
||||
|
|
@ -12,6 +13,8 @@ class ActivityStats extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final allSakeAsync = ref.watch(allSakeItemsProvider);
|
||||
|
||||
final apiUsageAsync = ref.watch(apiUsageCountProvider);
|
||||
|
||||
return allSakeAsync.when(
|
||||
data: (sakes) {
|
||||
final individualSakes = sakes.where((s) => s.itemType == ItemType.sake).toList();
|
||||
|
|
@ -25,7 +28,17 @@ class ActivityStats extends ConsumerWidget {
|
|||
}).toSet();
|
||||
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 apiColor = isExhausted
|
||||
? appColors.error
|
||||
: isLow
|
||||
? Colors.orange
|
||||
: appColors.brandPrimary;
|
||||
|
||||
// Bento Grid: 総登録数を大カード、お気に入り・撮影日数を小カード2枚横並び
|
||||
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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -188,6 +188,8 @@ class SakeListItem extends ConsumerWidget {
|
|||
if (!isMenuMode && sake.itemType != ItemType.set) ...[
|
||||
const SizedBox(height: 6),
|
||||
_buildSpecLine(context, appColors),
|
||||
const SizedBox(height: 4),
|
||||
_buildRecordedDate(appColors),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
|
@ -200,6 +202,36 @@ class SakeListItem extends ConsumerWidget {
|
|||
); // 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) {
|
||||
final type = sake.hiddenSpecs.type;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
|
|||
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<AppColors>()!.warning,
|
||||
),
|
||||
);
|
||||
_cancel(); // Force exit edit mode
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
|||
|
||||
Future<void> _createBackup() async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.38+45
|
||||
version: 1.0.42+49
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.1
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
"date": "2026-04-16",
|
||||
"name": "Ponshu Room 1.0.37 (2026-04-16)",
|
||||
"version": "v1.0.37",
|
||||
"name": "Ponshu Room 1.0.42 (2026-04-16)",
|
||||
"version": "v1.0.42",
|
||||
"apks": {
|
||||
"eiji": {
|
||||
"lite": {
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.37/ponshu_room_consumer_eiji.apk",
|
||||
"size_mb": 89.1,
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.42/ponshu_room_consumer_eiji.apk",
|
||||
"size_mb": 89.2,
|
||||
"filename": "ponshu_room_consumer_eiji.apk"
|
||||
}
|
||||
},
|
||||
"maita": {
|
||||
"lite": {
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.37/ponshu_room_consumer_maita.apk",
|
||||
"size_mb": 89.1,
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.42/ponshu_room_consumer_maita.apk",
|
||||
"size_mb": 89.2,
|
||||
"filename": "ponshu_room_consumer_maita.apk"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue