1228 lines
36 KiB
Markdown
1228 lines
36 KiB
Markdown
|
|
# 🚀 Phase 2: Implementation Plan
|
|||
|
|
|
|||
|
|
**Status**: Ready to Start
|
|||
|
|
**Last Updated**: 2026-01-21
|
|||
|
|
**Estimated Total Time**: 26 hours
|
|||
|
|
**Target Completion**: Before release (1月31日)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📊 Overview
|
|||
|
|
|
|||
|
|
Phase 2 focuses on **enhancing user experience** with three major features:
|
|||
|
|
|
|||
|
|
1. **Help Button Placement** (Pattern C: Hybrid) - 6 hours
|
|||
|
|
2. **AI-Powered Recommendations** ("あわせて飲みたい") - 12 hours
|
|||
|
|
3. **AI Analysis Info Editing** - 8 hours
|
|||
|
|
|
|||
|
|
**Total**: 26 hours (~3-4 days of focused work)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🎯 Feature 1: Help Button Placement (Pattern C: Hybrid)
|
|||
|
|
|
|||
|
|
### Problem
|
|||
|
|
Current state:
|
|||
|
|
- Single "ガイド・ヘルプ" button in soul_screen.dart
|
|||
|
|
- User must navigate away from context to find help
|
|||
|
|
- No contextual help for specific features
|
|||
|
|
|
|||
|
|
### Solution: Pattern C (Hybrid Approach)
|
|||
|
|
|
|||
|
|
**Keep**: Global help button in AppBar
|
|||
|
|
**Add**: Contextual "?" icons next to relevant sections
|
|||
|
|
|
|||
|
|
### User Research
|
|||
|
|
**Best Practice Examples**:
|
|||
|
|
- Duolingo: ? icons next to complex features
|
|||
|
|
- Notion: Contextual help in modals
|
|||
|
|
- GitHub: ? icons in settings pages
|
|||
|
|
- Figma: Help tooltips on hover
|
|||
|
|
|
|||
|
|
**User Feedback**:
|
|||
|
|
> "少なくとも説明対象の該当タブ内(現在の称号、バッジケースはマイページ、酒向タイプはソムリエタブ)に配置した方がわかりやすいよね?"
|
|||
|
|
|
|||
|
|
### Implementation
|
|||
|
|
|
|||
|
|
#### Step 1: Create Reusable Help Icon Widget (1 hour)
|
|||
|
|
|
|||
|
|
Create `lib/widgets/contextual_help_icon.dart`:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|||
|
|
|
|||
|
|
/// Contextual help icon that shows a bottom sheet with help content
|
|||
|
|
class ContextualHelpIcon extends StatelessWidget {
|
|||
|
|
final String title;
|
|||
|
|
final String content;
|
|||
|
|
final Widget? customContent; // For complex help (tables, images)
|
|||
|
|
|
|||
|
|
const ContextualHelpIcon({
|
|||
|
|
super.key,
|
|||
|
|
required this.title,
|
|||
|
|
required this.content,
|
|||
|
|
this.customContent,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return IconButton(
|
|||
|
|
icon: Icon(
|
|||
|
|
LucideIcons.helpCircle,
|
|||
|
|
size: 18,
|
|||
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
|||
|
|
),
|
|||
|
|
tooltip: 'ヘルプを表示',
|
|||
|
|
onPressed: () => _showHelpSheet(context),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _showHelpSheet(BuildContext context) {
|
|||
|
|
showModalBottomSheet(
|
|||
|
|
context: context,
|
|||
|
|
isScrollControlled: true,
|
|||
|
|
shape: const RoundedRectangleBorder(
|
|||
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|||
|
|
),
|
|||
|
|
builder: (context) => DraggableScrollableSheet(
|
|||
|
|
initialChildSize: 0.6,
|
|||
|
|
minChildSize: 0.4,
|
|||
|
|
maxChildSize: 0.9,
|
|||
|
|
expand: false,
|
|||
|
|
builder: (context, scrollController) => _HelpSheetContent(
|
|||
|
|
title: title,
|
|||
|
|
content: content,
|
|||
|
|
customContent: customContent,
|
|||
|
|
scrollController: scrollController,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class _HelpSheetContent extends StatelessWidget {
|
|||
|
|
final String title;
|
|||
|
|
final String content;
|
|||
|
|
final Widget? customContent;
|
|||
|
|
final ScrollController scrollController;
|
|||
|
|
|
|||
|
|
const _HelpSheetContent({
|
|||
|
|
required this.title,
|
|||
|
|
required this.content,
|
|||
|
|
this.customContent,
|
|||
|
|
required this.scrollController,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return Container(
|
|||
|
|
padding: const EdgeInsets.all(24),
|
|||
|
|
child: ListView(
|
|||
|
|
controller: scrollController,
|
|||
|
|
children: [
|
|||
|
|
// Drag handle
|
|||
|
|
Center(
|
|||
|
|
child: Container(
|
|||
|
|
width: 40,
|
|||
|
|
height: 4,
|
|||
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2),
|
|||
|
|
borderRadius: BorderRadius.circular(2),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
// Title
|
|||
|
|
Row(
|
|||
|
|
children: [
|
|||
|
|
Icon(
|
|||
|
|
LucideIcons.helpCircle,
|
|||
|
|
color: Theme.of(context).colorScheme.primary,
|
|||
|
|
),
|
|||
|
|
const SizedBox(width: 12),
|
|||
|
|
Expanded(
|
|||
|
|
child: Text(
|
|||
|
|
title,
|
|||
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
const Divider(),
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
|
|||
|
|
// Content
|
|||
|
|
if (customContent != null)
|
|||
|
|
customContent!
|
|||
|
|
else
|
|||
|
|
Text(
|
|||
|
|
content,
|
|||
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|||
|
|
height: 1.6,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
const SizedBox(height: 24),
|
|||
|
|
|
|||
|
|
// Close button
|
|||
|
|
FilledButton.tonal(
|
|||
|
|
onPressed: () => Navigator.pop(context),
|
|||
|
|
child: const Text('閉じる'),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Step 2: Add to soul_screen.dart (2 hours)
|
|||
|
|
|
|||
|
|
**Location 1: Level & Title Card**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// In soul_screen.dart, modify the LevelTitleCard header
|
|||
|
|
Row(
|
|||
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|||
|
|
children: [
|
|||
|
|
Text(
|
|||
|
|
'現在の称号',
|
|||
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
ContextualHelpIcon(
|
|||
|
|
title: 'レベルと称号について',
|
|||
|
|
customContent: _buildLevelHelpContent(context),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
Widget _buildLevelHelpContent(BuildContext context) {
|
|||
|
|
return Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|||
|
|
children: [
|
|||
|
|
Text(
|
|||
|
|
'レベルの上げ方',
|
|||
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 8),
|
|||
|
|
Text(
|
|||
|
|
'日本酒を1本登録するごとに 10 EXP 獲得できます。\nメニューを作成するとボーナスが入ることも!',
|
|||
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
Text(
|
|||
|
|
'称号一覧',
|
|||
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 8),
|
|||
|
|
_buildLevelTable(context),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Location 2: Badge Case**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// In soul_screen.dart, modify the BadgeCase header
|
|||
|
|
Row(
|
|||
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|||
|
|
children: [
|
|||
|
|
Text(
|
|||
|
|
'バッジコレクション',
|
|||
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
ContextualHelpIcon(
|
|||
|
|
title: 'バッジについて',
|
|||
|
|
content: '''バッジは特定の条件を達成すると獲得できます。
|
|||
|
|
|
|||
|
|
例:
|
|||
|
|
🍶 初めての一歩: 最初の日本酒を登録
|
|||
|
|
👹 東北制覇: 東北6県すべての日本酒を登録
|
|||
|
|
🌶️ 辛口党: 日本酒度+5以上の辛口酒を10本登録
|
|||
|
|
|
|||
|
|
バッジを集めて、日本酒マスターを目指しましょう!''',
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Step 3: Add to sommelier_screen.dart (2 hours)
|
|||
|
|
|
|||
|
|
**Location: Sake Type Chart**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// In sommelier_screen.dart, add help icon next to chart title
|
|||
|
|
Row(
|
|||
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|||
|
|
children: [
|
|||
|
|
Text(
|
|||
|
|
'あなたの酒向タイプ',
|
|||
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
ContextualHelpIcon(
|
|||
|
|
title: 'チャートの見方',
|
|||
|
|
customContent: _buildChartHelpContent(context),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
Widget _buildChartHelpContent(BuildContext context) {
|
|||
|
|
return Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|||
|
|
children: [
|
|||
|
|
Text(
|
|||
|
|
'AIがあなたの登録した日本酒の味覚データを分析し、好みの傾向をチャート化します。',
|
|||
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
_buildChartAxisExplanation(context, '華やか', '香りが高い、フルーティー'),
|
|||
|
|
_buildChartAxisExplanation(context, '芳醇', 'コク・旨味が強い、濃厚'),
|
|||
|
|
_buildChartAxisExplanation(context, '重厚', '苦味やボディ感、飲みごたえ'),
|
|||
|
|
_buildChartAxisExplanation(context, '穏やか', 'アルコール感が控えめ、優しい'),
|
|||
|
|
_buildChartAxisExplanation(context, '軽快', 'さっぱり、爽やか'),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildChartAxisExplanation(BuildContext context, String axis, String description) {
|
|||
|
|
return Padding(
|
|||
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|||
|
|
child: Row(
|
|||
|
|
children: [
|
|||
|
|
Container(
|
|||
|
|
width: 8,
|
|||
|
|
height: 8,
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: Theme.of(context).colorScheme.primary,
|
|||
|
|
shape: BoxShape.circle,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(width: 12),
|
|||
|
|
Expanded(
|
|||
|
|
child: RichText(
|
|||
|
|
text: TextSpan(
|
|||
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|||
|
|
children: [
|
|||
|
|
TextSpan(
|
|||
|
|
text: '$axis: ',
|
|||
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|||
|
|
),
|
|||
|
|
TextSpan(text: description),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Step 4: Testing (1 hour)
|
|||
|
|
|
|||
|
|
**Test Cases**:
|
|||
|
|
- [ ] Help icon visible in all 3 locations
|
|||
|
|
- [ ] Bottom sheet opens smoothly
|
|||
|
|
- [ ] Content displays correctly in Light mode
|
|||
|
|
- [ ] Content displays correctly in Dark mode
|
|||
|
|
- [ ] Draggable sheet works (resize)
|
|||
|
|
- [ ] Close button works
|
|||
|
|
- [ ] Tap outside to dismiss works
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🤖 Feature 2: AI-Powered "あわせて飲みたい" Recommendations
|
|||
|
|
|
|||
|
|
### Current State (Problem)
|
|||
|
|
```dart
|
|||
|
|
// lib/services/sake_recommendation_service.dart:25
|
|||
|
|
List<SakeItem> getRecommendations(SakeItem currentSake) {
|
|||
|
|
// 現在: ユーザーの登録済みデータから類似を探すだけ
|
|||
|
|
return allSakes.where((sake) =>
|
|||
|
|
sake.key != currentSake.key &&
|
|||
|
|
_isSimilar(sake, currentSake)
|
|||
|
|
).take(3).toList();
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Limitations**:
|
|||
|
|
- Only recommends from user's existing collection
|
|||
|
|
- No "discovery" of new sake
|
|||
|
|
- Simple similarity matching (not AI-powered)
|
|||
|
|
|
|||
|
|
### User Feedback
|
|||
|
|
> "「あわせて飲みたい」の実用的な実装も優先度が高いです"
|
|||
|
|
> "未知の銘柄のおすすめの方がニーズがあると思います"
|
|||
|
|
|
|||
|
|
### Solution: Gemini API-Powered Recommendations
|
|||
|
|
|
|||
|
|
**Two-Tier Approach**:
|
|||
|
|
1. **Tier 1**: Similar from user's collection (fast, offline)
|
|||
|
|
2. **Tier 2**: AI-generated recommendations from Gemini API (slow, online)
|
|||
|
|
|
|||
|
|
### Implementation
|
|||
|
|
|
|||
|
|
#### Step 1: Enhance SakeRecommendationService (4 hours)
|
|||
|
|
|
|||
|
|
Modify `lib/services/sake_recommendation_service.dart`:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:google_generative_ai/google_generative_ai.dart';
|
|||
|
|
import '../secrets.dart';
|
|||
|
|
import 'analysis_cache_service.dart'; // Reuse cache
|
|||
|
|
|
|||
|
|
class SakeRecommendationService {
|
|||
|
|
static final _gemini = GenerativeModel(
|
|||
|
|
model: 'gemini-2.0-flash-exp', // Fast, cheap model for recommendations
|
|||
|
|
apiKey: geminiApiKey,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
/// Get recommendations with two-tier approach
|
|||
|
|
static Future<RecommendationResult> getRecommendations({
|
|||
|
|
required SakeItem currentSake,
|
|||
|
|
required List<SakeItem> allUserSakes,
|
|||
|
|
bool includeAiRecommendations = true,
|
|||
|
|
}) async {
|
|||
|
|
// Tier 1: Similar from user's collection (always fast)
|
|||
|
|
final similarFromCollection = _getSimilarFromCollection(
|
|||
|
|
currentSake: currentSake,
|
|||
|
|
allSakes: allUserSakes,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Tier 2: AI recommendations (optional, cached)
|
|||
|
|
List<AiRecommendation>? aiRecommendations;
|
|||
|
|
if (includeAiRecommendations) {
|
|||
|
|
aiRecommendations = await _getAiRecommendations(
|
|||
|
|
currentSake: currentSake,
|
|||
|
|
userTasteProfile: _buildTasteProfile(allUserSakes),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return RecommendationResult(
|
|||
|
|
similarFromCollection: similarFromCollection,
|
|||
|
|
aiRecommendations: aiRecommendations ?? [],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Tier 1: Fast similarity matching
|
|||
|
|
static List<SakeItem> _getSimilarFromCollection({
|
|||
|
|
required SakeItem currentSake,
|
|||
|
|
required List<SakeItem> allSakes,
|
|||
|
|
}) {
|
|||
|
|
// Existing logic (kept as fallback)
|
|||
|
|
return allSakes
|
|||
|
|
.where((sake) => sake.key != currentSake.key)
|
|||
|
|
.map((sake) => (sake: sake, score: _calculateSimilarity(sake, currentSake)))
|
|||
|
|
.where((record) => record.score > 0.6)
|
|||
|
|
.toList()
|
|||
|
|
..sort((a, b) => b.score.compareTo(a.score));
|
|||
|
|
return sorted.take(3).map((r) => r.sake).toList();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Tier 2: AI-powered recommendations
|
|||
|
|
static Future<List<AiRecommendation>> _getAiRecommendations({
|
|||
|
|
required SakeItem currentSake,
|
|||
|
|
required TasteProfile userTasteProfile,
|
|||
|
|
}) async {
|
|||
|
|
// Cache key
|
|||
|
|
final cacheKey = 'rec_${currentSake.key}_${userTasteProfile.hashCode}';
|
|||
|
|
|
|||
|
|
// Check cache first
|
|||
|
|
final cached = await AnalysisCacheService.getCachedAnalysis(cacheKey);
|
|||
|
|
if (cached != null) {
|
|||
|
|
debugPrint('💰 AI recommendations cache hit');
|
|||
|
|
return _parseAiRecommendations(cached);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Call Gemini API
|
|||
|
|
final prompt = _buildRecommendationPrompt(currentSake, userTasteProfile);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
final response = await _gemini.generateContent([Content.text(prompt)]);
|
|||
|
|
final text = response.text ?? '';
|
|||
|
|
|
|||
|
|
// Cache result
|
|||
|
|
await AnalysisCacheService.cacheAnalysis(cacheKey, text);
|
|||
|
|
|
|||
|
|
return _parseAiRecommendations(text);
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('⚠️ AI recommendation failed: $e');
|
|||
|
|
return []; // Return empty, Tier 1 will still work
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static String _buildRecommendationPrompt(
|
|||
|
|
SakeItem currentSake,
|
|||
|
|
TasteProfile userTasteProfile,
|
|||
|
|
) {
|
|||
|
|
return '''あなたは日本酒ソムリエです。以下の情報に基づいて、おすすめの日本酒を3つ提案してください。
|
|||
|
|
|
|||
|
|
【現在見ている日本酒】
|
|||
|
|
- 名前: ${currentSake.displayData.name}
|
|||
|
|
- 蔵元: ${currentSake.displayData.brewery ?? '不明'}
|
|||
|
|
- 地域: ${currentSake.displayData.prefecture ?? '不明'}
|
|||
|
|
- タイプ: ${currentSake.hiddenSpecs.type ?? '不明'}
|
|||
|
|
- 甘辛度: ${currentSake.hiddenSpecs.sweetnessScore?.toStringAsFixed(1) ?? '不明'}
|
|||
|
|
- 濃淡度: ${currentSake.hiddenSpecs.bodyScore?.toStringAsFixed(1) ?? '不明'}
|
|||
|
|
|
|||
|
|
【ユーザーの好みの傾向】
|
|||
|
|
- 平均甘辛度: ${userTasteProfile.avgSweetness.toStringAsFixed(1)}
|
|||
|
|
- 平均濃淡度: ${userTasteProfile.avgBody.toStringAsFixed(1)}
|
|||
|
|
- 好きな地域: ${userTasteProfile.favoritePrefectures.join(', ')}
|
|||
|
|
- 好きなタイプ: ${userTasteProfile.favoriteTypes.join(', ')}
|
|||
|
|
|
|||
|
|
【指示】
|
|||
|
|
1. この日本酒の特徴を踏まえて、「一緒に飲むと良い」日本酒を3つ提案してください
|
|||
|
|
2. 提案は「対比」と「相乗効果」の両方を考慮してください
|
|||
|
|
- 対比: 全く違う味わいで新しい発見
|
|||
|
|
- 相乗効果: 似た系統で深掘り
|
|||
|
|
3. 実在する銘柄を提案してください(創作は不可)
|
|||
|
|
4. 各提案には以下を含めてください:
|
|||
|
|
- 銘柄名
|
|||
|
|
- 蔵元名
|
|||
|
|
- 地域(都道府県)
|
|||
|
|
- おすすめ理由(30文字以内)
|
|||
|
|
|
|||
|
|
【出力形式】
|
|||
|
|
以下のJSON形式で出力してください:
|
|||
|
|
```json
|
|||
|
|
[
|
|||
|
|
{
|
|||
|
|
"name": "銘柄名",
|
|||
|
|
"brewery": "蔵元名",
|
|||
|
|
"prefecture": "都道府県",
|
|||
|
|
"reason": "おすすめ理由"
|
|||
|
|
},
|
|||
|
|
...
|
|||
|
|
]
|
|||
|
|
```''';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static List<AiRecommendation> _parseAiRecommendations(String jsonText) {
|
|||
|
|
try {
|
|||
|
|
// Extract JSON from markdown code block if present
|
|||
|
|
final jsonMatch = RegExp(r'```json\s*(\[.*?\])\s*```', dotAll: true).firstMatch(jsonText);
|
|||
|
|
final jsonString = jsonMatch?.group(1) ?? jsonText;
|
|||
|
|
|
|||
|
|
final List<dynamic> parsed = jsonDecode(jsonString);
|
|||
|
|
return parsed.map((item) => AiRecommendation.fromJson(item)).toList();
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('⚠️ Failed to parse AI recommendations: $e');
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static TasteProfile _buildTasteProfile(List<SakeItem> allSakes) {
|
|||
|
|
if (allSakes.isEmpty) {
|
|||
|
|
return TasteProfile.empty();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final validSweetness = allSakes
|
|||
|
|
.where((s) => s.hiddenSpecs.sweetnessScore != null)
|
|||
|
|
.map((s) => s.hiddenSpecs.sweetnessScore!)
|
|||
|
|
.toList();
|
|||
|
|
|
|||
|
|
final validBody = allSakes
|
|||
|
|
.where((s) => s.hiddenSpecs.bodyScore != null)
|
|||
|
|
.map((s) => s.hiddenSpecs.bodyScore!)
|
|||
|
|
.toList();
|
|||
|
|
|
|||
|
|
// Most common prefectures
|
|||
|
|
final prefectureCounts = <String, int>{};
|
|||
|
|
for (final sake in allSakes) {
|
|||
|
|
final pref = sake.displayData.prefecture;
|
|||
|
|
if (pref != null && pref.isNotEmpty) {
|
|||
|
|
prefectureCounts[pref] = (prefectureCounts[pref] ?? 0) + 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
final favoritePrefectures = (prefectureCounts.entries.toList()
|
|||
|
|
..sort((a, b) => b.value.compareTo(a.value)))
|
|||
|
|
.take(3)
|
|||
|
|
.map((e) => e.key)
|
|||
|
|
.toList();
|
|||
|
|
|
|||
|
|
// Most common types
|
|||
|
|
final typeCounts = <String, int>{};
|
|||
|
|
for (final sake in allSakes) {
|
|||
|
|
final type = sake.hiddenSpecs.type;
|
|||
|
|
if (type != null && type.isNotEmpty) {
|
|||
|
|
typeCounts[type] = (typeCounts[type] ?? 0) + 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
final favoriteTypes = (typeCounts.entries.toList()
|
|||
|
|
..sort((a, b) => b.value.compareTo(a.value)))
|
|||
|
|
.take(3)
|
|||
|
|
.map((e) => e.key)
|
|||
|
|
.toList();
|
|||
|
|
|
|||
|
|
return TasteProfile(
|
|||
|
|
avgSweetness: validSweetness.isEmpty ? 0.0 : validSweetness.reduce((a, b) => a + b) / validSweetness.length,
|
|||
|
|
avgBody: validBody.isEmpty ? 0.0 : validBody.reduce((a, b) => a + b) / validBody.length,
|
|||
|
|
favoritePrefectures: favoritePrefectures,
|
|||
|
|
favoriteTypes: favoriteTypes,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Models
|
|||
|
|
class RecommendationResult {
|
|||
|
|
final List<SakeItem> similarFromCollection;
|
|||
|
|
final List<AiRecommendation> aiRecommendations;
|
|||
|
|
|
|||
|
|
RecommendationResult({
|
|||
|
|
required this.similarFromCollection,
|
|||
|
|
required this.aiRecommendations,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class AiRecommendation {
|
|||
|
|
final String name;
|
|||
|
|
final String brewery;
|
|||
|
|
final String prefecture;
|
|||
|
|
final String reason;
|
|||
|
|
|
|||
|
|
AiRecommendation({
|
|||
|
|
required this.name,
|
|||
|
|
required this.brewery,
|
|||
|
|
required this.prefecture,
|
|||
|
|
required this.reason,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
factory AiRecommendation.fromJson(Map<String, dynamic> json) {
|
|||
|
|
return AiRecommendation(
|
|||
|
|
name: json['name'] as String,
|
|||
|
|
brewery: json['brewery'] as String,
|
|||
|
|
prefecture: json['prefecture'] as String,
|
|||
|
|
reason: json['reason'] as String,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class TasteProfile {
|
|||
|
|
final double avgSweetness;
|
|||
|
|
final double avgBody;
|
|||
|
|
final List<String> favoritePrefectures;
|
|||
|
|
final List<String> favoriteTypes;
|
|||
|
|
|
|||
|
|
TasteProfile({
|
|||
|
|
required this.avgSweetness,
|
|||
|
|
required this.avgBody,
|
|||
|
|
required this.favoritePrefectures,
|
|||
|
|
required this.favoriteTypes,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
factory TasteProfile.empty() {
|
|||
|
|
return TasteProfile(
|
|||
|
|
avgSweetness: 0.0,
|
|||
|
|
avgBody: 0.0,
|
|||
|
|
favoritePrefectures: [],
|
|||
|
|
favoriteTypes: [],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
int get hashCode => Object.hash(
|
|||
|
|
avgSweetness.toStringAsFixed(1),
|
|||
|
|
avgBody.toStringAsFixed(1),
|
|||
|
|
favoritePrefectures.join(','),
|
|||
|
|
favoriteTypes.join(','),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Step 2: Update UI in sake_detail_screen.dart (4 hours)
|
|||
|
|
|
|||
|
|
Modify the "あわせて飲みたい" section:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// In sake_detail_screen.dart, replace existing recommendation section
|
|||
|
|
Widget _buildRecommendationsSection() {
|
|||
|
|
return FutureBuilder<RecommendationResult>(
|
|||
|
|
future: SakeRecommendationService.getRecommendations(
|
|||
|
|
currentSake: _sake,
|
|||
|
|
allUserSakes: ref.watch(rawSakeListItemsProvider).valueOrNull ?? [],
|
|||
|
|
includeAiRecommendations: true,
|
|||
|
|
),
|
|||
|
|
builder: (context, snapshot) {
|
|||
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|||
|
|
return _buildRecommendationLoadingState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final result = snapshot.data;
|
|||
|
|
if (result == null) {
|
|||
|
|
return _buildRecommendationErrorState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final hasSimilar = result.similarFromCollection.isNotEmpty;
|
|||
|
|
final hasAi = result.aiRecommendations.isNotEmpty;
|
|||
|
|
|
|||
|
|
if (!hasSimilar && !hasAi) {
|
|||
|
|
return _buildRecommendationEmptyState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|||
|
|
children: [
|
|||
|
|
// Header
|
|||
|
|
Row(
|
|||
|
|
children: [
|
|||
|
|
Icon(LucideIcons.sparkles, color: Theme.of(context).colorScheme.primary),
|
|||
|
|
const SizedBox(width: 8),
|
|||
|
|
Expanded(
|
|||
|
|
child: Text(
|
|||
|
|
'あわせて飲みたい',
|
|||
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
ContextualHelpIcon(
|
|||
|
|
title: '「あわせて飲みたい」について',
|
|||
|
|
content: '''この日本酒と一緒に楽しむのにおすすめの銘柄を提案します。
|
|||
|
|
|
|||
|
|
🍶 あなたのコレクションから
|
|||
|
|
登録済みの日本酒から、似た味わいの銘柄を表示します。
|
|||
|
|
|
|||
|
|
✨ AIのおすすめ
|
|||
|
|
味の傾向を分析して、新しい銘柄を提案します。
|
|||
|
|
実際に購入する際の参考にしてください。''',
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
|
|||
|
|
// Tier 1: Similar from collection
|
|||
|
|
if (hasSimilar) ...[
|
|||
|
|
Text(
|
|||
|
|
'🍶 あなたのコレクションから',
|
|||
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|||
|
|
color: Theme.of(context).colorScheme.secondary,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 8),
|
|||
|
|
...result.similarFromCollection.map((sake) => _buildSimilarCard(sake)),
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
],
|
|||
|
|
|
|||
|
|
// Tier 2: AI recommendations
|
|||
|
|
if (hasAi) ...[
|
|||
|
|
Text(
|
|||
|
|
'✨ AIのおすすめ(未登録の銘柄)',
|
|||
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|||
|
|
color: Theme.of(context).colorScheme.primary,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 8),
|
|||
|
|
...result.aiRecommendations.map((rec) => _buildAiRecommendationCard(rec)),
|
|||
|
|
],
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildAiRecommendationCard(AiRecommendation rec) {
|
|||
|
|
return Card(
|
|||
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|||
|
|
child: ListTile(
|
|||
|
|
leading: Container(
|
|||
|
|
width: 40,
|
|||
|
|
height: 40,
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: Theme.of(context).colorScheme.primaryContainer,
|
|||
|
|
shape: BoxShape.circle,
|
|||
|
|
),
|
|||
|
|
child: Icon(
|
|||
|
|
LucideIcons.sparkles,
|
|||
|
|
size: 20,
|
|||
|
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
title: Text(
|
|||
|
|
rec.name,
|
|||
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|||
|
|
),
|
|||
|
|
subtitle: Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|||
|
|
children: [
|
|||
|
|
const SizedBox(height: 4),
|
|||
|
|
Text('${rec.brewery} / ${rec.prefecture}'),
|
|||
|
|
const SizedBox(height: 4),
|
|||
|
|
Text(
|
|||
|
|
rec.reason,
|
|||
|
|
style: TextStyle(
|
|||
|
|
color: Theme.of(context).colorScheme.secondary,
|
|||
|
|
fontSize: 12,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
trailing: IconButton(
|
|||
|
|
icon: const Icon(LucideIcons.search),
|
|||
|
|
tooltip: 'ネットで検索',
|
|||
|
|
onPressed: () => _searchOnline(rec.name),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _searchOnline(String sakeName) async {
|
|||
|
|
final query = Uri.encodeComponent('$sakeName 日本酒 購入');
|
|||
|
|
final url = 'https://www.google.com/search?q=$query';
|
|||
|
|
// Open in browser (use url_launcher package)
|
|||
|
|
// await launchUrl(Uri.parse(url));
|
|||
|
|
|
|||
|
|
// For now, just show snackbar
|
|||
|
|
if (mounted) {
|
|||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|||
|
|
SnackBar(content: Text('「$sakeName」を検索...')),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildRecommendationLoadingState() {
|
|||
|
|
return Center(
|
|||
|
|
child: Column(
|
|||
|
|
children: [
|
|||
|
|
const CircularProgressIndicator(),
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
Text(
|
|||
|
|
'AIがおすすめを分析中...',
|
|||
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildRecommendationEmptyState() {
|
|||
|
|
return Center(
|
|||
|
|
child: Column(
|
|||
|
|
children: [
|
|||
|
|
Icon(
|
|||
|
|
LucideIcons.info,
|
|||
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
|||
|
|
size: 48,
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
Text(
|
|||
|
|
'関連する日本酒を追加すると\nおすすめが表示されます',
|
|||
|
|
textAlign: TextAlign.center,
|
|||
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|||
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Step 3: Testing & Caching (2 hours)
|
|||
|
|
|
|||
|
|
**Test Scenarios**:
|
|||
|
|
1. **No collection**: Show AI recommendations only
|
|||
|
|
2. **Small collection (1-3 items)**: Show similar + AI
|
|||
|
|
3. **Large collection (50+ items)**: Show similar (Tier 1) first, AI loads async
|
|||
|
|
4. **Offline mode**: Tier 1 works, Tier 2 fails gracefully
|
|||
|
|
5. **Cache hit**: Fast loading (< 100ms)
|
|||
|
|
6. **API limit reached**: Fallback to Tier 1 only
|
|||
|
|
|
|||
|
|
**Cache Strategy**:
|
|||
|
|
- Cache key: `rec_{sake_key}_{taste_profile_hash}`
|
|||
|
|
- TTL: 7 days (taste profile changes slowly)
|
|||
|
|
- Invalidation: When user adds/removes sake
|
|||
|
|
|
|||
|
|
#### Step 4: pubspec.yaml Update (1 hour)
|
|||
|
|
|
|||
|
|
Add required packages:
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
dependencies:
|
|||
|
|
url_launcher: ^6.2.2 # For "ネットで検索" button
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Step 5: Cost Analysis & Monitoring (1 hour)
|
|||
|
|
|
|||
|
|
**Gemini API Cost**:
|
|||
|
|
- Model: `gemini-2.0-flash-exp` (cheapest)
|
|||
|
|
- Tokens per request: ~500 input + 300 output = 800 total
|
|||
|
|
- Cost: ~$0.0001 per request
|
|||
|
|
- With caching: ~90% cache hit rate
|
|||
|
|
- Daily cost for 100 users: ~$0.01-0.02
|
|||
|
|
|
|||
|
|
**Monitoring**:
|
|||
|
|
- Add analytics event: `recommendation_api_call`
|
|||
|
|
- Track: Cache hit rate, response time, error rate
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ✏️ Feature 3: AI Analysis Info Editing
|
|||
|
|
|
|||
|
|
### Problem
|
|||
|
|
Currently, users cannot correct AI mistakes:
|
|||
|
|
- Wrong sake type ("純米大吟醸" vs "純米吟醸")
|
|||
|
|
- Incorrect sweetness/body scores
|
|||
|
|
- Missing information
|
|||
|
|
|
|||
|
|
### Solution: Inline Editing with Save
|
|||
|
|
|
|||
|
|
### Implementation
|
|||
|
|
|
|||
|
|
#### Step 1: Make Fields Editable (4 hours)
|
|||
|
|
|
|||
|
|
Modify `lib/screens/sake_detail_screen.dart`:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// Add state for editing mode
|
|||
|
|
bool _isEditingAiInfo = false;
|
|||
|
|
|
|||
|
|
// Controllers for each field
|
|||
|
|
late final TextEditingController _typeController;
|
|||
|
|
late final TextEditingController _sweetnessController;
|
|||
|
|
late final TextEditingController _bodyController;
|
|||
|
|
late final TextEditingController _polishingController;
|
|||
|
|
late final TextEditingController _alcoholController;
|
|||
|
|
late final TextEditingController _sakeMeterController;
|
|||
|
|
late final TextEditingController _riceController;
|
|||
|
|
late final TextEditingController _yeastController;
|
|||
|
|
late final TextEditingController _manufacturingController;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void initState() {
|
|||
|
|
super.initState();
|
|||
|
|
// Initialize controllers
|
|||
|
|
_typeController = TextEditingController(text: _sake.hiddenSpecs.type);
|
|||
|
|
_sweetnessController = TextEditingController(
|
|||
|
|
text: _sake.hiddenSpecs.sweetnessScore?.toStringAsFixed(1) ?? '',
|
|||
|
|
);
|
|||
|
|
// ... (initialize all other controllers)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void dispose() {
|
|||
|
|
// Dispose all controllers
|
|||
|
|
_typeController.dispose();
|
|||
|
|
_sweetnessController.dispose();
|
|||
|
|
// ... (dispose all controllers)
|
|||
|
|
super.dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Modify ExpansionTile to include edit button
|
|||
|
|
ExpansionTile(
|
|||
|
|
leading: Icon(LucideIcons.sparkles, color: Theme.of(context).colorScheme.primary),
|
|||
|
|
title: Row(
|
|||
|
|
children: [
|
|||
|
|
Expanded(
|
|||
|
|
child: Text(
|
|||
|
|
'AIで分析された情報',
|
|||
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
if (!_isEditingAiInfo)
|
|||
|
|
IconButton(
|
|||
|
|
icon: const Icon(LucideIcons.edit2, size: 18),
|
|||
|
|
tooltip: '編集',
|
|||
|
|
onPressed: () => setState(() => _isEditingAiInfo = true),
|
|||
|
|
)
|
|||
|
|
else
|
|||
|
|
Row(
|
|||
|
|
mainAxisSize: MainAxisSize.min,
|
|||
|
|
children: [
|
|||
|
|
TextButton(
|
|||
|
|
onPressed: _cancelAiInfoEdit,
|
|||
|
|
child: const Text('キャンセル'),
|
|||
|
|
),
|
|||
|
|
FilledButton.icon(
|
|||
|
|
onPressed: _saveAiInfo,
|
|||
|
|
icon: const Icon(LucideIcons.save, size: 16),
|
|||
|
|
label: const Text('保存'),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
children: [
|
|||
|
|
Padding(
|
|||
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|||
|
|
child: Column(
|
|||
|
|
children: [
|
|||
|
|
_buildEditableSpecRow('特定名称', _typeController, _isEditingAiInfo),
|
|||
|
|
_buildEditableSpecRow('甘辛度', _sweetnessController, _isEditingAiInfo, keyboardType: TextInputType.number),
|
|||
|
|
_buildEditableSpecRow('濃淡度', _bodyController, _isEditingAiInfo, keyboardType: TextInputType.number),
|
|||
|
|
const Divider(height: 24),
|
|||
|
|
_buildEditableSpecRow('精米歩合', _polishingController, _isEditingAiInfo, suffix: '%'),
|
|||
|
|
_buildEditableSpecRow('アルコール分', _alcoholController, _isEditingAiInfo, suffix: '度'),
|
|||
|
|
_buildEditableSpecRow('日本酒度', _sakeMeterController, _isEditingAiInfo),
|
|||
|
|
_buildEditableSpecRow('酒米', _riceController, _isEditingAiInfo),
|
|||
|
|
_buildEditableSpecRow('酵母', _yeastController, _isEditingAiInfo),
|
|||
|
|
_buildEditableSpecRow('製造年月', _manufacturingController, _isEditingAiInfo),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
Widget _buildEditableSpecRow(
|
|||
|
|
String label,
|
|||
|
|
TextEditingController controller,
|
|||
|
|
bool isEditing, {
|
|||
|
|
TextInputType? keyboardType,
|
|||
|
|
String? suffix,
|
|||
|
|
}) {
|
|||
|
|
return Padding(
|
|||
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|||
|
|
child: Row(
|
|||
|
|
children: [
|
|||
|
|
SizedBox(
|
|||
|
|
width: 100,
|
|||
|
|
child: Text(
|
|||
|
|
label,
|
|||
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|||
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
Expanded(
|
|||
|
|
child: isEditing
|
|||
|
|
? TextField(
|
|||
|
|
controller: controller,
|
|||
|
|
keyboardType: keyboardType,
|
|||
|
|
decoration: InputDecoration(
|
|||
|
|
isDense: true,
|
|||
|
|
suffixText: suffix,
|
|||
|
|
border: const OutlineInputBorder(),
|
|||
|
|
),
|
|||
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|||
|
|
)
|
|||
|
|
: Text(
|
|||
|
|
controller.text.isEmpty ? '-' : '${controller.text}${suffix ?? ''}',
|
|||
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|||
|
|
fontWeight: FontWeight.w500,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _cancelAiInfoEdit() {
|
|||
|
|
setState(() {
|
|||
|
|
_isEditingAiInfo = false;
|
|||
|
|
// Reset controllers to original values
|
|||
|
|
_typeController.text = _sake.hiddenSpecs.type ?? '';
|
|||
|
|
_sweetnessController.text = _sake.hiddenSpecs.sweetnessScore?.toStringAsFixed(1) ?? '';
|
|||
|
|
// ... (reset all controllers)
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _saveAiInfo() async {
|
|||
|
|
// Validate inputs
|
|||
|
|
final sweetness = double.tryParse(_sweetnessController.text);
|
|||
|
|
final body = double.tryParse(_bodyController.text);
|
|||
|
|
final polishing = double.tryParse(_polishingController.text);
|
|||
|
|
final alcohol = double.tryParse(_alcoholController.text);
|
|||
|
|
final sakeMeter = double.tryParse(_sakeMeterController.text);
|
|||
|
|
|
|||
|
|
// Create updated sake item
|
|||
|
|
final updatedSpecs = _sake.hiddenSpecs.copyWith(
|
|||
|
|
type: _typeController.text.isEmpty ? null : _typeController.text,
|
|||
|
|
sweetnessScore: sweetness,
|
|||
|
|
bodyScore: body,
|
|||
|
|
polishingRatio: polishing,
|
|||
|
|
alcoholContent: alcohol,
|
|||
|
|
sakeMeterValue: sakeMeter,
|
|||
|
|
riceVariety: _riceController.text.isEmpty ? null : _riceController.text,
|
|||
|
|
yeast: _yeastController.text.isEmpty ? null : _yeastController.text,
|
|||
|
|
manufacturingYearMonth: _manufacturingController.text.isEmpty ? null : _manufacturingController.text,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
final updatedSake = _sake.copyWith(hiddenSpecs: updatedSpecs);
|
|||
|
|
|
|||
|
|
// Save to Hive
|
|||
|
|
try {
|
|||
|
|
final box = Hive.box<SakeItem>('sake_items');
|
|||
|
|
await box.put(_sake.key, updatedSake);
|
|||
|
|
|
|||
|
|
setState(() {
|
|||
|
|
_sake = updatedSake;
|
|||
|
|
_isEditingAiInfo = false;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (mounted) {
|
|||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|||
|
|
const SnackBar(content: Text('✅ 保存しました')),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
if (mounted) {
|
|||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|||
|
|
SnackBar(content: Text('⚠️ 保存に失敗しました: $e')),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Step 2: Add Validation (2 hours)
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
String? _validateNumber(String value, {double? min, double? max}) {
|
|||
|
|
if (value.isEmpty) return null; // Allow empty
|
|||
|
|
|
|||
|
|
final number = double.tryParse(value);
|
|||
|
|
if (number == null) return '数値を入力してください';
|
|||
|
|
|
|||
|
|
if (min != null && number < min) return '$min以上で入力してください';
|
|||
|
|
if (max != null && number > max) return '$max以下で入力してください';
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Apply validation to fields
|
|||
|
|
TextField(
|
|||
|
|
controller: _sweetnessController,
|
|||
|
|
keyboardType: TextInputType.number,
|
|||
|
|
decoration: InputDecoration(
|
|||
|
|
isDense: true,
|
|||
|
|
border: const OutlineInputBorder(),
|
|||
|
|
errorText: _validateNumber(_sweetnessController.text, min: -10, max: 10),
|
|||
|
|
helperText: '-10 〜 +10',
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Step 3: Testing (2 hours)
|
|||
|
|
|
|||
|
|
**Test Cases**:
|
|||
|
|
- [ ] Edit button appears when not in edit mode
|
|||
|
|
- [ ] Cancel button resets all fields
|
|||
|
|
- [ ] Save button validates inputs
|
|||
|
|
- [ ] Save button writes to Hive correctly
|
|||
|
|
- [ ] UI updates after save
|
|||
|
|
- [ ] Empty fields saved as null
|
|||
|
|
- [ ] Number validation works (min/max)
|
|||
|
|
- [ ] Type dropdown works (if implemented)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📅 Implementation Schedule
|
|||
|
|
|
|||
|
|
### Week 1 (Mon-Wed)
|
|||
|
|
- **Day 1**: Feature 1 - Help Button Placement (6 hours)
|
|||
|
|
- **Day 2**: Feature 2 - AI Recommendations (Part 1: Service layer, 6 hours)
|
|||
|
|
- **Day 3**: Feature 2 - AI Recommendations (Part 2: UI layer, 6 hours)
|
|||
|
|
|
|||
|
|
### Week 2 (Thu-Fri)
|
|||
|
|
- **Day 4**: Feature 3 - AI Info Editing (Part 1: Editable fields, 4 hours)
|
|||
|
|
- **Day 5**: Feature 3 - AI Info Editing (Part 2: Validation & testing, 4 hours)
|
|||
|
|
|
|||
|
|
**Total**: 26 hours over 5 days
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ✅ Definition of Done
|
|||
|
|
|
|||
|
|
Each feature is considered complete when:
|
|||
|
|
|
|||
|
|
### Feature 1: Help Buttons
|
|||
|
|
- [ ] Help icons visible in all 3 locations
|
|||
|
|
- [ ] Bottom sheets open/close smoothly
|
|||
|
|
- [ ] Content accurate and helpful
|
|||
|
|
- [ ] Works in Light and Dark modes
|
|||
|
|
- [ ] Tested on real device
|
|||
|
|
|
|||
|
|
### Feature 2: AI Recommendations
|
|||
|
|
- [ ] Tier 1 (similar) works offline
|
|||
|
|
- [ ] Tier 2 (AI) works online with caching
|
|||
|
|
- [ ] Graceful degradation when offline/API fails
|
|||
|
|
- [ ] "ネットで検索" button works
|
|||
|
|
- [ ] Cache hit rate > 80%
|
|||
|
|
- [ ] Tested with 1, 10, 50, 100 sake items
|
|||
|
|
|
|||
|
|
### Feature 3: AI Info Editing
|
|||
|
|
- [ ] All fields editable
|
|||
|
|
- [ ] Validation prevents invalid input
|
|||
|
|
- [ ] Save writes to Hive correctly
|
|||
|
|
- [ ] Cancel resets fields
|
|||
|
|
- [ ] Works in both Light and Dark modes
|
|||
|
|
- [ ] Tested with various input scenarios
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🚨 Risks & Mitigation
|
|||
|
|
|
|||
|
|
### Risk 1: Gemini API Rate Limiting
|
|||
|
|
**Mitigation**:
|
|||
|
|
- Aggressive caching (90%+ hit rate)
|
|||
|
|
- Tier 1 fallback always works
|
|||
|
|
- User sees Tier 1 immediately, Tier 2 loads async
|
|||
|
|
|
|||
|
|
### Risk 2: AI Recommendations Quality
|
|||
|
|
**Mitigation**:
|
|||
|
|
- Prompt engineering for accurate results
|
|||
|
|
- JSON parsing with error handling
|
|||
|
|
- Manual testing with real sake data
|
|||
|
|
- User feedback mechanism (future)
|
|||
|
|
|
|||
|
|
### Risk 3: Editing Breaks Hive Data
|
|||
|
|
**Mitigation**:
|
|||
|
|
- Thorough input validation
|
|||
|
|
- copyWith() to preserve other fields
|
|||
|
|
- Automatic backup before edit (future)
|
|||
|
|
- Extensive testing
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📊 Success Metrics
|
|||
|
|
|
|||
|
|
### Feature 1: Help Buttons
|
|||
|
|
- **Adoption Rate**: % of users who tap help icons
|
|||
|
|
- **Target**: > 30% within first week
|
|||
|
|
|
|||
|
|
### Feature 2: AI Recommendations
|
|||
|
|
- **API Call Rate**: Calls per day
|
|||
|
|
- **Cache Hit Rate**: > 80%
|
|||
|
|
- **User Engagement**: % who tap "ネットで検索"
|
|||
|
|
- **Target**: > 50% engagement
|
|||
|
|
|
|||
|
|
### Feature 3: AI Info Editing
|
|||
|
|
- **Edit Rate**: % of sake items edited by users
|
|||
|
|
- **Target**: > 10% (users correct AI mistakes)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🔗 Related Documents
|
|||
|
|
|
|||
|
|
- [PROJECT_TODO.md](PROJECT_TODO.md) - Active tasks
|
|||
|
|
- [DARK_MODE_COLOR_GUIDELINES.md](DARK_MODE_COLOR_GUIDELINES.md) - UI guidelines
|
|||
|
|
- [gamification_specification.md](gamification_specification.md) - Badge system
|
|||
|
|
- [RECOMMENDATION_EXPANSION_PLAN.md](RECOMMENDATION_EXPANSION_PLAN.md) - Phase 3 expansion
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
**Status**: Ready to implement
|
|||
|
|
**Next Action**: Choose Feature 1, 2, or 3 to start
|
|||
|
|
**Recommended Order**: Feature 1 → Feature 3 → Feature 2 (Quick wins first)
|