diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6589924..34b8e46 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,11 @@ "Bash(git rebase:*)", "Bash(cat:*)", "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": [], "ask": [] diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9c60b3e..4e888a9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -29,6 +29,9 @@ android { targetSdk = 34 versionCode = flutter.versionCode versionName = flutter.versionName + ndk { + abiFilters.add("arm64-v8a") + } } buildTypes { diff --git a/lib/models/maps/prefecture_tile_layout.dart b/lib/models/maps/prefecture_tile_layout.dart new file mode 100644 index 0000000..0d51254 --- /dev/null +++ b/lib/models/maps/prefecture_tile_layout.dart @@ -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 get getLayout => finalLayout; + + static const Map 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), + }; +} diff --git a/lib/models/sake_item.dart b/lib/models/sake_item.dart index 50668c0..4dad9b8 100644 --- a/lib/models/sake_item.dart +++ b/lib/models/sake_item.dart @@ -277,6 +277,6 @@ class SakeItem extends HiveObject { } // Compact JSON for QR ecosystem 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}}'; } } diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart index 3edec73..b6dc2f6 100644 --- a/lib/models/user_profile.dart +++ b/lib/models/user_profile.dart @@ -46,6 +46,12 @@ class UserProfile extends HiveObject { @HiveField(11, defaultValue: false) bool hasCompletedOnboarding; + @HiveField(12) + String? nickname; + + @HiveField(13) + String? gender; // 'male', 'female', 'other', null + UserProfile({ this.fontPreference = 'sans', this.displayMode = 'list', @@ -57,6 +63,8 @@ class UserProfile extends HiveObject { this.isBusinessMode = false, this.defaultMarkup = 3.0, this.hasCompletedOnboarding = false, + this.nickname, + this.gender, }); UserProfile copyWith({ @@ -70,6 +78,8 @@ class UserProfile extends HiveObject { bool? isBusinessMode, double? defaultMarkup, bool? hasCompletedOnboarding, + String? nickname, + String? gender, }) { return UserProfile( fontPreference: fontPreference ?? this.fontPreference, @@ -82,6 +92,8 @@ class UserProfile extends HiveObject { isBusinessMode: isBusinessMode ?? this.isBusinessMode, defaultMarkup: defaultMarkup ?? this.defaultMarkup, hasCompletedOnboarding: hasCompletedOnboarding ?? this.hasCompletedOnboarding, + nickname: nickname ?? this.nickname, + gender: gender ?? this.gender, ); } } diff --git a/lib/models/user_profile.g.dart b/lib/models/user_profile.g.dart index e467e21..696f7da 100644 --- a/lib/models/user_profile.g.dart +++ b/lib/models/user_profile.g.dart @@ -27,13 +27,15 @@ class UserProfileAdapter extends TypeAdapter { isBusinessMode: fields[9] == null ? false : fields[9] as bool, defaultMarkup: fields[10] == null ? 3.0 : fields[10] as double, hasCompletedOnboarding: fields[11] == null ? false : fields[11] as bool, + nickname: fields[12] as String?, + gender: fields[13] as String?, ); } @override void write(BinaryWriter writer, UserProfile obj) { writer - ..writeByte(10) + ..writeByte(12) ..writeByte(0) ..write(obj.fontPreference) ..writeByte(3) @@ -53,7 +55,11 @@ class UserProfileAdapter extends TypeAdapter { ..writeByte(10) ..write(obj.defaultMarkup) ..writeByte(11) - ..write(obj.hasCompletedOnboarding); + ..write(obj.hasCompletedOnboarding) + ..writeByte(12) + ..write(obj.nickname) + ..writeByte(13) + ..write(obj.gender); } @override diff --git a/lib/providers/sake_list_provider.dart b/lib/providers/sake_list_provider.dart index f695786..b17dfb3 100644 --- a/lib/providers/sake_list_provider.dart +++ b/lib/providers/sake_list_provider.dart @@ -99,7 +99,7 @@ final sakeListProvider = Provider>>((ref) { filtered.sort((a, b) => a.displayData.name.compareTo(b.displayData.name)); break; case SortMode.custom: - default: + // Use Manual Sort Order return sortOrderAsync.when( data: (sortOrder) { @@ -155,6 +155,26 @@ class SakeOrderController extends Notifier { final sakeOrderControllerProvider = NotifierProvider(SakeOrderController.new); +// 5. All Items Provider (フィルタなし、全件取得用) +// ソムリエ診断など、フィルタリングせず全体の傾向を見たい場合に使用 +final allSakeItemsProvider = Provider>>((ref) { + final rawListAsync = ref.watch(rawSakeListItemsProvider); + + return rawListAsync.when( + data: (rawList) { + if (rawList.isEmpty) return const AsyncValue.data([]); + + // ソートのみ適用(新しい順) + final sorted = List.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 on Stream { Stream startWith(T initial) async* { yield initial; diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 2e2636c..54318a8 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -44,10 +44,12 @@ class UserProfileNotifier extends Notifier { await _save(newState); } - Future setIdentity({String? mbti, DateTime? birthdate}) async { + Future setIdentity({String? mbti, DateTime? birthdate, String? nickname, String? gender}) async { final newState = state.copyWith( mbti: mbti ?? state.mbti, birthdate: birthdate ?? state.birthdate, + nickname: nickname ?? state.nickname, + gender: gender ?? state.gender, updatedAt: DateTime.now(), ); await _save(newState); diff --git a/lib/screens/camera_screen.dart b/lib/screens/camera_screen.dart index d6bea4e..631fe00 100644 --- a/lib/screens/camera_screen.dart +++ b/lib/screens/camera_screen.dart @@ -14,6 +14,8 @@ import '../widgets/analyzing_dialog.dart'; import '../models/sake_item.dart'; import '../theme/app_theme.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import 'package:image_picker/image_picker.dart'; // Gallery Import + enum CameraMode { createItem, @@ -213,21 +215,49 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr if (!mounted) return; + _handleCapturedImage(imagePath); + + + } catch (e) { + debugPrint('Capture Error: $e'); + } finally { + if (mounted) { + setState(() { + _isTakingPicture = false; + }); + } + } + } + + + + Future _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 _handleCapturedImage(String imagePath, {bool fromGallery = false}) async { // IF RETURN PATH Mode if (widget.mode == CameraMode.returnPath) { Navigator.of(context).pop(imagePath); return; } - _capturedImages.add(imagePath); + setState(() { + _capturedImages.add(imagePath); + }); // Show Confirmation Dialog await showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( - title: const Text('写真を保存しました'), - content: const Text('さらに別の面も撮影すると、\nAI解析の精度が大幅にアップします!'), + title: Text(fromGallery ? '画像を読み込みました' : '写真を保存しました'), + content: const Text('さらに別の面も撮影・追加すると、\nAI解析の精度がアップします!'), actions: [ OutlinedButton( onPressed: () { @@ -239,28 +269,18 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr ), FilledButton( onPressed: () { - // Take another photo (Dismiss dialog) + // Return to capture (Dismiss dialog) Navigator.of(context).pop(); }, style: FilledButton.styleFrom( backgroundColor: AppTheme.posimaiBlue, foregroundColor: Colors.white, ), - child: const Text('さらに撮影'), + child: const Text('さらに追加'), ), ], ), ); - - } catch (e) { - debugPrint('Capture Error: $e'); - } finally { - if (mounted) { - setState(() { - _isTakingPicture = false; - }); - } - } } Future _analyzeImages() async { @@ -560,37 +580,54 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr ], ), ), - // Shutter Button + // Bottom Control Area Padding( - padding: const EdgeInsets.only(bottom: 32.0), - child: GestureDetector( - onTap: _takePicture, - child: Container( - height: 80, - width: 80, - decoration: BoxDecoration( - 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), + padding: const EdgeInsets.only(bottom: 32.0, left: 24, right: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Gallery Button (Left) + IconButton( + icon: const Icon(LucideIcons.image, color: Colors.white, size: 32), + onPressed: _pickFromGallery, + tooltip: 'ギャラリーから選択', ), - 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, - ), + + // Shutter Button (Center) + GestureDetector( + onTap: _takePicture, + child: Container( + height: 80, + width: 80, + decoration: BoxDecoration( + 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), + ], ), ), ], diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index e135f5b..2d32ca6 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -39,7 +39,6 @@ class HomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final fontPref = ref.watch(fontPreferenceProvider); final displayMode = ref.watch(displayModeProvider); final sakeListAsync = ref.watch(sakeListProvider); @@ -148,7 +147,6 @@ class HomeScreen extends ConsumerWidget { body: SafeArea( child: Column( children: [ - if (!isMenuMode) if (!isMenuMode) SakeFilterChips( mode: isBusinessMode ? FilterChipMode.business : FilterChipMode.personal @@ -231,7 +229,7 @@ class HomeScreen extends ConsumerWidget { SpeedDialChild( child: const Text('🍶', style: TextStyle(fontSize: 24)), backgroundColor: Colors.white, - label: 'お品書き作成', + label: 'お品書きを作成', labelStyle: const TextStyle(fontWeight: FontWeight.bold), onTap: () { Navigator.push( @@ -243,7 +241,7 @@ class HomeScreen extends ConsumerWidget { SpeedDialChild( child: const Icon(LucideIcons.packagePlus, color: Colors.orange), backgroundColor: Colors.white, - label: 'セット商品を追加', + label: 'セットを作成', labelStyle: const TextStyle(fontWeight: FontWeight.bold), onTap: () { showDialog( @@ -255,7 +253,7 @@ class HomeScreen extends ConsumerWidget { SpeedDialChild( child: const Icon(LucideIcons.image, color: AppTheme.posimaiBlue), backgroundColor: Colors.white, - label: '画像の読み込み', + label: 'ギャラリーから選択', labelStyle: const TextStyle(fontWeight: FontWeight.bold), onTap: () async { HapticFeedback.heavyImpact(); @@ -265,7 +263,7 @@ class HomeScreen extends ConsumerWidget { SpeedDialChild( child: const Icon(LucideIcons.camera, color: AppTheme.posimaiBlue), backgroundColor: Colors.white, - label: '商品を撮影', + label: 'カメラで撮影', labelStyle: const TextStyle(fontWeight: FontWeight.bold), onTap: () { Navigator.of(context).push( diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 3a13da7..dae430b 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -50,7 +50,10 @@ class _MainScreenState extends ConsumerState { // Define Navigation Items final List destinations = isBusiness ? 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.barChart), label: '分析'), NavigationDestination(icon: Icon(LucideIcons.store), label: '店舗'), diff --git a/lib/screens/menu_creation_screen.dart b/lib/screens/menu_creation_screen.dart index 86e13d2..956caab 100644 --- a/lib/screens/menu_creation_screen.dart +++ b/lib/screens/menu_creation_screen.dart @@ -29,9 +29,7 @@ class MenuCreationScreen extends ConsumerWidget { final displayMode = ref.watch(displayModeProvider); final sakeListAsync = ref.watch(sakeListProvider); - final isSearching = ref.watch(sakeSearchQueryProvider).isNotEmpty; final showSelectedOnly = ref.watch(menuShowSelectedOnlyProvider); - final showFavorites = ref.watch(sakeFilterFavoriteProvider); final selectedPrefecture = ref.watch(sakeFilterPrefectureProvider); return Scaffold( diff --git a/lib/screens/placeholders/brewery_map_screen.dart b/lib/screens/placeholders/brewery_map_screen.dart index f532a12..64cd965 100644 --- a/lib/screens/placeholders/brewery_map_screen.dart +++ b/lib/screens/placeholders/brewery_map_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.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 '../../models/maps/japan_map_data.dart'; @@ -81,8 +81,8 @@ class _BreweryMapScreenState extends ConsumerState { height: 420, // Increased to 420 to prevent Okinawa from being cut off child: LayoutBuilder( builder: (context, constraints) { - // Map logical width is approx 26 cols * 32.0 = 832.0 - const double mapWidth = 26 * 32.0; + // Map logical width is approx 13 cols * (46 + 4) = 650 + const double mapWidth = 650.0; // Calculate scale to fit width (95% to allow slight margin) final availableWidth = constraints.maxWidth; @@ -110,7 +110,7 @@ class _BreweryMapScreenState extends ConsumerState { minScale: fitScale * 0.95, maxScale: fitScale * 6.0, constrained: false, - child: PixelJapanMap( + child: PrefectureTileMap( visitedPrefectures: visitedPrefectures, onPrefectureTap: (pref) { _showPrefectureStats(context, pref, sakeList); diff --git a/lib/screens/placeholders/sommelier_screen.dart b/lib/screens/placeholders/sommelier_screen.dart index 3a43ede..e6ecad4 100644 --- a/lib/screens/placeholders/sommelier_screen.dart +++ b/lib/screens/placeholders/sommelier_screen.dart @@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import '../../providers/sake_list_provider.dart'; import '../../services/shuko_diagnosis_service.dart'; +import '../../providers/theme_provider.dart'; // v1.1 Fix import '../../theme/app_theme.dart'; import '../../widgets/sake_radar_chart.dart'; @@ -60,7 +61,8 @@ class _SommelierScreenState extends ConsumerState { @override 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); return Scaffold( @@ -70,15 +72,25 @@ class _SommelierScreenState extends ConsumerState { ), body: sakeListAsync.when( 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( padding: const EdgeInsets.all(24.0), child: Column( children: [ + Text( + diagnosisService.getGreeting(userProfile.nickname), + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), Screenshot( controller: _screenshotController, - child: _buildShukoCard(context, profile), + child: _buildShukoCard(context, baseProfile, personalizedTitle, userProfile.nickname), // Pass nickname ), const SizedBox(height: 32), _buildActionButtons(context), @@ -92,7 +104,7 @@ class _SommelierScreenState extends ConsumerState { ); } - Widget _buildShukoCard(BuildContext context, ShukoProfile profile) { + Widget _buildShukoCard(BuildContext context, ShukoProfile profile, ShukoTitle titleInfo, String? nickname) { final isDark = Theme.of(context).brightness == Brightness.dark; return Container( @@ -147,14 +159,16 @@ class _SommelierScreenState extends ConsumerState { children: [ // 1. Header (Name & Rank) Text( - 'あなたの酒向タイプ', + (nickname != null && nickname.isNotEmpty) + ? '$nicknameさんの酒向タイプ' + : 'あなたの酒向タイプ', style: Theme.of(context).textTheme.bodySmall?.copyWith(letterSpacing: 1.5), ), const SizedBox(height: 16), // 2. Title Text( - profile.title, + titleInfo.title, textAlign: TextAlign.center, style: TextStyle( fontSize: 28, @@ -172,7 +186,7 @@ class _SommelierScreenState extends ConsumerState { const SizedBox(height: 16), Text( - profile.description, + titleInfo.description, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( height: 1.5, diff --git a/lib/screens/sake_detail_screen.dart b/lib/screens/sake_detail_screen.dart index 5acb5a3..a792878 100644 --- a/lib/screens/sake_detail_screen.dart +++ b/lib/screens/sake_detail_screen.dart @@ -1,5 +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'; @@ -18,6 +19,7 @@ import '../services/pricing_calculator.dart'; import '../providers/theme_provider.dart'; import '../models/user_profile.dart'; import 'camera_screen.dart'; +import '../widgets/common/munyun_like_button.dart'; class SakeDetailScreen extends ConsumerStatefulWidget { @@ -33,11 +35,21 @@ class _SakeDetailScreenState extends ConsumerState { // To trigger rebuilds if we don't switch to a stream late SakeItem _sake; int _currentImageIndex = 0; + final FocusNode _memoFocusNode = FocusNode(); // Polish: Focus logic @override void initState() { super.initState(); _sake = widget.sake; + _memoFocusNode.addListener(() { + setState(() {}); // Rebuild to hide/show hint + }); + } + + @override + void dispose() { + _memoFocusNode.dispose(); + super.dispose(); } @override @@ -70,11 +82,9 @@ class _SakeDetailScreenState extends ConsumerState { pinned: true, iconTheme: const IconThemeData(color: Colors.white), actions: [ - IconButton( - icon: Icon(_sake.userData.isFavorite ? Icons.favorite : Icons.favorite_border), - color: _sake.userData.isFavorite ? Colors.pink : Colors.white, - tooltip: 'お気に入り', - onPressed: () => _toggleFavorite(), + MunyunLikeButton( + isLiked: _sake.userData.isFavorite, + onTap: () => _toggleFavorite(), ), IconButton( icon: const Icon(LucideIcons.refreshCw), @@ -86,7 +96,10 @@ class _SakeDetailScreenState extends ConsumerState { icon: const Icon(LucideIcons.trash2), color: Colors.white, tooltip: '削除', - onPressed: () => _showDeleteDialog(context), + onPressed: () { + HapticFeedback.heavyImpact(); + _showDeleteDialog(context); + }, ), ], flexibleSpace: FlexibleSpaceBar( @@ -260,7 +273,7 @@ class _SakeDetailScreenState extends ConsumerState { ), ), 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 { ), ), 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 { ], ), const SizedBox(height: 12), + const SizedBox(height: 12), TextField( controller: TextEditingController(text: _sake.userData.memo ?? ''), + focusNode: _memoFocusNode, maxLines: 4, decoration: InputDecoration( - hintText: 'お店独自の情報をメモ', + hintText: _memoFocusNode.hasFocus ? '' : 'メモを入力後に自動保存', // Disappear on focus + hintStyle: TextStyle(color: Colors.grey.withValues(alpha: 0.5)), // Lighter color border: const OutlineInputBorder(), filled: true, fillColor: Theme.of(context).cardColor, @@ -574,6 +590,7 @@ class _SakeDetailScreenState extends ConsumerState { Future _toggleFavorite() async { + HapticFeedback.mediumImpact(); final box = Hive.box('sake_items'); final newItem = _sake.copyWith(isFavorite: !_sake.userData.isFavorite); diff --git a/lib/screens/soul_screen.dart b/lib/screens/soul_screen.dart index 70a3620..b99b51d 100644 --- a/lib/screens/soul_screen.dart +++ b/lib/screens/soul_screen.dart @@ -23,8 +23,6 @@ class _SoulScreenState extends ConsumerState { @override Widget build(BuildContext context) { final userProfile = ref.watch(userProfileProvider); - final themeMode = userProfile.themeMode; - final fontPref = userProfile.fontPreference; return Scaffold( appBar: AppBar( @@ -56,6 +54,22 @@ class _SoulScreenState extends ConsumerState { trailing: Icon(LucideIcons.chevronRight, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[600] : null), 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 { 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 '未設定'; + } + } } diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index 3934cb6..560d51d 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'dart:convert'; +import 'dart:async'; // TimeoutException import 'package:archive/archive_io.dart'; +import 'package:flutter/foundation.dart'; // debugPrint import 'package:googleapis/drive/v3.dart' as drive; 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'; @@ -35,7 +37,7 @@ class BackupService { try { await _googleSignIn.signInSilently(); } catch (e) { - print('⚠️ サイレントサインインエラー: $e'); + debugPrint('⚠️ サイレントサインインエラー: $e'); } } @@ -60,7 +62,7 @@ class BackupService { final account = await _googleSignIn.signIn(); return account; } catch (error) { - print('❌ Google Sign In エラー: $error'); + debugPrint('❌ Google Sign In エラー: $error'); return null; } } @@ -87,14 +89,14 @@ class BackupService { // 1. サインイン確認 final account = _googleSignIn.currentUser; if (account == null) { - print('❌ サインインが必要です'); + debugPrint('❌ サインインが必要です'); return false; } // 2. Drive APIクライアントを作成 final authClient = await _googleSignIn.authenticatedClient(); if (authClient == null) { - print('❌ 認証クライアントの取得に失敗しました'); + debugPrint('❌ 認証クライアントの取得に失敗しました'); return false; } @@ -103,7 +105,7 @@ class BackupService { // 3. バックアップZIPファイルを作成 final zipFile = await _createBackupZip(); if (zipFile == null) { - print('❌ バックアップファイルの作成に失敗しました'); + debugPrint('❌ バックアップファイルの作成に失敗しました'); return false; } @@ -115,7 +117,7 @@ class BackupService { return success; } catch (error) { - print('❌ バックアップ作成エラー: $error'); + debugPrint('❌ バックアップ作成エラー: $error'); return false; } } @@ -186,7 +188,7 @@ class BackupService { }).toList(); final sakeItemsFile = File(path.join(backupDir.path, 'sake_items.json')); 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を作成 final settings = Map.from(settingsBox.toMap()); @@ -219,10 +221,10 @@ class BackupService { // 6. 一時ディレクトリを削除 await backupDir.delete(recursive: true); - print('✅ バックアップZIPファイル作成完了: $zipPath'); + debugPrint('✅ バックアップZIPファイル作成完了: $zipPath'); return File(zipPath); } catch (error) { - print('❌ ZIP作成エラー: $error'); + debugPrint('❌ ZIP作成エラー: $error'); return null; } } @@ -230,6 +232,7 @@ class BackupService { /// Google DriveにZIPファイルをアップロード Future _uploadToDrive(drive.DriveApi driveApi, File zipFile) async { try { + debugPrint('[BACKUP] 📤 アップロード開始: ${zipFile.lengthSync()} bytes'); // 1. 既存のバックアップファイルを検索 final fileList = await driveApi.files.list( q: "name = '$backupFileName' and trashed = false", @@ -241,9 +244,9 @@ class BackupService { for (var file in fileList.files!) { try { await driveApi.files.delete(file.id!); - print('🗑️ 既存のバックアップファイルを削除しました: ${file.id}'); + debugPrint('🗑️ [BACKUP] 既存ファイルを削除: ${file.id}'); } catch (e) { - print('⚠️ 既存ファイルの削除に失敗 (無視して続行): $e'); + debugPrint('⚠️ [BACKUP] 既存ファイル削除失敗 (無視): $e'); } } } @@ -254,40 +257,40 @@ class BackupService { final media = drive.Media(zipFile.openRead(), zipFile.lengthSync()); + debugPrint('[BACKUP] 🚀 Driveへ送信中...'); final uploadedFile = await driveApi.files.create( driveFile, uploadMedia: media, - ); + ).timeout(const Duration(minutes: 3), onTimeout: () { + throw TimeoutException('アップロードがタイムアウトしました (3分)'); + }); if (uploadedFile.id == null) { - print('❌ アップロード後のID取得失敗'); + debugPrint('❌ [BACKUP] ID取得失敗'); return false; } - print('✅ Google Driveにアップロードリクエスト完了 ID: ${uploadedFile.id}'); + debugPrint('✅ [BACKUP] アップロード完了 ID: ${uploadedFile.id}'); - // 4. 検証ステップ:正しくアップロードされたか確認 - // APIの反映ラグを考慮して少し待機してから確認 + // 4. 検証ステップ int retryCount = 0; bool verified = false; while (retryCount < 3 && !verified) { await Future.delayed(Duration(milliseconds: 1000 * (retryCount + 1))); - try { final check = await driveApi.files.get(uploadedFile.id!); - // getが成功すればファイルは存在する verified = true; - print('✅ アップロード検証成功: ファイル存在確認済み'); - } catch (e) { - print('⚠️ 検証試行 ${retryCount + 1} 失敗: $e'); + debugPrint('✅ [BACKUP] 検証成功'); + } catch (e) { + debugPrint('⚠️ [BACKUP] 検証試行 ${retryCount + 1} 失敗: $e'); } retryCount++; } return verified; } catch (error) { - print('❌ アップロードエラー: $error'); + debugPrint('❌ [BACKUP] アップロードエラー: $error'); return false; } } @@ -309,14 +312,14 @@ class BackupService { // 1. サインイン確認 final account = _googleSignIn.currentUser; if (account == null) { - print('❌ サインインが必要です'); + debugPrint('❌ サインインが必要です'); return false; } // 2. Drive APIクライアントを作成 final authClient = await _googleSignIn.authenticatedClient(); if (authClient == null) { - print('❌ 認証クライアントの取得に失敗しました'); + debugPrint('❌ 認証クライアントの取得に失敗しました'); return false; } @@ -328,7 +331,7 @@ class BackupService { // 4. Google Driveからダウンロード final zipFile = await _downloadFromDrive(driveApi); if (zipFile == null) { - print('❌ ダウンロードに失敗しました'); + debugPrint('❌ ダウンロードに失敗しました'); return false; } @@ -340,7 +343,7 @@ class BackupService { return success; } catch (error) { - print('❌ 復元エラー: $error'); + debugPrint('❌ 復元エラー: $error'); return false; } } @@ -355,10 +358,10 @@ class BackupService { if (zipFile != null) { await zipFile.copy(backupPath); await zipFile.delete(); - print('✅ 復元前のデータを退避しました: $backupPath'); + debugPrint('✅ 復元前のデータを退避しました: $backupPath'); } } catch (error) { - print('⚠️ データ退避エラー: $error'); + debugPrint('⚠️ データ退避エラー: $error'); } } @@ -372,7 +375,7 @@ class BackupService { ); if (fileList.files == null || fileList.files!.isEmpty) { - print('❌ バックアップファイルが見つかりません'); + debugPrint('❌ バックアップファイルが見つかりません'); return null; } @@ -392,10 +395,10 @@ class BackupService { final sink = downloadFile.openWrite(); await media.stream.pipe(sink); - print('✅ ダウンロード完了: $downloadPath'); + debugPrint('✅ ダウンロード完了: $downloadPath'); return downloadFile; } catch (error) { - print('❌ ダウンロードエラー: $error'); + debugPrint('❌ ダウンロードエラー: $error'); return null; } } @@ -427,13 +430,13 @@ class BackupService { final outFile = File(extractPath); await outFile.create(recursive: true); await outFile.writeAsBytes(data); - print('📦 展開: $filename (${data.length} bytes)'); + debugPrint('📦 展開: $filename (${data.length} bytes)'); } } // デバッグ: 展開されたファイル一覧を表示 - print('📂 展開ディレクトリの中身:'); - extractDir.listSync(recursive: true).forEach((f) => print(' - ${path.basename(f.path)}')); + debugPrint('📂 展開ディレクトリの中身:'); + extractDir.listSync(recursive: true).forEach((f) => debugPrint(' - ${path.basename(f.path)}')); // 2. sake_items.jsonを検索 (ルートまたはサブディレクトリ) File? sakeItemsFile; @@ -443,12 +446,12 @@ class BackupService { sakeItemsFile = potentialFiles.firstWhere((f) => path.basename(f.path) == 'sake_items.json'); } catch (e) { // 見つからない場合 - print('❌ sake_items.json が見つかりません'); + debugPrint('❌ sake_items.json が見つかりません'); } if (sakeItemsFile != null && await sakeItemsFile.exists()) { final sakeItemsJson = json.decode(await sakeItemsFile.readAsString()) as List; - print('🔍 復元対象データ数: ${sakeItemsJson.length}件'); + debugPrint('🔍 復元対象データ数: ${sakeItemsJson.length}件'); final sakeBox = Hive.box('sake_items'); await sakeBox.clear(); @@ -496,7 +499,7 @@ class BackupService { // IDを保持するためにput()を使用(add()は新しいキーを生成してしまう) await sakeBox.put(item.id, item); } - print('✅ SakeItemsを復元しました(${sakeItemsJson.length}件)'); + debugPrint('✅ SakeItemsを復元しました(${sakeItemsJson.length}件)'); // UI更新のためにわずかに待機 await Future.delayed(const Duration(milliseconds: 500)); } @@ -506,7 +509,7 @@ class BackupService { try { settingsFile = potentialFiles.firstWhere((f) => path.basename(f.path) == 'settings.json'); } catch (e) { - print('⚠️ settings.json が見つかりません (スキップ)'); + debugPrint('⚠️ settings.json が見つかりません (スキップ)'); } if (settingsFile != null && await settingsFile.exists()) { @@ -517,7 +520,7 @@ class BackupService { for (var entry in settingsJson.entries) { await settingsBox.put(entry.key, entry.value); } - print('✅ 設定を復元しました'); + debugPrint('✅ 設定を復元しました'); } // 4. 画像ファイルを復元 (sake_items.jsonと同じ階層のimagesフォルダを探す) @@ -535,21 +538,21 @@ class BackupService { await imageFile.copy(path.join(appDir.path, fileName)); } } - print('✅ 画像ファイルを復元しました(${imageFiles.length}ファイル)'); + debugPrint('✅ 画像ファイルを復元しました(${imageFiles.length}ファイル)'); } } // 5. 一時ディレクトリを削除 await extractDir.delete(recursive: true); - print('✅ データの復元が完了しました'); + debugPrint('✅ データの復元が完了しました'); return true; } catch (error) { - print('❌ 復元処理エラー: $error'); + debugPrint('❌ 復元処理エラー: $error'); // スタックトレースも出す - print(error); + debugPrint(error.toString()); if (error is Error) { - print(error.stackTrace); + debugPrint(error.stackTrace.toString()); } return false; } @@ -573,7 +576,7 @@ class BackupService { return fileList.files != null && fileList.files!.isNotEmpty; } catch (error) { - print('❌ バックアップ確認エラー: $error'); + debugPrint('❌ バックアップ確認エラー: $error'); return false; } } diff --git a/lib/services/shuko_diagnosis_service.dart b/lib/services/shuko_diagnosis_service.dart index a1d2123..574d448 100644 --- a/lib/services/shuko_diagnosis_service.dart +++ b/lib/services/shuko_diagnosis_service.dart @@ -102,6 +102,36 @@ class ShukoDiagnosisService { 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 { diff --git a/lib/widgets/common/munyun_like_button.dart b/lib/widgets/common/munyun_like_button.dart new file mode 100644 index 0000000..51d58f8 --- /dev/null +++ b/lib/widgets/common/munyun_like_button.dart @@ -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 createState() => _MunyunLikeButtonState(); +} + +class _MunyunLikeButtonState extends State { + // 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, + ); + } +} diff --git a/lib/widgets/home/sake_grid_item.dart b/lib/widgets/home/sake_grid_item.dart index 2f7c1e2..b202da9 100644 --- a/lib/widgets/home/sake_grid_item.dart +++ b/lib/widgets/home/sake_grid_item.dart @@ -181,7 +181,7 @@ class SakeGridItem extends ConsumerWidget { top: AppTheme.spacingSmall, right: AppTheme.spacingSmall, child: Icon( - Icons.favorite, + LucideIcons.heart, color: Colors.pink, size: 20, ), diff --git a/lib/widgets/map/prefecture_tile_map.dart b/lib/widgets/map/prefecture_tile_map.dart new file mode 100644 index 0000000..2dc04fc --- /dev/null +++ b/lib/widgets/map/prefecture_tile_map.dart @@ -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 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, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/backup_settings_section.dart b/lib/widgets/settings/backup_settings_section.dart index 38e269a..9f338d3 100644 --- a/lib/widgets/settings/backup_settings_section.dart +++ b/lib/widgets/settings/backup_settings_section.dart @@ -156,6 +156,34 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring } return Column( children: [ _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( color: isDark ? const Color(0xFF1E1E1E) : null, child: Column( diff --git a/pubspec.yaml b/pubspec.yaml index de0d1c0..76af514 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -104,6 +104,7 @@ flutter: assets: - assets/fonts/NotoSansJP-Regular.ttf - assets/images/ + - assets/rive/ fonts: - family: NotoSansJP fonts: diff --git a/tools/synology/docker-compose.yml b/tools/synology/docker-compose.yml index 855b802..997d43d 100644 --- a/tools/synology/docker-compose.yml +++ b/tools/synology/docker-compose.yml @@ -1,58 +1,83 @@ version: '3.8' +# ========================================== +# 🍶 Ponshu Room "AI Factory" Setup +# ========================================== +# このファイルはSynology Container Managerで「プロジェクト」として使用します。 +# 3つの主要コンテナ (Gitea, Postgres, MCP) を一度に立ち上げます。 +# +# 変更推奨箇所: +# - GITEA__database__PASSWD: 強固なパスワードに変更してください +# - POSTGRES_PASSWORD: 上記と同じパスワードに変更してください + services: - # 【守りの要】Gitea本体: コードの原本保管庫 + # ---------------------------------------- + # 1. Gitea (Git Server) + # 役割: コードの「原本」を管理する倉庫。 + # ---------------------------------------- gitea: image: gitea/gitea:1.21 container_name: gitea environment: - - USER_UID=1026 + - USER_UID=1026 # Synologyの一般的なユーザーID (環境に合わせて変更可) - USER_GID=100 - GITEA__database__DB_TYPE=postgres - GITEA__database__HOST=db:5432 - GITEA__database__NAME=gitea - GITEA__database__USER=gitea - - GITEA__database__PASSWD=gitea_password # 適宜変更してください + - GITEA__database__PASSWD=gitea_password # 【変更推奨】DBパスワード restart: always networks: - gitea_network volumes: - - ./gitea:/data + - ./gitea:/data # リポジトリデータ (永続化) - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro ports: - - "3000:3000" # ブラウザでアクセスするポート - - "2222:22" # SSH用ポート + - "3000:3000" # Web UI用ポート (ブラウザからアクセス) + - "2222:22" # SSH用ポート (Git操作用) - # 【データベース】GiteaとMCPのデータを保存 + # ---------------------------------------- + # 2. PostgreSQL (Database) + # 役割: Giteaのユーザー情報や設定を保存するDB。 + # ---------------------------------------- db: image: postgres:15 container_name: gitea_db restart: always environment: - POSTGRES_USER=gitea - - POSTGRES_PASSWORD=gitea_password + - POSTGRES_PASSWORD=gitea_password # 【変更推奨】Giteaの設定と合わせる - POSTGRES_DB=gitea networks: - gitea_network 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: image: node:20-slim container_name: mcp_server working_dir: /app volumes: - - ./mcp:/app - - ./gitea:/data/gitea_files # AIがGiteaのファイルを直接覗けるように接続 + - ./mcp:/app # MCPサーバーのコード置き場 + - ./gitea:/data/gitea_files # AIがGiteaのファイルを読み書きするための共有設定 environment: - 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 networks: - gitea_network +# ---------------------------------------- +# Network Setting +# 内部通信用の専用ネットワーク +# ---------------------------------------- networks: gitea_network: driver: bridge