ponshu-room-lite/docs/PHASE_2_IMPLEMENTATION_PLAN.md

1228 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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