From 1ac43b0c4e9694668a18d0e1c97a81b2cb0f230b Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 19 Nov 2025 19:50:39 -0600 Subject: [PATCH] Only show appropriate external editors on each platform, redirect to appstore on error --- assets/changelog.json | 9 + lib/widgets/advanced_edit_options_sheet.dart | 248 ++++++++++++------- pubspec.yaml | 2 +- 3 files changed, 175 insertions(+), 84 deletions(-) diff --git a/assets/changelog.json b/assets/changelog.json index 43fb914..fd13b69 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,13 @@ { + "1.4.0": { + "content": [ + "• IMPROVED: Advanced editing options now only show apps available on your platform (iOS/Android)", + "• IMPROVED: When an OSM editor app isn't installed, automatically redirect to the appropriate app store", + "• IMPROVED: Better error handling for external editor launches with app store fallback", + "• Supported editors: Vespucci (Android), StreetComplete (Android), EveryDoor (both), Go Map!! (iOS)", + "• Web editors (iD, RapiD) remain available on all platforms as before" + ] + }, "1.3.4": { "content": [ "• NEW: 'Pause Upload Queue' toggle in Offline Settings - stops uploads while keeping live data access", diff --git a/lib/widgets/advanced_edit_options_sheet.dart b/lib/widgets/advanced_edit_options_sheet.dart index aa79cdd..7f82e27 100644 --- a/lib/widgets/advanced_edit_options_sheet.dart +++ b/lib/widgets/advanced_edit_options_sheet.dart @@ -4,15 +4,106 @@ import 'package:url_launcher/url_launcher.dart'; import '../models/osm_node.dart'; import '../services/localization_service.dart'; +/// Information about an OSM editor app +class EditorInfo { + final String name; + final String subtitle; + final IconData icon; + final String? urlScheme; // null means no custom scheme - go straight to store + final String? androidStoreUrl; + final String? iosStoreUrl; + final bool availableOnAndroid; + final bool availableOnIOS; + + const EditorInfo({ + required this.name, + required this.subtitle, + required this.icon, + this.urlScheme, // Made optional + this.androidStoreUrl, + this.iosStoreUrl, + required this.availableOnAndroid, + required this.availableOnIOS, + }); +} + class AdvancedEditOptionsSheet extends StatelessWidget { final OsmNode node; const AdvancedEditOptionsSheet({super.key, required this.node}); + /// Mobile editor apps with their platform availability and store URLs + List get _mobileEditors => [ + EditorInfo( + name: LocalizationService.instance.t('advancedEdit.vespucci'), + subtitle: LocalizationService.instance.t('advancedEdit.vespucciSubtitle'), + icon: Icons.android, + urlScheme: 'josm:/load_and_zoom?select=node${node.id}', // Has documented deep link support + androidStoreUrl: 'https://play.google.com/store/apps/details?id=de.blau.android', + availableOnAndroid: true, + availableOnIOS: false, + ), + EditorInfo( + name: LocalizationService.instance.t('advancedEdit.streetComplete'), + subtitle: LocalizationService.instance.t('advancedEdit.streetCompleteSubtitle'), + icon: Icons.place, + urlScheme: null, // No documented deep link support - go straight to store + androidStoreUrl: 'https://play.google.com/store/apps/details?id=de.westnordost.streetcomplete', + availableOnAndroid: true, + availableOnIOS: false, + ), + EditorInfo( + name: LocalizationService.instance.t('advancedEdit.everyDoor'), + subtitle: LocalizationService.instance.t('advancedEdit.everyDoorSubtitle'), + icon: Icons.map, + urlScheme: null, // No documented deep link support - go straight to store + androidStoreUrl: 'https://play.google.com/store/apps/details?id=info.zverev.ilya.every_door', + iosStoreUrl: 'https://apps.apple.com/app/every-door/id1621945342', + availableOnAndroid: true, + availableOnIOS: true, + ), + EditorInfo( + name: LocalizationService.instance.t('advancedEdit.goMap'), + subtitle: LocalizationService.instance.t('advancedEdit.goMapSubtitle'), + icon: Icons.phone_iphone, + urlScheme: null, // No documented deep link support - go straight to store + iosStoreUrl: 'https://apps.apple.com/app/go-map/id592990211', + availableOnAndroid: false, + availableOnIOS: true, + ), + ]; + + /// Web editor apps (always available on all platforms) + List get _webEditors => [ + EditorInfo( + name: LocalizationService.instance.t('advancedEdit.iDEditor'), + subtitle: LocalizationService.instance.t('advancedEdit.iDEditorSubtitle'), + icon: Icons.public, + urlScheme: 'https://www.openstreetmap.org/edit?editor=id&node=${node.id}', + availableOnAndroid: true, + availableOnIOS: true, + ), + EditorInfo( + name: LocalizationService.instance.t('advancedEdit.rapidEditor'), + subtitle: LocalizationService.instance.t('advancedEdit.rapidEditorSubtitle'), + icon: Icons.speed, + urlScheme: 'https://rapideditor.org/edit#map=19/0/0&nodes=${node.id}', + availableOnAndroid: true, + availableOnIOS: true, + ), + ]; + @override Widget build(BuildContext context) { final locService = LocalizationService.instance; + // Filter mobile editors based on current platform + final availableMobileEditors = _mobileEditors.where((editor) { + if (Platform.isAndroid) return editor.availableOnAndroid; + if (Platform.isIOS) return editor.availableOnIOS; + return false; // Other platforms don't have mobile editors + }).toList(); + return SafeArea( child: Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), @@ -39,62 +130,17 @@ class AdvancedEditOptionsSheet extends StatelessWidget { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), - _buildEditorTile( - context: context, - icon: Icons.public, - title: locService.t('advancedEdit.iDEditor'), - subtitle: locService.t('advancedEdit.iDEditorSubtitle'), - onTap: () => _launchEditor(context, 'https://www.openstreetmap.org/edit?editor=id&node=${node.id}'), - ), - _buildEditorTile( - context: context, - icon: Icons.speed, - title: locService.t('advancedEdit.rapidEditor'), - subtitle: locService.t('advancedEdit.rapidEditorSubtitle'), - onTap: () => _launchEditor(context, 'https://rapideditor.org/edit#map=19/0/0&nodes=${node.id}'), - ), + ..._webEditors.map((editor) => _buildEditorTile(context, editor)), - const SizedBox(height: 16), - - // Mobile Editors Section - Text( - locService.t('advancedEdit.mobileEditors'), - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - - if (Platform.isAndroid) ...[ - _buildEditorTile( - context: context, - icon: Icons.android, - title: locService.t('advancedEdit.vespucci'), - subtitle: locService.t('advancedEdit.vespucciSubtitle'), - onTap: () => _launchEditor(context, 'vespucci://edit?node=${node.id}'), - ), - _buildEditorTile( - context: context, - icon: Icons.place, - title: locService.t('advancedEdit.streetComplete'), - subtitle: locService.t('advancedEdit.streetCompleteSubtitle'), - onTap: () => _launchEditor(context, 'streetcomplete://quest?node=${node.id}'), - ), - _buildEditorTile( - context: context, - icon: Icons.map, - title: locService.t('advancedEdit.everyDoor'), - subtitle: locService.t('advancedEdit.everyDoorSubtitle'), - onTap: () => _launchEditor(context, 'everydoor://edit?node=${node.id}'), - ), - ], - - if (Platform.isIOS) ...[ - _buildEditorTile( - context: context, - icon: Icons.phone_iphone, - title: locService.t('advancedEdit.goMap'), - subtitle: locService.t('advancedEdit.goMapSubtitle'), - onTap: () => _launchEditor(context, 'gomaposm://edit?node=${node.id}'), + // Mobile Editors Section (only show if there are available editors) + if (availableMobileEditors.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + locService.t('advancedEdit.mobileEditors'), + style: Theme.of(context).textTheme.titleMedium, ), + const SizedBox(height: 8), + ...availableMobileEditors.map((editor) => _buildEditorTile(context, editor)), ], const SizedBox(height: 16), @@ -113,44 +159,80 @@ class AdvancedEditOptionsSheet extends StatelessWidget { ); } - Widget _buildEditorTile({ - required BuildContext context, - required IconData icon, - required String title, - required String subtitle, - required VoidCallback onTap, - }) { + Widget _buildEditorTile(BuildContext context, EditorInfo editor) { return ListTile( - leading: Icon(icon, size: 24), - title: Text(title), - subtitle: Text(subtitle), + leading: Icon(editor.icon, size: 24), + title: Text(editor.name), + subtitle: Text(editor.subtitle), trailing: const Icon(Icons.launch, size: 18), - onTap: onTap, + onTap: () => _launchEditor(context, editor), contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 4), ); } - void _launchEditor(BuildContext context, String url) async { - final locService = LocalizationService.instance; + void _launchEditor(BuildContext context, EditorInfo editor) async { Navigator.pop(context); // Close the sheet first - try { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } else { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenEditor'))), - ); - } - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenEditor'))), - ); + // If app has a custom URL scheme, try to open it + if (editor.urlScheme != null) { + try { + final uri = Uri.parse(editor.urlScheme!); + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (launched) return; // Success - app opened + } catch (e) { + // App launch failed - continue to app store } } + + // No custom scheme or app launch failed - redirect to app store + await _redirectToAppStore(context, editor); + } + + Future _redirectToAppStore(BuildContext context, EditorInfo editor) async { + final locService = LocalizationService.instance; + + try { + if (Platform.isAndroid && editor.androidStoreUrl != null) { + // Try native Play Store first, then web fallback + final packageName = _extractAndroidPackageName(editor.androidStoreUrl!); + if (packageName != null) { + final marketUri = Uri.parse('market://details?id=$packageName'); + try { + final launched = await launchUrl(marketUri, mode: LaunchMode.externalApplication); + if (launched) return; + } catch (e) { + // Fall back to web Play Store + } + } + + // Web Play Store fallback + final webStoreUri = Uri.parse(editor.androidStoreUrl!); + await launchUrl(webStoreUri, mode: LaunchMode.externalApplication); + return; + } else if (Platform.isIOS && editor.iosStoreUrl != null) { + // iOS App Store + final iosStoreUri = Uri.parse(editor.iosStoreUrl!); + await launchUrl(iosStoreUri, mode: LaunchMode.externalApplication); + return; + } + } catch (e) { + // Fall through to show error message + } + + // Could not open app or store - show error message + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenEditor'))), + ); + } + } + + /// Extract Android package name from Play Store URL for market:// scheme + String? _extractAndroidPackageName(String playStoreUrl) { + final uri = Uri.tryParse(playStoreUrl); + if (uri == null) return null; + + // Extract from "id=" parameter in Play Store URLs + return uri.queryParameters['id']; } } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 2911015..d7e4b5c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 1.3.4+12 # The thing after the + is the version code, incremented with each release +version: 1.4.0+13 # 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+