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
This commit is contained in:
parent
1a50c739a1
commit
d760e7bf08
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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<AppColors>()!;
|
||||
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int> 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<AppColors>()!;
|
||||
|
||||
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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SakeDetailScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
// 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<SakeDetailScreen> {
|
|||
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<SakeDetailScreen> {
|
|||
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<SakeItem>('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<SakeItem>('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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue