import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'dart:io'; // File import '../../providers/sake_list_provider.dart'; import '../../screens/sake_detail_screen.dart'; // Detail Screen import '../../widgets/map/prefecture_tile_map.dart'; import '../../theme/app_colors.dart'; import '../../models/maps/japan_map_data.dart'; import '../../providers/ui_experiment_provider.dart'; class BreweryMapScreen extends ConsumerStatefulWidget { const BreweryMapScreen({super.key}); @override ConsumerState createState() => _BreweryMapScreenState(); } class _BreweryMapScreenState extends ConsumerState { final TransformationController _transformationController = TransformationController(); bool _isMapInitialized = false; @override void dispose() { _transformationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final sakeListAsync = ref.watch(sakeListProvider); final isMapColorful = ref.watch(uiExperimentProvider).isMapColorful; final appColors = Theme.of(context).extension()!; return sakeListAsync.when( data: (sakeList) { // Extract visited prefectures final visitedPrefectures = sakeList .map((s) => s.displayData.prefecture) .where((p) => p.isNotEmpty) .where((p) => p != '不明' && p != '海外') .map((p) => p) .toSet(); final totalPrefs = 47; final visitedCount = visitedPrefectures.length; final progress = visitedCount / totalPrefs; return Scaffold( appBar: AppBar( title: const Text('酒蔵マップ'), centerTitle: true, ), body: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 12), // 1. Stats Card (Compact) Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: _buildStatsCard(context, progress, visitedCount), ), const SizedBox(height: 8), // 2. Legend Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: Row( mainAxisAlignment: MainAxisAlignment.start, // Left align children: [ // Unvisited _buildLegendDot( appColors, appColors.divider, '未開拓' ), const SizedBox(width: 12), // Visited (Dynamic based on mode) if (isMapColorful) ...[ _buildLegendDot(appColors, const Color(0xFF6A7FF0), null), // Hokkaido Blue const SizedBox(width: 2), _buildLegendDot(appColors, const Color(0xFF39D353), null), // Kanto Green const SizedBox(width: 2), _buildLegendDot(appColors, const Color(0xFFFF7B7B), '制覇済 (地域色)'), // Kyushu Pink ] else ...[ _buildLegendDot(appColors, appColors.brandPrimary, '制覇済'), ], ], ), ), const SizedBox(height: 4), // 3. Map (Maximized & Interactive) // Use LayoutBuilder to determine available width and set initial scale SizedBox( height: 420, // Increased to 420 to prevent Okinawa from being cut off child: LayoutBuilder( builder: (context, constraints) { // Map logical width is approx 12 cols * (46 + 4) = 600 const double mapWidth = 600.0; // Calculate scale to fit width (95% to allow slight margin) final availableWidth = constraints.maxWidth; final fitScale = (availableWidth / mapWidth) * 0.95; if (!_isMapInitialized) { // Center horizontally final xOffset = (availableWidth - (mapWidth * fitScale)) / 2; final matrix = Matrix4.identity() ..translate(xOffset, 10.0) ..scale(fitScale); _transformationController.value = matrix; _isMapInitialized = true; } return Stack( children: [ InteractiveViewer( transformationController: _transformationController, // Large boundary margin to allow panning even at min scale boundaryMargin: const EdgeInsets.all(500), // Allow zooming out strictly to the fit size (with 5% margin for gesture safety) minScale: fitScale * 0.95, maxScale: fitScale * 6.0, constrained: false, child: PrefectureTileMap( visitedPrefectures: visitedPrefectures, onPrefectureTap: (pref) { // Drill-down to list _showSakeListModal(context, pref, sakeList); }, ), ), Positioned( bottom: 16, right: 16, child: FloatingActionButton.small( heroTag: 'map_reset', backgroundColor: appColors.surfaceElevated, foregroundColor: appColors.brandPrimary, elevation: 2, onPressed: () { // Reset to initial state (Fit Width) final xOffset = (availableWidth - (mapWidth * fitScale)) / 2; final matrix = Matrix4.identity() ..translate(xOffset, 10.0) ..scale(fitScale); _transformationController.value = matrix; }, child: const Icon(LucideIcons.rotateCcw, size: 20), ), ), ], ); } ), ), const SizedBox(height: 12), // 4. Regional Status Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('地域別制覇状況', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, color: appColors.textPrimary)), const SizedBox(height: 12), _buildRegionalStatusGrid(context, visitedPrefectures, sakeList), ], ), ), const SizedBox(height: 40), ], ), ), ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Center(child: Text('エラーが発生しました: $err')), ); } // Show Drill-down Modal void _showSakeListModal(BuildContext context, String pref, List sakeList) { final appColors = Theme.of(context).extension()!; // Filter sakes for this prefecture final sakesInPref = sakeList.where((s) => s.displayData.prefecture?.contains(pref) ?? false).toList(); if (sakesInPref.isEmpty) { // Fallback for unvisited (should effectively be handled by map logic, but good for safety) ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$pref: 記録はありません'), behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 1), ), ); return; } showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: Colors.transparent, // For custom rounded aesthetic builder: (dialogContext) { return DraggableScrollableSheet( initialChildSize: 0.6, minChildSize: 0.4, maxChildSize: 0.9, builder: (dialogContext, scrollController) { return Container( decoration: BoxDecoration( color: Theme.of(context).scaffoldBackgroundColor, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( children: [ // Handle Center( child: Container( margin: const EdgeInsets.only(top: 12), width: 40, height: 4, decoration: BoxDecoration(color: appColors.divider, borderRadius: BorderRadius.circular(2)), ), ), // Header Padding( padding: const EdgeInsets.all(16.0), child: Row( children: [ Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: appColors.brandPrimary.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon(LucideIcons.mapPin, color: appColors.brandPrimary), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( pref, style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: appColors.textPrimary), ), Text( '${sakesInPref.length}本の記録', style: TextStyle(color: appColors.textSecondary, fontSize: 13), ), ], ), ), IconButton( onPressed: () => Navigator.pop(dialogContext), icon: Icon(Icons.close, color: appColors.iconDefault), ), ], ), ), Divider(height: 1, color: appColors.divider), // List Expanded( child: ListView.builder( controller: scrollController, itemCount: sakesInPref.length, itemBuilder: (dialogContext, index) { final sake = sakesInPref[index]; return ListTile( leading: ClipRRect( borderRadius: BorderRadius.circular(8), child: sake.displayData.imagePaths.isNotEmpty ? Image.file( File(sake.displayData.imagePaths.first), width: 50, height: 50, fit: BoxFit.cover, errorBuilder: (c, o, s) => Container( width: 50, height: 50, color: appColors.surfaceSubtle, child: Icon(Icons.broken_image, size: 20, color: appColors.iconSubtle), ), ) : Container( width: 50, height: 50, color: appColors.surfaceSubtle, child: Icon(LucideIcons.glassWater, size: 20, color: appColors.iconSubtle), ), ), title: Text( sake.displayData.name, style: TextStyle(fontWeight: FontWeight.bold, color: appColors.textPrimary), maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( sake.displayData.brewery, style: TextStyle(color: appColors.textSecondary, fontSize: 12), maxLines: 1, ), trailing: Icon(Icons.chevron_right, color: appColors.iconSubtle), onTap: () { // Navigate to Detail Navigator.push( dialogContext, MaterialPageRoute( builder: (context) => SakeDetailScreen(sake: sake), ), ); }, ); }, ), ), ], ), ); }, ); }, ); } Widget _buildStatsCard(BuildContext context, double progress, int visitedCount) { final appColors = Theme.of(context).extension()!; return Container( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), // Compact padding decoration: BoxDecoration( color: appColors.surfaceElevated, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: appColors.divider.withValues(alpha: 0.2), blurRadius: 10, offset: const Offset(0, 4), ), ], ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Column( children: [ Text('制覇率', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: appColors.textSecondary)), const SizedBox(height: 4), Text( '${(progress * 100).toStringAsFixed(1)}%', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: appColors.brandPrimary, ), ), ], ), Container(width: 1, height: 30, color: appColors.divider), Column( children: [ Text('制覇数', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: appColors.textSecondary)), const SizedBox(height: 4), // Font Fix: Use Row instead of RichText to inherit Theme Font (e.g. DotGothic) correctly Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text( '$visitedCount', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: appColors.brandPrimary, ), ), const SizedBox(width: 4), Text( '/ 47', style: TextStyle(fontSize: 14, color: appColors.textSecondary), ), ], ), ], ), ], ), ); } Widget _buildRegionalStatusGrid(BuildContext context, Set visitedPrefectures, List sakeList) { // Group logic could be moved to JapanMapData helper if complex, but simple loop works here final regions = JapanMapData.regionNames.keys.toList(); return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, // Updated to 3 columns as requested childAspectRatio: 1.3, // Adjusted ratio to prevent bottom overflow (was 1.6) crossAxisSpacing: 12, mainAxisSpacing: 12, ), itemCount: regions.length, itemBuilder: (context, index) { final regionId = regions[index]; final regionName = JapanMapData.regionNames[regionId] ?? ''; final prefsInRegion = JapanMapData.prefectureNames.entries .where((e) => JapanMapData.getRegionId(e.key) == regionId) .map((e) { // Fix: Don't strip '道' from '北海道'. Only strip suffixes from others. if (e.value == '北海道') return '北海道'; return e.value.replaceAll(RegExp(r'(都|府|県)$'), ''); }) .toList(); final totalInRegion = prefsInRegion.length; final visitedInRegion = prefsInRegion.where((p) => visitedPrefectures.any((vp) => vp.startsWith(p))).length; final isComplete = visitedInRegion == totalInRegion && totalInRegion > 0; return _buildRegionCard(context, regionName, visitedInRegion, totalInRegion, isComplete, () { _showRegionDetailDialog(context, regionName, prefsInRegion, sakeList); }); }, ); } void _showRegionDetailDialog(BuildContext context, String regionName, List prefs, List sakeList) { final appColors = Theme.of(context).extension()!; showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (dialogContext) { return DraggableScrollableSheet( initialChildSize: 0.5, minChildSize: 0.3, maxChildSize: 0.8, expand: false, builder: (dialogContext, scrollController) { return Column( children: [ const SizedBox(height: 12), Container(width: 40, height: 4, decoration: BoxDecoration(color: appColors.divider, borderRadius: BorderRadius.circular(2))), const SizedBox(height: 16), Text('$regionNameの制覇状況', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: appColors.textPrimary)), const SizedBox(height: 16), Expanded( child: ListView.separated( controller: scrollController, itemCount: prefs.length, separatorBuilder: (_, __) => Divider(height: 1, color: appColors.divider), itemBuilder: (dialogContext, index) { final pref = prefs[index]; // Find count final count = sakeList.where((s) => s.displayData.prefecture?.contains(pref) ?? false).length; final isConquered = count > 0; return ListTile( leading: Icon( isConquered ? LucideIcons.checkCircle2 : LucideIcons.circle, color: isConquered ? appColors.brandPrimary : appColors.iconSubtle, ), title: Text(pref, style: TextStyle(color: appColors.textPrimary)), trailing: Text( '$count本', style: TextStyle( fontWeight: isConquered ? FontWeight.bold : FontWeight.normal, color: isConquered ? appColors.brandPrimary : appColors.textSecondary ) ), onTap: isConquered ? () => _showSakeListModal(dialogContext, pref, sakeList) : null, ); }, ), ), ], ); }, ); }, ); } Widget _buildRegionCard(BuildContext context, String name, int current, int total, bool isComplete, VoidCallback onTap) { final appColors = Theme.of(context).extension()!; final color = isComplete ? appColors.brandPrimary : appColors.surfaceElevated; final textColor = isComplete ? appColors.surfaceSubtle : appColors.textPrimary; final subTextColor = isComplete ? appColors.surfaceSubtle.withValues(alpha: 0.8) : appColors.textSecondary; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), // Tighter padding decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(12), border: isComplete ? null : Border.all(color: appColors.divider), // Use divider color for dark mode visibility boxShadow: [ if(!isComplete) BoxShadow(color: appColors.divider.withValues(alpha: 0.2), blurRadius: 4, offset: const Offset(0,2)) ] ), child: Column( // changed to column for 3-grid layout mainAxisAlignment: MainAxisAlignment.center, children: [ Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: textColor)), const SizedBox(height: 6), // Progress Bar & Counts Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text('$current', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: textColor)), Text('/$total', style: TextStyle(fontSize: 11, color: subTextColor)), ], ), const SizedBox(height: 4), // Tiny Progress Bar to add color without overwhelming ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: total > 0 ? current / total : 0, backgroundColor: isComplete ? appColors.surfaceSubtle.withValues(alpha: 0.3) : appColors.divider, valueColor: AlwaysStoppedAnimation( isComplete ? appColors.surfaceSubtle : appColors.brandPrimary ), minHeight: 3, ), ), ], ), ), ], ), ), ); } Widget _buildLegendDot(AppColors appColors, Color color, String? label) { return Row( mainAxisSize: MainAxisSize.min, children: [ Container(width: 10, height: 10, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))), if (label != null) ...[ const SizedBox(width: 4), Text(label, style: TextStyle(fontSize: 11, color: appColors.textSecondary)), ], ], ); } }