ponshu-room-lite/lib/screens/features/brewery_map_screen.dart

577 lines
24 KiB
Dart
Raw Normal View History

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<BreweryMapScreen> createState() => _BreweryMapScreenState();
}
class _BreweryMapScreenState extends ConsumerState<BreweryMapScreen> {
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<AppColors>()!;
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
2026-01-15 15:53:44 +00:00
_buildLegendDot(
appColors,
appColors.divider,
2026-01-15 15:53:44 +00:00
'未開拓'
),
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<dynamic> sakeList) {
final appColors = Theme.of(context).extension<AppColors>()!;
// 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<AppColors>()!;
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<String> visitedPrefectures, List<dynamic> 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<String> prefs, List<dynamic> sakeList) {
final appColors = Theme.of(context).extension<AppColors>()!;
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<AppColors>()!;
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<Color>(
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)),
],
],
);
}
}