configurable button width, always enable network status indicator, new version migration logic available through changelog_service

This commit is contained in:
stopflock
2025-11-12 15:53:14 -06:00
parent d57b2f64b1
commit 3810dfa8d2
9 changed files with 191 additions and 19 deletions

View File

@@ -1,4 +1,7 @@
{
"1.3.1": {
"content": "• UX: Network status indicator is now always enabled by default\n• UX: Direction control buttons now use configurable dimensions\n• INTERNAL: Cleaned up advanced settings - removed redundant network status toggle"
},
"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."
},

View File

@@ -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
@@ -114,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;

View File

@@ -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(),
],
),

View File

@@ -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');
}
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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';
@@ -75,7 +76,7 @@ class AddNodeSheet extends StatelessWidget {
onPressed: session.directions.length > 1 ? () => appState.removeDirection() : null,
tooltip: 'Remove current direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 32),
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Add button
IconButton(
@@ -83,7 +84,7 @@ class AddNodeSheet extends StatelessWidget {
onPressed: () => appState.addDirection(),
tooltip: 'Add new direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 32),
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Cycle button
IconButton(
@@ -91,7 +92,7 @@ class AddNodeSheet extends StatelessWidget {
onPressed: session.directions.length > 1 ? () => appState.cycleDirection() : null,
tooltip: 'Cycle through directions',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 32),
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
],
],

View File

@@ -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';
@@ -76,7 +77,7 @@ class EditNodeSheet extends StatelessWidget {
onPressed: session.directions.length > 1 ? () => appState.removeDirection() : null,
tooltip: 'Remove current direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 32),
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Add button
IconButton(
@@ -84,7 +85,7 @@ class EditNodeSheet extends StatelessWidget {
onPressed: () => appState.addDirection(),
tooltip: 'Add new direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 32),
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Cycle button
IconButton(
@@ -92,7 +93,7 @@ class EditNodeSheet extends StatelessWidget {
onPressed: session.directions.length > 1 ? () => appState.cycleDirection() : null,
tooltip: 'Cycle through directions',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 32),
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
],
],

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 1.3.0+8 # 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+