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)
|