mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-08-24 03:42:45 +02:00
UX basically working
This commit is contained in:
@@ -11,6 +11,27 @@ class OsmCameraNode {
|
|||||||
required this.tags,
|
required this.tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'lat': coord.latitude,
|
||||||
|
'lon': coord.longitude,
|
||||||
|
'tags': tags,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory OsmCameraNode.fromJson(Map<String, dynamic> json) {
|
||||||
|
final tags = <String, String>{};
|
||||||
|
if (json['tags'] != null) {
|
||||||
|
(json['tags'] as Map<String, dynamic>).forEach((k, v) {
|
||||||
|
tags[k.toString()] = v.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return OsmCameraNode(
|
||||||
|
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
|
||||||
|
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
|
||||||
|
tags: tags,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool get hasDirection =>
|
bool get hasDirection =>
|
||||||
tags.containsKey('direction') || tags.containsKey('camera:direction');
|
tags.containsKey('direction') || tags.containsKey('camera:direction');
|
||||||
|
|
||||||
|
|||||||
+222
-101
@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import '../models/camera_profile.dart';
|
import '../models/camera_profile.dart';
|
||||||
import 'profile_editor.dart';
|
import 'profile_editor.dart';
|
||||||
|
import '../services/offline_area_service.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -18,7 +19,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
// Authentication section
|
// 1. Authentication section
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
appState.isLoggedIn ? Icons.person : Icons.login,
|
appState.isLoggedIn ? Icons.person : Icons.login,
|
||||||
@@ -27,7 +28,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
title: Text(appState.isLoggedIn
|
title: Text(appState.isLoggedIn
|
||||||
? 'Logged in as ${appState.username}'
|
? 'Logged in as ${appState.username}'
|
||||||
: 'Log in to OpenStreetMap'),
|
: 'Log in to OpenStreetMap'),
|
||||||
subtitle: appState.isLoggedIn
|
subtitle: appState.isLoggedIn
|
||||||
? const Text('Tap to logout')
|
? const Text('Tap to logout')
|
||||||
: const Text('Required to submit camera data'),
|
: const Text('Required to submit camera data'),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
@@ -39,7 +40,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(appState.isLoggedIn
|
content: Text(appState.isLoggedIn
|
||||||
? 'Logged in as ${appState.username}'
|
? 'Logged in as ${appState.username}'
|
||||||
: 'Logged out'),
|
: 'Logged out'),
|
||||||
backgroundColor: appState.isLoggedIn ? Colors.green : Colors.grey,
|
backgroundColor: appState.isLoggedIn ? Colors.green : Colors.grey,
|
||||||
@@ -48,7 +49,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Test connection (only when logged in)
|
// 1.5 Test connection (only when logged in)
|
||||||
if (appState.isLoggedIn)
|
if (appState.isLoggedIn)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.wifi_protected_setup),
|
leading: const Icon(Icons.wifi_protected_setup),
|
||||||
@@ -73,6 +74,109 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
// 2. Upload mode selector
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.cloud_upload),
|
||||||
|
title: const Text('Upload Destination'),
|
||||||
|
subtitle: const Text('Choose where cameras are uploaded'),
|
||||||
|
trailing: DropdownButton<UploadMode>(
|
||||||
|
value: appState.uploadMode,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: UploadMode.production,
|
||||||
|
child: Text('Production'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: UploadMode.sandbox,
|
||||||
|
child: Text('Sandbox'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: UploadMode.simulate,
|
||||||
|
child: Text('Simulate'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (mode) {
|
||||||
|
if (mode != null) appState.setUploadMode(mode);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Help text
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12),
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
switch (appState.uploadMode) {
|
||||||
|
case UploadMode.production:
|
||||||
|
return const Text('Upload to the live OSM database (visible to all users)', style: TextStyle(fontSize: 12, color: Colors.black87));
|
||||||
|
case UploadMode.sandbox:
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Uploads go to the OSM Sandbox (safe for testing, resets regularly).',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.orange),
|
||||||
|
),
|
||||||
|
SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.redAccent),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
case UploadMode.simulate:
|
||||||
|
default:
|
||||||
|
return const Text('Simulate uploads (does not contact OSM servers)', style: TextStyle(fontSize: 12, color: Colors.deepPurple));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
// 3. Queue management
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.queue),
|
||||||
|
title: Text('Pending uploads: ${appState.pendingCount}'),
|
||||||
|
subtitle: appState.uploadMode == UploadMode.simulate
|
||||||
|
? const Text('Simulate mode enabled – uploads simulated')
|
||||||
|
: appState.uploadMode == UploadMode.sandbox
|
||||||
|
? const Text('Sandbox mode – uploads go to OSM Sandbox')
|
||||||
|
: const Text('Tap to view queue'),
|
||||||
|
onTap: appState.pendingCount > 0 ? () {
|
||||||
|
_showQueueDialog(context, appState);
|
||||||
|
} : null,
|
||||||
|
),
|
||||||
|
if (appState.pendingCount > 0)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.clear_all),
|
||||||
|
title: const Text('Clear Upload Queue'),
|
||||||
|
subtitle: Text('Remove all ${appState.pendingCount} pending uploads'),
|
||||||
|
onTap: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Clear Queue'),
|
||||||
|
content: Text('Remove all ${appState.pendingCount} pending uploads?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
appState.clearQueue();
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Queue cleared')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
// 4. Camera Profiles
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -143,107 +247,38 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
// Upload mode selector - Production/Sandbox/Simulate
|
// 5. --- Offline Areas Section ---
|
||||||
ListTile(
|
const Padding(
|
||||||
leading: const Icon(Icons.cloud_upload),
|
padding: EdgeInsets.only(bottom: 8.0),
|
||||||
title: const Text('Upload Destination'),
|
child: Text('Offline Areas', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
subtitle: const Text('Choose where cameras are uploaded'),
|
|
||||||
trailing: DropdownButton<UploadMode>(
|
|
||||||
value: appState.uploadMode,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: UploadMode.production,
|
|
||||||
child: Text('Production'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: UploadMode.sandbox,
|
|
||||||
child: Text('Sandbox'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: UploadMode.simulate,
|
|
||||||
child: Text('Simulate'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: (mode) {
|
|
||||||
if (mode != null) appState.setUploadMode(mode);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Help text
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12),
|
|
||||||
child: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
switch (appState.uploadMode) {
|
|
||||||
case UploadMode.production:
|
|
||||||
return const Text('Upload to the live OSM database (visible to all users)', style: TextStyle(fontSize: 12, color: Colors.black87));
|
|
||||||
case UploadMode.sandbox:
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Uploads go to the OSM Sandbox (safe for testing, resets regularly).',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.orange),
|
|
||||||
),
|
|
||||||
SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.',
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.redAccent),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
case UploadMode.simulate:
|
|
||||||
default:
|
|
||||||
return const Text('Simulate uploads (does not contact OSM servers)', style: TextStyle(fontSize: 12, color: Colors.deepPurple));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
_OfflineAreasSection(),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
// Queue management
|
// 6. About/info button
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.queue),
|
leading: const Icon(Icons.info_outline),
|
||||||
title: Text('Pending uploads: ${appState.pendingCount}'),
|
title: const Text('About / Info'),
|
||||||
subtitle: appState.uploadMode == UploadMode.simulate
|
onTap: () async {
|
||||||
? const Text('Simulate mode enabled – uploads simulated')
|
// show dialog with text (replace with file contents as needed)
|
||||||
: appState.uploadMode == UploadMode.sandbox
|
showDialog(
|
||||||
? const Text('Sandbox mode – uploads go to OSM Sandbox')
|
context: context,
|
||||||
: const Text('Tap to view queue'),
|
builder: (context) => AlertDialog(
|
||||||
onTap: appState.pendingCount > 0 ? () {
|
title: const Text('About This App'),
|
||||||
_showQueueDialog(context, appState);
|
content: SingleChildScrollView(
|
||||||
} : null,
|
child: Text(
|
||||||
),
|
'Flock Map App\n\nBuilt with Flutter.\n\nOffline areas, privacy-respecting, designed for OpenStreetMap camera tagging.\n\n(Replace this with info.txt contents.)',
|
||||||
if (appState.pendingCount > 0)
|
),
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.clear_all),
|
|
||||||
title: const Text('Clear Upload Queue'),
|
|
||||||
subtitle: Text('Remove all ${appState.pendingCount} pending uploads'),
|
|
||||||
onTap: () {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Clear Queue'),
|
|
||||||
content: Text('Remove all ${appState.pendingCount} pending uploads?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
appState.clearQueue();
|
|
||||||
Navigator.pop(context);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Queue cleared')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text('Clear'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
actions: [
|
||||||
},
|
TextButton(
|
||||||
),
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -331,3 +366,89 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Offline Areas UI section ---
|
||||||
|
|
||||||
|
class _OfflineAreasSection extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_OfflineAreasSection> createState() => _OfflineAreasSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfflineAreasSectionState extends State<_OfflineAreasSection> {
|
||||||
|
final OfflineAreaService service = OfflineAreaService();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Polling for now; can improve with ChangeNotifier or Streams pattern later.
|
||||||
|
Future.doWhile(() async {
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
if (!mounted) return false;
|
||||||
|
setState(() {});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final areas = service.offlineAreas;
|
||||||
|
if (areas.isEmpty) {
|
||||||
|
return const ListTile(
|
||||||
|
leading: Icon(Icons.download_for_offline),
|
||||||
|
title: Text('No offline areas'),
|
||||||
|
subtitle: Text('Download a map area for offline use.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
children: areas.map((area) {
|
||||||
|
String subtitle =
|
||||||
|
'Z${area.minZoom}-${area.maxZoom}\n' +
|
||||||
|
'Lat: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
|
||||||
|
'Lat: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
|
||||||
|
if (area.status == OfflineAreaStatus.complete) {
|
||||||
|
subtitle += '\nCameras cached: ${area.cameras.length}';
|
||||||
|
}
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(area.status == OfflineAreaStatus.complete
|
||||||
|
? Icons.cloud_done
|
||||||
|
: area.status == OfflineAreaStatus.error
|
||||||
|
? Icons.error
|
||||||
|
: Icons.download_for_offline),
|
||||||
|
title: Text('Area ${area.id.substring(0, 6)}...'),
|
||||||
|
subtitle: Text(subtitle),
|
||||||
|
isThreeLine: true,
|
||||||
|
trailing: area.status == OfflineAreaStatus.downloading
|
||||||
|
? SizedBox(
|
||||||
|
width: 64,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
LinearProgressIndicator(value: area.progress),
|
||||||
|
Text(
|
||||||
|
'${(area.progress * 100).toStringAsFixed(0)}%',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
tooltip: 'Delete offline area',
|
||||||
|
onPressed: () async {
|
||||||
|
service.deleteArea(area.id);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onLongPress: area.status == OfflineAreaStatus.downloading
|
||||||
|
? () {
|
||||||
|
service.cancelDownload(area.id);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||||
|
import '../models/osm_camera_node.dart';
|
||||||
|
|
||||||
|
/// Model for an offline area
|
||||||
|
enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
||||||
|
|
||||||
|
class OfflineArea {
|
||||||
|
final String id;
|
||||||
|
final LatLngBounds bounds;
|
||||||
|
final int minZoom;
|
||||||
|
final int maxZoom;
|
||||||
|
final String directory; // base dir for area storage
|
||||||
|
OfflineAreaStatus status;
|
||||||
|
double progress; // 0.0 - 1.0
|
||||||
|
int tilesDownloaded;
|
||||||
|
int tilesTotal;
|
||||||
|
List<OsmCameraNode> cameras;
|
||||||
|
|
||||||
|
OfflineArea({
|
||||||
|
required this.id,
|
||||||
|
required this.bounds,
|
||||||
|
required this.minZoom,
|
||||||
|
required this.maxZoom,
|
||||||
|
required this.directory,
|
||||||
|
this.status = OfflineAreaStatus.downloading,
|
||||||
|
this.progress = 0,
|
||||||
|
this.tilesDownloaded = 0,
|
||||||
|
this.tilesTotal = 0,
|
||||||
|
this.cameras = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for managing download, storage, and retrieval of offline map areas and cameras.
|
||||||
|
class OfflineAreaService {
|
||||||
|
static final OfflineAreaService _instance = OfflineAreaService._();
|
||||||
|
factory OfflineAreaService() => _instance;
|
||||||
|
OfflineAreaService._();
|
||||||
|
|
||||||
|
final List<OfflineArea> _areas = [];
|
||||||
|
|
||||||
|
List<OfflineArea> get offlineAreas => List.unmodifiable(_areas);
|
||||||
|
|
||||||
|
/// Start downloading an area: tiles and camera points.
|
||||||
|
/// [onProgress] is called with 0.0..1.0, [onComplete] when finished or failed.
|
||||||
|
Future<void> downloadArea({
|
||||||
|
required String id,
|
||||||
|
required LatLngBounds bounds,
|
||||||
|
required int minZoom,
|
||||||
|
required int maxZoom,
|
||||||
|
required String directory,
|
||||||
|
void Function(double progress)? onProgress,
|
||||||
|
void Function(OfflineAreaStatus status)? onComplete,
|
||||||
|
}) async {
|
||||||
|
final area = OfflineArea(
|
||||||
|
id: id,
|
||||||
|
bounds: bounds,
|
||||||
|
minZoom: minZoom,
|
||||||
|
maxZoom: maxZoom,
|
||||||
|
directory: directory,
|
||||||
|
);
|
||||||
|
_areas.add(area);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// STEP 1: Tiles (incl. global z=1..4)
|
||||||
|
final tileTasks = _computeTileList(bounds, minZoom, maxZoom);
|
||||||
|
final globalTiles = _computeTileList(_globalWorldBounds(), 1, 4);
|
||||||
|
final allTiles = {...tileTasks, ...globalTiles};
|
||||||
|
area.tilesTotal = allTiles.length;
|
||||||
|
|
||||||
|
int done = 0;
|
||||||
|
for (final tile in allTiles) {
|
||||||
|
if (area.status == OfflineAreaStatus.cancelled) break;
|
||||||
|
await _downloadTile(tile[0], tile[1], tile[2], directory);
|
||||||
|
done++;
|
||||||
|
area.tilesDownloaded = done;
|
||||||
|
area.progress = done / area.tilesTotal;
|
||||||
|
if (onProgress != null) onProgress(area.progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 2: Fetch cameras for this bbox (all, not limited!)
|
||||||
|
final cameras = await _downloadAllCameras(bounds);
|
||||||
|
area.cameras = cameras;
|
||||||
|
await _saveCameras(cameras, directory);
|
||||||
|
|
||||||
|
area.status = OfflineAreaStatus.complete;
|
||||||
|
area.progress = 1.0;
|
||||||
|
if (onComplete != null) onComplete(area.status);
|
||||||
|
} catch (e) {
|
||||||
|
area.status = OfflineAreaStatus.error;
|
||||||
|
if (onComplete != null) onComplete(area.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancelDownload(String id) {
|
||||||
|
final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found');
|
||||||
|
area.status = OfflineAreaStatus.cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteArea(String id) async {
|
||||||
|
final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found');
|
||||||
|
Directory(area.directory).delete(recursive: true);
|
||||||
|
_areas.remove(area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TILE LOGIC ---
|
||||||
|
|
||||||
|
/// Returns set of [z, x, y] tuples needed to cover [bounds] at [zMin]..[zMax].
|
||||||
|
Set<List<int>> _computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||||
|
Set<List<int>> tiles = {};
|
||||||
|
for (int z = zMin; z <= zMax; z++) {
|
||||||
|
final minTile = _latLonToTile(bounds.southWest.latitude, bounds.southWest.longitude, z);
|
||||||
|
final maxTile = _latLonToTile(bounds.northEast.latitude, bounds.northEast.longitude, z);
|
||||||
|
for (int x = minTile[0]; x <= maxTile[0]; x++) {
|
||||||
|
for (int y = minTile[1]; y <= maxTile[1]; y++) {
|
||||||
|
tiles.add([z, x, y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts lat/lon+zoom to OSM tile xy as [x, y]
|
||||||
|
List<int> _latLonToTile(double lat, double lon, int zoom) {
|
||||||
|
final n = pow(2.0, zoom);
|
||||||
|
final xtile = ((lon + 180.0) / 360.0 * n).floor();
|
||||||
|
final ytile = ((1.0 - log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * n).floor();
|
||||||
|
return [xtile, ytile];
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLngBounds _globalWorldBounds() {
|
||||||
|
return LatLngBounds(LatLng(-85.0511, -180.0), LatLng(85.0511, 180.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadTile(int z, int x, int y, String baseDir) async {
|
||||||
|
final url = 'https://tile.openstreetmap.org/$z/$x/$y.png';
|
||||||
|
final dir = Directory('$baseDir/tiles/$z/$x');
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
final file = File('${dir.path}/$y.png');
|
||||||
|
if (await file.exists()) return; // already downloaded
|
||||||
|
final resp = await http.get(Uri.parse(url));
|
||||||
|
if (resp.statusCode == 200) {
|
||||||
|
await file.writeAsBytes(resp.bodyBytes);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to download tile $z/$x/$y');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CAMERA LOGIC ---
|
||||||
|
Future<List<OsmCameraNode>> _downloadAllCameras(LatLngBounds bounds) async {
|
||||||
|
// Overpass QL: fetch all cameras with no limit.
|
||||||
|
final sw = bounds.southWest;
|
||||||
|
final ne = bounds.northEast;
|
||||||
|
final bbox = [sw.latitude, sw.longitude, ne.latitude, ne.longitude].join(',');
|
||||||
|
final query = '[out:json][timeout:60];node["man_made"="surveillance"]["camera:mount"="pole"]($bbox);out body;';
|
||||||
|
final url = 'https://overpass-api.de/api/interpreter';
|
||||||
|
final resp = await http.post(Uri.parse(url), body: { 'data': query });
|
||||||
|
if (resp.statusCode != 200) {
|
||||||
|
throw Exception('Failed to fetch cameras');
|
||||||
|
}
|
||||||
|
final data = jsonDecode(resp.body);
|
||||||
|
return (data['elements'] as List<dynamic>?)?.map((e) => OsmCameraNode.fromJson(e)).toList() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveCameras(List<OsmCameraNode> cams, String dir) async {
|
||||||
|
final file = File('$dir/cameras.json');
|
||||||
|
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user