mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
- New MapLayerType.mapyOutdoor with Referer header support (tile server rejects requests without mapy.com Referer) - Headered layers get a dedicated NetworkTileProvider sharing the offline tile cache - Offline region downloads pass layer headers through TileDownloadService Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
847 lines
32 KiB
Dart
847 lines
32 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../models/map_layer.dart';
|
|
import '../providers/offline_tiles_provider.dart';
|
|
import '../widgets/map/polygon_draw_handler.dart';
|
|
|
|
/// Screen for downloading offline map tiles.
|
|
///
|
|
/// Allows drawing polygons/rectangles to select areas, choosing zoom levels,
|
|
/// and downloading tiles with real-time progress visualization.
|
|
class OfflineMapScreen extends StatefulWidget {
|
|
const OfflineMapScreen({super.key});
|
|
|
|
@override
|
|
State<OfflineMapScreen> createState() => _OfflineMapScreenState();
|
|
}
|
|
|
|
class _OfflineMapScreenState extends State<OfflineMapScreen> {
|
|
final MapController _mapController = MapController();
|
|
late OfflineTilesProvider _provider;
|
|
double _currentZoom = 8;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_provider = context.read<OfflineTilesProvider>();
|
|
_provider.refreshCacheSize();
|
|
_provider.refreshLocalStyles();
|
|
_provider.startPeerDiscovery();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_provider.stopPeerDiscovery();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(AppLocalizations.of(context)!.offlineMaps),
|
|
actions: [
|
|
Consumer<OfflineTilesProvider>(
|
|
builder: (context, provider, _) {
|
|
return IconButton(
|
|
icon: Badge(
|
|
isLabelVisible: provider.discoveredPeers.isNotEmpty,
|
|
label: Text('${provider.discoveredPeers.length}'),
|
|
child: Icon(
|
|
provider.isServerRunning
|
|
? Icons.wifi_tethering
|
|
: Icons.wifi_tethering_off,
|
|
),
|
|
),
|
|
tooltip: provider.isServerRunning
|
|
? 'Sharing tiles'
|
|
: 'Tile sharing off',
|
|
onPressed: () => _showSharingSheet(context, provider),
|
|
);
|
|
},
|
|
),
|
|
Consumer<OfflineTilesProvider>(
|
|
builder: (context, provider, _) {
|
|
return PopupMenuButton<MapLayer>(
|
|
icon: const Icon(Icons.layers),
|
|
tooltip: AppLocalizations.of(context)!.mapStyle,
|
|
onSelected: (layer) => provider.setSelectedLayer(layer),
|
|
itemBuilder: (_) => [
|
|
for (final layer in MapLayer.allLayers)
|
|
PopupMenuItem(
|
|
value: layer,
|
|
child: Row(
|
|
children: [
|
|
if (layer.type == provider.selectedLayer.type)
|
|
const Icon(Icons.check, size: 18)
|
|
else
|
|
const SizedBox(width: 18),
|
|
const SizedBox(width: 8),
|
|
Text(layer.name),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
Consumer<OfflineTilesProvider>(
|
|
builder: (context, provider, _) {
|
|
return FlutterMap(
|
|
mapController: _mapController,
|
|
options: MapOptions(
|
|
initialCenter: const LatLng(46.05, 14.5), // Slovenia
|
|
initialZoom: 8,
|
|
onPositionChanged: (camera, hasGesture) {
|
|
if (camera.zoom != _currentZoom) {
|
|
setState(() => _currentZoom = camera.zoom);
|
|
}
|
|
},
|
|
onTap: (tapPosition, point) {
|
|
if (provider.drawingMode != DrawingMode.none) {
|
|
provider.addVertex(point);
|
|
}
|
|
},
|
|
),
|
|
children: [
|
|
TileLayer(
|
|
urlTemplate: provider.selectedLayer.urlTemplate,
|
|
tileProvider: NetworkTileProvider(
|
|
headers: provider.selectedLayer.headers ?? const {},
|
|
),
|
|
userAgentPackageName: 'com.meshcore.sar',
|
|
maxZoom: provider.selectedLayer.maxZoom,
|
|
),
|
|
CoverageLayer(currentZoom: _currentZoom),
|
|
const PolygonDrawLayer(),
|
|
const DownloadProgressLayer(),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const DrawingToolbar(),
|
|
Positioned(
|
|
left: 16,
|
|
top: 16,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
|
|
borderRadius: BorderRadius.circular(8),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.15),
|
|
blurRadius: 4,
|
|
),
|
|
],
|
|
),
|
|
child: Text(
|
|
'Z ${_currentZoom.toStringAsFixed(1)}',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
_buildBottomPanel(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBottomPanel() {
|
|
return Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: Consumer<OfflineTilesProvider>(
|
|
builder: (context, provider, _) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
borderRadius:
|
|
const BorderRadius.vertical(top: Radius.circular(16)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.15),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Saved region presets dropdown
|
|
if (!provider.isDownloading &&
|
|
provider.localStyles
|
|
.any((s) => s.region != null)) ...[
|
|
DropdownButtonFormField<StyleInfo>(
|
|
decoration: const InputDecoration(
|
|
prefixIcon: Icon(Icons.bookmark, size: 20),
|
|
labelText: 'Saved regions',
|
|
isDense: true,
|
|
contentPadding: EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 8),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
isExpanded: true,
|
|
hint: Text(AppLocalizations.of(context)!.loadASavedRegion),
|
|
items: provider.localStyles
|
|
.where((s) => s.region != null)
|
|
.map((style) => DropdownMenuItem(
|
|
value: style,
|
|
child: Text(
|
|
'${style.displayName} '
|
|
'(z${style.region!.minZoom}-${style.region!.maxZoom}, '
|
|
'${_formatNumber(style.tileCount)} tiles)',
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
))
|
|
.toList(),
|
|
onChanged: (style) {
|
|
if (style != null) {
|
|
provider.loadPreset(style);
|
|
_fitMapToPolygons(provider);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
|
|
// Zoom range
|
|
if (!provider.isDownloading) ...[
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _ZoomSelector(
|
|
label: AppLocalizations.of(context)!.minZoom,
|
|
value: provider.minZoom,
|
|
onChanged: provider.setMinZoom,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: _ZoomSelector(
|
|
label: AppLocalizations.of(context)!.maxZoom,
|
|
value: provider.maxZoom,
|
|
onChanged: provider.setMaxZoom,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Tile estimate
|
|
Text(
|
|
provider.hasPolygons
|
|
? '~${_formatNumber(provider.estimatedTileCount)} tiles'
|
|
: 'Draw an area to download',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Cache: ${_formatBytes(provider.cacheSizeBytes)}',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.6),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
|
|
// Progress
|
|
if (provider.isDownloading) ...[
|
|
LinearProgressIndicator(
|
|
value: provider.progress.percent,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'${(provider.progress.percent * 100).toStringAsFixed(1)}% — '
|
|
'Downloaded: ${provider.progress.downloaded}, '
|
|
'Cached: ${provider.progress.skipped}, '
|
|
'Failed: ${provider.progress.failed} / '
|
|
'${provider.progress.total}',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
|
|
// Download complete summary
|
|
if (!provider.isDownloading &&
|
|
provider.progress.isComplete) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Done! ${provider.progress.downloaded} downloaded, '
|
|
'${provider.progress.skipped} cached, '
|
|
'${provider.progress.failed} failed',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Colors.green,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
// Action buttons
|
|
Row(
|
|
children: [
|
|
if (!provider.isDownloading) ...[
|
|
Expanded(
|
|
child: FilledButton.icon(
|
|
onPressed: provider.hasPolygons
|
|
? () => provider.startDownload()
|
|
: null,
|
|
icon: const Icon(Icons.download),
|
|
label: Text(AppLocalizations.of(context)!.download),
|
|
),
|
|
),
|
|
if (provider.tileOverlays.isNotEmpty) ...[
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
onPressed: provider.clearOverlays,
|
|
icon: const Icon(Icons.layers_clear),
|
|
tooltip: AppLocalizations.of(context)!.clearOverlay,
|
|
),
|
|
],
|
|
if (provider.cacheSizeBytes > 0) ...[
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
onPressed: () => _confirmClearCache(provider),
|
|
icon: const Icon(Icons.delete_forever),
|
|
tooltip: AppLocalizations.of(context)!.clearCache,
|
|
),
|
|
],
|
|
],
|
|
if (provider.isDownloading) ...[
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => provider.cancelDownload(),
|
|
icon: const Icon(Icons.cancel),
|
|
label: Text(AppLocalizations.of(context)!.cancel),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: Colors.red,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showSharingSheet(BuildContext context, OfflineTilesProvider provider) {
|
|
// Refresh catalogs and local styles when opening
|
|
provider.refreshPeerCatalogs();
|
|
provider.refreshLocalStyles();
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (sheetContext) {
|
|
return DraggableScrollableSheet(
|
|
initialChildSize: 0.65,
|
|
minChildSize: 0.3,
|
|
maxChildSize: 0.9,
|
|
expand: false,
|
|
builder: (context, scrollController) {
|
|
return Consumer<OfflineTilesProvider>(
|
|
builder: (context, provider, _) {
|
|
return ListView(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
// Header
|
|
Center(
|
|
child: Container(
|
|
width: 32,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[400],
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
'Tile Sharing',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Server toggle
|
|
SwitchListTile(
|
|
title: Text(AppLocalizations.of(context)!.shareMyTiles),
|
|
subtitle: Text(
|
|
provider.isServerRunning
|
|
? 'Other devices can fetch tiles from this device'
|
|
: 'Start serving cached tiles to nearby devices',
|
|
),
|
|
secondary: Icon(
|
|
provider.isServerRunning
|
|
? Icons.wifi_tethering
|
|
: Icons.wifi_tethering_off,
|
|
),
|
|
value: provider.isServerRunning,
|
|
onChanged: (_) => provider.toggleServer(),
|
|
),
|
|
|
|
// My cached maps
|
|
if (provider.localStyles.isNotEmpty) ...[
|
|
const Divider(),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(
|
|
'My Cached Maps',
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
),
|
|
...provider.localStyles.map((style) {
|
|
final isShowing =
|
|
provider.coverageStyle?.hash == style.hash;
|
|
return ListTile(
|
|
dense: true,
|
|
leading: const Icon(Icons.map, size: 20),
|
|
title: Text(style.displayName),
|
|
subtitle: Text(
|
|
'${_formatNumber(style.tileCount)} tiles, '
|
|
'${_formatBytes(style.sizeBytes)}',
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(
|
|
isShowing
|
|
? Icons.visibility
|
|
: Icons.visibility_off,
|
|
size: 20,
|
|
color: isShowing
|
|
? Colors.blue
|
|
: null,
|
|
),
|
|
tooltip: isShowing
|
|
? 'Hide on map'
|
|
: 'Show on map',
|
|
onPressed: () {
|
|
provider.showCoverage(style);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline,
|
|
size: 20),
|
|
tooltip: AppLocalizations.of(context)!.delete,
|
|
onPressed: () => _confirmDeleteStyle(
|
|
context, provider, style),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
|
|
// Peers section
|
|
const Divider(),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'Nearby Devices',
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
),
|
|
if (provider.isFetchingCatalogs)
|
|
const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child:
|
|
CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
else
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, size: 20),
|
|
tooltip: AppLocalizations.of(context)!.refresh,
|
|
onPressed: () =>
|
|
provider.refreshPeerCatalogs(),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.add, size: 20),
|
|
tooltip: AppLocalizations.of(context)!.addPeerManually,
|
|
onPressed: () =>
|
|
_showAddPeerDialog(context, provider),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
if (provider.discoveredPeers.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(
|
|
'No peers found on the local network.\n'
|
|
'Make sure other devices have tile sharing enabled.',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall
|
|
?.copyWith(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Peer catalogs — show each peer and their available styles
|
|
...provider.peerCatalogs.map((catalog) => _buildPeerCard(
|
|
context, provider, catalog)),
|
|
|
|
// Peers without catalogs yet (just discovered, not queried)
|
|
...provider.discoveredPeers
|
|
.where((peer) => !provider.peerCatalogs
|
|
.any((c) => c.peer == peer))
|
|
.map((peer) => ListTile(
|
|
leading: const Icon(Icons.devices),
|
|
title: Text(peer.ipAddress),
|
|
subtitle: Text(AppLocalizations.of(context)!.fetchingCatalog),
|
|
trailing: IconButton(
|
|
icon: const Icon(
|
|
Icons.remove_circle_outline,
|
|
size: 20),
|
|
onPressed: () =>
|
|
provider.removePeer(peer),
|
|
),
|
|
)),
|
|
|
|
// Sync progress
|
|
if (provider.isSyncing || provider.syncStatus.isNotEmpty) ...[
|
|
const Divider(),
|
|
if (provider.isSyncing)
|
|
LinearProgressIndicator(
|
|
value: provider.syncProgress > 0
|
|
? provider.syncProgress
|
|
: null,
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
provider.syncStatus,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
),
|
|
if (provider.isSyncing)
|
|
TextButton(
|
|
onPressed: () => provider.cancelSync(),
|
|
child: Text(AppLocalizations.of(context)!.cancel),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildPeerCard(
|
|
BuildContext context,
|
|
OfflineTilesProvider provider,
|
|
PeerCatalog catalog,
|
|
) {
|
|
return Card(
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.devices, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
catalog.peer.ipAddress,
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.remove_circle_outline, size: 18),
|
|
onPressed: () => provider.removePeer(catalog.peer),
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
],
|
|
),
|
|
if (catalog.styles.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
'No cached tiles on this device',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
),
|
|
...catalog.styles.map((style) {
|
|
// Check if we already have this style locally
|
|
final localMatch = provider.localStyles
|
|
.where((s) => s.hash == style.hash);
|
|
final localCount =
|
|
localMatch.isNotEmpty ? localMatch.first.tileCount : 0;
|
|
final missingTiles = style.tileCount - localCount;
|
|
|
|
return ListTile(
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.map_outlined, size: 20),
|
|
title: Text(style.displayName),
|
|
subtitle: Text(
|
|
'${_formatNumber(style.tileCount)} tiles, '
|
|
'${_formatBytes(style.sizeBytes)}'
|
|
'${localCount > 0 ? ' (you have ${_formatNumberInline(localCount)})' : ''}',
|
|
),
|
|
trailing: missingTiles > 0
|
|
? TextButton.icon(
|
|
onPressed: provider.isSyncing
|
|
? null
|
|
: () => provider.syncStyleFromPeers(style),
|
|
icon: const Icon(Icons.download, size: 16),
|
|
label: Text(
|
|
missingTiles == style.tileCount
|
|
? 'Get all'
|
|
: '+${_formatNumber(missingTiles)}',
|
|
),
|
|
)
|
|
: const Icon(Icons.check_circle,
|
|
color: Colors.green, size: 20),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatNumberInline(int n) {
|
|
if (n < 1000) return '$n';
|
|
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
|
|
return '${(n / 1000000).toStringAsFixed(1)}M';
|
|
}
|
|
|
|
void _fitMapToPolygons(OfflineTilesProvider provider) {
|
|
if (provider.polygons.isEmpty) return;
|
|
|
|
var minLat = 90.0, maxLat = -90.0;
|
|
var minLng = 180.0, maxLng = -180.0;
|
|
for (final poly in provider.polygons) {
|
|
for (final p in poly) {
|
|
if (p.latitude < minLat) minLat = p.latitude;
|
|
if (p.latitude > maxLat) maxLat = p.latitude;
|
|
if (p.longitude < minLng) minLng = p.longitude;
|
|
if (p.longitude > maxLng) maxLng = p.longitude;
|
|
}
|
|
}
|
|
|
|
_mapController.fitCamera(
|
|
CameraFit.bounds(
|
|
bounds: LatLngBounds(
|
|
LatLng(minLat, minLng),
|
|
LatLng(maxLat, maxLng),
|
|
),
|
|
padding: const EdgeInsets.all(50),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showAddPeerDialog(
|
|
BuildContext context, OfflineTilesProvider provider) {
|
|
final controller = TextEditingController();
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(AppLocalizations.of(context)!.addPeer),
|
|
content: TextField(
|
|
controller: controller,
|
|
decoration: const InputDecoration(
|
|
labelText: 'IP Address',
|
|
hintText: '192.168.1.100',
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
autofocus: true,
|
|
onSubmitted: (value) {
|
|
if (value.trim().isNotEmpty) {
|
|
provider.addManualPeer(value.trim());
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(AppLocalizations.of(context)!.cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
final ip = controller.text.trim();
|
|
if (ip.isNotEmpty) {
|
|
provider.addManualPeer(ip);
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
child: Text(AppLocalizations.of(context)!.add),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmDeleteStyle(
|
|
BuildContext context,
|
|
OfflineTilesProvider provider,
|
|
StyleInfo style,
|
|
) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(AppLocalizations.of(context)!.deleteStyleConfirm(style.displayName)),
|
|
content: Text(
|
|
'${_formatNumber(style.tileCount)} tiles, '
|
|
'${_formatBytes(style.sizeBytes)} will be deleted.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(AppLocalizations.of(context)!.cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
provider.deleteStyle(style);
|
|
Navigator.pop(dialogContext);
|
|
},
|
|
child:
|
|
Text(AppLocalizations.of(context)!.delete, style: const TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmClearCache(OfflineTilesProvider provider) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(AppLocalizations.of(context)!.clearOfflineCache),
|
|
content: Text(
|
|
'This will delete ${_formatBytes(provider.cacheSizeBytes)} of cached tiles.'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(AppLocalizations.of(context)!.cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
provider.clearCache();
|
|
Navigator.pop(context);
|
|
},
|
|
child:
|
|
Text(AppLocalizations.of(context)!.delete, style: const TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatBytes(int bytes) {
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
if (bytes < 1024 * 1024 * 1024) {
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
}
|
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|
}
|
|
|
|
String _formatNumber(int n) {
|
|
if (n < 1000) return '$n';
|
|
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
|
|
return '${(n / 1000000).toStringAsFixed(1)}M';
|
|
}
|
|
}
|
|
|
|
class _ZoomSelector extends StatelessWidget {
|
|
final String label;
|
|
final int value;
|
|
final ValueChanged<int> onChanged;
|
|
|
|
const _ZoomSelector({
|
|
required this.label,
|
|
required this.value,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Slider(
|
|
value: value.toDouble(),
|
|
min: 0,
|
|
max: 19,
|
|
divisions: 19,
|
|
label: '$value',
|
|
onChanged: (v) => onChanged(v.round()),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 24,
|
|
child: Text(
|
|
'$value',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|