mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
245 lines
8.0 KiB
Dart
245 lines
8.0 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
import 'package:latlong2/latlong.dart';
|
||
|
||
import '../app_state.dart';
|
||
import '../widgets/map_view.dart';
|
||
import 'package:flutter_map/flutter_map.dart';
|
||
import '../services/offline_area_service.dart';
|
||
import '../widgets/add_camera_sheet.dart';
|
||
|
||
class HomeScreen extends StatefulWidget {
|
||
const HomeScreen({super.key});
|
||
|
||
@override
|
||
State<HomeScreen> createState() => _HomeScreenState();
|
||
}
|
||
|
||
class _HomeScreenState extends State<HomeScreen> {
|
||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||
final MapController _mapController = MapController();
|
||
bool _followMe = true;
|
||
|
||
void _openAddCameraSheet() {
|
||
final appState = context.read<AppState>();
|
||
appState.startAddSession();
|
||
final session = appState.session!; // guaranteed non‑null now
|
||
|
||
_scaffoldKey.currentState!.showBottomSheet(
|
||
(ctx) => AddCameraSheet(session: session),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final appState = context.watch<AppState>();
|
||
|
||
return Scaffold(
|
||
key: _scaffoldKey,
|
||
appBar: AppBar(
|
||
title: const Text('Flock Map'),
|
||
actions: [
|
||
IconButton(
|
||
tooltip: _followMe ? 'Disable follow‑me' : 'Enable follow‑me',
|
||
icon: Icon(_followMe ? Icons.gps_fixed : Icons.gps_off),
|
||
onPressed: () => setState(() => _followMe = !_followMe),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.settings),
|
||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||
),
|
||
],
|
||
),
|
||
body: MapView(
|
||
controller: _mapController,
|
||
followMe: _followMe,
|
||
onUserGesture: () {
|
||
if (_followMe) setState(() => _followMe = false);
|
||
},
|
||
),
|
||
floatingActionButton: appState.session == null
|
||
? Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
FloatingActionButton.extended(
|
||
onPressed: _openAddCameraSheet,
|
||
icon: const Icon(Icons.add_location_alt),
|
||
label: const Text('Tag Camera'),
|
||
heroTag: 'tag_camera_fab',
|
||
),
|
||
const SizedBox(height: 12),
|
||
FloatingActionButton.extended(
|
||
onPressed: () => showDialog(
|
||
context: context,
|
||
builder: (ctx) => DownloadAreaDialog(controller: _mapController),
|
||
),
|
||
icon: const Icon(Icons.download_for_offline),
|
||
label: const Text('Download'),
|
||
heroTag: 'download_fab',
|
||
),
|
||
],
|
||
)
|
||
: null,
|
||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- Download area dialog ---
|
||
class DownloadAreaDialog extends StatefulWidget {
|
||
final MapController controller;
|
||
const DownloadAreaDialog({super.key, required this.controller});
|
||
|
||
@override
|
||
State<DownloadAreaDialog> createState() => _DownloadAreaDialogState();
|
||
}
|
||
|
||
class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||
double _zoom = 15;
|
||
int? _minZoom;
|
||
int? _tileCount;
|
||
double? _mbEstimate;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||
}
|
||
|
||
void _recomputeEstimates() {
|
||
var bounds = widget.controller.camera.visibleBounds;
|
||
// If the visible area is nearly zero, nudge the bounds for estimation
|
||
const double epsilon = 0.0002;
|
||
final latSpan = (bounds.north - bounds.south).abs();
|
||
final lngSpan = (bounds.east - bounds.west).abs();
|
||
if (latSpan < epsilon && lngSpan < epsilon) {
|
||
bounds = LatLngBounds(
|
||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude - epsilon),
|
||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude + epsilon)
|
||
);
|
||
} else if (latSpan < epsilon) {
|
||
bounds = LatLngBounds(
|
||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude),
|
||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude)
|
||
);
|
||
} else if (lngSpan < epsilon) {
|
||
bounds = LatLngBounds(
|
||
LatLng(bounds.southWest.latitude, bounds.southWest.longitude - epsilon),
|
||
LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon)
|
||
);
|
||
}
|
||
final minZoom = OfflineAreaService().findDynamicMinZoom(bounds);
|
||
final maxZoom = _zoom.toInt();
|
||
final nTiles = OfflineAreaService().computeTileList(bounds, minZoom, maxZoom).length;
|
||
const kbPerTile = 6.5; // Empirically ~6.5kB average for OSM tiles at z=1-19
|
||
final totalMb = (nTiles * kbPerTile) / 1024.0;
|
||
setState(() {
|
||
_minZoom = minZoom;
|
||
_tileCount = nTiles;
|
||
_mbEstimate = totalMb;
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final bounds = widget.controller.camera.visibleBounds;
|
||
final maxZoom = _zoom.toInt();
|
||
// We recompute estimates when the zoom slider changes
|
||
return AlertDialog(
|
||
title: Row(
|
||
children: const [
|
||
Icon(Icons.download_for_offline),
|
||
SizedBox(width: 10),
|
||
Text("Download Map Area"),
|
||
],
|
||
),
|
||
content: SizedBox(
|
||
width: 350,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Max zoom level'),
|
||
Text('Z${_zoom.toStringAsFixed(0)}'),
|
||
],
|
||
),
|
||
Slider(
|
||
min: 12,
|
||
max: 19,
|
||
divisions: 7,
|
||
label: 'Z${_zoom.toStringAsFixed(0)}',
|
||
value: _zoom,
|
||
onChanged: (v) {
|
||
setState(() => _zoom = v);
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||
},
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Storage estimate:'),
|
||
Text(_mbEstimate == null
|
||
? '…'
|
||
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB'),
|
||
],
|
||
),
|
||
if (_minZoom != null)
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text('Min zoom:'),
|
||
Text('Z$_minZoom'),
|
||
],
|
||
)
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: const Text('Cancel'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () async {
|
||
try {
|
||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||
final dir = "${appDocDir.path}/$id";
|
||
// Fire and forget: don't await download, so dialog closes immediately
|
||
// ignore: unawaited_futures
|
||
OfflineAreaService().downloadArea(
|
||
id: id,
|
||
bounds: bounds,
|
||
minZoom: _minZoom ?? 12,
|
||
maxZoom: maxZoom,
|
||
directory: dir,
|
||
onProgress: (progress) {},
|
||
onComplete: (status) {},
|
||
);
|
||
Navigator.pop(context);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Download started!'),
|
||
),
|
||
);
|
||
} catch (e) {
|
||
Navigator.pop(context);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Failed to start download: $e'),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
child: const Text('Download'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|