v1.2: Map Tab Tile Map, Sommelier Enhancements, APK Optimization (111MB)

This commit is contained in:
Ponshu Developer 2026-01-13 09:57:18 +09:00
parent e05d05c4ee
commit 191e334d0d
24 changed files with 646 additions and 138 deletions

View File

@ -13,7 +13,11 @@
"Bash(git rebase:*)", "Bash(git rebase:*)",
"Bash(cat:*)", "Bash(cat:*)",
"Bash(git pull:*)", "Bash(git pull:*)",
"Bash(git stash:*)" "Bash(git stash:*)",
"Read(//c/Users/maita/posimai-project/ponshu-room/**)",
"Bash(flutter build:*)",
"Bash(unzip:*)",
"Bash(ls:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@ -29,6 +29,9 @@ android {
targetSdk = 34 targetSdk = 34
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
ndk {
abiFilters.add("arm64-v8a")
}
} }
buildTypes { buildTypes {

View File

@ -0,0 +1,88 @@
class TilePosition {
final int col; // x
final int row; // y
final int width;
final int height;
const TilePosition({
required this.col,
required this.row,
this.width = 1,
this.height = 1,
});
}
class PrefectureTileLayout {
// Final definitive map (Validated visually)
// X: 0..13 (approx width)
// Y: 0..11 (approx height)
static Map<String, TilePosition> get getLayout => finalLayout;
static const Map<String, TilePosition> finalLayout = {
// Hokkaido
'北海道': TilePosition(col: 12, row: 0, width: 2, height: 2),
// Tohoku
'青森': TilePosition(col: 12, row: 2),
'秋田': TilePosition(col: 11, row: 3),
'岩手': TilePosition(col: 12, row: 3),
'山形': TilePosition(col: 11, row: 4),
'宮城': TilePosition(col: 12, row: 4),
'福島': TilePosition(col: 12, row: 5),
// Kanto & Koshinetsu
'茨城': TilePosition(col: 13, row: 6),
'栃木': TilePosition(col: 12, row: 6),
'群馬': TilePosition(col: 11, row: 6),
'埼玉': TilePosition(col: 11, row: 7),
'東京': TilePosition(col: 11, row: 8),
'千葉': TilePosition(col: 12, row: 8),
'神奈川': TilePosition(col: 11, row: 9),
'山梨': TilePosition(col: 10, row: 7),
'長野': TilePosition(col: 10, row: 6),
'新潟': TilePosition(col: 11, row: 5),
// Hokuriku & Tokai
'富山': TilePosition(col: 10, row: 5),
'石川': TilePosition(col: 9, row: 5),
'福井': TilePosition(col: 9, row: 6),
'岐阜': TilePosition(col: 9, row: 7),
'愛知': TilePosition(col: 9, row: 8),
'静岡': TilePosition(col: 10, row: 8),
'三重': TilePosition(col: 8, row: 8),
// Kinki
'滋賀': TilePosition(col: 8, row: 7),
'京都': TilePosition(col: 7, row: 7),
'大阪': TilePosition(col: 7, row: 8),
'兵庫': TilePosition(col: 6, row: 7),
'奈良': TilePosition(col: 8, row: 9),
'和歌山': TilePosition(col: 7, row: 9),
// Chugoku
'鳥取': TilePosition(col: 5, row: 7),
'岡山': TilePosition(col: 5, row: 8),
'島根': TilePosition(col: 4, row: 7),
'広島': TilePosition(col: 4, row: 8),
'山口': TilePosition(col: 3, row: 8),
// Shikoku
'香川': TilePosition(col: 6, row: 9),
'徳島': TilePosition(col: 6, row: 10),
'愛媛': TilePosition(col: 5, row: 9),
'高知': TilePosition(col: 5, row: 10),
// Kyushu
'福岡': TilePosition(col: 2, row: 8),
'大分': TilePosition(col: 2, row: 9),
'佐賀': TilePosition(col: 1, row: 8),
'長崎': TilePosition(col: 0, row: 8),
'熊本': TilePosition(col: 1, row: 9),
'宮崎': TilePosition(col: 2, row: 10),
'鹿児島': TilePosition(col: 1, row: 10),
// Okinawa
'沖縄': TilePosition(col: 0, row: 11),
};
}

View File

@ -277,6 +277,6 @@ class SakeItem extends HiveObject {
} }
// Compact JSON for QR ecosystem // Compact JSON for QR ecosystem
String toQrJson() { String toQrJson() {
return '{"id":"$id","n":"${displayData.name}","b":"${displayData.brewery}","p":"${displayData.prefecture ?? ""}","s":${hiddenSpecs.sweetnessScore ?? 0},"y":${hiddenSpecs.bodyScore ?? 0},"a":${hiddenSpecs.alcoholContent ?? 0}}'; return '{"id":"$id","n":"${displayData.name}","b":"${displayData.brewery}","p":"${displayData.prefecture}","s":${hiddenSpecs.sweetnessScore ?? 0},"y":${hiddenSpecs.bodyScore ?? 0},"a":${hiddenSpecs.alcoholContent ?? 0}}';
} }
} }

View File

