From d760e7bf086455a1cc2df894354af089bb2ff6a7 Mon Sep 17 00:00:00 2001 From: Ponshu Developer Date: Mon, 16 Feb 2026 19:08:09 +0900 Subject: [PATCH] chore: distribution prep - Kotlin DSL signing, .gitignore, build() split - Fix DISTRIBUTION_API_KEY_SETUP.md: Groovy -> Kotlin DSL, Windows keytool cmds - Add release signing config to build.gradle.kts (key.properties fallback) - Add .serena/, key.properties, ponshu_release.jks to .gitignore - Include Claude's build() split: SakeDetailSliverAppBar, SakeBasicInfoSection - sake_detail_screen.dart: 1099 -> 795 lines (-304 lines) - Remove tmp_commit_msg.txt --- .gitignore | 7 +- android/app/build.gradle.kts | 33 +- docs/DISTRIBUTION_API_KEY_SETUP.md | 170 ++++++++ .../sections/sake_basic_info_section.dart | 230 +++++++++++ .../widgets/sake_detail_sliver_app_bar.dart | 167 ++++++++ lib/screens/sake_detail_screen.dart | 365 ++---------------- tmp_commit_msg.txt | 7 - 7 files changed, 630 insertions(+), 349 deletions(-) create mode 100644 docs/DISTRIBUTION_API_KEY_SETUP.md create mode 100644 lib/screens/sake_detail/sections/sake_basic_info_section.dart create mode 100644 lib/screens/sake_detail/widgets/sake_detail_sliver_app_bar.dart delete mode 100644 tmp_commit_msg.txt diff --git a/.gitignore b/.gitignore index 92e14ba..67fb226 100644 --- a/.gitignore +++ b/.gitignore @@ -53,10 +53,15 @@ lib/libsecrets.dart *.aab *.ipa -# IDE / Editor +# IDE / Editor / MCP Tools .claude/ .cursor/ .vscode/ +.serena/ + +# Release signing +android/app/ponshu_release.jks +android/key.properties # Environment / Secrets .env diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ac880c2..783c2b3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -6,6 +8,13 @@ plugins { id("com.google.gms.google-services") } +// Release signing: key.properties から読み込み(存在しない場合はdebug署名にフォールバック) +val keyPropertiesFile = rootProject.file("app/key.properties") +val keyProperties = Properties() +if (keyPropertiesFile.exists()) { + keyProperties.load(keyPropertiesFile.inputStream()) +} + android { namespace = "com.posimai.ponshu_room" compileSdk = 36 @@ -21,7 +30,7 @@ android { } defaultConfig { - // Pro version package name (different from Lite version: com.posimai.ponshu_room.lite) + // Lite版のアプリID(Pro版: com.posimai.ponshu_room) applicationId = "com.posimai.ponshu_room_lite" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. @@ -35,15 +44,27 @@ android { // multiDexEnabled = true } + signingConfigs { + if (keyPropertiesFile.exists()) { + create("release") { + keyAlias = keyProperties["keyAlias"] as String + keyPassword = keyProperties["keyPassword"] as String + storeFile = file(keyProperties["storeFile"] as String) + storePassword = keyProperties["storePassword"] as String + } + } + } + buildTypes { getByName("release") { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - // Explicitly disable R8 + // key.properties が存在すればrelease署名、なければdebug署名(開発用) + signingConfig = if (keyPropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } isMinifyEnabled = false isShrinkResources = false - // proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } } diff --git a/docs/DISTRIBUTION_API_KEY_SETUP.md b/docs/DISTRIBUTION_API_KEY_SETUP.md new file mode 100644 index 0000000..5b2bd04 --- /dev/null +++ b/docs/DISTRIBUTION_API_KEY_SETUP.md @@ -0,0 +1,170 @@ +# 配布用 Gemini API キー設定ガイド + +このガイドは、知人配布用 APK に埋め込む **専用 Gemini API キー** を発行・設定する手順書です。 + +## なぜ専用キーが必要か + +開発用キー(`lib/secrets.local.dart` に設定済み)をそのまま配布 APK に使うと: +- APK を逆コンパイルすれば誰でもキーを抽出できる +- あなたのクォータ(無料枠)を知らない第三者に使い尽くされる可能性がある +- キーを無効化すると開発環境も壊れる + +**解決策**: 配布専用キーを発行し、Androidパッケージ名で制限する。 + +--- + +## 手順 + +### 1. Google Cloud Console で新しい API キーを発行 + +1. [Google AI Studio](https://aistudio.google.com/) にアクセス +2. 右上のメニューから「Get API key」→「Create API key in new project」 + - または既存プロジェクトに追加: 「Create API key」→ プロジェクト選択 +3. 発行されたキーをメモ(`AIzaSy...` 形式) + +### 2. API キーに制限を設定 + +[Google Cloud Console API 認証情報ページ](https://console.cloud.google.com/apis/credentials) にアクセス: + +1. 作成したキーをクリック → 「キーを編集」 +2. **API の制限**: + - 「キーを制限する」を選択 + - 「Generative Language API」のみ選択 +3. **アプリケーションの制限**: + - 「Android アプリ」を選択 + - 「アイテムを追加」をクリック + - パッケージ名: `com.posimai.ponshu_room_lite` + - SHA-1 証明書フィンガープリント: (後述) +4. 保存 + +### 3. リリース署名の SHA-1 フィンガープリントを取得 + +配布 APK はリリースキーストアで署名する必要があります。 + +**キーストアが未作成の場合(Windowsコマンドプロンプト):** + +```cmd +keytool -genkeypair -v -keystore android\app\ponshu_release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias ponshu_key +``` + +パスワードを設定し、組織情報を入力。 + +> ⚠️ **パスワードは安全な場所に記録してください。紛失するとアプリの更新ができなくなります。** + +**SHA-1 の取得(Windowsコマンドプロンプト):** + +```cmd +keytool -list -v -keystore android\app\ponshu_release.jks -alias ponshu_key +``` + +出力の `SHA1:` 行をコピーして Google Cloud Console に貼り付ける。 + +### 4. key.properties にパスワードを設定 + +`android/key.properties` を新規作成(このファイルは .gitignore に含まれています): + +```properties +storePassword=あなたが設定したパスワード +keyPassword=あなたが設定したパスワード +keyAlias=ponshu_key +storeFile=ponshu_release.jks +``` + +### 5. build.gradle.kts にリリース署名を設定 + +`android/app/build.gradle.kts` を以下のように変更: + +```kotlin +import java.util.Properties + +// key.properties からリリース署名情報を読み込み +val keyPropertiesFile = rootProject.file("app/key.properties") +val keyProperties = Properties() +if (keyPropertiesFile.exists()) { + keyProperties.load(keyPropertiesFile.inputStream()) +} + +android { + // ... 既存設定 ... + + signingConfigs { + if (keyPropertiesFile.exists()) { + create("release") { + keyAlias = keyProperties["keyAlias"] as String + keyPassword = keyProperties["keyPassword"] as String + storeFile = file(keyProperties["storeFile"] as String) + storePassword = keyProperties["storePassword"] as String + } + } + } + + buildTypes { + getByName("release") { + signingConfig = if (keyPropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + isMinifyEnabled = false + isShrinkResources = false + } + } +} +``` + +> key.properties が存在しない場合は自動的に debug 署名にフォールバックします。 + +### 6. 配布 APK のビルド + +```cmd +flutter build apk --release --dart-define=GEMINI_API_KEY=AIzaSy...(配布用キー) +``` + +出力ファイル: `build\app\outputs\flutter-apk\app-release.apk` + +--- + +## 配布方法 + +### Google Drive 経由(推奨) +1. `app-release.apk` を Google Drive にアップロード +2. 共有リンクを知人に送付 +3. 受信者は Android の「設定 > セキュリティ > 提供元不明のアプリ」を有効化してインストール + +### LINE で直接送信 +1. APKファイルをLINEのトーク画面にドラッグ&ドロップ +2. 受信者がファイルをタップしてインストール + +### adb 直接転送(テスト時) +```cmd +adb install build\app\outputs\flutter-apk\app-release.apk +``` + +--- + +## API クォータ管理 + +| 設定 | 推奨値 | 説明 | +|------|--------|------| +| 1日あたりのリクエスト上限 | 100〜200 | Google Cloud Console → API → クォータで設定 | +| 無料枠 | 1,500回/日 | Gemini 2.5 Flash 無料枠 | + +Google Cloud Console で **アラート** を設定しておくことを推奨: +- 「APIs & Services」→「Gemini API」→「クォータ」→「アラートの設定」 + +--- + +## チェックリスト + +- [ ] 配布用 API キーを Google AI Studio で発行 +- [ ] API 制限: Generative Language API のみ +- [ ] アプリ制限: `com.posimai.ponshu_room_lite` の SHA-1 +- [ ] リリースキーストア(`ponshu_release.jks`)を作成 +- [ ] `android/key.properties` にパスワードを設定 +- [ ] キーストアと key.properties が `.gitignore` に含まれていることを確認 +- [ ] `flutter build apk --release --dart-define=GEMINI_API_KEY=<配布用キー>` で APK ビルド +- [ ] テスト端末でインストール・動作確認 + +--- + +**Last Updated**: 2026-02-16 diff --git a/lib/screens/sake_detail/sections/sake_basic_info_section.dart b/lib/screens/sake_detail/sections/sake_basic_info_section.dart new file mode 100644 index 0000000..e48fbef --- /dev/null +++ b/lib/screens/sake_detail/sections/sake_basic_info_section.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../../models/sake_item.dart'; +import '../../../theme/app_colors.dart'; +import '../../../constants/app_constants.dart'; +import '../../../providers/theme_provider.dart'; +import '../../../services/mbti_compatibility_service.dart'; + +/// 銘柄名・蔵元/都道府県・タグ・AI確信度バッジ・MBTI相性バッジ・キャッチコピーを表示するセクション +class SakeBasicInfoSection extends ConsumerWidget { + final SakeItem sake; + final VoidCallback onTapName; + final VoidCallback onTapBrewery; + final VoidCallback onTapTags; + final void Function(BuildContext context, CompatibilityResult result, AppColors appColors) onTapMbtiCompatibility; + + const SakeBasicInfoSection({ + super.key, + required this.sake, + required this.onTapName, + required this.onTapBrewery, + required this.onTapTags, + required this.onTapMbtiCompatibility, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appColors = Theme.of(context).extension()!; + + // AI Confidence Logic (Theme Aware) + final score = sake.metadata.aiConfidence ?? 0; + final Color confidenceColor = score >= AppConstants.confidenceScoreHigh + ? appColors.brandPrimary + : score >= AppConstants.confidenceScoreMedium + ? appColors.textSecondary + : appColors.textTertiary; + + // MBTI Compatibility + final userProfile = ref.watch(userProfileProvider); + final mbtiType = userProfile.mbti; + final mbtiResult = (mbtiType != null && mbtiType.isNotEmpty) + ? MBTICompatibilityService.calculateCompatibility(mbtiType, sake) + : null; + final showMbti = mbtiResult != null && mbtiResult.hasResult; + final badgeColor = showMbti + ? (mbtiResult.starRating >= 4 + ? appColors.brandPrimary + : mbtiResult.starRating >= 3 + ? appColors.textSecondary + : appColors.textTertiary) + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Confidence Badge + if (sake.metadata.aiConfidence != null && sake.itemType != ItemType.set) + Center( + child: Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: confidenceColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: confidenceColor.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.sparkles, size: 14, color: confidenceColor), + const SizedBox(width: 6), + Text( + 'AI確信度: $score%', + style: TextStyle( + color: confidenceColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ), + ), + + // MBTI Compatibility Badge + if (showMbti) + Center( + child: GestureDetector( + onTap: () => onTapMbtiCompatibility(context, mbtiResult, appColors), + child: Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: badgeColor!.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: badgeColor.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$mbtiType相性: ', + style: TextStyle( + color: appColors.textSecondary, + fontSize: 12, + ), + ), + Text( + mbtiResult.starDisplay, + style: TextStyle( + color: badgeColor, + fontSize: 14, + letterSpacing: 1, + ), + ), + const SizedBox(width: 4), + Icon( + LucideIcons.info, + size: 12, + color: appColors.iconSubtle, + ), + ], + ), + ), + ), + ), + + // Brand Name + Center( + child: InkWell( + onTap: onTapName, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + sake.displayData.displayName, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 8), + Icon(LucideIcons.pencil, size: 18, color: appColors.iconSubtle), + ], + ), + ), + ), + const SizedBox(height: 8), + + // Brand / Prefecture + if (sake.itemType != ItemType.set) + Center( + child: InkWell( + onTap: onTapBrewery, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + '${sake.displayData.displayBrewery} / ${sake.displayData.displayPrefecture}', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: appColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + Icon(LucideIcons.pencil, size: 16, color: appColors.iconSubtle), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Tags Row + if (sake.hiddenSpecs.flavorTags.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTapTags, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 8, + children: sake.hiddenSpecs.flavorTags + .map((tag) => Chip( + label: Text(tag, style: const TextStyle(fontSize: 10)), + visualDensity: VisualDensity.compact, + backgroundColor: + Theme.of(context).primaryColor.withValues(alpha: 0.1), + )) + .toList(), + ), + ), + ), + ), + ), + + const SizedBox(height: 24), + + // AI Catchcopy (Mincho) + if (sake.displayData.catchCopy != null && sake.itemType != ItemType.set) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + sake.displayData.catchCopy!, + style: GoogleFonts.zenOldMincho( + fontSize: 24, + height: 1.5, + fontWeight: FontWeight.w500, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Theme.of(context).primaryColor, + ), + textAlign: TextAlign.center, + ), + ), + ], + ); + } +} diff --git a/lib/screens/sake_detail/widgets/sake_detail_sliver_app_bar.dart b/lib/screens/sake_detail/widgets/sake_detail_sliver_app_bar.dart new file mode 100644 index 0000000..b19ea15 --- /dev/null +++ b/lib/screens/sake_detail/widgets/sake_detail_sliver_app_bar.dart @@ -0,0 +1,167 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../../models/sake_item.dart'; +import '../../../theme/app_colors.dart'; +import '../../../widgets/common/munyun_like_button.dart'; + +/// SakeDetailScreen の SliverAppBar を担当するウィジェット +/// 画像カルーセル(複数枚)/単一画像表示、FAB付き写真編集ボタン、スクリムを含む +class SakeDetailSliverAppBar extends StatelessWidget { + final SakeItem sake; + final int currentImageIndex; + final VoidCallback onToggleFavorite; + final VoidCallback onReanalyze; + final VoidCallback onDelete; + final ValueChanged onPageChanged; + final VoidCallback onPhotoEdit; + + const SakeDetailSliverAppBar({ + super.key, + required this.sake, + required this.currentImageIndex, + required this.onToggleFavorite, + required this.onReanalyze, + required this.onDelete, + required this.onPageChanged, + required this.onPhotoEdit, + }); + + @override + Widget build(BuildContext context) { + final appColors = Theme.of(context).extension()!; + + return SliverAppBar( + expandedHeight: 400.0, + floating: false, + pinned: true, + backgroundColor: Theme.of(context).primaryColor, + iconTheme: const IconThemeData(color: Colors.white), + actions: [ + MunyunLikeButton( + isLiked: sake.userData.isFavorite, + onTap: onToggleFavorite, + ), + IconButton( + icon: const Icon(LucideIcons.refreshCw), + color: Colors.white, + tooltip: 'AI再解析', + onPressed: onReanalyze, + ), + IconButton( + icon: const Icon(LucideIcons.trash2), + color: Colors.white, + tooltip: '削除', + onPressed: onDelete, + ), + ], + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + sake.displayData.imagePaths.length > 1 + ? Stack( + fit: StackFit.expand, + children: [ + PageView.builder( + itemCount: sake.displayData.imagePaths.length, + onPageChanged: onPageChanged, + itemBuilder: (context, index) { + final imageWidget = Image.file( + File(sake.displayData.imagePaths[index]), + fit: BoxFit.cover, + ); + + // Apply Hero only to the first image for smooth transition from Grid/List + if (index == 0) { + return Hero( + tag: sake.id, + child: imageWidget, + ); + } + return imageWidget; + }, + ), + // Simple Indicator + Positioned( + bottom: 16, + right: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + '${currentImageIndex + 1} / ${sake.displayData.imagePaths.length}', + style: const TextStyle( + color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + ), + ), + ), + // Photo Edit Button + Positioned( + bottom: 16, + left: 16, + child: FloatingActionButton.small( + heroTag: 'photo_edit', + backgroundColor: Colors.white, + onPressed: onPhotoEdit, + child: Icon(LucideIcons.image, color: appColors.iconDefault), + ), + ), + ], + ) + : Stack( + fit: StackFit.expand, + children: [ + Hero( + tag: sake.id, + child: sake.displayData.imagePaths.isNotEmpty + ? Image.file( + File(sake.displayData.imagePaths.first), + fit: BoxFit.cover, + ) + : Container( + color: appColors.surfaceSubtle, + child: Icon(LucideIcons.image, size: 80, color: appColors.iconSubtle), + ), + ), + // Photo Edit Button for single image + Positioned( + bottom: 16, + left: 16, + child: FloatingActionButton.small( + heroTag: 'photo_edit_single', + backgroundColor: Colors.white, + onPressed: onPhotoEdit, + child: Icon(LucideIcons.image, color: appColors.iconDefault), + ), + ), + ], + ), + // Scrim for Header Icons Visibility + Positioned( + top: 0, + left: 0, + right: 0, + height: 100, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/sake_detail_screen.dart b/lib/screens/sake_detail_screen.dart index 80c9545..24ab6c1 100644 --- a/lib/screens/sake_detail_screen.dart +++ b/lib/screens/sake_detail_screen.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../models/sake_item.dart'; @@ -11,18 +10,19 @@ import '../widgets/analyzing_dialog.dart'; import '../widgets/sake_3d_carousel_with_reason.dart'; import 'package:hive_flutter/hive_flutter.dart'; import '../providers/sake_list_provider.dart'; -import '../providers/theme_provider.dart'; import 'sake_detail/sections/sake_pricing_section.dart'; import '../theme/app_colors.dart'; import '../constants/app_constants.dart'; -import '../widgets/common/munyun_like_button.dart'; import '../widgets/sake_detail/sake_detail_chart.dart'; import '../widgets/sake_detail/sake_detail_memo.dart'; import '../widgets/sake_detail/sake_detail_specs.dart'; import 'sake_detail/widgets/sake_photo_edit_modal.dart'; import 'sake_detail/sections/sake_mbti_stamp_section.dart'; +import 'sake_detail/sections/sake_basic_info_section.dart'; +import 'sake_detail/widgets/sake_detail_sliver_app_bar.dart'; import '../services/mbti_compatibility_service.dart'; import '../widgets/sakenowa/sakenowa_detail_recommendation_section.dart'; +// Note: google_fonts and theme_provider are now used in sake_basic_info_section.dart class SakeDetailScreen extends ConsumerStatefulWidget { @@ -83,15 +83,6 @@ class _SakeDetailScreenState extends ConsumerState { Widget build(BuildContext context) { final appColors = Theme.of(context).extension()!; - // Determine confidence text color (CRITICAL FIX: Use AppColors for theme consistency) - // AI Confidence Logic (Theme Aware) - final score = _sake.metadata.aiConfidence ?? 0; - final Color confidenceColor = score >= AppConstants.confidenceScoreHigh - ? appColors.brandPrimary // High confidence: Primary brand color - : score >= AppConstants.confidenceScoreMedium - ? appColors.textSecondary // Medium confidence: Secondary text color - : appColors.textTertiary; // Low confidence: Tertiary (muted) - // スマートレコメンド (Phase 1-8 Enhanced) final allSakeAsync = ref.watch(allSakeItemsProvider); final allSake = allSakeAsync.asData?.value ?? []; @@ -108,139 +99,17 @@ class _SakeDetailScreenState extends ConsumerState { return Scaffold( body: CustomScrollView( slivers: [ - SliverAppBar( - expandedHeight: 400.0, - floating: false, - pinned: true, - backgroundColor: Theme.of(context).primaryColor, - iconTheme: const IconThemeData(color: Colors.white), - actions: [ - MunyunLikeButton( - isLiked: _sake.userData.isFavorite, - onTap: () => _toggleFavorite(), - ), - IconButton( - icon: const Icon(LucideIcons.refreshCw), - color: Colors.white, - tooltip: 'AI再解析', - onPressed: () => _reanalyze(context), - ), - IconButton( - icon: const Icon(LucideIcons.trash2), - color: Colors.white, - tooltip: '削除', - onPressed: () { - HapticFeedback.heavyImpact(); - _showDeleteDialog(context); - }, - ), - ], - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - _sake.displayData.imagePaths.length > 1 - ? Stack( - fit: StackFit.expand, - children: [ - PageView.builder( - itemCount: _sake.displayData.imagePaths.length, - onPageChanged: (index) => setState(() => _currentImageIndex = index), - itemBuilder: (context, index) { - final imageWidget = Image.file( - File(_sake.displayData.imagePaths[index]), - fit: BoxFit.cover, - ); - - // Apply Hero only to the first image for smooth transition from Grid/List - if (index == 0) { - return Hero( - tag: _sake.id, - child: imageWidget, - ); - } - return imageWidget; - }, - ), - // Simple Indicator - Positioned( - bottom: 16, - right: 16, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - '${_currentImageIndex + 1} / ${_sake.displayData.imagePaths.length}', - style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), - ), - ), - ), - // Photo Edit Button - Positioned( - bottom: 16, - left: 16, - child: FloatingActionButton.small( - heroTag: 'photo_edit', - backgroundColor: Colors.white, - onPressed: () => _showPhotoEditModal(context), - child: Icon(LucideIcons.image, color: appColors.iconDefault), - ), - ), - ], - ) - : Stack( - fit: StackFit.expand, - children: [ - Hero( - tag: _sake.id, - child: _sake.displayData.imagePaths.isNotEmpty - ? Image.file( - File(_sake.displayData.imagePaths.first), - fit: BoxFit.cover, - ) - : Container( - color: appColors.surfaceSubtle, - child: Icon(LucideIcons.image, size: 80, color: appColors.iconSubtle), - ), - ), - // Photo Edit Button for single image - Positioned( - bottom: 16, - left: 16, - child: FloatingActionButton.small( - heroTag: 'photo_edit_single', - backgroundColor: Colors.white, - onPressed: () => _showPhotoEditModal(context), - child: Icon(LucideIcons.image, color: appColors.iconDefault), - ), - ), - ], - ), - // Scrim for Header Icons Visibility - Positioned( - top: 0, - left: 0, - right: 0, - height: 100, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.7), - Colors.transparent, - ], - ), - ), - ), - ), - ], - ), - ), + SakeDetailSliverAppBar( + sake: _sake, + currentImageIndex: _currentImageIndex, + onToggleFavorite: _toggleFavorite, + onReanalyze: () => _reanalyze(context), + onDelete: () { + HapticFeedback.heavyImpact(); + _showDeleteDialog(context); + }, + onPageChanged: (index) => setState(() => _currentImageIndex = index), + onPhotoEdit: () => _showPhotoEditModal(context), ), SliverToBoxAdapter( child: Container( @@ -263,197 +132,23 @@ class _SakeDetailScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Confidence Badge - if (_sake.metadata.aiConfidence != null && _sake.itemType != ItemType.set) - Center( - child: Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: confidenceColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: confidenceColor.withValues(alpha: 0.5)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(LucideIcons.sparkles, size: 14, color: confidenceColor), - const SizedBox(width: 6), - Text( - 'AI確信度: $score%', - style: TextStyle( - color: confidenceColor, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ], - ), - ), - ), - - // MBTI Compatibility Badge (Star Rating Pattern B) - Consumer( - builder: (context, ref, child) { - final userProfile = ref.watch(userProfileProvider); - final mbtiType = userProfile.mbti; - if (mbtiType == null || mbtiType.isEmpty) return const SizedBox.shrink(); - - final result = MBTICompatibilityService.calculateCompatibility(mbtiType, _sake); - if (!result.hasResult) return const SizedBox.shrink(); - - final badgeColor = result.starRating >= 4 - ? appColors.brandPrimary - : result.starRating >= 3 - ? appColors.textSecondary - : appColors.textTertiary; - - return Center( - child: GestureDetector( - onTap: () => _showMbtiCompatibilityDialog(context, result, appColors), - child: Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: badgeColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: badgeColor.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '$mbtiType相性: ', - style: TextStyle( - color: appColors.textSecondary, - fontSize: 12, - ), - ), - Text( - result.starDisplay, - style: TextStyle( - color: badgeColor, - fontSize: 14, - letterSpacing: 1, - ), - ), - const SizedBox(width: 4), - Icon( - LucideIcons.info, - size: 12, - color: appColors.iconSubtle, - ), - ], - ), - ), - ), - ); - }, - ), - - // Brand Name - Center( - child: InkWell( - onTap: () => _showTextEditDialog( - context, - title: '銘柄名を編集', - initialValue: _sake.displayData.displayName, - onSave: (value) async { - final box = Hive.box('sake_items'); - final updated = _sake.copyWith(name: value, isUserEdited: true); - await box.put(_sake.key, updated); - setState(() => _sake = updated); - }, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - _sake.displayData.displayName, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(width: 8), - Icon(LucideIcons.pencil, size: 18, color: appColors.iconSubtle), - ], - ), - ), - ), - const SizedBox(height: 8), - - // Brand / Prefecture - if (_sake.itemType != ItemType.set) - Center( - child: InkWell( - onTap: () => _showBreweryEditDialog(context), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - '${_sake.displayData.displayBrewery} / ${_sake.displayData.displayPrefecture}', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: appColors.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: 8), - Icon(LucideIcons.pencil, size: 16, color: appColors.iconSubtle), - ], - ), - ), - ), - const SizedBox(height: 16), - - // Tags Row - if (_sake.hiddenSpecs.flavorTags.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _showTagEditDialog(context), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Wrap( - alignment: WrapAlignment.center, - spacing: 8, - children: _sake.hiddenSpecs.flavorTags.map((tag) => Chip( - label: Text(tag, style: const TextStyle(fontSize: 10)), - visualDensity: VisualDensity.compact, - backgroundColor: Theme.of(context).primaryColor.withValues(alpha: 0.1), - )).toList(), - ), - ), - ), - ), - ), - - const SizedBox(height: 24), - - // AI Catchcopy (Mincho) - if (_sake.displayData.catchCopy != null && _sake.itemType != ItemType.set) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - _sake.displayData.catchCopy!, - style: GoogleFonts.zenOldMincho( - fontSize: 24, - height: 1.5, - fontWeight: FontWeight.w500, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Theme.of(context).primaryColor, // Adaptive - ), - textAlign: TextAlign.center, + // Basic Info: AI確信度・MBTI相性・銘柄名・蔵元・タグ・キャッチコピー (Extracted) + SakeBasicInfoSection( + sake: _sake, + onTapName: () => _showTextEditDialog( + context, + title: '銘柄名を編集', + initialValue: _sake.displayData.displayName, + onSave: (value) async { + final box = Hive.box('sake_items'); + final updated = _sake.copyWith(name: value, isUserEdited: true); + await box.put(_sake.key, updated); + setState(() => _sake = updated); + }, ), + onTapBrewery: () => _showBreweryEditDialog(context), + onTapTags: () => _showTagEditDialog(context), + onTapMbtiCompatibility: _showMbtiCompatibilityDialog, ), const SizedBox(height: 24), diff --git a/tmp_commit_msg.txt b/tmp_commit_msg.txt deleted file mode 100644 index 9186bbc..0000000 --- a/tmp_commit_msg.txt +++ /dev/null @@ -1,7 +0,0 @@ -chore: Clean up repository - remove temp files, strengthen .gitignore - -- Remove APK build artifacts from working directory -- Move old review/analysis reports to docs/archive/ -- Move initial setup docs to docs/archive/ -- Add .apk/.aab/.ipa/.claude/.cursor/.env to .gitignore -- Remove .claude/ from git tracking