36 KiB
🚀 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:
- Help Button Placement (Pattern C: Hybrid) - 6 hours
- AI-Powered Recommendations ("あわせて飲みたい") - 12 hours
- 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:
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
// 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
// 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
// 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)
// 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:
- Tier 1: Similar from user's collection (fast, offline)
- Tier 2: AI-generated recommendations from Gemini API (slow, online)
Implementation
Step 1: Enhance SakeRecommendationService (4 hours)
Modify lib/services/sake_recommendation_service.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:
// 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:
- No collection: Show AI recommendations only
- Small collection (1-3 items): Show similar + AI
- Large collection (50+ items): Show similar (Tier 1) first, AI loads async
- Offline mode: Tier 1 works, Tier 2 fails gracefully
- Cache hit: Fast loading (< 100ms)
- 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:
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:
// 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)
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 - Active tasks
- DARK_MODE_COLOR_GUIDELINES.md - UI guidelines
- gamification_specification.md - Badge system
- 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)