# 🚀 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 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 getRecommendations({ required SakeItem currentSake, required List 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? aiRecommendations; if (includeAiRecommendations) { aiRecommendations = await _getAiRecommendations( currentSake: currentSake, userTasteProfile: _buildTasteProfile(allUserSakes), ); } return RecommendationResult( similarFromCollection: similarFromCollection, aiRecommendations: aiRecommendations ?? [], ); } /// Tier 1: Fast similarity matching static List _getSimilarFromCollection({ required SakeItem currentSake, required List 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> _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 _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 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 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 = {}; 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 = {}; 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 similarFromCollection; final List 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 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 favoritePrefectures; final List 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( 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 _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('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)