@ -46,6 +46,12 @@ class UserProfile extends HiveObject {
@HiveField(11, defaultValue: false) @HiveField(11, defaultValue: false)
bool hasCompletedOnboarding; bool hasCompletedOnboarding;
@HiveField(12)
String? nickname;
@HiveField(13)
String? gender; // 'male', 'female', 'other', null
UserProfile({ UserProfile({
this.fontPreference = 'sans', this.fontPreference = 'sans',
this.displayMode = 'list', this.displayMode = 'list',
@ -57,6 +63,8 @@ class UserProfile extends HiveObject {
this.isBusinessMode = false, this.isBusinessMode = false,
this.defaultMarkup = 3.0, this.defaultMarkup = 3.0,
this.hasCompletedOnboarding = false, this.hasCompletedOnboarding = false,
this.nickname,
this.gender,
}); });
UserProfile copyWith({ UserProfile copyWith({
@ -70,6 +78,8 @@ class UserProfile extends HiveObject {
bool? isBusinessMode, bool? isBusinessMode,
double? defaultMarkup, double? defaultMarkup,
bool? hasCompletedOnboarding, bool? hasCompletedOnboarding,
String? nickname,
String? gender,
}) { }) {
return UserProfile( return UserProfile(
fontPreference: fontPreference ?? this.fontPreference, fontPreference: fontPreference ?? this.fontPreference,
@ -82,6 +92,8 @@ class UserProfile extends HiveObject {
isBusinessMode: isBusinessMode ?? this.isBusinessMode, isBusinessMode: isBusinessMode ?? this.isBusinessMode,
defaultMarkup: defaultMarkup ?? this.defaultMarkup, defaultMarkup: defaultMarkup ?? this.defaultMarkup,
hasCompletedOnboarding: hasCompletedOnboarding ?? this.hasCompletedOnboarding, hasCompletedOnboarding: hasCompletedOnboarding ?? this.hasCompletedOnboarding,
nickname: nickname ?? this.nickname,
gender: gender ?? this.gender,
); );
} }
} }

View File

@ -27,13 +27,15 @@ class UserProfileAdapter extends TypeAdapter<UserProfile> {
isBusinessMode: fields[9] == null ? false : fields[9] as bool, isBusinessMode: fields[9] == null ? false : fields[9] as bool,
defaultMarkup: fields[10] == null ? 3.0 : fields[10] as double, defaultMarkup: fields[10] == null ? 3.0 : fields[10] as double,
hasCompletedOnboarding: fields[11] == null ? false : fields[11] as bool, hasCompletedOnboarding: fields[11] == null ? false : fields[11] as bool,
nickname: fields[12] as String?,
gender: fields[13] as String?,
); );
} }
@override @override
void write(BinaryWriter writer, UserProfile obj) { void write(BinaryWriter writer, UserProfile obj) {
writer writer
..writeByte(10) ..writeByte(12)
..writeByte(0) ..writeByte(0)
..write(obj.fontPreference) ..write(obj.fontPreference)
..writeByte(3) ..writeByte(3)
@ -53,7 +55,11 @@ class UserProfileAdapter extends TypeAdapter<UserProfile> {
..writeByte(10) ..writeByte(10)
..write(obj.defaultMarkup) ..write(obj.defaultMarkup)
..writeByte(11) ..writeByte(11)
..write(obj.hasCompletedOnboarding); ..write(obj.hasCompletedOnboarding)
..writeByte(12)
..write(obj.nickname)
..writeByte(13)
..write(obj.gender);
} }
@override @override

View File

@ -99,7 +99,7 @@ final sakeListProvider = Provider<AsyncValue<List<SakeItem>>>((ref) {
filtered.sort((a, b) => a.displayData.name.compareTo(b.displayData.name)); filtered.sort((a, b) => a.displayData.name.compareTo(b.displayData.name));
break; break;
case SortMode.custom: case SortMode.custom:
default:
// Use Manual Sort Order // Use Manual Sort Order
return sortOrderAsync.when( return sortOrderAsync.when(
data: (sortOrder) { data: (sortOrder) {
@ -155,6 +155,26 @@ class SakeOrderController extends Notifier<void> {
final sakeOrderControllerProvider = NotifierProvider<SakeOrderController, void>(SakeOrderController.new); final sakeOrderControllerProvider = NotifierProvider<SakeOrderController, void>(SakeOrderController.new);
// 5. All Items Provider ()
// 使
final allSakeItemsProvider = Provider<AsyncValue<List<SakeItem>>>((ref) {
final rawListAsync = ref.watch(rawSakeListItemsProvider);
return rawListAsync.when(
data: (rawList) {
if (rawList.isEmpty) return const AsyncValue.data([]);
//
final sorted = List<SakeItem>.from(rawList);
sorted.sort((a, b) => b.metadata.createdAt.compareTo(a.metadata.createdAt));
return AsyncValue.data(sorted);
},
loading: () => const AsyncValue.loading(),
error: (e, s) => AsyncValue.error(e, s),
);
});
extension StreamStartWith<T> on Stream<T> { extension StreamStartWith<T> on Stream<T> {
Stream<T> startWith(T initial) async* { Stream<T> startWith(T initial) async* {
yield initial; yield initial;

View File

@ -44,10 +44,12 @@ class UserProfileNotifier extends Notifier<UserProfile> {
await _save(newState); await _save(newState);
} }
Future<void> setIdentity({String? mbti, DateTime? birthdate}) async { Future<void> setIdentity({String? mbti, DateTime? birthdate, String? nickname, String? gender}) async {
final newState = state.copyWith( final newState = state.copyWith(
mbti: mbti ?? state.mbti, mbti: mbti ?? state.mbti,
birthdate: birthdate ?? state.birthdate, birthdate: birthdate ?? state.birthdate,
nickname: nickname ?? state.nickname,
gender: gender ?? state.gender,
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
); );
await _save(newState); await _save(newState);

View File

@ -14,6 +14,8 @@ import '../widgets/analyzing_dialog.dart';
import '../models/sake_item.dart'; import '../models/sake_item.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; // Gallery Import
enum CameraMode { enum CameraMode {
createItem, createItem,
@ -213,21 +215,49 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
if (!mounted) return; if (!mounted) return;
_handleCapturedImage(imagePath);
} catch (e) {
debugPrint('Capture Error: $e');
} finally {
if (mounted) {
setState(() {
_isTakingPicture = false;
});
}
}
}
Future<void> _pickFromGallery() async {
final picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
if (image != null && mounted) {
await _handleCapturedImage(image.path, fromGallery: true);
}
}
Future<void> _handleCapturedImage(String imagePath, {bool fromGallery = false}) async {
// IF RETURN PATH Mode // IF RETURN PATH Mode
if (widget.mode == CameraMode.returnPath) { if (widget.mode == CameraMode.returnPath) {
Navigator.of(context).pop(imagePath); Navigator.of(context).pop(imagePath);
return; return;
} }
_capturedImages.add(imagePath); setState(() {
_capturedImages.add(imagePath);
});
// Show Confirmation Dialog // Show Confirmation Dialog
await showDialog( await showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('写真を保存しました'), title: Text(fromGallery ? '画像を読み込みました' : '写真を保存しました'),
content: const Text('さらに別の面も撮影すると、\nAI解析の精度が大幅にアップします'), content: const Text('さらに別の面も撮影・追加すると、\nAI解析の精度がアップします!'),
actions: [ actions: [
OutlinedButton( OutlinedButton(
onPressed: () { onPressed: () {
@ -239,28 +269,18 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
), ),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
// Take another photo (Dismiss dialog) // Return to capture (Dismiss dialog)
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppTheme.posimaiBlue, backgroundColor: AppTheme.posimaiBlue,
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
child: const Text('さらに撮影'), child: const Text('さらに追加'),
), ),
], ],
), ),
); );
} catch (e) {
debugPrint('Capture Error: $e');
} finally {
if (mounted) {
setState(() {
_isTakingPicture = false;
});
}
}
} }
Future<void> _analyzeImages() async { Future<void> _analyzeImages() async {
@ -560,37 +580,54 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
], ],
), ),
), ),
// Shutter Button // Bottom Control Area
Padding( Padding(
padding: const EdgeInsets.only(bottom: 32.0), padding: const EdgeInsets.only(bottom: 32.0, left: 24, right: 24),
child: GestureDetector( child: Row(
onTap: _takePicture, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
child: Container( crossAxisAlignment: CrossAxisAlignment.center,
height: 80, children: [
width: 80, // Gallery Button (Left)
decoration: BoxDecoration( IconButton(
shape: BoxShape.circle, icon: const Icon(LucideIcons.image, color: Colors.white, size: 32),
border: Border.all( onPressed: _pickFromGallery,
color: _quotaLockoutTime != null ? Colors.red : Colors.white, tooltip: 'ギャラリーから選択',
width: 4
),
color: _isTakingPicture
? Colors.white.withValues(alpha: 0.5)
: (_quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent),
), ),
child: Center(
child: _quotaLockoutTime != null // Shutter Button (Center)
? const Icon(LucideIcons.timer, color: Colors.white, size: 30) GestureDetector(
: Container( onTap: _takePicture,
height: 60, child: Container(
width: 60, height: 80,
decoration: BoxDecoration( width: 80,
shape: BoxShape.circle, decoration: BoxDecoration(
color: _quotaLockoutTime != null ? Colors.grey : Colors.white, shape: BoxShape.circle,
), border: Border.all(
color: _quotaLockoutTime != null ? Colors.red : Colors.white,
width: 4
), ),
color: _isTakingPicture
? Colors.white.withValues(alpha: 0.5)
: (_quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent),
),
child: Center(
child: _quotaLockoutTime != null
? const Icon(LucideIcons.timer, color: Colors.white, size: 30)
: Container(
height: 60,
width: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _quotaLockoutTime != null ? Colors.grey : Colors.white,
),
),
),
),
), ),
),
// Right Spacer (Balance)
const SizedBox(width: 48),
],
), ),
), ),
], ],

View File

@ -39,7 +39,6 @@ class HomeScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final fontPref = ref.watch(fontPreferenceProvider);
final displayMode = ref.watch(displayModeProvider); final displayMode = ref.watch(displayModeProvider);
final sakeListAsync = ref.watch(sakeListProvider); final sakeListAsync = ref.watch(sakeListProvider);
@ -148,7 +147,6 @@ class HomeScreen extends ConsumerWidget {
body: SafeArea( body: SafeArea(
child: Column( child: Column(
children: [ children: [
if (!isMenuMode)
if (!isMenuMode) if (!isMenuMode)
SakeFilterChips( SakeFilterChips(
mode: isBusinessMode ? FilterChipMode.business : FilterChipMode.personal mode: isBusinessMode ? FilterChipMode.business : FilterChipMode.personal
@ -231,7 +229,7 @@ class HomeScreen extends ConsumerWidget {
SpeedDialChild( SpeedDialChild(
child: const Text('🍶', style: TextStyle(fontSize: 24)), child: const Text('🍶', style: TextStyle(fontSize: 24)),
backgroundColor: Colors.white, backgroundColor: Colors.white,
label: 'お品書き作成', label: 'お品書き作成',
labelStyle: const TextStyle(fontWeight: FontWeight.bold), labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
@ -243,7 +241,7 @@ class HomeScreen extends ConsumerWidget {
SpeedDialChild( SpeedDialChild(
child: const Icon(LucideIcons.packagePlus, color: Colors.orange), child: const Icon(LucideIcons.packagePlus, color: Colors.orange),
backgroundColor: Colors.white, backgroundColor: Colors.white,
label: 'セット商品を追加', label: 'セットを作成',
labelStyle: const TextStyle(fontWeight: FontWeight.bold), labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () { onTap: () {
showDialog( showDialog(
@ -255,7 +253,7 @@ class HomeScreen extends ConsumerWidget {
SpeedDialChild( SpeedDialChild(
child: const Icon(LucideIcons.image, color: AppTheme.posimaiBlue), child: const Icon(LucideIcons.image, color: AppTheme.posimaiBlue),
backgroundColor: Colors.white, backgroundColor: Colors.white,
label: '画像の読み込み', label: 'ギャラリーから選択',
labelStyle: const TextStyle(fontWeight: FontWeight.bold), labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () async { onTap: () async {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
@ -265,7 +263,7 @@ class HomeScreen extends ConsumerWidget {
SpeedDialChild( SpeedDialChild(
child: const Icon(LucideIcons.camera, color: AppTheme.posimaiBlue), child: const Icon(LucideIcons.camera, color: AppTheme.posimaiBlue),
backgroundColor: Colors.white, backgroundColor: Colors.white,
label: '商品を撮影', label: 'カメラで撮影',
labelStyle: const TextStyle(fontWeight: FontWeight.bold), labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(context).push(

View File

@ -50,7 +50,10 @@ class _MainScreenState extends ConsumerState<MainScreen> {
// Define Navigation Items // Define Navigation Items
final List<NavigationDestination> destinations = isBusiness final List<NavigationDestination> destinations = isBusiness
? const [ ? const [
NavigationDestination(icon: Icon(LucideIcons.package), label: '在庫'), NavigationDestination(
icon: Padding(padding: EdgeInsets.only(bottom: 2), child: Text('🍶', style: TextStyle(fontSize: 22))),
label: 'ホーム',
),
NavigationDestination(icon: Icon(LucideIcons.instagram), label: '販促'), NavigationDestination(icon: Icon(LucideIcons.instagram), label: '販促'),
NavigationDestination(icon: Icon(LucideIcons.barChart), label: '分析'), NavigationDestination(icon: Icon(LucideIcons.barChart), label: '分析'),
NavigationDestination(icon: Icon(LucideIcons.store), label: '店舗'), NavigationDestination(icon: Icon(LucideIcons.store), label: '店舗'),

View File

@ -29,9 +29,7 @@ class MenuCreationScreen extends ConsumerWidget {
final displayMode = ref.watch(displayModeProvider); final displayMode = ref.watch(displayModeProvider);
final sakeListAsync = ref.watch(sakeListProvider); final sakeListAsync = ref.watch(sakeListProvider);
final isSearching = ref.watch(sakeSearchQueryProvider).isNotEmpty;
final showSelectedOnly = ref.watch(menuShowSelectedOnlyProvider); final showSelectedOnly = ref.watch(menuShowSelectedOnlyProvider);
final showFavorites = ref.watch(sakeFilterFavoriteProvider);
final selectedPrefecture = ref.watch(sakeFilterPrefectureProvider); final selectedPrefecture = ref.watch(sakeFilterPrefectureProvider);
return Scaffold( return Scaffold(

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../../providers/sake_list_provider.dart'; import '../../providers/sake_list_provider.dart';
import '../../widgets/map/pixel_japan_map.dart'; import '../../widgets/map/prefecture_tile_map.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../models/maps/japan_map_data.dart'; import '../../models/maps/japan_map_data.dart';
@ -81,8 +81,8 @@ class _BreweryMapScreenState extends ConsumerState<BreweryMapScreen> {
height: 420, // Increased to 420 to prevent Okinawa from being cut off height: 420, // Increased to 420 to prevent Okinawa from being cut off
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Map logical width is approx 26 cols * 32.0 = 832.0 // Map logical width is approx 13 cols * (46 + 4) = 650
const double mapWidth = 26 * 32.0; const double mapWidth = 650.0;
// Calculate scale to fit width (95% to allow slight margin) // Calculate scale to fit width (95% to allow slight margin)
final availableWidth = constraints.maxWidth; final availableWidth = constraints.maxWidth;
@ -110,7 +110,7 @@ class _BreweryMapScreenState extends ConsumerState<BreweryMapScreen> {
minScale: fitScale * 0.95, minScale: fitScale * 0.95,
maxScale: fitScale * 6.0, maxScale: fitScale * 6.0,
constrained: false, constrained: false,
child: PixelJapanMap( child: PrefectureTileMap(
visitedPrefectures: visitedPrefectures, visitedPrefectures: visitedPrefectures,
onPrefectureTap: (pref) { onPrefectureTap: (pref) {
_showPrefectureStats(context, pref, sakeList); _showPrefectureStats(context, pref, sakeList);

View File

@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../../providers/sake_list_provider.dart'; import '../../providers/sake_list_provider.dart';
import '../../services/shuko_diagnosis_service.dart'; import '../../services/shuko_diagnosis_service.dart';
import '../../providers/theme_provider.dart'; // v1.1 Fix
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../widgets/sake_radar_chart.dart'; import '../../widgets/sake_radar_chart.dart';
@ -60,7 +61,8 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sakeListAsync = ref.watch(sakeListProvider); final sakeListAsync = ref.watch(allSakeItemsProvider); // v1.2:
final userProfile = ref.watch(userProfileProvider); // v1.1
final diagnosisService = ref.watch(shukoDiagnosisServiceProvider); final diagnosisService = ref.watch(shukoDiagnosisServiceProvider);
return Scaffold( return Scaffold(
@ -70,15 +72,25 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
), ),
body: sakeListAsync.when( body: sakeListAsync.when(
data: (sakeList) { data: (sakeList) {
final profile = diagnosisService.diagnose(sakeList); final baseProfile = diagnosisService.diagnose(sakeList);
// Personalize Title
final personalizedTitle = diagnosisService.personalizeTitle(
ShukoTitle(title: baseProfile.title, description: baseProfile.description),
userProfile.gender
);
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
children: [ children: [
Text(
diagnosisService.getGreeting(userProfile.nickname),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Screenshot( Screenshot(
controller: _screenshotController, controller: _screenshotController,
child: _buildShukoCard(context, profile), child: _buildShukoCard(context, baseProfile, personalizedTitle, userProfile.nickname), // Pass nickname
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
_buildActionButtons(context), _buildActionButtons(context),
@ -92,7 +104,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
); );
} }
Widget _buildShukoCard(BuildContext context, ShukoProfile profile) { Widget _buildShukoCard(BuildContext context, ShukoProfile profile, ShukoTitle titleInfo, String? nickname) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return Container( return Container(
@ -147,14 +159,16 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
children: [ children: [
// 1. Header (Name & Rank) // 1. Header (Name & Rank)
Text( Text(
'あなたの酒向タイプ', (nickname != null && nickname.isNotEmpty)
? '$nicknameさんの酒向タイプ'
: 'あなたの酒向タイプ',
style: Theme.of(context).textTheme.bodySmall?.copyWith(letterSpacing: 1.5), style: Theme.of(context).textTheme.bodySmall?.copyWith(letterSpacing: 1.5),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 2. Title // 2. Title
Text( Text(
profile.title, titleInfo.title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 28, fontSize: 28,
@ -172,7 +186,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
profile.description, titleInfo.description,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
height: 1.5, height: 1.5,

View File

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
@ -18,6 +19,7 @@ import '../services/pricing_calculator.dart';
import '../providers/theme_provider.dart'; import '../providers/theme_provider.dart';
import '../models/user_profile.dart'; import '../models/user_profile.dart';
import 'camera_screen.dart'; import 'camera_screen.dart';
import '../widgets/common/munyun_like_button.dart';
class SakeDetailScreen extends ConsumerStatefulWidget { class SakeDetailScreen extends ConsumerStatefulWidget {
@ -33,11 +35,21 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
// To trigger rebuilds if we don't switch to a stream // To trigger rebuilds if we don't switch to a stream
late SakeItem _sake; late SakeItem _sake;
int _currentImageIndex = 0; int _currentImageIndex = 0;
final FocusNode _memoFocusNode = FocusNode(); // Polish: Focus logic
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_sake = widget.sake; _sake = widget.sake;
_memoFocusNode.addListener(() {
setState(() {}); // Rebuild to hide/show hint
});
}
@override
void dispose() {
_memoFocusNode.dispose();
super.dispose();
} }
@override @override
@ -70,11 +82,9 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
pinned: true, pinned: true,
iconTheme: const IconThemeData(color: Colors.white), iconTheme: const IconThemeData(color: Colors.white),
actions: [ actions: [
IconButton( MunyunLikeButton(
icon: Icon(_sake.userData.isFavorite ? Icons.favorite : Icons.favorite_border), isLiked: _sake.userData.isFavorite,
color: _sake.userData.isFavorite ? Colors.pink : Colors.white, onTap: () => _toggleFavorite(),
tooltip: 'お気に入り',
onPressed: () => _toggleFavorite(),
), ),
IconButton( IconButton(
icon: const Icon(LucideIcons.refreshCw), icon: const Icon(LucideIcons.refreshCw),
@ -86,7 +96,10 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
icon: const Icon(LucideIcons.trash2), icon: const Icon(LucideIcons.trash2),
color: Colors.white, color: Colors.white,
tooltip: '削除', tooltip: '削除',
onPressed: () => _showDeleteDialog(context), onPressed: () {
HapticFeedback.heavyImpact();
_showDeleteDialog(context);
},
), ),
], ],
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
@ -260,7 +273,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Icon(LucideIcons.edit3, size: 20, color: Colors.grey[600]), Icon(LucideIcons.pencil, size: 18, color: Colors.grey[600]),
], ],
), ),
), ),
@ -287,7 +300,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Icon(LucideIcons.edit3, size: 18, color: Colors.grey[500]), Icon(LucideIcons.pencil, size: 16, color: Colors.grey[500]),
], ],
), ),
), ),
@ -437,11 +450,14 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const SizedBox(height: 12),
TextField( TextField(
controller: TextEditingController(text: _sake.userData.memo ?? ''), controller: TextEditingController(text: _sake.userData.memo ?? ''),
focusNode: _memoFocusNode,
maxLines: 4, maxLines: 4,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'お店独自の情報をメモ', hintText: _memoFocusNode.hasFocus ? '' : 'メモを入力後に自動保存', // Disappear on focus
hintStyle: TextStyle(color: Colors.grey.withValues(alpha: 0.5)), // Lighter color
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
filled: true, filled: true,
fillColor: Theme.of(context).cardColor, fillColor: Theme.of(context).cardColor,
@ -574,6 +590,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
Future<void> _toggleFavorite() async { Future<void> _toggleFavorite() async {
HapticFeedback.mediumImpact();
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
final newItem = _sake.copyWith(isFavorite: !_sake.userData.isFavorite); final newItem = _sake.copyWith(isFavorite: !_sake.userData.isFavorite);

View File

@ -23,8 +23,6 @@ class _SoulScreenState extends ConsumerState<SoulScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final userProfile = ref.watch(userProfileProvider); final userProfile = ref.watch(userProfileProvider);
final themeMode = userProfile.themeMode;
final fontPref = userProfile.fontPreference;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -56,6 +54,22 @@ class _SoulScreenState extends ConsumerState<SoulScreen> {
trailing: Icon(LucideIcons.chevronRight, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[600] : null), trailing: Icon(LucideIcons.chevronRight, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[600] : null),
onTap: () => _pickBirthDate(context, userProfile.birthdate), onTap: () => _pickBirthDate(context, userProfile.birthdate),
), ),
ListTile(
leading: Icon(LucideIcons.user, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[400] : null),
title: const Text('ニックネーム'),
subtitle: Text(userProfile.nickname ?? '未設定'),
trailing: Icon(LucideIcons.chevronRight, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[600] : null),
onTap: () => _showNicknameDialog(context, userProfile.nickname),
),
const Divider(height: 1),
ListTile(
leading: Icon(LucideIcons.users, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[400] : null),
title: const Text('性別'),
subtitle: Text(_getGenderLabel(userProfile.gender)),
trailing: Icon(LucideIcons.chevronRight, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[600] : null),
onTap: () => _showGenderDialog(context, userProfile.gender),
),
const Divider(height: 1),
], ],
), ),
), ),
@ -230,4 +244,78 @@ class _SoulScreenState extends ConsumerState<SoulScreen> {
ref.read(userProfileProvider.notifier).setIdentity(birthdate: picked); ref.read(userProfileProvider.notifier).setIdentity(birthdate: picked);
} }
} }
void _showNicknameDialog(BuildContext context, String? current) {
final controller = TextEditingController(text: current);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('ニックネーム変更'),
content: TextField(
controller: controller,
decoration: const InputDecoration(hintText: '呼び名を入力'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('キャンセル'),
),
TextButton(
onPressed: () {
ref.read(userProfileProvider.notifier).setIdentity(nickname: controller.text);
Navigator.pop(context);
},
child: const Text('保存'),
),
],
),
);
}
void _showGenderDialog(BuildContext context, String? current) {
showDialog(
context: context,
builder: (context) => SimpleDialog(
title: const Text('性別を選択'),
children: [
_buildGenderOption(context, 'male', '男性', current),
_buildGenderOption(context, 'female', '女性', current),
_buildGenderOption(context, 'other', 'その他', current),
_buildGenderOption(context, '', '回答しない', current),
],
),
);
}
Widget _buildGenderOption(BuildContext context, String? value, String label, String? current) {
return SimpleDialogOption(
onPressed: () {
ref.read(userProfileProvider.notifier).setIdentity(gender: value);
Navigator.pop(context);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Icon(
value == current ? Icons.check_circle : Icons.circle_outlined,
color: value == current ? Theme.of(context).primaryColor : Colors.grey,
),
const SizedBox(width: 16),
Text(label),
],
),
),
);
}
String _getGenderLabel(String? gender) {
switch (gender) {
case 'male': return '男性';
case 'female': return '女性';
case 'other': return 'その他';
default: return '未設定';
}
}
} }

View File

@ -1,6 +1,8 @@
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'dart:async'; // TimeoutException
import 'package:archive/archive_io.dart'; import 'package:archive/archive_io.dart';
import 'package:flutter/foundation.dart'; // debugPrint
import 'package:googleapis/drive/v3.dart' as drive; import 'package:googleapis/drive/v3.dart' as drive;
import 'package:google_sign_in/google_sign_in.dart'; import 'package:google_sign_in/google_sign_in.dart';
import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart'; import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
@ -35,7 +37,7 @@ class BackupService {
try { try {
await _googleSignIn.signInSilently(); await _googleSignIn.signInSilently();
} catch (e) { } catch (e) {
print('⚠️ サイレントサインインエラー: $e'); debugPrint('⚠️ サイレントサインインエラー: $e');
} }
} }
@ -60,7 +62,7 @@ class BackupService {
final account = await _googleSignIn.signIn(); final account = await _googleSignIn.signIn();
return account; return account;
} catch (error) { } catch (error) {
print('❌ Google Sign In エラー: $error'); debugPrint('❌ Google Sign In エラー: $error');
return null; return null;
} }
} }
@ -87,14 +89,14 @@ class BackupService {
// 1. // 1.
final account = _googleSignIn.currentUser; final account = _googleSignIn.currentUser;
if (account == null) { if (account == null) {
print('❌ サインインが必要です'); debugPrint('❌ サインインが必要です');
return false; return false;
} }
// 2. Drive APIクライアントを作成 // 2. Drive APIクライアントを作成
final authClient = await _googleSignIn.authenticatedClient(); final authClient = await _googleSignIn.authenticatedClient();
if (authClient == null) { if (authClient == null) {
print('❌ 認証クライアントの取得に失敗しました'); debugPrint('❌ 認証クライアントの取得に失敗しました');
return false; return false;
} }
@ -103,7 +105,7 @@ class BackupService {
// 3. ZIPファイルを作成 // 3. ZIPファイルを作成
final zipFile = await _createBackupZip(); final zipFile = await _createBackupZip();
if (zipFile == null) { if (zipFile == null) {
print('❌ バックアップファイルの作成に失敗しました'); debugPrint('❌ バックアップファイルの作成に失敗しました');
return false; return false;
} }
@ -115,7 +117,7 @@ class BackupService {
return success; return success;
} catch (error) { } catch (error) {
print('❌ バックアップ作成エラー: $error'); debugPrint('❌ バックアップ作成エラー: $error');
return false; return false;
} }
} }
@ -186,7 +188,7 @@ class BackupService {
}).toList(); }).toList();
final sakeItemsFile = File(path.join(backupDir.path, 'sake_items.json')); final sakeItemsFile = File(path.join(backupDir.path, 'sake_items.json'));
await sakeItemsFile.writeAsString(json.encode(sakeItems)); await sakeItemsFile.writeAsString(json.encode(sakeItems));
print('📄 sake_items.json 作成: ${await sakeItemsFile.length()} bytes'); debugPrint('📄 sake_items.json 作成: ${await sakeItemsFile.length()} bytes');
// 3. settings.jsonを作成 // 3. settings.jsonを作成
final settings = Map<String, dynamic>.from(settingsBox.toMap()); final settings = Map<String, dynamic>.from(settingsBox.toMap());
@ -219,10 +221,10 @@ class BackupService {
// 6. // 6.
await backupDir.delete(recursive: true); await backupDir.delete(recursive: true);
print('✅ バックアップZIPファイル作成完了: $zipPath'); debugPrint('✅ バックアップZIPファイル作成完了: $zipPath');
return File(zipPath); return File(zipPath);
} catch (error) { } catch (error) {
print('❌ ZIP作成エラー: $error'); debugPrint('❌ ZIP作成エラー: $error');
return null; return null;
} }
} }
@ -230,6 +232,7 @@ class BackupService {
/// Google DriveにZIPファイルをアップロード /// Google DriveにZIPファイルをアップロード
Future<bool> _uploadToDrive(drive.DriveApi driveApi, File zipFile) async { Future<bool> _uploadToDrive(drive.DriveApi driveApi, File zipFile) async {
try { try {
debugPrint('[BACKUP] 📤 アップロード開始: ${zipFile.lengthSync()} bytes');
// 1. // 1.
final fileList = await driveApi.files.list( final fileList = await driveApi.files.list(
q: "name = '$backupFileName' and trashed = false", q: "name = '$backupFileName' and trashed = false",
@ -241,9 +244,9 @@ class BackupService {
for (var file in fileList.files!) { for (var file in fileList.files!) {
try { try {
await driveApi.files.delete(file.id!); await driveApi.files.delete(file.id!);
print('🗑️ 既存のバックアップファイルを削除しました: ${file.id}'); debugPrint('🗑️ [BACKUP] 既存ファイルを削除: ${file.id}');
} catch (e) { } catch (e) {
print('⚠️ 既存ファイルの削除に失敗 (無視して続行): $e'); debugPrint('⚠️ [BACKUP] 既存ファイル削除失敗 (無視): $e');
} }
} }
} }
@ -254,40 +257,40 @@ class BackupService {
final media = drive.Media(zipFile.openRead(), zipFile.lengthSync()); final media = drive.Media(zipFile.openRead(), zipFile.lengthSync());
debugPrint('[BACKUP] 🚀 Driveへ送信中...');
final uploadedFile = await driveApi.files.create( final uploadedFile = await driveApi.files.create(
driveFile, driveFile,
uploadMedia: media, uploadMedia: media,
); ).timeout(const Duration(minutes: 3), onTimeout: () {
throw TimeoutException('アップロードがタイムアウトしました (3分)');
});
if (uploadedFile.id == null) { if (uploadedFile.id == null) {
print('❌ アップロード後のID取得失敗'); debugPrint('❌ [BACKUP] ID取得失敗');
return false; return false;
} }
print('✅ Google Driveにアップロードリクエスト完了 ID: ${uploadedFile.id}'); debugPrint('✅ [BACKUP] アップロード完了 ID: ${uploadedFile.id}');
// 4. // 4.
// APIの反映ラグを考慮して少し待機してから確認
int retryCount = 0; int retryCount = 0;
bool verified = false; bool verified = false;
while (retryCount < 3 && !verified) { while (retryCount < 3 && !verified) {
await Future.delayed(Duration(milliseconds: 1000 * (retryCount + 1))); await Future.delayed(Duration(milliseconds: 1000 * (retryCount + 1)));
try { try {
final check = await driveApi.files.get(uploadedFile.id!); final check = await driveApi.files.get(uploadedFile.id!);
// getが成功すればファイルは存在する
verified = true; verified = true;
print('✅ アップロード検証成功: ファイル存在確認済み'); debugPrint('✅ [BACKUP] 検証成功');
} catch (e) { } catch (e) {
print('⚠️ 検証試行 ${retryCount + 1} 失敗: $e'); debugPrint('⚠️ [BACKUP] 検証試行 ${retryCount + 1} 失敗: $e');
} }
retryCount++; retryCount++;
} }
return verified; return verified;
} catch (error) { } catch (error) {
print(' アップロードエラー: $error'); debugPrint('❌ [BACKUP] アップロードエラー: $error');
return false; return false;
} }
} }
@ -309,14 +312,14 @@ class BackupService {
// 1. // 1.
final account = _googleSignIn.currentUser; final account = _googleSignIn.currentUser;
if (account == null) { if (account == null) {
print('❌ サインインが必要です'); debugPrint('❌ サインインが必要です');
return false; return false;
} }
// 2. Drive APIクライアントを作成 // 2. Drive APIクライアントを作成
final authClient = await _googleSignIn.authenticatedClient(); final authClient = await _googleSignIn.authenticatedClient();
if (authClient == null) { if (authClient == null) {
print('❌ 認証クライアントの取得に失敗しました'); debugPrint('❌ 認証クライアントの取得に失敗しました');
return false; return false;
} }
@ -328,7 +331,7 @@ class BackupService {
// 4. Google Driveからダウンロード // 4. Google Driveからダウンロード
final zipFile = await _downloadFromDrive(driveApi); final zipFile = await _downloadFromDrive(driveApi);
if (zipFile == null) { if (zipFile == null) {
print('❌ ダウンロードに失敗しました'); debugPrint('❌ ダウンロードに失敗しました');
return false; return false;
} }
@ -340,7 +343,7 @@ class BackupService {
return success; return success;
} catch (error) { } catch (error) {
print('❌ 復元エラー: $error'); debugPrint('❌ 復元エラー: $error');
return false; return false;
} }
} }
@ -355,10 +358,10 @@ class BackupService {
if (zipFile != null) { if (zipFile != null) {
await zipFile.copy(backupPath); await zipFile.copy(backupPath);
await zipFile.delete(); await zipFile.delete();
print('✅ 復元前のデータを退避しました: $backupPath'); debugPrint('✅ 復元前のデータを退避しました: $backupPath');
} }
} catch (error) { } catch (error) {
print('⚠️ データ退避エラー: $error'); debugPrint('⚠️ データ退避エラー: $error');
} }
} }
@ -372,7 +375,7 @@ class BackupService {
); );
if (fileList.files == null || fileList.files!.isEmpty) { if (fileList.files == null || fileList.files!.isEmpty) {
print('❌ バックアップファイルが見つかりません'); debugPrint('❌ バックアップファイルが見つかりません');
return null; return null;
} }
@ -392,10 +395,10 @@ class BackupService {
final sink = downloadFile.openWrite(); final sink = downloadFile.openWrite();
await media.stream.pipe(sink); await media.stream.pipe(sink);
print('✅ ダウンロード完了: $downloadPath'); debugPrint('✅ ダウンロード完了: $downloadPath');
return downloadFile; return downloadFile;
} catch (error) { } catch (error) {
print('❌ ダウンロードエラー: $error'); debugPrint('❌ ダウンロードエラー: $error');
return null; return null;
} }
} }
@ -427,13 +430,13 @@ class BackupService {
final outFile = File(extractPath); final outFile = File(extractPath);
await outFile.create(recursive: true); await outFile.create(recursive: true);
await outFile.writeAsBytes(data); await outFile.writeAsBytes(data);
print('📦 展開: $filename (${data.length} bytes)'); debugPrint('📦 展開: $filename (${data.length} bytes)');
} }
} }
// : // :
print('📂 展開ディレクトリの中身:'); debugPrint('📂 展開ディレクトリの中身:');
extractDir.listSync(recursive: true).forEach((f) => print(' - ${path.basename(f.path)}')); extractDir.listSync(recursive: true).forEach((f) => debugPrint(' - ${path.basename(f.path)}'));
// 2. sake_items.jsonを検索 () // 2. sake_items.jsonを検索 ()
File? sakeItemsFile; File? sakeItemsFile;
@ -443,12 +446,12 @@ class BackupService {
sakeItemsFile = potentialFiles.firstWhere((f) => path.basename(f.path) == 'sake_items.json'); sakeItemsFile = potentialFiles.firstWhere((f) => path.basename(f.path) == 'sake_items.json');
} catch (e) { } catch (e) {
// //
print('❌ sake_items.json が見つかりません'); debugPrint('❌ sake_items.json が見つかりません');
} }
if (sakeItemsFile != null && await sakeItemsFile.exists()) { if (sakeItemsFile != null && await sakeItemsFile.exists()) {
final sakeItemsJson = json.decode(await sakeItemsFile.readAsString()) as List; final sakeItemsJson = json.decode(await sakeItemsFile.readAsString()) as List;
print('🔍 復元対象データ数: ${sakeItemsJson.length}'); debugPrint('🔍 復元対象データ数: ${sakeItemsJson.length}');
final sakeBox = Hive.box<SakeItem>('sake_items'); final sakeBox = Hive.box<SakeItem>('sake_items');
await sakeBox.clear(); await sakeBox.clear();
@ -496,7 +499,7 @@ class BackupService {
// IDを保持するためにput()使add() // IDを保持するためにput()使add()
await sakeBox.put(item.id, item); await sakeBox.put(item.id, item);
} }
print('✅ SakeItemsを復元しました${sakeItemsJson.length}件)'); debugPrint('✅ SakeItemsを復元しました${sakeItemsJson.length}件)');
// UI更新のためにわずかに待機 // UI更新のためにわずかに待機
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
} }
@ -506,7 +509,7 @@ class BackupService {
try { try {
settingsFile = potentialFiles.firstWhere((f) => path.basename(f.path) == 'settings.json'); settingsFile = potentialFiles.firstWhere((f) => path.basename(f.path) == 'settings.json');
} catch (e) { } catch (e) {
print('⚠️ settings.json が見つかりません (スキップ)'); debugPrint('⚠️ settings.json が見つかりません (スキップ)');
} }
if (settingsFile != null && await settingsFile.exists()) { if (settingsFile != null && await settingsFile.exists()) {
@ -517,7 +520,7 @@ class BackupService {
for (var entry in settingsJson.entries) { for (var entry in settingsJson.entries) {
await settingsBox.put(entry.key, entry.value); await settingsBox.put(entry.key, entry.value);
} }
print('✅ 設定を復元しました'); debugPrint('✅ 設定を復元しました');
} }
// 4. (sake_items.jsonと同じ階層のimagesフォルダを探す) // 4. (sake_items.jsonと同じ階層のimagesフォルダを探す)
@ -535,21 +538,21 @@ class BackupService {
await imageFile.copy(path.join(appDir.path, fileName)); await imageFile.copy(path.join(appDir.path, fileName));
} }
} }
print('✅ 画像ファイルを復元しました(${imageFiles.length}ファイル)'); debugPrint('✅ 画像ファイルを復元しました(${imageFiles.length}ファイル)');
} }
} }
// 5. // 5.
await extractDir.delete(recursive: true); await extractDir.delete(recursive: true);
print('✅ データの復元が完了しました'); debugPrint('✅ データの復元が完了しました');
return true; return true;
} catch (error) { } catch (error) {
print('❌ 復元処理エラー: $error'); debugPrint('❌ 復元処理エラー: $error');
// //
print(error); debugPrint(error.toString());
if (error is Error) { if (error is Error) {
print(error.stackTrace); debugPrint(error.stackTrace.toString());
} }
return false; return false;
} }
@ -573,7 +576,7 @@ class BackupService {
return fileList.files != null && fileList.files!.isNotEmpty; return fileList.files != null && fileList.files!.isNotEmpty;
} catch (error) { } catch (error) {
print('❌ バックアップ確認エラー: $error'); debugPrint('❌ バックアップ確認エラー: $error');
return false; return false;
} }
} }

