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

379 lines
16 KiB
Dart

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 '../../theme/app_theme.dart';
import '../../models/maps/japan_map_data.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);
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: [
_buildLegendDot(Colors.grey[300]!, '未開拓'),
const SizedBox(width: 12),
_buildLegendDot(AppTheme.posimaiBlue, '制覇済'),
],
),
),
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 26 cols * 32.0 = 832.0
const double mapWidth = 26 * 32.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: PixelJapanMap(
visitedPrefectures: visitedPrefectures,
onPrefectureTap: (pref) {
_showPrefectureStats(context, pref, sakeList);
},
),
),
Positioned(
bottom: 16,
right: 16,
child: FloatingActionButton.small(
heroTag: 'map_reset',
backgroundColor: Colors.white.withValues(alpha: 0.9),
foregroundColor: AppTheme.posimaiBlue,
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)),
const SizedBox(height: 12),
_buildRegionalStatusGrid(context, visitedPrefectures, sakeList),
],
),
),
const SizedBox(height: 40),
],
),
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('エラーが発生しました: $err')),
);
}
// Helper to show prefecture stats toast/snackbar
void _showPrefectureStats(BuildContext context, String pref, List<dynamic> sakeList) {
final count = sakeList.where((s) => s.displayData.prefecture?.contains(pref) ?? false).length;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$pref: $count本 記録済み'),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
Widget _buildStatsCard(BuildContext context, double progress, int visitedCount) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), // Compact padding
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Column(
children: [
Text('制覇率', style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 4),
Text(
'${(progress * 100).toStringAsFixed(1)}%',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: AppTheme.posimaiBlue),
),
],
),
Container(width: 1, height: 30, color: Colors.grey[200]),
Column(
children: [
Text('制覇数', style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 4),
RichText(
text: TextSpan(
children: [
TextSpan(
text: '$visitedCount',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.bodyLarge?.color),
),
TextSpan(text: ' / 47', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
],
),
),
],
),
],
),
);
}
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: 2, // Reverting to 2 columns as requested to avoid overflow
childAspectRatio: 2.5, // Wider aspect ratio for 2 columns
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) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return DraggableScrollableSheet(
initialChildSize: 0.5,
minChildSize: 0.3,
maxChildSize: 0.8,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
const SizedBox(height: 12),
Container(width: 40, height: 4, decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 16),
Text('$regionNameの制覇状況', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
controller: scrollController,
itemCount: prefs.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, 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 ? AppTheme.posimaiBlue : Colors.grey[300],
),
title: Text(pref),
trailing: Text(
'$count本',
style: TextStyle(
fontWeight: isConquered ? FontWeight.bold : FontWeight.normal,
color: isConquered ? AppTheme.posimaiBlue : Colors.grey
)
),
);
},
),
),
],
);
},
);
},
);
}
Widget _buildRegionCard(BuildContext context, String name, int current, int total, bool isComplete, VoidCallback onTap) {
final color = isComplete ? AppTheme.posimaiBlue : Theme.of(context).cardColor;
final textColor = isComplete ? Colors.white : Theme.of(context).textTheme.bodyLarge?.color;
final subTextColor = isComplete ? Colors.white.withValues(alpha: 0.8) : Colors.grey[600];
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: Colors.grey[200]!), // Lighter border
boxShadow: [
if(!isComplete) BoxShadow(color: Colors.black.withValues(alpha: 0.03), 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: 2),
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)),
],
)
],
),
),
);
}
Widget _buildLegendDot(Color color, String label) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey)),
],
);
}
}