ponshu-room-lite/docs/PHASE_2_IMPLEMENTATION_PLAN.md

36 KiB
Raw Blame 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:

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:

  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:

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:

  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:

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)


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)