diff --git a/DEVELOPER.md b/DEVELOPER.md
index a0b731d..193411c 100644
--- a/DEVELOPER.md
+++ b/DEVELOPER.md
@@ -800,33 +800,104 @@ The app uses a **clean, release-triggered workflow** that rebuilds from scratch
## Build & Development Setup
### Prerequisites
-- **Flutter SDK**: Latest stable version
-- **Xcode**: For iOS builds (macOS only)
-- **Android Studio**: For Android builds
-- **Git**: For version control
+
+**macOS** (required for iOS builds; Android-only contributors can use macOS or Linux):
+
+| Tool | Install | Notes |
+|------|---------|-------|
+| **Homebrew** | [brew.sh](https://brew.sh) | Package manager for macOS |
+| **Flutter SDK 3.35+** | `brew install --cask flutter` | Installs Flutter + Dart (3.35+ required for RadioGroup widget) |
+| **Xcode** | Mac App Store | Required for iOS builds |
+| **CocoaPods** | `brew install cocoapods` | Required for iOS plugin resolution |
+| **Android SDK** | See below | Required for Android builds |
+| **Java 17+** | `brew install --cask temurin` | Required by Android toolchain (skip if already installed) |
+
+After installing, verify with:
+```bash
+flutter doctor # All checks should be green
+```
+
+### Android SDK Setup (without Android Studio)
+
+You don't need the full Android Studio IDE. Install the command-line tools and let Flutter's build system pull what it needs:
+
+```bash
+# 1. Install command-line tools
+brew install --cask android-commandlinetools
+
+# 2. Create the SDK directory and install required components
+mkdir -p ~/Library/Android/sdk/licenses
+# Write license acceptance hashes
+printf "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > ~/Library/Android/sdk/licenses/android-sdk-license
+printf "\n84831b9409646a918e30573bab4c9c91346d8abd" > ~/Library/Android/sdk/licenses/android-sdk-preview-license
+
+# 3. Install platform tools and the SDK platform Flutter needs
+"$(brew --prefix android-commandlinetools)/cmdline-tools/latest/bin/sdkmanager" \
+ --sdk_root="$HOME/Library/Android/sdk" \
+ "platform-tools" "platforms;android-36" "build-tools;35.0.0"
+
+# 4. Copy cmdline-tools into the SDK root (Flutter expects them there)
+mkdir -p ~/Library/Android/sdk/cmdline-tools
+cp -R "$(brew --prefix android-commandlinetools)/cmdline-tools/latest" \
+ ~/Library/Android/sdk/cmdline-tools/latest
+
+# 5. Point Flutter at the SDK and accept licenses
+flutter config --android-sdk ~/Library/Android/sdk
+yes | flutter doctor --android-licenses
+```
+
+> **Note:** The first `flutter build apk` will auto-download additional components it needs (NDK, CMake, etc). This is normal and only happens once.
### OAuth2 Setup
-**Required registrations:**
+To run the app with working OSM authentication, register OAuth2 applications:
+
1. **Production OSM**: https://www.openstreetmap.org/oauth2/applications
2. **Sandbox OSM**: https://master.apis.dev.openstreetmap.org/oauth2/applications
-**Configuration:**
+For local builds, create `build_keys.conf` (gitignored):
```bash
-cp lib/keys.dart.example lib/keys.dart
-# Edit keys.dart with your OAuth2 client IDs
+cp build_keys.conf.example build_keys.conf
+# Edit build_keys.conf with your OAuth2 client IDs
```
-### iOS Setup
+You can also pass keys directly via `--dart-define`:
```bash
-cd ios && pod install
+flutter run --dart-define=OSM_PROD_CLIENTID=your_id --dart-define=OSM_SANDBOX_CLIENTID=your_id
```
+### First Build
+
+```bash
+# 1. Install dependencies
+flutter pub get
+
+# 2. Generate icons and splash screens (gitignored, must be regenerated)
+./gen_icons_splashes.sh
+
+# 3. Build Android
+flutter build apk --debug \
+ --dart-define=OSM_PROD_CLIENTID=your_id \
+ --dart-define=OSM_SANDBOX_CLIENTID=your_id
+
+# 4. Build iOS (macOS only, no signing needed for testing)
+flutter build ios --no-codesign \
+ --dart-define=OSM_PROD_CLIENTID=your_id \
+ --dart-define=OSM_SANDBOX_CLIENTID=your_id
+```
+
+> **Important:** You must run `./gen_icons_splashes.sh` before the first build. The generated icons and splash screen assets are gitignored, so the build will fail without this step.
+
### Running
+
```bash
-flutter pub get
-./gen_icons_splashes.sh
-flutter run --dart-define=OSM_PROD_CLIENT_ID=[your OAuth2 client ID]
+# Run on connected device or simulator
+flutter run --dart-define=OSM_PROD_CLIENTID=your_id --dart-define=OSM_SANDBOX_CLIENTID=your_id
+
+# Or use the build script (reads keys from build_keys.conf)
+./do_builds.sh # Both platforms
+./do_builds.sh --android # Android only
+./do_builds.sh --ios # iOS only
```
### Testing
diff --git a/README.md b/README.md
index 1f98124..061a8db 100644
--- a/README.md
+++ b/README.md
@@ -90,12 +90,16 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
- Code organization and contribution guidelines
- Debugging tips and troubleshooting
-**Quick setup:**
+**Quick setup (macOS with Homebrew):**
```shell
-flutter pub get
-cp lib/keys.dart.example lib/keys.dart
-# Add OAuth2 client IDs, then: flutter run
+brew install --cask flutter # Install Flutter SDK
+brew install cocoapods # Required for iOS
+flutter pub get # Install dependencies
+./gen_icons_splashes.sh # Generate icons & splash screens (required before first build)
+cp build_keys.conf.example build_keys.conf # Add your OSM OAuth2 client IDs
+./do_builds.sh # Build both platforms
```
+See [DEVELOPER.md](DEVELOPER.md) for cross-platform instructions and Android SDK setup.
**Releases**: The app uses GitHub's release system for automated building and store uploads. Simply create a GitHub release and use the "pre-release" checkbox to control whether builds go to app stores - checked for beta releases, unchecked for production releases.
@@ -103,43 +107,7 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
-### Needed Bugfixes
-- Make submission guide scarier
-- Tile cache trimming? Does fluttermap handle?
-- Filter NSI suggestions based on what has already been typed in
-- NSI sometimes doesn't populate a dropdown, maybe always on the second tag added during an edit session?
-- Clean cache when nodes have been deleted by others
-
-### Current Development
-- Support check_date= tag, update on all edits, quick button to update that only
-- Support source= tag, default to survey, let user pick a different value
-- Add ability to downvote suspected locations which are old enough
-- Turn by turn navigation or at least swipe nav sheet up to see a list
-- Import/Export map providers
-- Update default profiles from the website on launch to capture changes
-
-### Future Features & Wishlist
-- Tap direction slider to enter integer directly
-- Tap pending queue item to edit again before submitting
-- Optional reason message when deleting
-- Update offline area data while browsing?
-- Save named locations to more easily navigate to home or work
-- Offline navigation (pending vector map tiles)
-
-### Maybes
-- Icons/glyphs for profiles
-- "Universal Links" for better handling of profile import when app is not installed
-- Yellow ring for devices missing specific tag details
-- Android Auto / CarPlay
-- "Cache accumulating" offline area? Most recent / most viewed?
-- Grab the full latest database for each profile just like for suspected locations (instead of overpass)?
-- Custom data providers? (OSM/Overpass alternatives)
-- Offer options for extracting nodes which are attached to a way/relation?
- - Auto extract (how?)
- - Leave it alone (wrong answer unless user chooses intentionally)
- - Manual cleanup (cognitive load for users)
- - Delete the old one (also wrong answer unless user chooses intentionally)
- - Give multiple of these options??
+See [GitHub Issues](https://github.com/FoggedLens/deflock-app/issues) for the full list of planned features, known bugs, and ideas.
---
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 6b9f9c4..339fc1c 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -39,7 +39,7 @@ android {
// ────────────────────────────────────────────────────────────
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
// ────────────────────────────────────────────────────────────
- minSdk = 23
+ minSdk = maxOf(flutter.minSdkVersion, 23)
targetSdk = 36
// Flutter tool injects these during `flutter build`
diff --git a/assets/changelog.json b/assets/changelog.json
index 3b5fc90..7f98474 100644
--- a/assets/changelog.json
+++ b/assets/changelog.json
@@ -1,4 +1,9 @@
{
+ "2.7.1": {
+ "content": [
+ "• Fixed operator profile selection being lost when moving node position, adjusting direction, or changing profiles"
+ ]
+ },
"2.6.4": {
"content": [
"• Added imperial units support (miles, feet) in addition to metric units (km, meters)",
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
index 7c56964..1dc6cf7 100644
--- a/ios/Flutter/AppFrameworkInfo.plist
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -21,6 +21,6 @@
CFBundleVersion
1.0
MinimumOSVersion
- 12.0
+ 13.0
diff --git a/ios/Podfile b/ios/Podfile
index e549ee2..620e46e 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
-# platform :ios, '12.0'
+# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 86f3226..691d14c 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -455,7 +455,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -588,7 +588,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -639,7 +639,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
diff --git a/lib/app_state.dart b/lib/app_state.dart
index 71a70c5..3f41450 100644
--- a/lib/app_state.dart
+++ b/lib/app_state.dart
@@ -447,6 +447,7 @@ class AppState extends ChangeNotifier {
Map? refinedTags,
Map? additionalExistingTags,
String? changesetComment,
+ bool updateOperatorProfile = false,
}) {
_sessionState.updateSession(
directionDeg: directionDeg,
@@ -456,6 +457,7 @@ class AppState extends ChangeNotifier {
refinedTags: refinedTags,
additionalExistingTags: additionalExistingTags,
changesetComment: changesetComment,
+ updateOperatorProfile: updateOperatorProfile,
);
// Check tutorial completion if position changed
@@ -473,6 +475,7 @@ class AppState extends ChangeNotifier {
Map? refinedTags,
Map? additionalExistingTags,
String? changesetComment,
+ bool updateOperatorProfile = false,
}) {
_sessionState.updateEditSession(
directionDeg: directionDeg,
@@ -483,6 +486,7 @@ class AppState extends ChangeNotifier {
refinedTags: refinedTags,
additionalExistingTags: additionalExistingTags,
changesetComment: changesetComment,
+ updateOperatorProfile: updateOperatorProfile,
);
// Check tutorial completion if position changed
diff --git a/lib/dev_config.dart b/lib/dev_config.dart
index ad0ea52..689db25 100644
--- a/lib/dev_config.dart
+++ b/lib/dev_config.dart
@@ -72,7 +72,7 @@ const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Ove
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-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 = true; // Hide navigation until fully implemented
@@ -144,6 +144,7 @@ const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render o
// NSI (Name Suggestion Index) configuration
const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful
+const int kNSIMaxSuggestions = 10; // Maximum number of tag value suggestions to fetch and display
// Map interaction configuration
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)
diff --git a/lib/services/nsi_service.dart b/lib/services/nsi_service.dart
index f799d26..79f63e1 100644
--- a/lib/services/nsi_service.dart
+++ b/lib/services/nsi_service.dart
@@ -84,8 +84,7 @@ class NSIService {
}
}
- // Limit to top 10 suggestions for UI performance
- if (suggestions.length >= 10) break;
+ if (suggestions.length >= kNSIMaxSuggestions) break;
}
return suggestions;
diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart
index b2dc90f..af0b8b3 100644
--- a/lib/services/routing_service.dart
+++ b/lib/services/routing_service.dart
@@ -27,7 +27,12 @@ class RouteResult {
class RoutingService {
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions';
static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)';
-
+ final http.Client _client;
+
+ RoutingService({http.Client? client}) : _client = client ?? http.Client();
+
+ void close() => _client.close();
+
// Calculate route between two points using alprwatch
Future calculateRoute({
required LatLng start,
@@ -40,10 +45,12 @@ class RoutingService {
final enabledProfiles = AppState.instance.enabledProfiles.map((p) {
final full = p.toJson();
+ final tags = Map.from(full['tags'] as Map);
+ tags.removeWhere((key, value) => value.isEmpty);
return {
'id': full['id'],
'name': full['name'],
- 'tags': full['tags'],
+ 'tags': tags,
};
}).toList();
@@ -65,7 +72,7 @@ class RoutingService {
debugPrint('[RoutingService] alprwatch request: $uri $params');
try {
- final response = await http.post(
+ final response = await _client.post(
uri,
headers: {
'User-Agent': _userAgent,
@@ -75,6 +82,16 @@ class RoutingService {
).timeout(kNavigationRoutingTimeout);
if (response.statusCode != 200) {
+ if (kDebugMode) {
+ debugPrint('[RoutingService] Error response body: ${response.body}');
+ } else {
+ const maxLen = 500;
+ final body = response.body;
+ final truncated = body.length > maxLen
+ ? '${body.substring(0, maxLen)}… [truncated]'
+ : body;
+ debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated');
+ }
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}');
}
diff --git a/lib/state/navigation_state.dart b/lib/state/navigation_state.dart
index f87ebea..feb090d 100644
--- a/lib/state/navigation_state.dart
+++ b/lib/state/navigation_state.dart
@@ -376,4 +376,10 @@ class NavigationState extends ChangeNotifier {
notifyListeners();
}
}
+
+ @override
+ void dispose() {
+ _routingService.close();
+ super.dispose();
+ }
}
diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart
index b48019f..6925295 100644
--- a/lib/state/session_state.dart
+++ b/lib/state/session_state.dart
@@ -215,6 +215,7 @@ class SessionState extends ChangeNotifier {
Map? refinedTags,
Map? additionalExistingTags,
String? changesetComment,
+ bool updateOperatorProfile = false,
}) {
if (_session == null) return;
@@ -232,7 +233,8 @@ class SessionState extends ChangeNotifier {
);
dirty = true;
}
- if (operatorProfile != _session!.operatorProfile) {
+ // Only update operator profile when explicitly requested
+ if (updateOperatorProfile && operatorProfile != _session!.operatorProfile) {
_session!.operatorProfile = operatorProfile;
dirty = true;
}
@@ -264,6 +266,7 @@ class SessionState extends ChangeNotifier {
Map? refinedTags,
Map? additionalExistingTags,
String? changesetComment,
+ bool updateOperatorProfile = false,
}) {
if (_editSession == null) return;
@@ -282,9 +285,9 @@ class SessionState extends ChangeNotifier {
// Handle direction requirements when profile changes
_handleDirectionRequirementsOnProfileChange(oldProfile, profile);
- // When profile changes but operator profile not explicitly provided,
+ // When profile changes and operator profile not being explicitly updated,
// restore the detected operator profile (if any)
- if (operatorProfile == null && _detectedOperatorProfile != null) {
+ if (!updateOperatorProfile && _detectedOperatorProfile != null) {
_editSession!.operatorProfile = _detectedOperatorProfile;
}
@@ -309,8 +312,8 @@ class SessionState extends ChangeNotifier {
dirty = true;
}
- // Only update operator profile if explicitly provided (including null) and different from current
- if (operatorProfile != _editSession!.operatorProfile) {
+ // Only update operator profile when explicitly requested
+ if (updateOperatorProfile && operatorProfile != _editSession!.operatorProfile) {
_editSession!.operatorProfile = operatorProfile; // This can be null
dirty = true;
}
diff --git a/lib/widgets/add_node_sheet.dart b/lib/widgets/add_node_sheet.dart
index c836866..7fef8ed 100644
--- a/lib/widgets/add_node_sheet.dart
+++ b/lib/widgets/add_node_sheet.dart
@@ -448,6 +448,7 @@ class _AddNodeSheetState extends State {
operatorProfile: result.operatorProfile,
refinedTags: result.refinedTags,
changesetComment: result.changesetComment,
+ updateOperatorProfile: true,
);
}
}
diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart
index 0299dbc..afe4cf1 100644
--- a/lib/widgets/edit_node_sheet.dart
+++ b/lib/widgets/edit_node_sheet.dart
@@ -503,6 +503,7 @@ class _EditNodeSheetState extends State {
refinedTags: result.refinedTags,
additionalExistingTags: result.additionalExistingTags,
changesetComment: result.changesetComment,
+ updateOperatorProfile: true,
);
}
}
diff --git a/lib/widgets/nsi_tag_value_field.dart b/lib/widgets/nsi_tag_value_field.dart
index 1f33c71..d05f752 100644
--- a/lib/widgets/nsi_tag_value_field.dart
+++ b/lib/widgets/nsi_tag_value_field.dart
@@ -25,33 +25,26 @@ class NSITagValueField extends StatefulWidget {
class _NSITagValueFieldState extends State {
late TextEditingController _controller;
- List _suggestions = [];
- bool _showingSuggestions = false;
- final LayerLink _layerLink = LayerLink();
- late OverlayEntry _overlayEntry;
final FocusNode _focusNode = FocusNode();
+ List _suggestions = [];
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
_loadSuggestions();
-
- _focusNode.addListener(_onFocusChanged);
- _controller.addListener(_onTextChanged);
}
@override
void didUpdateWidget(NSITagValueField oldWidget) {
super.didUpdateWidget(oldWidget);
-
+
// If the tag key changed, reload suggestions
if (oldWidget.tagKey != widget.tagKey) {
- _hideSuggestions(); // Hide old suggestions immediately
_suggestions.clear();
- _loadSuggestions(); // Load new suggestions for new key
+ _loadSuggestions();
}
-
+
// If the initial value changed, update the controller
if (oldWidget.initialValue != widget.initialValue) {
_controller.text = widget.initialValue;
@@ -62,38 +55,17 @@ class _NSITagValueFieldState extends State {
void dispose() {
_controller.dispose();
_focusNode.dispose();
- _hideSuggestions();
super.dispose();
}
- /// Get filtered suggestions based on current text input (case-sensitive)
- List _getFilteredSuggestions() {
- final currentText = _controller.text;
- if (currentText.isEmpty) {
- return _suggestions;
- }
-
- return _suggestions
- .where((suggestion) => suggestion.contains(currentText))
- .toList();
- }
-
- /// Handle text changes to update suggestion filtering
- void _onTextChanged() {
- if (_showingSuggestions) {
- // Update the overlay with filtered suggestions
- _updateSuggestionsOverlay();
- }
- }
-
void _loadSuggestions() async {
if (widget.tagKey.trim().isEmpty) return;
-
+
try {
final suggestions = await NSIService().getAllSuggestions(widget.tagKey);
if (mounted) {
setState(() {
- _suggestions = suggestions.take(10).toList(); // Limit to 10 suggestions
+ _suggestions = suggestions;
});
}
} catch (e) {
@@ -106,52 +78,78 @@ class _NSITagValueFieldState extends State {
}
}
- void _onFocusChanged() {
- final filteredSuggestions = _getFilteredSuggestions();
- if (_focusNode.hasFocus && filteredSuggestions.isNotEmpty && !widget.readOnly) {
- _showSuggestions();
- } else {
- _hideSuggestions();
- }
+ InputDecoration _buildDecoration({required bool showDropdownIcon}) {
+ return InputDecoration(
+ hintText: widget.hintText,
+ border: const OutlineInputBorder(),
+ isDense: true,
+ suffixIcon: showDropdownIcon
+ ? Icon(
+ Icons.arrow_drop_down,
+ color: _focusNode.hasFocus
+ ? Theme.of(context).primaryColor
+ : Colors.grey,
+ )
+ : null,
+ );
}
- void _showSuggestions() {
- final filteredSuggestions = _getFilteredSuggestions();
- if (_showingSuggestions || filteredSuggestions.isEmpty) return;
-
- _overlayEntry = _buildSuggestionsOverlay(filteredSuggestions);
- Overlay.of(context).insert(_overlayEntry);
- setState(() {
- _showingSuggestions = true;
- });
- }
-
- /// Update the suggestions overlay with current filtered suggestions
- void _updateSuggestionsOverlay() {
- final filteredSuggestions = _getFilteredSuggestions();
-
- if (filteredSuggestions.isEmpty) {
- _hideSuggestions();
- return;
+ @override
+ Widget build(BuildContext context) {
+ if (widget.readOnly) {
+ return TextField(
+ controller: _controller,
+ focusNode: _focusNode,
+ readOnly: true,
+ decoration: _buildDecoration(showDropdownIcon: false),
+ );
}
-
- if (_showingSuggestions) {
- // Remove current overlay and create new one with filtered suggestions
- _overlayEntry.remove();
- _overlayEntry = _buildSuggestionsOverlay(filteredSuggestions);
- Overlay.of(context).insert(_overlayEntry);
- }
- }
- /// Build the suggestions overlay with the given suggestions list
- OverlayEntry _buildSuggestionsOverlay(List suggestions) {
- return OverlayEntry(
- builder: (context) => Positioned(
- width: 250, // Slightly wider to fit more content in refine tags
- child: CompositedTransformFollower(
- link: _layerLink,
- showWhenUnlinked: false,
- offset: const Offset(0.0, 35.0), // Below the text field
+ return RawAutocomplete(
+ textEditingController: _controller,
+ focusNode: _focusNode,
+ optionsBuilder: (TextEditingValue textEditingValue) {
+ if (_suggestions.isEmpty) return const Iterable.empty();
+ if (textEditingValue.text.isEmpty) return _suggestions;
+ return _suggestions
+ .where((s) => s.contains(textEditingValue.text));
+ },
+ onSelected: (String selection) {
+ widget.onChanged(selection);
+ },
+ fieldViewBuilder: (
+ BuildContext context,
+ TextEditingController controller,
+ FocusNode focusNode,
+ VoidCallback onFieldSubmitted,
+ ) {
+ return TextField(
+ controller: controller,
+ focusNode: focusNode,
+ decoration: _buildDecoration(
+ showDropdownIcon: _suggestions.isNotEmpty,
+ ),
+ onChanged: (value) {
+ widget.onChanged(value);
+ },
+ onSubmitted: (_) {
+ // Only auto-complete when there's text to match against.
+ // Otherwise, pressing Done on an empty field would auto-select
+ // the first suggestion, preventing users from clearing values.
+ if (controller.text.isNotEmpty) {
+ onFieldSubmitted();
+ }
+ },
+ );
+ },
+ optionsViewBuilder: (
+ BuildContext context,
+ AutocompleteOnSelected onSelected,
+ Iterable options,
+ ) {
+ final optionList = options.toList(growable: false);
+ return Align(
+ alignment: Alignment.topLeft,
child: Material(
elevation: 4.0,
borderRadius: BorderRadius.circular(8.0),
@@ -160,68 +158,20 @@ class _NSITagValueFieldState extends State {
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
- itemCount: suggestions.length,
+ itemCount: optionList.length,
itemBuilder: (context, index) {
- final suggestion = suggestions[index];
+ final option = optionList[index];
return ListTile(
dense: true,
- title: Text(suggestion, style: const TextStyle(fontSize: 14)),
- onTap: () => _selectSuggestion(suggestion),
+ title: Text(option, style: const TextStyle(fontSize: 14)),
+ onTap: () => onSelected(option),
);
},
),
),
),
- ),
- ),
+ );
+ },
);
}
-
- void _hideSuggestions() {
- if (!_showingSuggestions) return;
-
- _overlayEntry.remove();
- setState(() {
- _showingSuggestions = false;
- });
- }
-
- void _selectSuggestion(String suggestion) {
- _controller.text = suggestion;
- widget.onChanged(suggestion);
- _hideSuggestions();
- }
-
- @override
- Widget build(BuildContext context) {
- final filteredSuggestions = _getFilteredSuggestions();
-
- return CompositedTransformTarget(
- link: _layerLink,
- child: TextField(
- controller: _controller,
- focusNode: _focusNode,
- readOnly: widget.readOnly,
- decoration: InputDecoration(
- hintText: widget.hintText,
- border: const OutlineInputBorder(),
- isDense: true,
- suffixIcon: _suggestions.isNotEmpty && !widget.readOnly
- ? Icon(
- Icons.arrow_drop_down,
- color: _showingSuggestions ? Theme.of(context).primaryColor : Colors.grey,
- )
- : null,
- ),
- onChanged: widget.readOnly ? null : (value) {
- widget.onChanged(value);
- },
- onTap: () {
- if (!widget.readOnly && filteredSuggestions.isNotEmpty) {
- _showSuggestions();
- }
- },
- ),
- );
- }
-}
\ No newline at end of file
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 1a50411..86b10e2 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,10 +1,10 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
-version: 2.6.4+47 # The thing after the + is the version code, incremented with each release
+version: 2.7.0+47 # 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+
+ sdk: ">=3.8.0 <4.0.0" # RadioGroup widget requires Dart 3.8+ (Flutter 3.35+)
dependencies:
flutter:
diff --git a/test/models/node_profile_test.dart b/test/models/node_profile_test.dart
new file mode 100644
index 0000000..28b1fb8
--- /dev/null
+++ b/test/models/node_profile_test.dart
@@ -0,0 +1,76 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:deflockapp/models/node_profile.dart';
+
+void main() {
+ group('NodeProfile', () {
+ test('toJson/fromJson round-trip preserves all fields', () {
+ final profile = NodeProfile(
+ id: 'test-id',
+ name: 'Test Profile',
+ tags: const {'man_made': 'surveillance', 'camera:type': 'fixed'},
+ builtin: true,
+ requiresDirection: false,
+ submittable: true,
+ editable: false,
+ fov: 90.0,
+ );
+
+ final json = profile.toJson();
+ final restored = NodeProfile.fromJson(json);
+
+ expect(restored.id, equals(profile.id));
+ expect(restored.name, equals(profile.name));
+ expect(restored.tags, equals(profile.tags));
+ expect(restored.builtin, equals(profile.builtin));
+ expect(restored.requiresDirection, equals(profile.requiresDirection));
+ expect(restored.submittable, equals(profile.submittable));
+ expect(restored.editable, equals(profile.editable));
+ expect(restored.fov, equals(profile.fov));
+ });
+
+ test('getDefaults returns expected profiles', () {
+ final defaults = NodeProfile.getDefaults();
+
+ expect(defaults.length, greaterThanOrEqualTo(10));
+
+ final ids = defaults.map((p) => p.id).toSet();
+ expect(ids, contains('builtin-flock'));
+ expect(ids, contains('builtin-generic-alpr'));
+ expect(ids, contains('builtin-motorola'));
+ expect(ids, contains('builtin-shotspotter'));
+ });
+
+ test('empty tag values exist in default profiles', () {
+ // Documents that profiles like builtin-flock ship with camera:mount: ''
+ // This is the root cause of the HTTP 400 bug — the routing service must
+ // filter these out before sending to the API.
+ final defaults = NodeProfile.getDefaults();
+ final flock = defaults.firstWhere((p) => p.id == 'builtin-flock');
+
+ expect(flock.tags.containsKey('camera:mount'), isTrue);
+ expect(flock.tags['camera:mount'], equals(''));
+ });
+
+ test('equality is based on id', () {
+ final a = NodeProfile(
+ id: 'same-id',
+ name: 'Profile A',
+ tags: const {'tag': 'a'},
+ );
+ final b = NodeProfile(
+ id: 'same-id',
+ name: 'Profile B',
+ tags: const {'tag': 'b'},
+ );
+ final c = NodeProfile(
+ id: 'different-id',
+ name: 'Profile A',
+ tags: const {'tag': 'a'},
+ );
+
+ expect(a, equals(b));
+ expect(a.hashCode, equals(b.hashCode));
+ expect(a, isNot(equals(c)));
+ });
+ });
+}
diff --git a/test/services/routing_service_test.dart b/test/services/routing_service_test.dart
new file mode 100644
index 0000000..757000e
--- /dev/null
+++ b/test/services/routing_service_test.dart
@@ -0,0 +1,204 @@
+import 'dart:convert';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:latlong2/latlong.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'package:deflockapp/app_state.dart';
+import 'package:deflockapp/models/node_profile.dart';
+import 'package:deflockapp/services/routing_service.dart';
+
+class MockHttpClient extends Mock implements http.Client {}
+
+class MockAppState extends Mock implements AppState {}
+
+void main() {
+ late MockHttpClient mockClient;
+ late MockAppState mockAppState;
+ late RoutingService service;
+
+ final start = const LatLng(38.9, -77.0);
+ final end = const LatLng(39.0, -77.1);
+
+ setUpAll(() {
+ registerFallbackValue(Uri.parse('https://example.com'));
+ });
+
+ setUp(() {
+ SharedPreferences.setMockInitialValues({
+ 'navigation_avoidance_distance': 100,
+ });
+
+ mockClient = MockHttpClient();
+ mockAppState = MockAppState();
+ AppState.instance = mockAppState;
+
+ service = RoutingService(client: mockClient);
+ });
+
+ tearDown(() {
+ AppState.instance = MockAppState();
+ });
+
+ group('RoutingService', () {
+ test('empty tags are filtered from request body', () async {
+ // Profile with empty tag values (like builtin-flock has camera:mount: '')
+ final profiles = [
+ NodeProfile(
+ id: 'test-profile',
+ name: 'Test Profile',
+ tags: const {
+ 'man_made': 'surveillance',
+ 'surveillance:type': 'ALPR',
+ 'camera:mount': '', // empty value - should be filtered
+ },
+ ),
+ ];
+ when(() => mockAppState.enabledProfiles).thenReturn(profiles);
+
+ // Capture the request body
+ when(() => mockClient.post(
+ any(),
+ headers: any(named: 'headers'),
+ body: any(named: 'body'),
+ )).thenAnswer((invocation) async {
+ return http.Response(
+ json.encode({
+ 'ok': true,
+ 'result': {
+ 'route': {
+ 'coordinates': [
+ [-77.0, 38.9],
+ [-77.1, 39.0],
+ ],
+ 'distance': 1000.0,
+ 'duration': 600.0,
+ },
+ },
+ }),
+ 200,
+ );
+ });
+
+ await service.calculateRoute(start: start, end: end);
+
+ final captured = verify(() => mockClient.post(
+ any(),
+ headers: any(named: 'headers'),
+ body: captureAny(named: 'body'),
+ )).captured;
+
+ final body = json.decode(captured.last as String) as Map;
+ final enabledProfiles = body['enabled_profiles'] as List;
+ final tags = enabledProfiles[0]['tags'] as Map;
+
+ // camera:mount with empty value should be stripped
+ expect(tags.containsKey('camera:mount'), isFalse);
+ // Non-empty tags should remain
+ expect(tags['man_made'], equals('surveillance'));
+ expect(tags['surveillance:type'], equals('ALPR'));
+ });
+
+ test('successful route parsing', () async {
+ when(() => mockAppState.enabledProfiles).thenReturn([]);
+
+ when(() => mockClient.post(
+ any(),
+ headers: any(named: 'headers'),
+ body: any(named: 'body'),
+ )).thenAnswer((_) async => http.Response(
+ json.encode({
+ 'ok': true,
+ 'result': {
+ 'route': {
+ 'coordinates': [
+ [-77.0, 38.9],
+ [-77.05, 38.95],
+ [-77.1, 39.0],
+ ],
+ 'distance': 15000.0,
+ 'duration': 1200.0,
+ },
+ },
+ }),
+ 200,
+ ));
+
+ final result = await service.calculateRoute(start: start, end: end);
+
+ expect(result.waypoints, hasLength(3));
+ expect(result.waypoints.first.latitude, equals(38.9));
+ expect(result.waypoints.first.longitude, equals(-77.0));
+ expect(result.distanceMeters, equals(15000.0));
+ expect(result.durationSeconds, equals(1200.0));
+ });
+
+ test('HTTP error throws RoutingException with status code', () async {
+ when(() => mockAppState.enabledProfiles).thenReturn([]);
+
+ when(() => mockClient.post(
+ any(),
+ headers: any(named: 'headers'),
+ body: any(named: 'body'),
+ )).thenAnswer((_) async => http.Response(
+ 'Bad Request',
+ 400,
+ reasonPhrase: 'Bad Request',
+ ));
+
+ expect(
+ () => service.calculateRoute(start: start, end: end),
+ throwsA(isA().having(
+ (e) => e.message,
+ 'message',
+ contains('400'),
+ )),
+ );
+ });
+
+ test('network error is wrapped in RoutingException', () async {
+ when(() => mockAppState.enabledProfiles).thenReturn([]);
+
+ when(() => mockClient.post(
+ any(),
+ headers: any(named: 'headers'),
+ body: any(named: 'body'),
+ )).thenThrow(http.ClientException('Connection refused'));
+
+ expect(
+ () => service.calculateRoute(start: start, end: end),
+ throwsA(isA().having(
+ (e) => e.message,
+ 'message',
+ startsWith('Network error:'),
+ )),
+ );
+ });
+
+ test('API-level error surfaces alprwatch message', () async {
+ when(() => mockAppState.enabledProfiles).thenReturn([]);
+
+ when(() => mockClient.post(
+ any(),
+ headers: any(named: 'headers'),
+ body: any(named: 'body'),
+ )).thenAnswer((_) async => http.Response(
+ json.encode({
+ 'ok': false,
+ 'error': 'Invalid profile configuration',
+ }),
+ 200,
+ ));
+
+ expect(
+ () => service.calculateRoute(start: start, end: end),
+ throwsA(isA().having(
+ (e) => e.message,
+ 'message',
+ contains('Invalid profile configuration'),
+ )),
+ );
+ });
+ });
+}