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へ保存する
/// 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.')
// Allow setting for UI updates ( await sakeItem.save() )
set displayData(DisplayData val) {
_displayData = val;
// save() setter await
// unawaited save()
// sakenowa_auto_matching_service.dart await save()
}
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) {
_hiddenSpecs = val;
}

View File

@ -8,7 +8,6 @@ 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';
@ -39,7 +38,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
debugPrint('Offline detected: Saving as draft...');
try {
await DraftService.saveDraft(capturedImages, reason: DraftReason.offline);
await DraftService.saveDraft(capturedImages);
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;
// ignore: use_build_context_synchronously
@ -169,12 +125,6 @@ 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) {
@ -293,7 +243,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
// AIサーバー混雑503
if (e is GeminiCongestionException) {
try {
await DraftService.saveDraft(capturedImages, reason: DraftReason.congestion);
await DraftService.saveDraft(capturedImages);
if (!mounted) return;
navigator.pop(); // Close camera screen
messenger.showSnackBar(
@ -328,46 +278,12 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
return;
}
// Quota 429
// Quota 429
final errStr = e.toString();
if (errStr.contains('Quota') || errStr.contains('429')) {
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;
setState(() {
quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
});
}
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 '../theme/app_colors.dart';
import 'camera_analysis_mixin.dart';
import 'camera_exposure_painter.dart';
enum CameraMode {
@ -476,8 +475,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
height: 180,
width: 48, // Wider for easier tapping
child: CustomPaint(
key: ValueKey(_currentExposureOffset),
painter: ExposureSliderPainter(
key: ValueKey(_currentExposureOffset), // Force repaint on value change
painter: _ExposureSliderPainter(
currentValue: _currentExposureOffset,
minValue: _minExposure,
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(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.clipboardCheck, size: 60, color: appColors.iconSubtle),
Icon(LucideIcons.clipboardCheck, size: 60, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(t['noMenuItems'], style: const TextStyle(fontWeight: FontWeight.bold)),
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,
alignment: Alignment.center,
children: [
Icon(featureIcon, size: 48, color: appColors.iconSubtle),
Icon(featureIcon, size: 48, color: Colors.grey.shade400),
Positioned(
right: -8,
top: -8,

View File

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

View File

@ -4,7 +4,6 @@ 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';
@ -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 {
final appColors = Theme.of(context).extension<AppColors>()!;
@ -363,33 +344,18 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
width: 60,
height: 60,
decoration: BoxDecoration(
color: appColors.divider,
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
child: Icon(LucideIcons.image, color: appColors.iconSubtle),
child: const Icon(LucideIcons.image, color: Colors.grey),
),
title: const Text(
'解析待ち',
style: TextStyle(fontWeight: FontWeight.bold),
),
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,
),
),
],
subtitle: Text(
'撮影日時: ${draft.metadata.createdAt.toString().substring(0, 16)}',
style: TextStyle(fontSize: 12, color: appColors.textSecondary),
),
trailing: IconButton(
icon: Icon(LucideIcons.trash2, color: appColors.error),
@ -424,7 +390,7 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
style: ElevatedButton.styleFrom(
backgroundColor: appColors.brandPrimary,
foregroundColor: Colors.white,
disabledBackgroundColor: appColors.textTertiary,
disabledBackgroundColor: Colors.grey.shade400,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),

View File

@ -8,10 +8,6 @@ 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;
@ -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),
// 調

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

View File

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

View File

@ -3,7 +3,6 @@ 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 {
@ -13,8 +12,6 @@ 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();
@ -28,17 +25,7 @@ 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(
@ -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) ...[
const SizedBox(height: 6),
_buildSpecLine(context, appColors),
const SizedBox(height: 4),
_buildRecordedDate(appColors),
],
],
),
@ -202,36 +200,6 @@ 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;

View File

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

View File

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

View File

@ -86,7 +86,6 @@ 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) {
@ -94,7 +93,11 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
messenger.showSnackBar(
SnackBar(
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(
SnackBar(
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
# 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.42+49
version: 1.0.38+45
environment:
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', () {
test('should return SakeTasteStats from tasteStats map', () {
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",
"name": "Ponshu Room 1.0.42 (2026-04-16)",
"version": "v1.0.42",
"name": "Ponshu Room 1.0.37 (2026-04-16)",
"version": "v1.0.37",
"apks": {
"eiji": {
"lite": {
"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,
"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,
"filename": "ponshu_room_consumer_eiji.apk"
}
},
"maita": {
"lite": {
"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,
"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,
"filename": "ponshu_room_consumer_maita.apk"
}
}