ponshu-room-lite/docs/PHASE_2_IMPLEMENTATION_PLAN.md

1228 lines
36 KiB
Markdown
Raw Normal View History

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