diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a127420..c1bb3bf 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -42,6 +42,7 @@
+
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
index acc3e03..c84eeab 100644
--- a/lib/screens/home_screen.dart
+++ b/lib/screens/home_screen.dart
@@ -19,6 +19,7 @@ import '../models/osm_node.dart';
import '../models/suspected_location.dart';
import '../models/search_result.dart';
import '../services/changelog_service.dart';
+import '../services/deep_link_service.dart';
import 'coordinators/sheet_coordinator.dart';
import 'coordinators/navigation_coordinator.dart';
import 'coordinators/map_interaction_handler.dart';
@@ -57,10 +58,12 @@ class _HomeScreenState extends State with TickerProviderStateMixin {
_sheetCoordinator = SheetCoordinator();
_navigationCoordinator = NavigationCoordinator();
_mapInteractionHandler = MapInteractionHandler();
+ DeepLinkService().onNodeDeepLink = _handleNodeDeepLink;
}
@override
void dispose() {
+ DeepLinkService().onNodeDeepLink = null;
_mapController.dispose();
super.dispose();
}
@@ -286,6 +289,25 @@ class _HomeScreenState extends State with TickerProviderStateMixin {
);
}
+ void _handleNodeDeepLink(OsmNode node) {
+ try {
+ _mapController.animateTo(
+ dest: node.coord,
+ zoom: 16.0,
+ duration: const Duration(milliseconds: 600),
+ curve: Curves.easeOut,
+ );
+ } catch (e) {
+ debugPrint('[HomeScreen] Could not animate to deep link node: $e');
+ }
+
+ Future.delayed(const Duration(milliseconds: 700), () {
+ if (mounted) {
+ openNodeTagSheet(node);
+ }
+ });
+ }
+
void openNodeTagSheet(OsmNode node) {
// Handle the map interaction (centering and follow-me disable)
_mapInteractionHandler.handleNodeTap(
diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart
index 0c178b0..cdc7012 100644
--- a/lib/services/deep_link_service.dart
+++ b/lib/services/deep_link_service.dart
@@ -1,9 +1,13 @@
import 'dart:async';
+import 'dart:convert';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
+import 'package:latlong2/latlong.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
+import '../models/osm_node.dart';
+import 'http_client.dart';
import 'profile_import_service.dart';
import 'operator_profile_import_service.dart';
import '../screens/profile_editor.dart';
@@ -16,6 +20,9 @@ class DeepLinkService {
late AppLinks _appLinks;
StreamSubscription? _linkSubscription;
+
+ /// Callback for HomeScreen to receive node deep links
+ void Function(OsmNode node)? onNodeDeepLink;
/// Initialize deep link handling (sets up stream listener only)
Future init() async {
@@ -45,6 +52,9 @@ class DeepLinkService {
case 'profiles':
_handleProfilesLink(uri);
break;
+ case 'node':
+ _handleNodeLink(uri);
+ break;
case 'auth':
// OAuth links are handled by flutter_web_auth_2
debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2');
@@ -71,6 +81,59 @@ class DeepLinkService {
}
}
+ /// Handle node deep link: `deflockapp://node?id=`
+ Future _handleNodeLink(Uri uri) async {
+ final idStr = uri.queryParameters['id'];
+ final nodeId = int.tryParse(idStr ?? '');
+ if (nodeId == null) {
+ _showError('Invalid node link: missing or invalid ID');
+ return;
+ }
+
+ final node = await _fetchNodeById(nodeId);
+ if (node == null) {
+ _showError('Node $nodeId not found');
+ return;
+ }
+
+ if (onNodeDeepLink != null) {
+ onNodeDeepLink!(node);
+ } else {
+ debugPrint('[DeepLinkService] No node deep link handler registered');
+ }
+ }
+
+ /// Fetch an OSM node by ID from the OpenStreetMap API
+ Future _fetchNodeById(int nodeId) async {
+ try {
+ final url = Uri.parse('https://api.openstreetmap.org/api/0.6/node/$nodeId.json');
+ final client = UserAgentClient();
+ final response = await client.get(url);
+ if (response.statusCode != 200) return null;
+
+ final json = jsonDecode(response.body);
+ final elements = json['elements'] as List?;
+ if (elements == null || elements.isEmpty) return null;
+
+ final e = elements[0];
+ final tags = {};
+ if (e['tags'] != null) {
+ (e['tags'] as Map).forEach((k, v) {
+ tags[k] = v.toString();
+ });
+ }
+
+ return OsmNode(
+ id: e['id'] as int,
+ coord: LatLng((e['lat'] as num).toDouble(), (e['lon'] as num).toDouble()),
+ tags: tags,
+ );
+ } catch (e) {
+ debugPrint('[DeepLinkService] Failed to fetch node $nodeId: $e');
+ return null;
+ }
+ }
+
/// Handle profile-related deep links
void _handleProfilesLink(Uri uri) {
final segments = uri.pathSegments;
diff --git a/test/deep_link_service_test.dart b/test/deep_link_service_test.dart
new file mode 100644
index 0000000..0aefd95
--- /dev/null
+++ b/test/deep_link_service_test.dart
@@ -0,0 +1,26 @@
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ group('Node deep link URL parsing', () {
+ test('extracts node ID from valid link', () {
+ final uri = Uri.parse('deflockapp://node?id=1234567890');
+ expect(uri.host, 'node');
+ expect(uri.queryParameters['id'], '1234567890');
+ });
+
+ test('returns null for missing id param', () {
+ final uri = Uri.parse('deflockapp://node');
+ expect(uri.queryParameters['id'], isNull);
+ });
+
+ test('returns null for empty id param', () {
+ final uri = Uri.parse('deflockapp://node?id=');
+ expect(uri.queryParameters['id'], '');
+ });
+
+ test('returns null for non-numeric id', () {
+ final uri = Uri.parse('deflockapp://node?id=abc');
+ expect(int.tryParse(uri.queryParameters['id'] ?? ''), isNull);
+ });
+ });
+}