Compare commits

..

No commits in common. "5d8689b7eecfbcc8282af797242621a3d20655b9" and "aa933cf1e3664289513b9af1536e1638d6bc5778" have entirely different histories.

21 changed files with 115 additions and 666 deletions

View File

@ -157,20 +157,12 @@ class SakeItem extends HiveObject {
); );
} }
/// displayData/hiddenSpecs Hiveへ保存する // Allow setting for UI updates ( await sakeItem.save() )
/// 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 {
@ -184,7 +176,7 @@ class SakeItem extends HiveObject {
); );
} }
@Deprecated('Use applyUpdates() instead to ensure save() is always called.') // Allow setting for auto-matching
set hiddenSpecs(HiddenSpecs val) { set hiddenSpecs(HiddenSpecs val) {
_hiddenSpecs = val; _hiddenSpecs = val;
} }

View File

@ -8,7 +8,6 @@ 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';
@ -39,7 +38,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, reason: DraftReason.offline); await DraftService.saveDraft(capturedImages);
if (!mounted) return; if (!mounted) return;
@ -78,49 +77,6 @@ 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
@ -169,12 +125,6 @@ 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) {
@ -293,7 +243,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, reason: DraftReason.congestion); await DraftService.saveDraft(capturedImages);
if (!mounted) return; if (!mounted) return;
navigator.pop(); // Close camera screen navigator.pop(); // Close camera screen
messenger.showSnackBar( messenger.showSnackBar(
@ -328,46 +278,12 @@ 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')) {
try { setState(() {
await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit); quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
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

@ -1,70 +0,0 @@
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,7 +13,6 @@ 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 {
@ -476,8 +475,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), key: ValueKey(_currentExposureOffset), // Force repaint on value change
painter: ExposureSliderPainter( painter: _ExposureSliderPainter(
currentValue: _currentExposureOffset, currentValue: _currentExposureOffset,
minValue: _minExposure, minValue: _minExposure,
maxValue: _maxExposure, maxValue: _maxExposure,
@ -657,3 +656,63 @@ 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: appColors.iconSubtle), Icon(LucideIcons.clipboardCheck, size: 60, color: Colors.grey[400]),
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: TextStyle(color: appColors.textSecondary)), Text(t['goBackToList'], textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey)),
], ],
), ),
); );

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: appColors.iconSubtle), Icon(featureIcon, size: 48, color: Colors.grey.shade400),
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: Theme.of(context).extension<AppColors>()!.surfaceSubtle, backgroundColor: Colors.grey[200],
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: Padding( child: const Padding(
padding: const EdgeInsets.only(right: 12, top: 4, bottom: 4), padding: EdgeInsets.only(right: 12, top: 4, bottom: 4),
child: Icon(Icons.drag_indicator, color: appColors.iconSubtle), child: Icon(Icons.drag_indicator, color: Colors.grey),
), ),
), ),
Expanded( Expanded(

View File

@ -4,7 +4,6 @@ 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';
@ -55,24 +54,6 @@ 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>()!;
@ -363,34 +344,19 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
width: 60, width: 60,
height: 60, height: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: appColors.divider, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Icon(LucideIcons.image, color: appColors.iconSubtle), child: const Icon(LucideIcons.image, color: Colors.grey),
), ),
title: const Text( title: const Text(
'解析待ち', '解析待ち',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: Column( subtitle: Text(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'撮影日時: ${draft.metadata.createdAt.toString().substring(0, 16)}', '撮影日時: ${draft.metadata.createdAt.toString().substring(0, 16)}',
style: TextStyle(fontSize: 12, color: appColors.textSecondary), 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),
onPressed: () => _deleteDraft(draft), onPressed: () => _deleteDraft(draft),
@ -424,7 +390,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: appColors.textTertiary, disabledBackgroundColor: Colors.grey.shade400,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),

View File

@ -8,10 +8,6 @@ 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;
@ -165,24 +161,6 @@ 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

@ -1,75 +0,0 @@
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,13 +4,6 @@ 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
/// ///
/// ///
@ -36,23 +29,13 @@ class DraftService {
/// ```dart /// ```dart
/// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]); /// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]);
/// ``` /// ```
static Future<String> saveDraft( static Future<String> saveDraft(List<String> photoPaths) async {
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(),
@ -66,7 +49,7 @@ class DraftService {
rating: null, rating: null,
), ),
hiddenSpecs: HiddenSpecs( hiddenSpecs: HiddenSpecs(
description: reasonKey, description: 'オフライン時に撮影された写真です。オンライン復帰後に自動解析されます。',
tasteStats: {}, tasteStats: {},
flavorTags: [], flavorTags: [],
), ),

View File

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

View File

@ -3,7 +3,6 @@ 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 {
@ -13,8 +12,6 @@ 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();
@ -28,17 +25,7 @@ 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(
@ -147,57 +134,6 @@ 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,8 +188,6 @@ 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),
], ],
], ],
), ),
@ -202,36 +200,6 @@ 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] ?? appColors.divider; final regionColor = regionColors[regionId] ?? Colors.grey;
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(
SnackBar( const SnackBar(
content: const Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'), content: Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'),
backgroundColor: Theme.of(context).extension<AppColors>()!.warning, backgroundColor: Colors.orange,
), ),
); );
_cancel(); // Force exit edit mode _cancel(); // Force exit edit mode

View File

@ -86,7 +86,6 @@ 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) {
@ -94,7 +93,11 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text(success ? 'バックアップが完了しました' : 'バックアップに失敗しました'), content: Text(success ? 'バックアップが完了しました' : 'バックアップに失敗しました'),
backgroundColor: success ? appColors.success : appColors.error, // 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,
), ),
); );
} }
@ -181,7 +184,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text(success ? '復元が完了しました' : '復元に失敗しました'), content: Text(success ? '復元が完了しました' : '復元に失敗しました'),
backgroundColor: success ? appColors.success : appColors.error, backgroundColor: success ? Colors.green : Colors.red,
), ),
); );
} }

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

View File

@ -137,57 +137,6 @@ 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

@ -1,158 +0,0 @@
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.42 (2026-04-16)", "name": "Ponshu Room 1.0.37 (2026-04-16)",
"version": "v1.0.42", "version": "v1.0.37",
"apks": { "apks": {
"eiji": { "eiji": {
"lite": { "lite": {
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.42/ponshu_room_consumer_eiji.apk", "url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.37/ponshu_room_consumer_eiji.apk",
"size_mb": 89.2, "size_mb": 89.1,
"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.42/ponshu_room_consumer_maita.apk", "url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.37/ponshu_room_consumer_maita.apk",
"size_mb": 89.2, "size_mb": 89.1,
"filename": "ponshu_room_consumer_maita.apk" "filename": "ponshu_room_consumer_maita.apk"
} }
} }