mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-14 22:55:12 +10:00
51d6210920
- Introduced `mesh_ui.dart` with reusable widgets including SectionHeader, MeshCard, StatusChip, StatTile, AvatarCircle, SignalBars, RouteChip, PulseDot, BottomSheetHeader, ErrorRetryCard, and ListEntrance. - Implemented `path_map_ui.dart` for path map screens, featuring path distance calculations, playback controls, and a summary list of observed paths. - Created `themed_map_tile_layer.dart` for shared cached map tiles with automatic dark-mode treatment.
531 lines
17 KiB
Dart
531 lines
17 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../l10n/app_localizations.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../services/app_settings_service.dart';
|
|
import '../services/map_tile_cache_service.dart';
|
|
import '../widgets/adaptive_app_bar_title.dart';
|
|
import '../helpers/snack_bar_builder.dart';
|
|
import '../theme/mesh_theme.dart';
|
|
import '../widgets/mesh_ui.dart';
|
|
import '../widgets/themed_map_tile_layer.dart';
|
|
|
|
class MapCacheScreen extends StatefulWidget {
|
|
const MapCacheScreen({super.key});
|
|
|
|
@override
|
|
State<MapCacheScreen> createState() => _MapCacheScreenState();
|
|
}
|
|
|
|
class _MapCacheScreenState extends State<MapCacheScreen> {
|
|
static const double _mapMinZoom = 2.0;
|
|
static const double _mapMaxZoom = 18.0;
|
|
|
|
final MapController _mapController = MapController();
|
|
|
|
LatLngBounds? _selectedBounds;
|
|
int _minZoom = MapTileCacheService.defaultMinZoom;
|
|
int _maxZoom = MapTileCacheService.defaultMaxZoom;
|
|
int _estimatedTiles = 0;
|
|
bool _isDownloading = false;
|
|
int _completedTiles = 0;
|
|
int _failedTiles = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_loadSettings();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_mapController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
bool _isDesktopPlatform(TargetPlatform platform) {
|
|
return platform == TargetPlatform.linux ||
|
|
platform == TargetPlatform.windows ||
|
|
platform == TargetPlatform.macOS;
|
|
}
|
|
|
|
void _zoomMapBy(double delta) {
|
|
final camera = _mapController.camera;
|
|
final nextZoom = (camera.zoom + delta)
|
|
.clamp(_mapMinZoom, _mapMaxZoom)
|
|
.toDouble();
|
|
_mapController.move(camera.center, nextZoom);
|
|
}
|
|
|
|
void _resetMapView() {
|
|
final bounds = _selectedBounds;
|
|
if (bounds != null) {
|
|
_mapController.fitCamera(
|
|
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
|
|
);
|
|
return;
|
|
}
|
|
_mapController.move(const LatLng(0, 0), 2.0);
|
|
}
|
|
|
|
Widget _buildDesktopMapControls() {
|
|
return Positioned(
|
|
top: 12,
|
|
left: 12,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: MeshPalette.bg1.withValues(alpha: 0.90),
|
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
|
border: Border.all(color: MeshPalette.line2),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.add),
|
|
tooltip: context.l10n.map_zoomIn,
|
|
onPressed: () => _zoomMapBy(1),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.remove),
|
|
tooltip: context.l10n.map_zoomOut,
|
|
onPressed: () => _zoomMapBy(-1),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.my_location),
|
|
tooltip: context.l10n.map_centerMap,
|
|
onPressed: _resetMapView,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _loadSettings() {
|
|
final settings = context.read<AppSettingsService>().settings;
|
|
final bounds = MapTileCacheService.boundsFromJson(settings.mapCacheBounds);
|
|
final minZoom = settings.mapCacheMinZoom.clamp(3, 18);
|
|
final maxZoom = settings.mapCacheMaxZoom.clamp(3, 18);
|
|
final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
|
|
final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
|
|
setState(() {
|
|
_minZoom = safeMin;
|
|
_maxZoom = safeMax;
|
|
_selectedBounds = bounds;
|
|
});
|
|
_updateEstimate();
|
|
if (bounds != null) {
|
|
_mapController.fitCamera(
|
|
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _updateEstimate() {
|
|
if (_selectedBounds == null) {
|
|
setState(() {
|
|
_estimatedTiles = 0;
|
|
});
|
|
return;
|
|
}
|
|
final cacheService = context.read<MapTileCacheService>();
|
|
final count = cacheService.estimateTileCount(
|
|
_selectedBounds!,
|
|
_minZoom,
|
|
_maxZoom,
|
|
);
|
|
setState(() {
|
|
_estimatedTiles = count;
|
|
});
|
|
}
|
|
|
|
Future<void> _setBoundsFromView() async {
|
|
final bounds = _mapController.camera.visibleBounds;
|
|
await _saveBounds(bounds);
|
|
}
|
|
|
|
Future<void> _saveBounds(LatLngBounds bounds) async {
|
|
setState(() {
|
|
_selectedBounds = bounds;
|
|
});
|
|
final settings = context.read<AppSettingsService>();
|
|
await settings.setMapCacheBounds(MapTileCacheService.boundsToJson(bounds));
|
|
_updateEstimate();
|
|
}
|
|
|
|
Future<void> _clearBounds() async {
|
|
setState(() {
|
|
_selectedBounds = null;
|
|
_estimatedTiles = 0;
|
|
});
|
|
final settings = context.read<AppSettingsService>();
|
|
await settings.setMapCacheBounds(null);
|
|
}
|
|
|
|
Future<void> _saveZoomRange() async {
|
|
final settings = context.read<AppSettingsService>();
|
|
await settings.setMapCacheZoomRange(_minZoom, _maxZoom);
|
|
_updateEstimate();
|
|
}
|
|
|
|
Future<void> _startDownload() async {
|
|
final bounds = _selectedBounds;
|
|
if (bounds == null) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.mapCache_selectAreaFirst),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (_estimatedTiles == 0) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.mapCache_noTilesToDownload),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(context.l10n.mapCache_downloadTilesTitle),
|
|
content: Text(
|
|
context.l10n.mapCache_downloadTilesPrompt(_estimatedTiles),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: Text(context.l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
child: Text(context.l10n.mapCache_downloadAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true || !mounted) return;
|
|
|
|
final cacheService = context.read<MapTileCacheService>();
|
|
|
|
setState(() {
|
|
_isDownloading = true;
|
|
_completedTiles = 0;
|
|
_failedTiles = 0;
|
|
});
|
|
|
|
final result = await cacheService.downloadRegion(
|
|
bounds: bounds,
|
|
minZoom: _minZoom,
|
|
maxZoom: _maxZoom,
|
|
onProgress: (progress) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_completedTiles = progress.completed;
|
|
_failedTiles = progress.failed;
|
|
});
|
|
},
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_isDownloading = false;
|
|
_completedTiles = result.downloaded + result.failed;
|
|
_failedTiles = result.failed;
|
|
});
|
|
|
|
final message = result.failed > 0
|
|
? context.l10n.mapCache_cachedTilesWithFailed(
|
|
result.downloaded,
|
|
result.failed,
|
|
)
|
|
: context.l10n.mapCache_cachedTiles(result.downloaded);
|
|
showDismissibleSnackBar(context, content: Text(message));
|
|
}
|
|
|
|
Future<void> _clearCache() async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(context.l10n.mapCache_clearOfflineCacheTitle),
|
|
content: Text(context.l10n.mapCache_clearOfflineCachePrompt),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: Text(context.l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
child: Text(context.l10n.common_clear),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true || !mounted) return;
|
|
|
|
final cacheService = context.read<MapTileCacheService>();
|
|
await cacheService.clearCache();
|
|
if (!mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.mapCache_offlineCacheCleared),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tileCache = context.read<MapTileCacheService>();
|
|
final selectedBounds = _selectedBounds;
|
|
final l10n = context.l10n;
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
|
|
final progressValue = _estimatedTiles == 0
|
|
? 0.0
|
|
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: AdaptiveAppBarTitle(l10n.mapCache_title),
|
|
centerTitle: true,
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
FlutterMap(
|
|
mapController: _mapController,
|
|
options: MapOptions(
|
|
initialCenter: const LatLng(0, 0),
|
|
initialZoom: 2.0,
|
|
minZoom: _mapMinZoom,
|
|
maxZoom: _mapMaxZoom,
|
|
interactionOptions: InteractionOptions(
|
|
flags: ~InteractiveFlag.rotate,
|
|
scrollWheelVelocity: isDesktop ? 0.012 : 0.005,
|
|
cursorKeyboardRotationOptions:
|
|
CursorKeyboardRotationOptions.disabled(),
|
|
keyboardOptions: isDesktop
|
|
? const KeyboardOptions(
|
|
enableArrowKeysPanning: true,
|
|
enableWASDPanning: true,
|
|
enableRFZooming: true,
|
|
)
|
|
: const KeyboardOptions.disabled(),
|
|
),
|
|
),
|
|
children: [
|
|
ThemedMapTileLayer(tileCache: tileCache),
|
|
if (selectedBounds != null)
|
|
PolygonLayer(
|
|
polygons: [
|
|
Polygon(
|
|
points: _boundsToPolygon(selectedBounds),
|
|
borderStrokeWidth: 2,
|
|
color: Colors.blue.withValues(alpha: 0.2),
|
|
borderColor: Colors.blue,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
if (isDesktop) _buildDesktopMapControls(),
|
|
Positioned(
|
|
top: 12,
|
|
right: 12,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: MeshPalette.bg1.withValues(alpha: 0.93),
|
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
|
border: Border.all(color: MeshPalette.line2),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
child: Text(
|
|
selectedBounds == null
|
|
? l10n.mapCache_noAreaSelected
|
|
: _formatBounds(selectedBounds, l10n),
|
|
style: MeshTheme.mono(
|
|
fontSize: 11,
|
|
color: MeshPalette.ink2,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SafeArea(
|
|
top: false,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: scheme.surfaceContainerLow,
|
|
border: Border(top: BorderSide(color: scheme.outlineVariant)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SectionHeader(
|
|
l10n.mapCache_cacheArea,
|
|
padding: const EdgeInsets.fromLTRB(0, 12, 0, 8),
|
|
),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
icon: const Icon(Icons.crop_free),
|
|
label: Text(l10n.mapCache_useCurrentView),
|
|
onPressed: _isDownloading
|
|
? null
|
|
: _setBoundsFromView,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
TextButton(
|
|
onPressed: _isDownloading || selectedBounds == null
|
|
? null
|
|
: _clearBounds,
|
|
child: Text(l10n.common_clear),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
SectionHeader(
|
|
l10n.mapCache_zoomRange,
|
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
|
|
),
|
|
RangeSlider(
|
|
values: RangeValues(
|
|
_minZoom.toDouble(),
|
|
_maxZoom.toDouble(),
|
|
),
|
|
min: 3,
|
|
max: 18,
|
|
divisions: 15,
|
|
labels: RangeLabels('$_minZoom', '$_maxZoom'),
|
|
onChanged: _isDownloading
|
|
? null
|
|
: (values) {
|
|
setState(() {
|
|
_minZoom = values.start.round();
|
|
_maxZoom = values.end.round();
|
|
});
|
|
},
|
|
onChangeEnd: _isDownloading
|
|
? null
|
|
: (_) {
|
|
_saveZoomRange();
|
|
},
|
|
),
|
|
Text(
|
|
l10n.mapCache_estimatedTiles(_estimatedTiles),
|
|
style: MeshTheme.mono(
|
|
fontSize: 12,
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
if (_isDownloading) ...[
|
|
const SizedBox(height: 8),
|
|
LinearProgressIndicator(
|
|
value: progressValue,
|
|
color: MeshPalette.blue,
|
|
backgroundColor: scheme.surfaceContainerHighest,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.mapCache_downloadedTiles(
|
|
_completedTiles,
|
|
_estimatedTiles,
|
|
),
|
|
style: MeshTheme.mono(
|
|
fontSize: 12,
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
icon: const Icon(Icons.download),
|
|
label: Text(l10n.mapCache_downloadTilesButton),
|
|
onPressed: _isDownloading || selectedBounds == null
|
|
? null
|
|
: _startDownload,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
OutlinedButton(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: MeshPalette.alert,
|
|
side: const BorderSide(
|
|
color: MeshPalette.alertLine,
|
|
),
|
|
),
|
|
onPressed: _isDownloading ? null : _clearCache,
|
|
child: Text(l10n.mapCache_clearCacheButton),
|
|
),
|
|
],
|
|
),
|
|
if (_failedTiles > 0 && !_isDownloading)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
l10n.mapCache_failedDownloads(_failedTiles),
|
|
style: MeshTheme.mono(
|
|
fontSize: 12,
|
|
color: MeshPalette.alert,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<LatLng> _boundsToPolygon(LatLngBounds bounds) {
|
|
return [
|
|
bounds.northWest,
|
|
bounds.northEast,
|
|
bounds.southEast,
|
|
bounds.southWest,
|
|
];
|
|
}
|
|
|
|
String _formatBounds(LatLngBounds bounds, AppLocalizations l10n) {
|
|
return l10n.mapCache_boundsLabel(
|
|
bounds.north.toStringAsFixed(4),
|
|
bounds.south.toStringAsFixed(4),
|
|
bounds.east.toStringAsFixed(4),
|
|
bounds.west.toStringAsFixed(4),
|
|
);
|
|
}
|
|
}
|