View File

@ -102,6 +102,36 @@ class ShukoDiagnosisService {
description: '自分だけの好みを探索中の、未来の巨匠。', description: '自分だけの好みを探索中の、未来の巨匠。',
); );
} }
// v1.1: Personalization Logic
String getGreeting(String? nickname) {
if (nickname == null || nickname.trim().isEmpty) {
return 'ようこそ!';
}
return 'ようこそ、$nicknameさん';
}
ShukoTitle personalizeTitle(ShukoTitle original, String? gender) {
if (gender == null) return original;
String suffix = '';
String newTitle = original.title;
// Simple customization logic
if (gender == 'female') {
if (newTitle.contains('サムライ')) newTitle = newTitle.replaceAll('サムライ', '麗人');
if (newTitle.contains('貴族')) newTitle = newTitle.replaceAll('貴族', 'プリンセス');
if (newTitle.contains('賢者')) newTitle = newTitle.replaceAll('賢者', 'ミューズ');
if (newTitle.contains('杜氏')) newTitle = newTitle.replaceAll('杜氏', '看板娘'); // Playful
} else if (gender == 'male') {
if (newTitle.contains('貴族')) newTitle = newTitle.replaceAll('貴族', '貴公子');
}
return ShukoTitle(
title: newTitle,
description: original.description,
);
}
} }
class ShukoProfile { class ShukoProfile {

View File

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter/services.dart';
// Riveアニメーション用のウィジェットv1.2:
// : .rivファイルが存在しないためLucideIconsで表示されます
// TODO: assets/rive/munyun_heart.riv
class MunyunLikeButton extends StatefulWidget {
final bool isLiked;
final VoidCallback onTap;
final double size;
const MunyunLikeButton({
super.key,
required this.isLiked,
required this.onTap,
this.size = 24.0,
});
@override
State<MunyunLikeButton> createState() => _MunyunLikeButtonState();
}
class _MunyunLikeButtonState extends State<MunyunLikeButton> {
// v1.2: Riveパッケージのバージョン問題により
// .rivファイルの配置 + Riveパッケージ更新後に再度実装予定
@override
Widget build(BuildContext context) {
//
return _buildFallback();
}
Widget _buildFallback() {
return IconButton(
icon: Icon(
LucideIcons.heart, // Lucideには塗りつぶしハートがないため
),
color: widget.isLiked ? Colors.pink : Colors.white,
tooltip: 'お気に入り',
onPressed: widget.onTap,
);
}
}

View File

@ -181,7 +181,7 @@ class SakeGridItem extends ConsumerWidget {
top: AppTheme.spacingSmall, top: AppTheme.spacingSmall,
right: AppTheme.spacingSmall, right: AppTheme.spacingSmall,
child: Icon( child: Icon(
Icons.favorite, LucideIcons.heart,
color: Colors.pink, color: Colors.pink,
size: 20, size: 20,
), ),

View File

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import '../../models/maps/prefecture_tile_layout.dart';
import '../../theme/app_theme.dart';
class PrefectureTileMap extends StatelessWidget {
final Set<String> visitedPrefectures;
final Function(String) onPrefectureTap;
final double tileSize;
final double gap;
const PrefectureTileMap({
super.key,
required this.visitedPrefectures,
required this.onPrefectureTap,
this.tileSize = 46.0,
this.gap = 4.0,
});
@override
Widget build(BuildContext context) {
// 1. Determine Grid Bounds
int maxCol = 0;
int maxRow = 0;
PrefectureTileLayout.getLayout.forEach((_, pos) {
if (pos.col + pos.width > maxCol) maxCol = pos.col + pos.width;
if (pos.row + pos.height > maxRow) maxRow = pos.row + pos.height;
});
final double totalWidth = maxCol * (tileSize + gap) - gap;
final double totalHeight = maxRow * (tileSize + gap) - gap;
return SizedBox(
width: totalWidth,
height: totalHeight,
child: Stack(
children: PrefectureTileLayout.getLayout.entries.map((entry) {
final prefName = entry.key;
final pos = entry.value;
final isVisited = visitedPrefectures.contains(prefName) ||
visitedPrefectures.any((v) => v.startsWith(prefName)); // Robust check
return Positioned(
left: pos.col * (tileSize + gap),
top: pos.row * (tileSize + gap),
width: pos.width * tileSize + (pos.width - 1) * gap,
height: pos.height * tileSize + (pos.height - 1) * gap,
child: _buildTile(context, prefName, isVisited),
);
}).toList(),
),
);
}
Widget _buildTile(BuildContext context, String prefName, bool isVisited) {
final isDark = Theme.of(context).brightness == Brightness.dark;
// Color Logic
final baseColor = isVisited ? AppTheme.posimaiBlue : (isDark ? Colors.grey[800]! : Colors.grey[300]!);
final textColor = isVisited ? Colors.white : (isDark ? Colors.grey[400] : Colors.grey[700]);
final borderColor = isDark ? Colors.grey[700]! : Colors.white;
return Material(
color: baseColor,
borderRadius: BorderRadius.circular(6), // Rounded corners for "Block" look
child: InkWell(
onTap: () => onPrefectureTap(prefName),
borderRadius: BorderRadius.circular(6),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.white.withValues(alpha: 0.2), width: 1), // Subtle inner border
),
child: Text(
prefName,
style: TextStyle(
color: textColor,
fontSize: 12, // Small but legible
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
);
}
}

View File

@ -156,6 +156,34 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
return Column( return Column(
children: [ children: [
_buildSectionHeader(context, widget.title, LucideIcons.cloud), _buildSectionHeader(context, widget.title, LucideIcons.cloud),
// Wi-Fi推奨の注意書き (v1.2)
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(LucideIcons.wifi, color: Colors.blue, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
'バックアップにはWi-Fi環境を推奨します\n画像が多い場合、数百MB〜1GB以上になる可能性があります',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.blue[300] : Colors.blue[900],
),
),
),
],
),
),
Card( Card(
color: isDark ? const Color(0xFF1E1E1E) : null, color: isDark ? const Color(0xFF1E1E1E) : null,
child: Column( child: Column(

View File

@ -104,6 +104,7 @@ flutter:
assets: assets:
- assets/fonts/NotoSansJP-Regular.ttf - assets/fonts/NotoSansJP-Regular.ttf
- assets/images/ - assets/images/
- assets/rive/
fonts: fonts:
- family: NotoSansJP - family: NotoSansJP
fonts: fonts:

View File

@ -1,58 +1,83 @@
version: '3.8' version: '3.8'
# ==========================================
# 🍶 Ponshu Room "AI Factory" Setup
# ==========================================
# このファイルはSynology Container Managerで「プロジェクト」として使用します。
# 3つの主要コンテナ (Gitea, Postgres, MCP) を一度に立ち上げます。
#
# 変更推奨箇所:
# - GITEA__database__PASSWD: 強固なパスワードに変更してください
# - POSTGRES_PASSWORD: 上記と同じパスワードに変更してください
services: services:
# 【守りの要】Gitea本体: コードの原本保管庫 # ----------------------------------------
# 1. Gitea (Git Server)
# 役割: コードの「原本」を管理する倉庫。
# ----------------------------------------
gitea: gitea:
image: gitea/gitea:1.21 image: gitea/gitea:1.21
container_name: gitea container_name: gitea
environment: environment:
- USER_UID=1026 - USER_UID=1026 # Synologyの一般的なユーザーID (環境に合わせて変更可)
- USER_GID=100 - USER_GID=100
- GITEA__database__DB_TYPE=postgres - GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=db:5432 - GITEA__database__HOST=db:5432
- GITEA__database__NAME=gitea - GITEA__database__NAME=gitea
- GITEA__database__USER=gitea - GITEA__database__USER=gitea
- GITEA__database__PASSWD=gitea_password # 適宜変更してください - GITEA__database__PASSWD=gitea_password # 【変更推奨】DBパスワード
restart: always restart: always
networks: networks:
- gitea_network - gitea_network
volumes: volumes:
- ./gitea:/data - ./gitea:/data # リポジトリデータ (永続化)
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
ports: ports:
- "3000:3000" # ブラウザでアクセスするポート - "3000:3000" # Web UI用ポート (ブラウザからアクセス)
- "2222:22" # SSH用ポート - "2222:22" # SSH用ポート (Git操作用)
# 【データベース】GiteaとMCPのデータを保存 # ----------------------------------------
# 2. PostgreSQL (Database)
# 役割: Giteaのユーザー情報や設定を保存するDB。
# ----------------------------------------
db: db:
image: postgres:15 image: postgres:15
container_name: gitea_db container_name: gitea_db
restart: always restart: always
environment: environment:
- POSTGRES_USER=gitea - POSTGRES_USER=gitea
- POSTGRES_PASSWORD=gitea_password - POSTGRES_PASSWORD=gitea_password # 【変更推奨】Giteaの設定と合わせる
- POSTGRES_DB=gitea - POSTGRES_DB=gitea
networks: networks:
- gitea_network - gitea_network
volumes: volumes:
- ./postgres:/var/lib/postgresql/data - ./postgres:/var/lib/postgresql/data # DBデータ (永続化)
# 【攻めの要】MCP Server: AntigravityがNASを操作するための窓口 # ----------------------------------------
# 3. MCP Server (AI Bridge)
# 役割: AI (Antigravity/Claude) がNASの中を操作するための窓口。
# Phase 2B (AI自動化) で本格稼働します。
# ----------------------------------------
mcp-server: mcp-server:
image: node:20-slim image: node:20-slim
container_name: mcp_server container_name: mcp_server
working_dir: /app working_dir: /app
volumes: volumes:
- ./mcp:/app - ./mcp:/app # MCPサーバーのコード置き場
- ./gitea:/data/gitea_files # AIがGiteaのファイルを直接覗けるように接続 - ./gitea:/data/gitea_files # AIがGiteaのファイルを読み書きするための共有設定
environment: environment:
- NODE_ENV=development - NODE_ENV=development
command: sh -c "npm init -y && npm install @modelcontextprotocol/sdk && node index.js" # 初回起動時に必要なライブラリを自動インストールして待機
command: sh -c "npm init -y && npm install @modelcontextprotocol/sdk && node index.js || tail -f /dev/null"
restart: always restart: always
networks: networks:
- gitea_network - gitea_network
# ----------------------------------------
# Network Setting
# 内部通信用の専用ネットワーク
# ----------------------------------------
networks: networks:
gitea_network: gitea_network:
driver: bridge driver: bridge