mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 09:12:56 +00:00
Compare commits
21 Commits
v1.2.8-rel
...
v1.3.1-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0e3b822c | ||
|
|
181852766a | ||
|
|
f108929dce | ||
|
|
2cf840e74d | ||
|
|
3810dfa8d2 | ||
|
|
d57b2f64b1 | ||
|
|
e45f10e496 | ||
|
|
4ae0737016 | ||
|
|
ae93cff719 | ||
|
|
abdd494727 | ||
|
|
4ccf3cace3 | ||
|
|
ca049033e4 | ||
|
|
5cf8bb7725 | ||
|
|
e5ff4ac233 | ||
|
|
4040429865 | ||
|
|
90b7783aaf | ||
|
|
65cc6747bf | ||
|
|
5bd450eb14 | ||
|
|
b0a4128bb7 | ||
|
|
4cdbb9f404 | ||
|
|
8d05406ef5 |
14
README.md
14
README.md
@@ -6,6 +6,14 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
|
||||
**For complete documentation, tutorials, and community info, visit [deflock.me](https://deflock.me)**
|
||||
|
||||
<a href="https://apps.apple.com/us/app/deflock-me/id6752760780" style="display: inline-block;">
|
||||
<img src="https://toolbox.marketingtools.apple.com/api/v2/badges/download-on-the-app-store/black/en-us?releaseDate=1695859200" alt="Download on the App Store" style="width: 246px; height: 82px; vertical-align: middle; object-fit: contain;" />
|
||||
</a>
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=me.deflock.deflockapp" style="display: inline-block;">
|
||||
<img src="assets/GetItOnGooglePlay_Badge_Web_color_English.png" alt="Download on the Google Play Store" style="width: 246px; height: 82px; vertical-align: middle; object-fit: contain;" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## What This App Does
|
||||
@@ -90,11 +98,8 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
- Are offline areas preferred for fast loading even when online? Check working.
|
||||
- Fix network indicator - only done when fetch queue is empty!
|
||||
|
||||
### Recently Completed
|
||||
- **Multi-direction support**: Devices can now have multiple viewing directions (e.g., "90;180") with individual FOV cones
|
||||
- **Dynamic suspected location fields**: Server-controlled field display for suspected locations data
|
||||
|
||||
### Current Development
|
||||
- Suspected locations expansion to more regions
|
||||
- Import/Export map providers
|
||||
- Swap in alprwatch.org/directions avoidance routing API
|
||||
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
|
||||
@@ -104,7 +109,6 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
### Future Features & Wishlist
|
||||
- Update offline area nodes while browsing?
|
||||
- Offline navigation (pending vector map tiles)
|
||||
- Suspected locations expansion to more regions
|
||||
|
||||
### Maybes
|
||||
- Yellow ring for devices missing specific tag details
|
||||
|
||||
BIN
assets/GetItOnGooglePlay_Badge_Web_color_English.png
Normal file
BIN
assets/GetItOnGooglePlay_Badge_Web_color_English.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"1.3.1": {
|
||||
"content": "• UX: Network status indicator always enabled\n• UX: Direction slider wider on small screens\n• UX: Fixed iOS keyboard missing 'Done' in settings\n• UX: Fixed multi-direction nodes in upload queue\n• UX: Improved suspected locations loading indicator; removed popup, fixed stuck spinner"
|
||||
},
|
||||
"1.2.8": {
|
||||
"content": "• UX: Profile selection is now a required step to prevent accidental submission of default profile.\n• NEW: Note in welcome message about not submitting data you cannot vouch for personally (no street view etc)\n• NEW: Added default operator profiles for the most common private operators nationwide (Lowe's, Home Depot, et al)\n• NEW: Support for cardinal directions in OSM data, multiple directions on a node."
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>This app needs your location to show nearby cameras.</string>
|
||||
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
||||
@@ -485,10 +485,8 @@ class AppState extends ChangeNotifier {
|
||||
await _suspectedLocationState.setEnabled(enabled);
|
||||
}
|
||||
|
||||
Future<bool> refreshSuspectedLocations({
|
||||
void Function(String message, double? progress)? onProgress,
|
||||
}) async {
|
||||
return await _suspectedLocationState.refreshData(onProgress: onProgress);
|
||||
Future<bool> refreshSuspectedLocations() async {
|
||||
return await _suspectedLocationState.refreshData();
|
||||
}
|
||||
|
||||
void selectSuspectedLocation(SuspectedLocation location) {
|
||||
|
||||
@@ -44,7 +44,7 @@ const String kClientName = 'DeFlock';
|
||||
const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_latest.csv';
|
||||
|
||||
// Development/testing features - set to false for production builds
|
||||
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
|
||||
// Navigation features - set to false to hide navigation UI elements while in development
|
||||
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
|
||||
@@ -82,6 +82,12 @@ const int kProximityAlertMinDistance = 50; // meters
|
||||
const int kProximityAlertMaxDistance = 1000; // meters
|
||||
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
|
||||
|
||||
// Map interaction configuration
|
||||
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)
|
||||
const double kScrollWheelVelocity = 0.005; // Mouse scroll wheel zoom speed (default 0.005)
|
||||
const double kPinchZoomThreshold = 0.5; // How much pinch required to start zoom (default 0.5)
|
||||
const double kPinchMoveThreshold = 40.0; // How much drag required for two-finger pan (default 40.0)
|
||||
|
||||
// Tile fetch retry parameters (configurable backoff system)
|
||||
const int kTileFetchMaxAttempts = 16; // Number of retry attempts before giving up
|
||||
const int kTileFetchInitialDelayMs = 500; // Base delay for first retry (1 second)
|
||||
@@ -108,6 +114,10 @@ const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - or
|
||||
const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey
|
||||
const Color kNodeRingColorPendingDeletion = Color(0xC0F44336); // Node pending deletion - red, slightly transparent
|
||||
|
||||
// Direction slider control buttons configuration
|
||||
const double kDirectionButtonMinWidth = 22.0;
|
||||
const double kDirectionButtonMinHeight = 32.0;
|
||||
|
||||
// Helper functions for pixel-ratio scaling
|
||||
double getDirectionConeBorderWidth(BuildContext context) {
|
||||
// return _kDirectionConeBorderWidthBase * MediaQuery.of(context).devicePixelRatio;
|
||||
|
||||
@@ -28,8 +28,8 @@ class AdvancedSettingsScreen extends StatelessWidget {
|
||||
Divider(),
|
||||
SuspectedLocationsSection(),
|
||||
Divider(),
|
||||
NetworkStatusSection(),
|
||||
Divider(),
|
||||
// NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled
|
||||
// Divider(),
|
||||
TileProviderSection(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -244,6 +244,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Run any needed migrations first
|
||||
final versionsNeedingMigration = await ChangelogService().getVersionsNeedingMigration();
|
||||
for (final version in versionsNeedingMigration) {
|
||||
await ChangelogService().runMigration(version, appState);
|
||||
}
|
||||
|
||||
// Determine what popup to show
|
||||
final popupType = await ChangelogService().getPopupType();
|
||||
|
||||
if (!mounted) return; // Check again after async operation
|
||||
@@ -258,7 +267,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
break;
|
||||
|
||||
case PopupType.changelog:
|
||||
final changelogContent = ChangelogService().getChangelogForCurrentVersion();
|
||||
final changelogContent = await ChangelogService().getChangelogContentForDisplay();
|
||||
if (changelogContent != null) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
@@ -269,18 +278,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
break;
|
||||
|
||||
case PopupType.none:
|
||||
// No popup needed, but still update version tracking for future launches
|
||||
await ChangelogService().updateLastSeenVersion();
|
||||
// No popup needed
|
||||
break;
|
||||
}
|
||||
|
||||
// Complete the version change workflow (updates last seen version)
|
||||
await ChangelogService().completeVersionChange();
|
||||
|
||||
} catch (e) {
|
||||
// Silently handle errors to avoid breaking the app launch
|
||||
debugPrint('[HomeScreen] Error checking for popup: $e');
|
||||
// Still update version tracking in case of error
|
||||
|
||||
// Still complete version change to avoid getting stuck
|
||||
try {
|
||||
await ChangelogService().updateLastSeenVersion();
|
||||
await ChangelogService().completeVersionChange();
|
||||
} catch (e2) {
|
||||
debugPrint('[HomeScreen] Error updating version: $e2');
|
||||
debugPrint('[HomeScreen] Error completing version change: $e2');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
|
||||
@@ -181,7 +181,8 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _distanceController,
|
||||
keyboardType: TextInputType.number,
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
|
||||
@@ -119,7 +119,11 @@ class QueueSection extends StatelessWidget {
|
||||
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
|
||||
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.direction', params: [upload.direction.round().toString()]) + '\n' +
|
||||
locService.t('queue.direction', params: [
|
||||
upload.direction is String
|
||||
? upload.direction.toString()
|
||||
: upload.direction.round().toString()
|
||||
]) + '\n' +
|
||||
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
|
||||
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
|
||||
),
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../widgets/suspected_location_progress_dialog.dart';
|
||||
|
||||
class SuspectedLocationsSection extends StatelessWidget {
|
||||
const SuspectedLocationsSection({super.key});
|
||||
@@ -39,31 +38,19 @@ class SuspectedLocationsSection extends StatelessWidget {
|
||||
Future<void> handleRefresh() async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Show simple progress dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (progressContext) => SuspectedLocationProgressDialog(
|
||||
title: locService.t('suspectedLocations.updating'),
|
||||
message: locService.t('suspectedLocations.downloadingAndProcessing'),
|
||||
),
|
||||
);
|
||||
|
||||
// Start the refresh
|
||||
// Use the inline loading indicator by calling refreshSuspectedLocations
|
||||
// The loading state will be managed by suspected location state
|
||||
final success = await appState.refreshSuspectedLocations();
|
||||
|
||||
// Close progress dialog
|
||||
// Show result snackbar
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Show result snackbar
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success
|
||||
? locService.t('suspectedLocations.updateSuccess')
|
||||
: locService.t('suspectedLocations.updateFailed')),
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success
|
||||
? locService.t('suspectedLocations.updateSuccess')
|
||||
: locService.t('suspectedLocations.updateFailed')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +126,8 @@ class SuspectedLocationsSection extends StatelessWidget {
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
initialValue: appState.suspectedLocationMinDistance.toString(),
|
||||
keyboardType: TextInputType.number,
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'version_service.dart';
|
||||
import '../app_state.dart';
|
||||
|
||||
/// Service for managing changelog data and first launch detection
|
||||
class ChangelogService {
|
||||
@@ -67,6 +68,12 @@ class ChangelogService {
|
||||
debugPrint('[ChangelogService] Updated last seen version to: $currentVersion');
|
||||
}
|
||||
|
||||
/// Get the last seen version (for migration purposes)
|
||||
Future<String?> getLastSeenVersion() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_lastSeenVersionKey);
|
||||
}
|
||||
|
||||
/// Get changelog content for the current version
|
||||
String? getChangelogForCurrentVersion() {
|
||||
if (!_initialized || _changelogData == null) {
|
||||
@@ -86,6 +93,18 @@ class ChangelogService {
|
||||
return (content?.isEmpty == true) ? null : content;
|
||||
}
|
||||
|
||||
/// Get the changelog content that should be displayed (may be combined from multiple versions)
|
||||
/// This is the method home_screen should use to get content for the changelog popup
|
||||
Future<String?> getChangelogContentForDisplay() async {
|
||||
return await getCombinedChangelogContent();
|
||||
}
|
||||
|
||||
/// Complete the version change workflow - call this after showing popups
|
||||
/// This updates the last seen version so migrations don't run again
|
||||
Future<void> completeVersionChange() async {
|
||||
await updateLastSeenVersion();
|
||||
}
|
||||
|
||||
/// Get changelog content for a specific version
|
||||
String? getChangelogForVersion(String version) {
|
||||
if (!_initialized || _changelogData == null) return null;
|
||||
@@ -133,7 +152,7 @@ class ChangelogService {
|
||||
|
||||
// Version changed and there's changelog content
|
||||
if (hasVersionChanged) {
|
||||
final changelogContent = getChangelogForCurrentVersion();
|
||||
final changelogContent = await getCombinedChangelogContent();
|
||||
if (changelogContent != null) {
|
||||
return PopupType.changelog;
|
||||
}
|
||||
@@ -142,8 +161,139 @@ class ChangelogService {
|
||||
return PopupType.none;
|
||||
}
|
||||
|
||||
/// Check if version-change migrations need to be run
|
||||
/// Returns list of version strings that need migrations
|
||||
Future<List<String>> getVersionsNeedingMigration() async {
|
||||
final lastSeenVersion = await getLastSeenVersion();
|
||||
final currentVersion = VersionService().version;
|
||||
|
||||
if (lastSeenVersion == null) return []; // First launch, no migrations needed
|
||||
|
||||
final versionsNeedingMigration = <String>[];
|
||||
|
||||
// Check each version that could need migration
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '1.3.1')) {
|
||||
versionsNeedingMigration.add('1.3.1');
|
||||
}
|
||||
|
||||
// Future versions can be added here
|
||||
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
|
||||
// versionsNeedingMigration.add('2.0.0');
|
||||
// }
|
||||
|
||||
return versionsNeedingMigration;
|
||||
}
|
||||
|
||||
/// Get combined changelog content for all versions between last seen and current
|
||||
/// Returns null if no changelog content exists for any intermediate version
|
||||
Future<String?> getCombinedChangelogContent() async {
|
||||
if (!_initialized || _changelogData == null) return null;
|
||||
|
||||
final lastSeenVersion = await getLastSeenVersion();
|
||||
final currentVersion = VersionService().version;
|
||||
|
||||
if (lastSeenVersion == null) {
|
||||
// First launch - just return current version changelog
|
||||
return getChangelogForCurrentVersion();
|
||||
}
|
||||
|
||||
final intermediateVersions = <String>[];
|
||||
|
||||
// Collect all relevant versions between lastSeen and current (exclusive of lastSeen, inclusive of current)
|
||||
for (final entry in _changelogData!.entries) {
|
||||
final version = entry.key;
|
||||
final versionData = entry.value as Map<String, dynamic>?;
|
||||
final content = versionData?['content'] as String?;
|
||||
|
||||
// Skip versions with empty content
|
||||
if (content == null || content.isEmpty) continue;
|
||||
|
||||
// Include versions where: lastSeenVersion < version <= currentVersion
|
||||
if (needsMigration(lastSeenVersion, currentVersion, version)) {
|
||||
intermediateVersions.add(version);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort versions in descending order (newest first)
|
||||
intermediateVersions.sort((a, b) => compareVersions(b, a));
|
||||
|
||||
// Build changelog content
|
||||
final intermediateChangelogs = intermediateVersions.map((version) {
|
||||
final versionData = _changelogData![version] as Map<String, dynamic>;
|
||||
final content = versionData['content'] as String;
|
||||
return '**Version $version:**\n$content';
|
||||
}).toList();
|
||||
|
||||
return intermediateChangelogs.isNotEmpty ? intermediateChangelogs.join('\n\n---\n\n') : null;
|
||||
}
|
||||
|
||||
/// Check if the service is properly initialized
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
/// Run a specific migration by version number
|
||||
Future<void> runMigration(String version, AppState appState) async {
|
||||
debugPrint('[ChangelogService] Running $version migration');
|
||||
|
||||
switch (version) {
|
||||
case '1.3.1':
|
||||
// Enable network status indicator for all existing users
|
||||
await appState.setNetworkStatusIndicatorEnabled(true);
|
||||
debugPrint('[ChangelogService] 1.3.1 migration completed: enabled network status indicator');
|
||||
break;
|
||||
|
||||
// Future migrations can be added here
|
||||
// case '2.0.0':
|
||||
// await appState.doSomethingNew();
|
||||
// debugPrint('[ChangelogService] 2.0.0 migration completed');
|
||||
// break;
|
||||
|
||||
default:
|
||||
debugPrint('[ChangelogService] Unknown migration version: $version');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a migration should run
|
||||
/// Migration runs if: lastSeenVersion < migrationVersion <= currentVersion
|
||||
bool needsMigration(String lastSeenVersion, String currentVersion, String migrationVersion) {
|
||||
final lastVsMigration = compareVersions(lastSeenVersion, migrationVersion);
|
||||
final migrationVsCurrent = compareVersions(migrationVersion, currentVersion);
|
||||
|
||||
return lastVsMigration < 0 && migrationVsCurrent <= 0;
|
||||
}
|
||||
|
||||
/// Compare two version strings
|
||||
/// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
||||
/// Versions are expected in format "major.minor.patch" (e.g., "1.3.1")
|
||||
int compareVersions(String v1, String v2) {
|
||||
try {
|
||||
final v1Parts = v1.split('.').map(int.parse).toList();
|
||||
final v2Parts = v2.split('.').map(int.parse).toList();
|
||||
|
||||
// Ensure we have at least 3 parts (major.minor.patch)
|
||||
while (v1Parts.length < 3) v1Parts.add(0);
|
||||
while (v2Parts.length < 3) v2Parts.add(0);
|
||||
|
||||
// Compare major version first
|
||||
if (v1Parts[0] < v2Parts[0]) return -1;
|
||||
if (v1Parts[0] > v2Parts[0]) return 1;
|
||||
|
||||
// Major versions equal, compare minor version
|
||||
if (v1Parts[1] < v2Parts[1]) return -1;
|
||||
if (v1Parts[1] > v2Parts[1]) return 1;
|
||||
|
||||
// Major and minor equal, compare patch version
|
||||
if (v1Parts[2] < v2Parts[2]) return -1;
|
||||
if (v1Parts[2] > v2Parts[2]) return 1;
|
||||
|
||||
// All parts equal
|
||||
return 0;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[ChangelogService] Error comparing versions "$v1" vs "$v2": $e');
|
||||
// Safe fallback: assume they're different so we run migrations
|
||||
return v1 == v2 ? 0 : -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of popups that can be shown
|
||||
|
||||
@@ -150,7 +150,7 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
return [];
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final data = await compute(jsonDecode, response.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
if (elements.length > 20) {
|
||||
|
||||
@@ -11,6 +11,7 @@ class NodeCache {
|
||||
final Map<int, OsmNode> _nodes = {};
|
||||
|
||||
/// Add or update a batch of nodes in the cache.
|
||||
/// TODO: Consider moving to compute() if cache operations cause ANR
|
||||
void addOrUpdate(List<OsmNode> nodes) {
|
||||
for (var node in nodes) {
|
||||
final existing = _nodes[node.id];
|
||||
|
||||
@@ -127,9 +127,8 @@ class SuspectedLocationCache extends ChangeNotifier {
|
||||
/// Process raw CSV data and save to storage (calculates centroids once)
|
||||
Future<void> processAndSave(
|
||||
List<Map<String, dynamic>> rawData,
|
||||
DateTime fetchTime, {
|
||||
void Function(String message, double? progress)? onProgress,
|
||||
}) async {
|
||||
DateTime fetchTime,
|
||||
) async {
|
||||
try {
|
||||
debugPrint('[SuspectedLocationCache] Processing ${rawData.length} raw entries...');
|
||||
|
||||
@@ -141,10 +140,9 @@ class SuspectedLocationCache extends ChangeNotifier {
|
||||
for (int i = 0; i < rawData.length; i++) {
|
||||
final rowData = rawData[i];
|
||||
|
||||
// Report progress every 1000 entries
|
||||
// Log progress every 1000 entries for debugging
|
||||
if (i % 1000 == 0) {
|
||||
final progress = i / rawData.length;
|
||||
onProgress?.call('Calculating coordinates: ${i + 1}/${rawData.length}', progress);
|
||||
debugPrint('[SuspectedLocationCache] Processed ${i + 1}/${rawData.length} entries...');
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -22,7 +22,6 @@ class SuspectedLocationService {
|
||||
|
||||
final SuspectedLocationCache _cache = SuspectedLocationCache();
|
||||
bool _isEnabled = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
/// Get last fetch time
|
||||
DateTime? get lastFetchTime => _cache.lastFetchTime;
|
||||
@@ -30,9 +29,6 @@ class SuspectedLocationService {
|
||||
/// Check if suspected locations are enabled
|
||||
bool get isEnabled => _isEnabled;
|
||||
|
||||
/// Check if currently loading
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// Initialize the service - load from storage and check if refresh needed
|
||||
Future<void> init({bool offlineMode = false}) async {
|
||||
await _loadFromStorage();
|
||||
@@ -55,22 +51,31 @@ class SuspectedLocationService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKeyEnabled, enabled);
|
||||
|
||||
// If enabling for the first time and no data, fetch it in background
|
||||
if (enabled && !_cache.hasData) {
|
||||
_fetchData(); // Don't await - let it run in background so UI updates immediately
|
||||
}
|
||||
|
||||
// If disabling, clear the cache
|
||||
if (!enabled) {
|
||||
_cache.clear();
|
||||
}
|
||||
// Note: If enabling and no data, the state layer will call fetchDataIfNeeded()
|
||||
}
|
||||
|
||||
/// Manually refresh the data
|
||||
Future<bool> refreshData({
|
||||
void Function(String message, double? progress)? onProgress,
|
||||
}) async {
|
||||
return await _fetchData(onProgress: onProgress);
|
||||
/// Check if cache has any data
|
||||
bool get hasData => _cache.hasData;
|
||||
|
||||
/// Get last fetch time
|
||||
DateTime? get lastFetch => _cache.lastFetchTime;
|
||||
|
||||
/// Fetch data if needed (for enabling suspected locations when no data exists)
|
||||
Future<bool> fetchDataIfNeeded() async {
|
||||
if (!_shouldRefresh()) {
|
||||
debugPrint('[SuspectedLocationService] Data is fresh, skipping fetch');
|
||||
return true; // Already have fresh data
|
||||
}
|
||||
return await _fetchData();
|
||||
}
|
||||
|
||||
/// Force refresh the data (for manual refresh button)
|
||||
Future<bool> forceRefresh() async {
|
||||
return await _fetchData();
|
||||
}
|
||||
|
||||
/// Check if data should be refreshed
|
||||
@@ -95,14 +100,8 @@ class SuspectedLocationService {
|
||||
}
|
||||
|
||||
/// Fetch data from the CSV URL
|
||||
Future<bool> _fetchData({
|
||||
void Function(String message, double? progress)? onProgress,
|
||||
}) async {
|
||||
if (_isLoading) return false;
|
||||
|
||||
_isLoading = true;
|
||||
Future<bool> _fetchData() async {
|
||||
try {
|
||||
onProgress?.call('Downloading CSV data...', null);
|
||||
debugPrint('[SuspectedLocationService] Fetching CSV data from $kSuspectedLocationsCsvUrl');
|
||||
|
||||
final response = await http.get(
|
||||
@@ -117,14 +116,8 @@ class SuspectedLocationService {
|
||||
return false;
|
||||
}
|
||||
|
||||
onProgress?.call('Parsing CSV data...', 0.2);
|
||||
|
||||
// Parse CSV with proper field separator and quote handling
|
||||
final csvData = const CsvToListConverter(
|
||||
fieldDelimiter: ',',
|
||||
textDelimiter: '"',
|
||||
eol: '\n',
|
||||
).convert(response.body);
|
||||
final csvData = await compute(_parseCSV, response.body);
|
||||
debugPrint('[SuspectedLocationService] Parsed ${csvData.length} rows from CSV');
|
||||
|
||||
if (csvData.isEmpty) {
|
||||
@@ -174,11 +167,6 @@ class SuspectedLocationService {
|
||||
validRows++;
|
||||
}
|
||||
|
||||
// Report progress every 1000 rows
|
||||
if (rowIndex % 1000 == 0) {
|
||||
final progress = 0.4 + (rowIndex / dataRows.length) * 0.4; // 40% to 80% of total
|
||||
onProgress?.call('Processing row $rowIndex...', progress);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
// Skip rows that can't be parsed
|
||||
debugPrint('[SuspectedLocationService] Error parsing row $rowIndex: $e');
|
||||
@@ -186,18 +174,12 @@ class SuspectedLocationService {
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.call('Calculating coordinates...', 0.8);
|
||||
debugPrint('[SuspectedLocationService] Parsed $validRows valid rows from ${dataRows.length} total rows');
|
||||
|
||||
final fetchTime = DateTime.now();
|
||||
|
||||
// Process raw data and save (calculates centroids once)
|
||||
await _cache.processAndSave(rawDataList, fetchTime, onProgress: (message, progress) {
|
||||
// Map cache progress to final 20% (0.8 to 1.0)
|
||||
final finalProgress = 0.8 + (progress ?? 0) * 0.2;
|
||||
onProgress?.call(message, finalProgress);
|
||||
});
|
||||
|
||||
onProgress?.call('Complete!', 1.0);
|
||||
await _cache.processAndSave(rawDataList, fetchTime);
|
||||
|
||||
debugPrint('[SuspectedLocationService] Successfully fetched and stored $validRows valid raw entries (${rawDataList.length} total)');
|
||||
return true;
|
||||
@@ -206,8 +188,6 @@ class SuspectedLocationService {
|
||||
debugPrint('[SuspectedLocationService] Error fetching data: $e');
|
||||
debugPrint('[SuspectedLocationService] Stack trace: $stackTrace');
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,4 +203,13 @@ class SuspectedLocationService {
|
||||
LatLng(south, east),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple CSV parser for compute() - must be top-level function
|
||||
List<List<dynamic>> _parseCSV(String csvBody) {
|
||||
return const CsvToListConverter(
|
||||
fieldDelimiter: ',',
|
||||
textDelimiter: '"',
|
||||
eol: '\n',
|
||||
).convert(csvBody);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class SettingsState extends ChangeNotifier {
|
||||
FollowMeMode _followMeMode = FollowMeMode.follow;
|
||||
bool _proximityAlertsEnabled = false;
|
||||
int _proximityAlertDistance = kProximityAlertDefaultDistance;
|
||||
bool _networkStatusIndicatorEnabled = false;
|
||||
bool _networkStatusIndicatorEnabled = true;
|
||||
int _suspectedLocationMinDistance = 100; // meters
|
||||
List<TileProvider> _tileProviders = [];
|
||||
String _selectedTileTypeId = '';
|
||||
@@ -102,7 +102,7 @@ class SettingsState extends ChangeNotifier {
|
||||
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance;
|
||||
|
||||
// Load network status indicator setting
|
||||
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? false;
|
||||
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? true;
|
||||
|
||||
// Load suspected location minimum distance
|
||||
_suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100;
|
||||
|
||||
@@ -31,7 +31,7 @@ class SuspectedLocationState extends ChangeNotifier {
|
||||
bool get isEnabled => _service.isEnabled;
|
||||
|
||||
/// Whether currently loading data
|
||||
bool get isLoading => _isLoading || _service.isLoading;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// Last time data was fetched
|
||||
DateTime? get lastFetchTime => _service.lastFetchTime;
|
||||
@@ -45,18 +45,36 @@ class SuspectedLocationState extends ChangeNotifier {
|
||||
/// Enable or disable suspected locations
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
await _service.setEnabled(enabled);
|
||||
|
||||
// If enabling and no data exists, fetch it now
|
||||
if (enabled && !_service.hasData) {
|
||||
await _fetchData();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Manually refresh the data
|
||||
Future<bool> refreshData({
|
||||
void Function(String message, double? progress)? onProgress,
|
||||
}) async {
|
||||
/// Manually refresh the data (force refresh)
|
||||
Future<bool> refreshData() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final success = await _service.refreshData(onProgress: onProgress);
|
||||
final success = await _service.forceRefresh();
|
||||
return success;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal method to fetch data if needed with loading state management
|
||||
Future<bool> _fetchData() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final success = await _service.fetchDataIfNeeded();
|
||||
return success;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
@@ -66,34 +67,48 @@ class AddNodeSheet extends StatelessWidget {
|
||||
onChanged: requiresDirection ? (v) => appState.updateSession(directionDeg: v) : null,
|
||||
),
|
||||
),
|
||||
// Buttons on the right (only show if direction is required)
|
||||
if (requiresDirection) ...[
|
||||
const SizedBox(width: 8),
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove, size: 20),
|
||||
onPressed: session.directions.length > 1 ? () => appState.removeDirection() : null,
|
||||
tooltip: 'Remove current direction',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
// Direction control buttons - always show but grey out when direction not required
|
||||
const SizedBox(width: 8),
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.remove,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
// Add button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, size: 20),
|
||||
onPressed: () => appState.addDirection(),
|
||||
tooltip: 'Add new direction',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
? () => appState.removeDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
// Add button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
// Cycle button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.repeat, size: 20),
|
||||
onPressed: session.directions.length > 1 ? () => appState.cycleDirection() : null,
|
||||
tooltip: 'Cycle through directions',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: requiresDirection ? () => appState.addDirection() : null,
|
||||
tooltip: requiresDirection ? 'Add new direction' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
// Cycle button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.repeat,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
],
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
? () => appState.cycleDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
@@ -67,34 +68,48 @@ class EditNodeSheet extends StatelessWidget {
|
||||
onChanged: requiresDirection ? (v) => appState.updateEditSession(directionDeg: v) : null,
|
||||
),
|
||||
),
|
||||
// Buttons on the right (only show if direction is required)
|
||||
if (requiresDirection) ...[
|
||||
const SizedBox(width: 8),
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove, size: 20),
|
||||
onPressed: session.directions.length > 1 ? () => appState.removeDirection() : null,
|
||||
tooltip: 'Remove current direction',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
// Direction control buttons - always show but grey out when direction not required
|
||||
const SizedBox(width: 8),
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.remove,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
// Add button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, size: 20),
|
||||
onPressed: () => appState.addDirection(),
|
||||
tooltip: 'Add new direction',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
? () => appState.removeDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
// Add button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
// Cycle button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.repeat, size: 20),
|
||||
onPressed: session.directions.length > 1 ? () => appState.cycleDirection() : null,
|
||||
tooltip: 'Cycle through directions',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: requiresDirection ? () => appState.addDirection() : null,
|
||||
tooltip: requiresDirection ? 'Add new direction' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
// Cycle button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.repeat,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
],
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
? () => appState.cycleDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -49,7 +49,7 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1);
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -47,7 +47,7 @@ class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker>
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + 1);
|
||||
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -284,7 +284,6 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _refreshNodesFromProvider() {
|
||||
final appState = context.read<AppState>();
|
||||
_cameraController.refreshCamerasFromProvider(
|
||||
@@ -296,9 +295,6 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MapView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -316,13 +312,6 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
@@ -554,12 +543,15 @@ class MapViewState extends State<MapView> {
|
||||
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
|
||||
initialZoom: _positionManager.initialZoom ?? 15,
|
||||
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
|
||||
interactionOptions: const InteractionOptions(
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
),
|
||||
onPositionChanged: (pos, gesture) {
|
||||
setState(() {}); // Instant UI update for zoom, etc.
|
||||
if (gesture) {
|
||||
widget.onUserGesture();
|
||||
|
||||
|
||||
}
|
||||
|
||||
if (session != null) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 1.2.8+7 # The thing after the + is the version code, incremented with each release
|
||||
version: 1.3.1+9 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
|
||||
|
||||
Reference in New Issue
Block a user