From 3827a6fa1d7ecaf7e6cd3767eec6580acf8f26a1 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Feb 2026 13:45:19 -0700 Subject: [PATCH 01/15] Rewrite dev setup docs with tested, copy-pasteable instructions Replaces the vague "Latest stable version" prerequisites in DEVELOPER.md with concrete commands tested on a fresh macOS machine. Adds Android SDK setup without Android Studio, documents the gen_icons_splashes.sh requirement, and fixes the OAuth2 config to reference build_keys.conf instead of the removed keys.dart.example. Also includes Flutter SDK auto-migrations (iOS 13.0 min, gradle minSdk). Co-Authored-By: Claude Opus 4.6 --- DEVELOPER.md | 97 ++++++++++++++++++++++++---- README.md | 9 ++- android/app/build.gradle.kts | 2 +- ios/Flutter/AppFrameworkInfo.plist | 2 +- ios/Podfile | 2 +- ios/Runner.xcodeproj/project.pbxproj | 6 +- 6 files changed, 96 insertions(+), 22 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index a0b731d..0740999 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** | `brew install --cask flutter` | Installs Flutter + Dart | +| **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 +/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin/sdkmanager \ + --sdk_root=/Users/$USER/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 /opt/homebrew/share/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..cf81073 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,12 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with **Quick setup:** ```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 ``` **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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6b9f9c4..4f0c5c9 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 = flutter.minSdkVersion targetSdk = 36 // Flutter tool injects these during `flutter build` 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; From 4a342aee9dfc43b669474a3a410e66479c2c42f6 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Feb 2026 13:54:00 -0700 Subject: [PATCH 02/15] Bump Dart SDK constraint to >=3.8.0 and document Flutter 3.35+ requirement The RadioGroup widget (merged via PR #35) requires Flutter 3.35+ / Dart 3.8+. The old constraint (>=3.5.0) allowed older SDKs that don't have RadioGroup, causing cryptic build errors instead of a clear version mismatch from pub get. Co-Authored-By: Claude Opus 4.6 --- DEVELOPER.md | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index 0740999..6ada674 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -806,7 +806,7 @@ The app uses a **clean, release-triggered workflow** that rebuilds from scratch | Tool | Install | Notes | |------|---------|-------| | **Homebrew** | [brew.sh](https://brew.sh) | Package manager for macOS | -| **Flutter SDK** | `brew install --cask flutter` | Installs Flutter + Dart | +| **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 | diff --git a/pubspec.yaml b/pubspec.yaml index 1a50411..5e05501 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: "none" version: 2.6.4+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: From 0207f999ee0eed2c986bfb254d96ce15da74aca8 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Feb 2026 13:59:11 -0700 Subject: [PATCH 03/15] Address Copilot review feedback on PR #46 - build.gradle.kts: use maxOf(flutter.minSdkVersion, 23) to preserve the floor required by oauth2_client/flutter_web_auth_2 - DEVELOPER.md: replace hardcoded /opt/homebrew paths with $(brew --prefix) for Intel Mac compatibility, use $HOME instead of /Users/$USER for --sdk_root - README.md: label quick-start as macOS-specific, add cross-platform pointer to DEVELOPER.md Co-Authored-By: Claude Opus 4.6 --- DEVELOPER.md | 6 +++--- README.md | 3 ++- android/app/build.gradle.kts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index 6ada674..193411c 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -832,13 +832,13 @@ printf "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > ~/Library/Android/sdk/lice printf "\n84831b9409646a918e30573bab4c9c91346d8abd" > ~/Library/Android/sdk/licenses/android-sdk-preview-license # 3. Install platform tools and the SDK platform Flutter needs -/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin/sdkmanager \ - --sdk_root=/Users/$USER/Library/Android/sdk \ +"$(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 /opt/homebrew/share/android-commandlinetools/cmdline-tools/latest \ +cp -R "$(brew --prefix android-commandlinetools)/cmdline-tools/latest" \ ~/Library/Android/sdk/cmdline-tools/latest # 5. Point Flutter at the SDK and accept licenses diff --git a/README.md b/README.md index cf81073..d9a7364 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ 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 brew install --cask flutter # Install Flutter SDK brew install cocoapods # Required for iOS @@ -99,6 +99,7 @@ flutter pub get # Install dependencies 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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4f0c5c9..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 = flutter.minSdkVersion + minSdk = maxOf(flutter.minSdkVersion, 23) targetSdk = 36 // Flutter tool injects these during `flutter build` From 26c85df7e89060703f2c6a5c033bad866a9051e7 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 22:45:31 -0700 Subject: [PATCH 04/15] Fix dropdown dismiss by replacing manual overlay with RawAutocomplete NSITagValueField used raw OverlayEntry + CompositedTransformFollower with no tap-outside dismiss mechanism, causing suggestion dropdowns to stay visible when tapping elsewhere. Replace with Flutter's RawAutocomplete which handles dismiss, keyboard navigation, and accessibility out of the box. Co-Authored-By: Claude Opus 4.6 --- lib/widgets/nsi_tag_value_field.dart | 207 ++++++++++----------------- 1 file changed, 76 insertions(+), 131 deletions(-) diff --git a/lib/widgets/nsi_tag_value_field.dart b/lib/widgets/nsi_tag_value_field.dart index 1f33c71..314c597 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,73 @@ 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.take(10); + } + return _suggestions + .where((s) => s.contains(textEditingValue.text)) + .take(10); + }, + 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: (_) => onFieldSubmitted(), + ); + }, + optionsViewBuilder: ( + BuildContext context, + AutocompleteOnSelected onSelected, + Iterable options, + ) { + return Align( + alignment: Alignment.topLeft, child: Material( elevation: 4.0, borderRadius: BorderRadius.circular(8.0), @@ -160,68 +153,20 @@ class _NSITagValueFieldState extends State { child: ListView.builder( padding: EdgeInsets.zero, shrinkWrap: true, - itemCount: suggestions.length, + itemCount: options.length, itemBuilder: (context, index) { - final suggestion = suggestions[index]; + final option = options.elementAt(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 +} From ef6fc1c9c8d69e27334ae2f3934610298f967405 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 22:50:01 -0700 Subject: [PATCH 05/15] Materialize options iterable to list in optionsViewBuilder Avoids repeated iteration of the lazy .where().take() iterable on each call to .length and .elementAt() in ListView.builder. Co-Authored-By: Claude Opus 4.6 --- lib/widgets/nsi_tag_value_field.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/widgets/nsi_tag_value_field.dart b/lib/widgets/nsi_tag_value_field.dart index 314c597..2919abd 100644 --- a/lib/widgets/nsi_tag_value_field.dart +++ b/lib/widgets/nsi_tag_value_field.dart @@ -143,6 +143,7 @@ class _NSITagValueFieldState extends State { AutocompleteOnSelected onSelected, Iterable options, ) { + final optionList = options.toList(growable: false); return Align( alignment: Alignment.topLeft, child: Material( @@ -153,9 +154,9 @@ class _NSITagValueFieldState extends State { child: ListView.builder( padding: EdgeInsets.zero, shrinkWrap: true, - itemCount: options.length, + itemCount: optionList.length, itemBuilder: (context, index) { - final option = options.elementAt(index); + final option = optionList[index]; return ListTile( dense: true, title: Text(option, style: const TextStyle(fontSize: 14)), From ef4205f4bdda54ed63699b0359147fa5abdbc8e2 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Feb 2026 14:32:18 -0700 Subject: [PATCH 06/15] Make suggestion limit configurable and remove redundant .take(10) from widget Move hardcoded suggestion limit to kNSIMaxSuggestions in dev_config, and remove the redundant .take(10) from optionsBuilder since the fetch stage already caps results. Co-Authored-By: Claude Opus 4.6 --- lib/dev_config.dart | 1 + lib/services/nsi_service.dart | 3 +-- lib/widgets/nsi_tag_value_field.dart | 7 ++----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index ad0ea52..729efff 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -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/widgets/nsi_tag_value_field.dart b/lib/widgets/nsi_tag_value_field.dart index 2919abd..51685f1 100644 --- a/lib/widgets/nsi_tag_value_field.dart +++ b/lib/widgets/nsi_tag_value_field.dart @@ -110,12 +110,9 @@ class _NSITagValueFieldState extends State { focusNode: _focusNode, optionsBuilder: (TextEditingValue textEditingValue) { if (_suggestions.isEmpty) return const Iterable.empty(); - if (textEditingValue.text.isEmpty) { - return _suggestions.take(10); - } + if (textEditingValue.text.isEmpty) return _suggestions; return _suggestions - .where((s) => s.contains(textEditingValue.text)) - .take(10); + .where((s) => s.contains(textEditingValue.text)); }, onSelected: (String selection) { widget.onChanged(selection); From 6607e30038bf8c5ff7886850044341fd3efd5e71 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Feb 2026 10:06:38 -0700 Subject: [PATCH 07/15] Add tests for routing service and node profile serialization Route calculation to alprwatch API fails with HTTP 400 because built-in profiles include empty tag values (e.g. camera:mount: '') that get serialized into the request body and rejected by the API. Add routing_service_test.dart with 5 tests: - Empty tags filtered from request (reproduces the bug) - Successful route parsing - HTTP error handling - Network error wrapping - API-level error surfacing Add node_profile_test.dart with 4 tests: - toJson/fromJson round-trip - getDefaults returns expected profiles - Empty tag values exist in defaults (documents bug origin) - Equality based on id Tests require RoutingService to accept an injectable http.Client, which will be added in the next commit along with the fix. Co-Authored-By: Claude Opus 4.6 --- test/models/node_profile_test.dart | 76 +++++++++ test/services/routing_service_test.dart | 204 ++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 test/models/node_profile_test.dart create mode 100644 test/services/routing_service_test.dart 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'), + )), + ); + }); + }); +} From 71776ee8f0df0bf6e3709bc2c6abee91f1ad14c6 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Feb 2026 10:07:25 -0700 Subject: [PATCH 08/15] Fix route calculation HTTP 400 by filtering empty profile tags Built-in profiles (Flock, Motorola, etc.) include placeholder empty values like camera:mount: '' for user refinement. When these get serialized into the routing request body, the alprwatch API rejects them with HTTP 400. Fix: strip empty-valued tags from enabled_profiles before sending the routing request. Also refactor RoutingService to accept an injectable http.Client for testability, and log error response bodies for easier debugging of future API issues. Co-Authored-By: Claude Opus 4.6 --- lib/services/routing_service.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart index b2dc90f..3fa6f82 100644 --- a/lib/services/routing_service.dart +++ b/lib/services/routing_service.dart @@ -27,6 +27,9 @@ 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(); // Calculate route between two points using alprwatch Future calculateRoute({ @@ -40,10 +43,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 +70,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 +80,7 @@ class RoutingService { ).timeout(kNavigationRoutingTimeout); if (response.statusCode != 200) { + debugPrint('[RoutingService] Error response body: ${response.body}'); throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}'); } From 5abcc58a78d51a610ebca871e11dbfa7731c9931 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Feb 2026 11:17:29 -0700 Subject: [PATCH 09/15] Address PR review: truncate error response logs and close http client - Gate full error response body logging behind kDebugMode; truncate to 500 chars in release builds to avoid log noise and data exposure - Add RoutingService.close() and call from NavigationState.dispose() Co-Authored-By: Claude Opus 4.6 --- lib/services/routing_service.dart | 15 +++++++++++++-- lib/state/navigation_state.dart | 6 ++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart index 3fa6f82..af0b8b3 100644 --- a/lib/services/routing_service.dart +++ b/lib/services/routing_service.dart @@ -30,7 +30,9 @@ class RoutingService { 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, @@ -80,7 +82,16 @@ class RoutingService { ).timeout(kNavigationRoutingTimeout); if (response.statusCode != 200) { - debugPrint('[RoutingService] Error response body: ${response.body}'); + 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(); + } } From 311125e1f558bbc338797a48c4c6b20489ee6e01 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Feb 2026 14:53:45 -0700 Subject: [PATCH 10/15] Move README roadmap items to GitHub Issues Replace the inline roadmap section with a link to GitHub Issues. Each roadmap item has been created as a proper issue (#55-#76) for better tracking, discussion, and prioritization. Co-Authored-By: Claude Opus 4.6 --- README.md | 38 +------------------------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/README.md b/README.md index 1f98124..4a6e748 100644 --- a/README.md +++ b/README.md @@ -103,43 +103,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. --- From 2e0dcb1b2b62a2e0c8614e22ea8149482d0904ba Mon Sep 17 00:00:00 2001 From: stopflock Date: Mon, 9 Feb 2026 23:59:08 -0600 Subject: [PATCH 11/15] bump version --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 5e05501..86b10e2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ 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.8.0 <4.0.0" # RadioGroup widget requires Dart 3.8+ (Flutter 3.35+) From 19b3ca236e0652f47b65b0d99fde0e591adf609d Mon Sep 17 00:00:00 2001 From: stopflock Date: Tue, 10 Feb 2026 18:28:07 -0600 Subject: [PATCH 12/15] no longer lose operator profile selection when making other changes to a node --- assets/changelog.json | 5 +++++ lib/app_state.dart | 4 ++++ lib/dev_config.dart | 2 +- lib/state/session_state.dart | 13 ++++++++----- lib/widgets/add_node_sheet.dart | 1 + lib/widgets/edit_node_sheet.dart | 1 + 6 files changed, 20 insertions(+), 6 deletions(-) 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/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 729efff..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 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, ); } } From c7cfdc471cfc91a3c365642791fdb63d00aedc33 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 10 Feb 2026 18:56:18 -0700 Subject: [PATCH 13/15] Add tests demonstrating RawAutocomplete onSubmitted bug RawAutocomplete.onFieldSubmitted auto-selects the first option when called, which means pressing "Done" on an empty tag value field re-populates it with the first NSI suggestion. These tests prove the bug exists (unguarded path) and verify the fix (guarded path). Co-Authored-By: Claude Opus 4.6 --- test/widgets/nsi_tag_value_field_test.dart | 177 +++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 test/widgets/nsi_tag_value_field_test.dart diff --git a/test/widgets/nsi_tag_value_field_test.dart b/test/widgets/nsi_tag_value_field_test.dart new file mode 100644 index 0000000..cf87847 --- /dev/null +++ b/test/widgets/nsi_tag_value_field_test.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Tests for the tag value field clearing bug and fix. +/// +/// Root cause: RawAutocomplete.onFieldSubmitted auto-selects the first option +/// when called, and NSITagValueField's optionsBuilder returns ALL suggestions +/// when the text is empty. So pressing "Done" on the keyboard with an empty +/// field auto-selects the first NSI suggestion, making the value "pop back in". +void main() { + // Helper to build a RawAutocomplete widget tree that mirrors NSITagValueField + Widget buildAutocompleteField({ + required TextEditingController controller, + required FocusNode focusNode, + required List suggestions, + required ValueChanged onChanged, + required ValueChanged onSelected, + required bool guardOnSubmitted, + }) { + return MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: 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) => onSelected(selection), + fieldViewBuilder: + (context, controller, focusNode, onFieldSubmitted) { + return TextField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + onSubmitted: guardOnSubmitted + ? (_) { + if (controller.text.isNotEmpty) { + onFieldSubmitted(); + } + } + : (_) => onFieldSubmitted(), + ); + }, + optionsViewBuilder: (context, onSelected, options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + child: SizedBox( + height: 200, + child: ListView( + children: options + .map((o) => ListTile( + title: Text(o), + onTap: () => onSelected(o), + )) + .toList(), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + + group('NSITagValueField onSubmitted behavior', () { + testWidgets( + 'unguarded onFieldSubmitted auto-selects first suggestion on empty submit', + (tester) async { + String reportedValue = 'Hikvision'; + final controller = TextEditingController(text: 'Hikvision'); + final focusNode = FocusNode(); + + await tester.pumpWidget(buildAutocompleteField( + controller: controller, + focusNode: focusNode, + suggestions: ['Axis', 'Dahua', 'Hikvision'], + onChanged: (v) => reportedValue = v, + onSelected: (v) => reportedValue = v, + guardOnSubmitted: false, // old behavior + )); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + // Clear the field + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + expect(controller.text, equals('')); + + // Press "Done" on the keyboard + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + // Demonstrates the bug: first suggestion ('Axis') is auto-selected + expect(controller.text, equals('Axis'), + reason: 'Unguarded onFieldSubmitted auto-selects first suggestion'); + }); + + testWidgets( + 'guarded onFieldSubmitted keeps field empty on submit', + (tester) async { + String reportedValue = 'Hikvision'; + final controller = TextEditingController(text: 'Hikvision'); + final focusNode = FocusNode(); + + await tester.pumpWidget(buildAutocompleteField( + controller: controller, + focusNode: focusNode, + suggestions: ['Axis', 'Dahua', 'Hikvision'], + onChanged: (v) => reportedValue = v, + onSelected: (v) => reportedValue = v, + guardOnSubmitted: true, // fixed behavior + )); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + // Clear the field + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + expect(controller.text, equals('')); + expect(reportedValue, equals('')); + + // Press "Done" on the keyboard + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + // Field stays empty + expect(controller.text, equals(''), + reason: 'Field should stay empty after pressing Done'); + expect(reportedValue, equals(''), + reason: 'Parent should not receive a new value'); + }); + + testWidgets( + 'guarded onFieldSubmitted still auto-completes when text is present', + (tester) async { + String reportedValue = ''; + final controller = TextEditingController(); + final focusNode = FocusNode(); + + await tester.pumpWidget(buildAutocompleteField( + controller: controller, + focusNode: focusNode, + suggestions: ['Axis', 'Dahua', 'Hikvision'], + onChanged: (v) => reportedValue = v, + onSelected: (v) => reportedValue = v, + guardOnSubmitted: true, // fixed behavior + )); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + // Type a partial match + await tester.enterText(find.byType(TextField), 'Axi'); + await tester.pump(); + + // Press "Done" → should auto-complete to 'Axis' + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(controller.text, equals('Axis'), + reason: 'Should auto-complete partial text to first matching suggestion'); + expect(reportedValue, equals('Axis')); + }); + }); +} From af42e18f6e0619ced390dd26b77603e857f33a30 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 10 Feb 2026 18:56:28 -0700 Subject: [PATCH 14/15] Guard onFieldSubmitted on non-empty text to prevent cleared values reappearing When a user clears a tag value and presses Done, RawAutocomplete's onFieldSubmitted auto-selects the first option from the suggestions list. Since optionsBuilder returns all suggestions for empty text, this causes the cleared value to reappear. Guarding the call on non-empty text prevents the auto-selection while preserving autocomplete behavior when the user has typed a partial match. Co-Authored-By: Claude Opus 4.6 --- lib/widgets/nsi_tag_value_field.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/widgets/nsi_tag_value_field.dart b/lib/widgets/nsi_tag_value_field.dart index 51685f1..d05f752 100644 --- a/lib/widgets/nsi_tag_value_field.dart +++ b/lib/widgets/nsi_tag_value_field.dart @@ -132,7 +132,14 @@ class _NSITagValueFieldState extends State { onChanged: (value) { widget.onChanged(value); }, - onSubmitted: (_) => onFieldSubmitted(), + 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: ( From 75014be485669f959b3d967b85ada12ceccd0370 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 10 Feb 2026 19:01:09 -0700 Subject: [PATCH 15/15] Remove simulation tests that don't exercise production code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RawAutocomplete tests proved the pattern and fix but never instantiated NSITagValueField itself — no actual coverage gained. PR #36 replaces the widget entirely, so investing in testability here isn't worthwhile. Co-Authored-By: Claude Opus 4.6 --- test/widgets/nsi_tag_value_field_test.dart | 177 --------------------- 1 file changed, 177 deletions(-) delete mode 100644 test/widgets/nsi_tag_value_field_test.dart diff --git a/test/widgets/nsi_tag_value_field_test.dart b/test/widgets/nsi_tag_value_field_test.dart deleted file mode 100644 index cf87847..0000000 --- a/test/widgets/nsi_tag_value_field_test.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// Tests for the tag value field clearing bug and fix. -/// -/// Root cause: RawAutocomplete.onFieldSubmitted auto-selects the first option -/// when called, and NSITagValueField's optionsBuilder returns ALL suggestions -/// when the text is empty. So pressing "Done" on the keyboard with an empty -/// field auto-selects the first NSI suggestion, making the value "pop back in". -void main() { - // Helper to build a RawAutocomplete widget tree that mirrors NSITagValueField - Widget buildAutocompleteField({ - required TextEditingController controller, - required FocusNode focusNode, - required List suggestions, - required ValueChanged onChanged, - required ValueChanged onSelected, - required bool guardOnSubmitted, - }) { - return MaterialApp( - home: Scaffold( - body: Padding( - padding: const EdgeInsets.all(16), - child: 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) => onSelected(selection), - fieldViewBuilder: - (context, controller, focusNode, onFieldSubmitted) { - return TextField( - controller: controller, - focusNode: focusNode, - onChanged: onChanged, - onSubmitted: guardOnSubmitted - ? (_) { - if (controller.text.isNotEmpty) { - onFieldSubmitted(); - } - } - : (_) => onFieldSubmitted(), - ); - }, - optionsViewBuilder: (context, onSelected, options) { - return Align( - alignment: Alignment.topLeft, - child: Material( - child: SizedBox( - height: 200, - child: ListView( - children: options - .map((o) => ListTile( - title: Text(o), - onTap: () => onSelected(o), - )) - .toList(), - ), - ), - ), - ); - }, - ), - ), - ), - ); - } - - group('NSITagValueField onSubmitted behavior', () { - testWidgets( - 'unguarded onFieldSubmitted auto-selects first suggestion on empty submit', - (tester) async { - String reportedValue = 'Hikvision'; - final controller = TextEditingController(text: 'Hikvision'); - final focusNode = FocusNode(); - - await tester.pumpWidget(buildAutocompleteField( - controller: controller, - focusNode: focusNode, - suggestions: ['Axis', 'Dahua', 'Hikvision'], - onChanged: (v) => reportedValue = v, - onSelected: (v) => reportedValue = v, - guardOnSubmitted: false, // old behavior - )); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - - // Clear the field - await tester.enterText(find.byType(TextField), ''); - await tester.pump(); - expect(controller.text, equals('')); - - // Press "Done" on the keyboard - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(); - - // Demonstrates the bug: first suggestion ('Axis') is auto-selected - expect(controller.text, equals('Axis'), - reason: 'Unguarded onFieldSubmitted auto-selects first suggestion'); - }); - - testWidgets( - 'guarded onFieldSubmitted keeps field empty on submit', - (tester) async { - String reportedValue = 'Hikvision'; - final controller = TextEditingController(text: 'Hikvision'); - final focusNode = FocusNode(); - - await tester.pumpWidget(buildAutocompleteField( - controller: controller, - focusNode: focusNode, - suggestions: ['Axis', 'Dahua', 'Hikvision'], - onChanged: (v) => reportedValue = v, - onSelected: (v) => reportedValue = v, - guardOnSubmitted: true, // fixed behavior - )); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - - // Clear the field - await tester.enterText(find.byType(TextField), ''); - await tester.pump(); - expect(controller.text, equals('')); - expect(reportedValue, equals('')); - - // Press "Done" on the keyboard - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(); - - // Field stays empty - expect(controller.text, equals(''), - reason: 'Field should stay empty after pressing Done'); - expect(reportedValue, equals(''), - reason: 'Parent should not receive a new value'); - }); - - testWidgets( - 'guarded onFieldSubmitted still auto-completes when text is present', - (tester) async { - String reportedValue = ''; - final controller = TextEditingController(); - final focusNode = FocusNode(); - - await tester.pumpWidget(buildAutocompleteField( - controller: controller, - focusNode: focusNode, - suggestions: ['Axis', 'Dahua', 'Hikvision'], - onChanged: (v) => reportedValue = v, - onSelected: (v) => reportedValue = v, - guardOnSubmitted: true, // fixed behavior - )); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - - // Type a partial match - await tester.enterText(find.byType(TextField), 'Axi'); - await tester.pump(); - - // Press "Done" → should auto-complete to 'Axis' - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(); - - expect(controller.text, equals('Axis'), - reason: 'Should auto-complete partial text to first matching suggestion'); - expect(reportedValue, equals('Axis')); - }); - }); -}