diff --git a/README.md b/README.md index 256740a..596a553 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,6 @@ cp lib/keys.dart.example lib/keys.dart ### Needed Bugfixes - Make submission guide scarier -- "More..." button in profiles dropdown -> identify page - Node data fetching super slow; retries not working? - Tile cache trimming? Does fluttermap handle? - Filter NSI suggestions based on what has already been typed in @@ -118,13 +117,11 @@ cp lib/keys.dart.example lib/keys.dart - Turn by turn navigation or at least swipe nav sheet up to see a list - Import/Export map providers -### On Pause -- Offline navigation (pending vector map tiles) - ### Future Features & Wishlist - 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 - "Universal Links" for better handling of profile import when app not installed? diff --git a/assets/changelog.json b/assets/changelog.json index 8e8ebb7..86a91ef 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,11 @@ { + "2.5.0": { + "content": [ + "• NEW: 'Get more...' button in profile dropdowns - easily browse and import profiles from deflock.me/identify", + "• NEW: Profile creation choice dialog - when adding profiles in settings, choose between creating custom profiles or importing from website", + "• Enhanced profile discovery workflow - clearer path for users to find and import community-created profiles" + ] + }, "2.4.4": { "content": [ "• Search results now prioritize locations near your current map view" diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 5c57eca..c4b1278 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -279,7 +279,14 @@ "view": "Anzeigen", "deleteProfile": "Profil Löschen", "deleteProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?", - "profileDeleted": "Profil gelöscht" + "profileDeleted": "Profil gelöscht", + "getMore": "Weitere anzeigen...", + "addProfileChoice": "Profil Hinzufügen", + "addProfileChoiceMessage": "Wie möchten Sie ein Profil hinzufügen?", + "createCustomProfile": "Benutzerdefiniertes Profil Erstellen", + "createCustomProfileDescription": "Erstellen Sie ein Profil von Grund auf mit Ihren eigenen Tags", + "importFromWebsite": "Von Webseite Importieren", + "importFromWebsiteDescription": "Profile von deflock.me/identify durchsuchen und importieren" }, "mapTiles": { "title": "Karten-Kacheln", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 6ed604e..e037d73 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -316,7 +316,14 @@ "view": "View", "deleteProfile": "Delete Profile", "deleteProfileConfirm": "Are you sure you want to delete \"{}\"?", - "profileDeleted": "Profile deleted" + "profileDeleted": "Profile deleted", + "getMore": "Get more...", + "addProfileChoice": "Add Profile", + "addProfileChoiceMessage": "How would you like to add a profile?", + "createCustomProfile": "Create Custom Profile", + "createCustomProfileDescription": "Build a profile from scratch with your own tags", + "importFromWebsite": "Import from Website", + "importFromWebsiteDescription": "Browse and import profiles from deflock.me/identify" }, "mapTiles": { "title": "Map Tiles", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 4d8a07c..5aa01d1 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -316,7 +316,14 @@ "view": "Ver", "deleteProfile": "Eliminar Perfil", "deleteProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?", - "profileDeleted": "Perfil eliminado" + "profileDeleted": "Perfil eliminado", + "getMore": "Obtener más...", + "addProfileChoice": "Añadir Perfil", + "addProfileChoiceMessage": "¿Cómo desea añadir un perfil?", + "createCustomProfile": "Crear Perfil Personalizado", + "createCustomProfileDescription": "Crear un perfil desde cero con sus propias etiquetas", + "importFromWebsite": "Importar desde Sitio Web", + "importFromWebsiteDescription": "Explorar e importar perfiles desde deflock.me/identify" }, "mapTiles": { "title": "Tiles de Mapa", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 1018c92..ef238dd 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -316,7 +316,14 @@ "view": "Voir", "deleteProfile": "Supprimer Profil", "deleteProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?", - "profileDeleted": "Profil supprimé" + "profileDeleted": "Profil supprimé", + "getMore": "En obtenir plus...", + "addProfileChoice": "Ajouter Profil", + "addProfileChoiceMessage": "Comment souhaitez-vous ajouter un profil?", + "createCustomProfile": "Créer Profil Personnalisé", + "createCustomProfileDescription": "Créer un profil à partir de zéro avec vos propres balises", + "importFromWebsite": "Importer depuis Site Web", + "importFromWebsiteDescription": "Parcourir et importer des profils depuis deflock.me/identify" }, "mapTiles": { "title": "Tuiles de Carte", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index b281942..52c9ff3 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -316,7 +316,14 @@ "view": "Visualizza", "deleteProfile": "Elimina Profilo", "deleteProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?", - "profileDeleted": "Profilo eliminato" + "profileDeleted": "Profilo eliminato", + "getMore": "Ottieni altri...", + "addProfileChoice": "Aggiungi Profilo", + "addProfileChoiceMessage": "Come desideri aggiungere un profilo?", + "createCustomProfile": "Crea Profilo Personalizzato", + "createCustomProfileDescription": "Crea un profilo da zero con i tuoi tag", + "importFromWebsite": "Importa da Sito Web", + "importFromWebsiteDescription": "Sfoglia e importa profili da deflock.me/identify" }, "mapTiles": { "title": "Tile Mappa", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index ae2d36a..374a8f9 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -316,7 +316,14 @@ "view": "Ver", "deleteProfile": "Excluir Perfil", "deleteProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?", - "profileDeleted": "Perfil excluído" + "profileDeleted": "Perfil excluído", + "getMore": "Obter mais...", + "addProfileChoice": "Adicionar Perfil", + "addProfileChoiceMessage": "Como gostaria de adicionar um perfil?", + "createCustomProfile": "Criar Perfil Personalizado", + "createCustomProfileDescription": "Construir um perfil do zero com suas próprias tags", + "importFromWebsite": "Importar do Site", + "importFromWebsiteDescription": "Navegar e importar perfis do deflock.me/identify" }, "mapTiles": { "title": "Tiles do Mapa", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 7daf0fe..3a4fb8b 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -316,7 +316,14 @@ "view": "查看", "deleteProfile": "删除配置文件", "deleteProfileConfirm": "您确定要删除 \"{}\" 吗?", - "profileDeleted": "配置文件已删除" + "profileDeleted": "配置文件已删除", + "getMore": "获取更多...", + "addProfileChoice": "添加配置文件", + "addProfileChoiceMessage": "您希望如何添加配置文件?", + "createCustomProfile": "创建自定义配置文件", + "createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件", + "importFromWebsite": "从网站导入", + "importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件" }, "mapTiles": { "title": "地图瓦片", diff --git a/lib/screens/settings/sections/node_profiles_section.dart b/lib/screens/settings/sections/node_profiles_section.dart index 1618b65..26cb84f 100644 --- a/lib/screens/settings/sections/node_profiles_section.dart +++ b/lib/screens/settings/sections/node_profiles_section.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../../../app_state.dart'; import '../../../models/node_profile.dart'; import '../../../services/localization_service.dart'; +import '../../../widgets/profile_add_choice_dialog.dart'; import '../../profile_editor.dart'; class NodeProfilesSection extends StatelessWidget { @@ -27,18 +28,7 @@ class NodeProfilesSection extends StatelessWidget { style: Theme.of(context).textTheme.titleMedium, ), TextButton.icon( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProfileEditor( - profile: NodeProfile( - id: const Uuid().v4(), - name: '', - tags: const {}, - ), - ), - ), - ), + onPressed: () => _showAddProfileDialog(context), icon: const Icon(Icons.add), label: Text(locService.t('profiles.newProfile')), ), @@ -121,6 +111,34 @@ class NodeProfilesSection extends StatelessWidget { ); } + void _showAddProfileDialog(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => const ProfileAddChoiceDialog(), + ); + + // If user chose to create custom profile, open the profile editor + if (result == 'create') { + _createNewProfile(context); + } + // If user chose import from website, ProfileAddChoiceDialog handles opening the URL + } + + void _createNewProfile(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProfileEditor( + profile: NodeProfile( + id: const Uuid().v4(), + name: '', + tags: const {}, + ), + ), + ), + ); + } + void _showDeleteProfileDialog(BuildContext context, NodeProfile profile) { final locService = LocalizationService.instance; final appState = context.read(); diff --git a/lib/widgets/add_node_sheet.dart b/lib/widgets/add_node_sheet.dart index 9fd46d6..6ebcbe9 100644 --- a/lib/widgets/add_node_sheet.dart +++ b/lib/widgets/add_node_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../app_state.dart'; import '../dev_config.dart'; @@ -155,6 +156,101 @@ class _AddNodeSheetState extends State { ); } + Widget _buildProfileDropdown(BuildContext context, AppState appState, AddNodeSession session, List submittableProfiles, LocalizationService locService) { + return PopupMenuButton( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + session.profile?.name ?? locService.t('addNode.selectProfile'), + style: TextStyle( + fontSize: 16, + color: session.profile != null ? null : Colors.grey.shade600, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 20), + ], + ), + ), + itemBuilder: (context) => [ + // Regular profiles + ...submittableProfiles.map( + (profile) => PopupMenuItem( + value: 'profile_${profile.id}', + child: Text(profile.name), + ), + ), + // Divider + if (submittableProfiles.isNotEmpty) const PopupMenuDivider(), + // Get more... option + PopupMenuItem( + value: 'get_more', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.language, size: 16), + const SizedBox(width: 8), + Text( + locService.t('profiles.getMore'), + style: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'get_more') { + _openIdentifyWebsite(context); + } else if (value.startsWith('profile_')) { + final profileId = value.substring(8); // Remove 'profile_' prefix + final profile = submittableProfiles.firstWhere((p) => p.id == profileId); + appState.updateSession(profile: profile); + } + }, + ); + } + + void _openIdentifyWebsite(BuildContext context) async { + const url = 'https://deflock.me/identify'; + + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, // Force external browser + ); + } else { + if (context.mounted) { + _showErrorSnackBar(context, 'Unable to open website'); + } + } + } catch (e) { + if (context.mounted) { + _showErrorSnackBar(context, 'Error opening website: $e'); + } + } + } + + void _showErrorSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); + } + Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) { final requiresDirection = session.profile != null && session.profile!.requiresDirection; final is360Fov = session.profile?.fov == 360; @@ -353,14 +449,7 @@ class _AddNodeSheetState extends State { const SizedBox(height: 16), ListTile( title: Text(locService.t('addNode.profile')), - trailing: DropdownButton( - value: session.profile, - hint: Text(locService.t('addNode.selectProfile')), - items: submittableProfiles - .map((p) => DropdownMenuItem(value: p, child: Text(p.name))) - .toList(), - onChanged: (p) => appState.updateSession(profile: p), - ), + trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService), ), // Direction controls _buildDirectionControls(context, appState, session, locService), diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index c454517..0ca2b4f 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../app_state.dart'; import '../dev_config.dart'; @@ -340,14 +341,7 @@ class _EditNodeSheetState extends State { const SizedBox(height: 16), ListTile( title: Text(locService.t('editNode.profile')), - trailing: DropdownButton( - value: session.profile, - hint: Text(locService.t('editNode.selectProfile')), - items: submittableProfiles - .map((p) => DropdownMenuItem(value: p, child: Text(p.name))) - .toList(), - onChanged: (p) => appState.updateEditSession(profile: p), - ), + trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService), ), // Direction controls _buildDirectionControls(context, appState, session, locService), @@ -535,6 +529,101 @@ class _EditNodeSheetState extends State { ); } + Widget _buildProfileDropdown(BuildContext context, AppState appState, EditNodeSession session, List submittableProfiles, LocalizationService locService) { + return PopupMenuButton( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + session.profile?.name ?? locService.t('editNode.selectProfile'), + style: TextStyle( + fontSize: 16, + color: session.profile != null ? null : Colors.grey.shade600, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 20), + ], + ), + ), + itemBuilder: (context) => [ + // Regular profiles + ...submittableProfiles.map( + (profile) => PopupMenuItem( + value: 'profile_${profile.id}', + child: Text(profile.name), + ), + ), + // Divider + if (submittableProfiles.isNotEmpty) const PopupMenuDivider(), + // Get more... option + PopupMenuItem( + value: 'get_more', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.language, size: 16), + const SizedBox(width: 8), + Text( + locService.t('profiles.getMore'), + style: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'get_more') { + _openIdentifyWebsite(context); + } else if (value.startsWith('profile_')) { + final profileId = value.substring(8); // Remove 'profile_' prefix + final profile = submittableProfiles.firstWhere((p) => p.id == profileId); + appState.updateEditSession(profile: profile); + } + }, + ); + } + + void _openIdentifyWebsite(BuildContext context) async { + const url = 'https://deflock.me/identify'; + + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, // Force external browser + ); + } else { + if (context.mounted) { + _showErrorSnackBar(context, 'Unable to open website'); + } + } + } catch (e) { + if (context.mounted) { + _showErrorSnackBar(context, 'Error opening website: $e'); + } + } + } + + void _showErrorSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); + } + void _openAdvancedEdit(BuildContext context) { showModalBottomSheet( context: context, diff --git a/lib/widgets/profile_add_choice_dialog.dart b/lib/widgets/profile_add_choice_dialog.dart new file mode 100644 index 0000000..34fb2e9 --- /dev/null +++ b/lib/widgets/profile_add_choice_dialog.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../services/localization_service.dart'; + +/// Dialog offering users a choice between creating a custom profile or importing from website +class ProfileAddChoiceDialog extends StatelessWidget { + const ProfileAddChoiceDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) { + final locService = LocalizationService.instance; + + return AlertDialog( + title: Text(locService.t('profiles.addProfileChoice')), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(locService.t('profiles.addProfileChoiceMessage')), + const SizedBox(height: 16), + // Create custom profile option + Card( + child: ListTile( + leading: const Icon(Icons.add_circle_outline), + title: Text(locService.t('profiles.createCustomProfile')), + subtitle: Text(locService.t('profiles.createCustomProfileDescription')), + onTap: () => Navigator.of(context).pop('create'), + ), + ), + const SizedBox(height: 8), + // Import from website option + Card( + child: ListTile( + leading: const Icon(Icons.language), + title: Text(locService.t('profiles.importFromWebsite')), + subtitle: Text(locService.t('profiles.importFromWebsiteDescription')), + onTap: () => _openWebsite(context), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(locService.cancel), + ), + ], + ); + }, + ); + } + + void _openWebsite(BuildContext context) async { + const url = 'https://deflock.me/identify'; + + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, // Force external browser + ); + // Close dialog after opening website + if (context.mounted) { + Navigator.of(context).pop(); + } + } else { + if (context.mounted) { + _showErrorSnackBar(context, 'Unable to open website'); + } + } + } catch (e) { + if (context.mounted) { + _showErrorSnackBar(context, 'Error opening website: $e'); + } + } + } + + void _showErrorSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 0927da3..098f7de 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.4.4+42 # The thing after the + is the version code, incremented with each release +version: 2.5.0+43 # 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+