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

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

View File

@ -13,7 +13,11 @@
"Bash(git rebase:*)",
"Bash(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": []

View File

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

View File

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

View File

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

View File

@ -46,6 +46,12 @@ class UserProfile extends HiveObject {
@HiveField(11, defaultValue: false)
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,
);
}
}

View File

@ -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

View File

@ -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;

View File

@ -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);

View File

@ -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),
],
),
),
],

View File

@ -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(

View File

@ -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: '店舗'),

View File

@ -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(

View File

@ -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);

View File

@ -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,

View File

@ -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);

View File

@ -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 '未設定';
}
}
}

View File

@ -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;
}
}

View File

@ -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 {

View File

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

View File

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

View File

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

View File

@ -156,6 +156,34 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
return Column(
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(

View File

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

View File

@ -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