mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-03-21 18:33:51 +00:00
Merge branch 'main' of github.com:FoggedLens/deflock-app
* 'main' of github.com:FoggedLens/deflock-app: Remove simulation tests that don't exercise production code Guard onFieldSubmitted on non-empty text to prevent cleared values reappearing Add tests demonstrating RawAutocomplete onSubmitted bug no longer lose operator profile selection when making other changes to a node bump version Move README roadmap items to GitHub Issues Address PR review: truncate error response logs and close http client Fix route calculation HTTP 400 by filtering empty profile tags Add tests for routing service and node profile serialization Make suggestion limit configurable and remove redundant .take(10) from widget Materialize options iterable to list in optionsViewBuilder Fix dropdown dismiss by replacing manual overlay with RawAutocomplete Address Copilot review feedback on PR #46 Bump Dart SDK constraint to >=3.8.0 and document Flutter 3.35+ requirement Rewrite dev setup docs with tested, copy-pasteable instructions
This commit is contained in:
97
DEVELOPER.md
97
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
|
||||
|
||||
50
README.md
50
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>12.0</string>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -447,6 +447,7 @@ class AppState extends ChangeNotifier {
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? 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<String, String>? refinedTags,
|
||||
Map<String, String>? 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RouteResult> calculateRoute({
|
||||
required LatLng start,
|
||||
@@ -40,10 +45,12 @@ class RoutingService {
|
||||
|
||||
final enabledProfiles = AppState.instance.enabledProfiles.map((p) {
|
||||
final full = p.toJson();
|
||||
final tags = Map<String, String>.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}');
|
||||
}
|
||||
|
||||
|
||||
@@ -376,4 +376,10 @@ class NavigationState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_routingService.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ class SessionState extends ChangeNotifier {
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? 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<String, String>? refinedTags,
|
||||
Map<String, String>? 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;
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
|
||||
operatorProfile: result.operatorProfile,
|
||||
refinedTags: result.refinedTags,
|
||||
changesetComment: result.changesetComment,
|
||||
updateOperatorProfile: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,6 +503,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
|
||||
refinedTags: result.refinedTags,
|
||||
additionalExistingTags: result.additionalExistingTags,
|
||||
changesetComment: result.changesetComment,
|
||||
updateOperatorProfile: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,33 +25,26 @@ class NSITagValueField extends StatefulWidget {
|
||||
|
||||
class _NSITagValueFieldState extends State<NSITagValueField> {
|
||||
late TextEditingController _controller;
|
||||
List<String> _suggestions = [];
|
||||
bool _showingSuggestions = false;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
late OverlayEntry _overlayEntry;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
List<String> _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<NSITagValueField> {
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
_hideSuggestions();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Get filtered suggestions based on current text input (case-sensitive)
|
||||
List<String> _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<NSITagValueField> {
|
||||
}
|
||||
}
|
||||
|
||||
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<String> 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<String>(
|
||||
textEditingController: _controller,
|
||||
focusNode: _focusNode,
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
if (_suggestions.isEmpty) return const Iterable<String>.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<String> onSelected,
|
||||
Iterable<String> 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<NSITagValueField> {
|
||||
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();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
76
test/models/node_profile_test.dart
Normal file
76
test/models/node_profile_test.dart
Normal file
@@ -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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
204
test/services/routing_service_test.dart
Normal file
204
test/services/routing_service_test.dart
Normal file
@@ -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<String, dynamic>;
|
||||
final enabledProfiles = body['enabled_profiles'] as List<dynamic>;
|
||||
final tags = enabledProfiles[0]['tags'] as Map<String, dynamic>;
|
||||
|
||||
// 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<RoutingException>().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<RoutingException>().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<RoutingException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('Invalid profile configuration'),
|
||||
)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user