v1.2: Map Tab Tile Map, Sommelier Enhancements, APK Optimization (111MB)
This commit is contained in:
parent
e05d05c4ee
commit
191e334d0d
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ android {
|
|||
targetSdk = 34
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
ndk {
|
||||
abiFilters.add("arm64-v8a")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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}}';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,13 +27,15 @@ class UserProfileAdapter extends TypeAdapter<UserProfile> {
|
|||
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<UserProfile> {
|
|||
..writeByte(10)
|
||||
..write(obj.defaultMarkup)
|
||||
..writeByte(11)
|
||||
..write(obj.hasCompletedOnboarding);
|
||||
..write(obj.hasCompletedOnboarding)
|
||||
..writeByte(12)
|
||||
..write(obj.nickname)
|
||||
..writeByte(13)
|
||||
..write(obj.gender);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ final sakeListProvider = Provider<AsyncValue<List<SakeItem>>>((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<void> {
|
|||
|
||||
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> {
|
||||
Stream<T> startWith(T initial) async* {
|
||||
yield initial;
|
||||
|
|
|
|||
|
|
@ -44,10 +44,12 @@ class UserProfileNotifier extends Notifier<UserProfile> {
|
|||
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(
|
||||
mbti: mbti ?? state.mbti,
|
||||
birthdate: birthdate ?? state.birthdate,
|
||||
nickname: nickname ?? state.nickname,
|
||||
gender: gender ?? state.gender,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
await _save(newState);
|
||||
|
|
|
|||
|
|
@ -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<CameraScreen> with SingleTickerPr
|
|||
|
||||
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 (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<CameraScreen> 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<void> _analyzeImages() async {
|
||||
|
|
@ -560,37 +580,54 @@ class _CameraScreenState extends ConsumerState<CameraScreen> 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -50,7 +50,10 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
|||
// Define Navigation Items
|
||||
final List<NavigationDestination> 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: '店舗'),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<BreweryMapScreen> {
|
|||
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<BreweryMapScreen> {
|
|||
minScale: fitScale * 0.95,
|
||||
maxScale: fitScale * 6.0,
|
||||
constrained: false,
|
||||
child: PixelJapanMap(
|
||||
child: PrefectureTileMap(
|
||||
visitedPrefectures: visitedPrefectures,
|
||||
onPrefectureTap: (pref) {
|
||||
_showPrefectureStats(context, pref, sakeList);
|
||||
|
|
|
|||
|
|
@ -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<SommelierScreen> {
|
|||
|
||||
@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<SommelierScreen> {
|
|||
),
|
||||
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<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;
|
||||
|
||||
return Container(
|
||||
|
|
@ -147,14 +159,16 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
|||
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<SommelierScreen> {
|
|||
const SizedBox(height: 16),
|
||||
|
||||
Text(
|
||||
profile.description,
|
||||
titleInfo.description,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
height: 1.5,
|
||||
|
|
|
|||
|
|
@ -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<SakeDetailScreen> {
|
|||
// 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<SakeDetailScreen> {
|
|||
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<SakeDetailScreen> {
|
|||
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<SakeDetailScreen> {
|
|||
),
|
||||
),
|
||||
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),
|
||||
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),
|
||||
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<SakeDetailScreen> {
|
|||
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
HapticFeedback.mediumImpact();
|
||||
final box = Hive.box<SakeItem>('sake_items');
|
||||
|
||||
final newItem = _sake.copyWith(isFavorite: !_sake.userData.isFavorite);
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ class _SoulScreenState extends ConsumerState<SoulScreen> {
|
|||
@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<SoulScreen> {
|
|||
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<SoulScreen> {
|
|||
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 '未設定';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic>.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<bool> _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<SakeItem>('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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ flutter:
|
|||
assets:
|
||||
- assets/fonts/NotoSansJP-Regular.ttf
|
||||
- assets/images/
|
||||
- assets/rive/
|
||||
fonts:
|
||||
- family: NotoSansJP
|
||||
fonts:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue