From 78f1ef58de428742c6cc3fec79f76729399c95e7 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 25 Jul 2026 19:27:11 -0500 Subject: [PATCH] selectable/copyable node tags, pending submission status on details sheet, "view on osm" button styling --- lib/localizations/de.json | 10 +- lib/localizations/en.json | 10 +- lib/localizations/es.json | 10 +- lib/localizations/fr.json | 10 +- lib/localizations/it.json | 10 +- lib/localizations/nl.json | 10 +- lib/localizations/pl.json | 10 +- lib/localizations/pt.json | 10 +- lib/localizations/tr.json | 10 +- lib/localizations/uk.json | 10 +- lib/localizations/zh.json | 10 +- lib/widgets/node_tag_sheet.dart | 255 ++++++++++++++++++++++++++------ 12 files changed, 310 insertions(+), 55 deletions(-) diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 32e235e..0b79c33 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -115,9 +115,17 @@ "deleteReasonSpamVandalism": "Spam/Vandalismus", "deleteReasonCustom": "Benutzerdefiniert..", "deleteReasonHint": "Bitte Grund beschreiben...", - "mustBeLoggedInToDelete": "Sie müssen mit einem OpenStreetMap-Konto angemeldet sein, um Kartendaten beizutragen." + "mustBeLoggedInToDelete": "Sie müssen mit einem OpenStreetMap-Konto angemeldet sein, um Kartendaten beizutragen.", + "uploadPending": "Ausstehender Upload", + "uploadCreatingChangeset": "Changeset wird erstellt...", + "uploadUploading": "Wird hochgeladen...", + "uploadClosingChangeset": "Changeset wird geschlossen...", + "uploadError": "Upload fehlgeschlagen", + "uploadComplete": "Upload abgeschlossen", + "viewInQueue": "In Warteschlange anzeigen" }, + "addNode": { "profile": "Profil", "selectProfile": "Profil auswählen...", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index d2e7c69..5d64109 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -152,9 +152,17 @@ "deleteReasonSpamVandalism": "Spam/vandalism", "deleteReasonCustom": "Custom..", "deleteReasonHint": "Please describe the reason...", - "mustBeLoggedInToDelete": "You must be logged in with an OpenStreetMap account to contribute map data." + "mustBeLoggedInToDelete": "You must be logged in with an OpenStreetMap account to contribute map data.", + "uploadPending": "Pending upload", + "uploadCreatingChangeset": "Creating changeset...", + "uploadUploading": "Uploading...", + "uploadClosingChangeset": "Closing changeset...", + "uploadError": "Upload failed", + "uploadComplete": "Upload complete", + "viewInQueue": "View in Queue" }, + "addNode": { "profile": "Profile", "selectProfile": "Select a profile...", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 78b9a91..1ed5ba5 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -153,8 +153,16 @@ "deleteReasonCustom": "Personalizado..", "deleteReasonHint": "Por favor describa la razón...", - "mustBeLoggedInToDelete": "Debes iniciar sesión con una cuenta de OpenStreetMap para contribuir con datos del mapa." + "mustBeLoggedInToDelete": "Debes iniciar sesión con una cuenta de OpenStreetMap para contribuir con datos del mapa.", + "uploadPending": "Subida pendiente", + "uploadCreatingChangeset": "Creando changeset...", + "uploadUploading": "Subiendo...", + "uploadClosingChangeset": "Cerrando changeset...", + "uploadError": "Error al subir", + "uploadComplete": "Subida completada", + "viewInQueue": "Ver en la Cola" }, + "addNode": { "profile": "Perfil", "selectProfile": "Seleccionar un perfil...", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 4d8bed6..9fd9360 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -153,8 +153,16 @@ "deleteReasonCustom": "Personnalisé..", "deleteReasonHint": "Veuillez décrire la raison...", - "mustBeLoggedInToDelete": "Vous devez être connecté avec un compte OpenStreetMap pour contribuer aux données cartographiques." + "mustBeLoggedInToDelete": "Vous devez être connecté avec un compte OpenStreetMap pour contribuer aux données cartographiques.", + "uploadPending": "Téléchargement en attente", + "uploadCreatingChangeset": "Création du changeset...", + "uploadUploading": "Téléchargement en cours...", + "uploadClosingChangeset": "Fermeture du changeset...", + "uploadError": "Échec du téléchargement", + "uploadComplete": "Téléchargement terminé", + "viewInQueue": "Voir dans la File d'Attente" }, + "addNode": { "profile": "Profil", "selectProfile": "Sélectionner un profil...", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 4c6a158..e492fdc 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -153,8 +153,16 @@ "deleteReasonCustom": "Personalizzato..", "deleteReasonHint": "Descrivi il motivo...", - "mustBeLoggedInToDelete": "Devi aver effettuato l'accesso con un account OpenStreetMap per contribuire ai dati della mappa." + "mustBeLoggedInToDelete": "Devi aver effettuato l'accesso con un account OpenStreetMap per contribuire ai dati della mappa.", + "uploadPending": "Caricamento in sospeso", + "uploadCreatingChangeset": "Creazione changeset...", + "uploadUploading": "Caricamento...", + "uploadClosingChangeset": "Chiusura changeset...", + "uploadError": "Caricamento fallito", + "uploadComplete": "Caricamento completato", + "viewInQueue": "Visualizza in Coda" }, + "addNode": { "profile": "Profilo", "selectProfile": "Seleziona un profilo...", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 57bdcd5..a272fa8 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -153,8 +153,16 @@ "deleteReasonCustom": "Aangepast..", "deleteReasonHint": "Beschrijf de reden...", - "mustBeLoggedInToDelete": "U moet ingelogd zijn met een OpenStreetMap-account om kaartgegevens bij te dragen." + "mustBeLoggedInToDelete": "U moet ingelogd zijn met een OpenStreetMap-account om kaartgegevens bij te dragen.", + "uploadPending": "Upload in behandeling", + "uploadCreatingChangeset": "Changeset maken...", + "uploadUploading": "Uploaden...", + "uploadClosingChangeset": "Changeset sluiten...", + "uploadError": "Upload mislukt", + "uploadComplete": "Upload voltooid", + "viewInQueue": "Bekijk in Wachtrij" }, + "addNode": { "profile": "Profiel", "selectProfile": "Selecteer een profiel...", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index a19fcc7..16a642d 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -153,8 +153,16 @@ "deleteReasonCustom": "Niestandardowy..", "deleteReasonHint": "Opisz powód...", - "mustBeLoggedInToDelete": "Musisz być zalogowany na konto OpenStreetMap, aby dodawać dane do mapy." + "mustBeLoggedInToDelete": "Musisz być zalogowany na konto OpenStreetMap, aby dodawać dane do mapy.", + "uploadPending": "Przesyłanie oczekujące", + "uploadCreatingChangeset": "Tworzenie zestawu zmian...", + "uploadUploading": "Przesyłanie...", + "uploadClosingChangeset": "Zamykanie zestawu zmian...", + "uploadError": "Przesyłanie nie powiodło się", + "uploadComplete": "Przesyłanie zakończone", + "viewInQueue": "Zobacz w Kolejce" }, + "addNode": { "profile": "Profil", "selectProfile": "Wybierz profil...", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index fdef74b..9fef12d 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -153,8 +153,16 @@ "deleteReasonCustom": "Personalizado..", "deleteReasonHint": "Descreva o motivo...", - "mustBeLoggedInToDelete": "Você precisa estar conectado com uma conta do OpenStreetMap para contribuir com dados do mapa." + "mustBeLoggedInToDelete": "Você precisa estar conectado com uma conta do OpenStreetMap para contribuir com dados do mapa.", + "uploadPending": "Envio pendente", + "uploadCreatingChangeset": "Criando changeset...", + "uploadUploading": "Enviando...", + "uploadClosingChangeset": "Fechando changeset...", + "uploadError": "Falha no envio", + "uploadComplete": "Envio concluído", + "viewInQueue": "Ver na Fila" }, + "addNode": { "profile": "Perfil", "selectProfile": "Selecionar um perfil...", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index f03f133..59f102c 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -153,8 +153,16 @@ "deleteReasonCustom": "Özel..", "deleteReasonHint": "Lütfen nedeni açıklayın...", - "mustBeLoggedInToDelete": "Harita verilerine katkıda bulunmak için bir OpenStreetMap hesabıyla oturum açmış olmanız gerekir." + "mustBeLoggedInToDelete": "Harita verilerine katkıda bulunmak için bir OpenStreetMap hesabıyla oturum açmış olmanız gerekir.", + "uploadPending": "Yükleme bekliyor", + "uploadCreatingChangeset": "Değişiklik seti oluşturuluyor...", + "uploadUploading": "Yükleniyor...", + "uploadClosingChangeset": "Değişiklik seti kapatılıyor...", + "uploadError": "Yükleme başarısız oldu", + "uploadComplete": "Yükleme tamamlandı", + "viewInQueue": "Sırada Görüntüle" }, + "addNode": { "profile": "Profil", "selectProfile": "Bir profil seçin...", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index 336f913..8808956 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -153,8 +153,16 @@ "deleteReasonCustom": "Власний..", "deleteReasonHint": "Опишіть причину...", - "mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти." + "mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти.", + "uploadPending": "Завантаження очікує", + "uploadCreatingChangeset": "Створення набору змін...", + "uploadUploading": "Завантаження...", + "uploadClosingChangeset": "Закриття набору змін...", + "uploadError": "Помилка завантаження", + "uploadComplete": "Завантаження завершено", + "viewInQueue": "Переглянути в Черзі" }, + "addNode": { "profile": "Профіль", "selectProfile": "Оберіть профіль...", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 6f0be6a..a7aed98 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -153,8 +153,16 @@ "deleteReasonCustom": "自定义..", "deleteReasonHint": "请描述原因...", - "mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。" + "mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。", + "uploadPending": "上传待处理", + "uploadCreatingChangeset": "正在创建变更集...", + "uploadUploading": "正在上传...", + "uploadClosingChangeset": "正在关闭变更集...", + "uploadError": "上传失败", + "uploadComplete": "上传完成", + "viewInQueue": "在队列中查看" }, + "addNode": { "profile": "配置文件", "selectProfile": "选择配置文件...", diff --git a/lib/widgets/node_tag_sheet.dart b/lib/widgets/node_tag_sheet.dart index 99d8af7..7e2286f 100644 --- a/lib/widgets/node_tag_sheet.dart +++ b/lib/widgets/node_tag_sheet.dart @@ -2,12 +2,15 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:collection/collection.dart'; import '../models/osm_node.dart'; +import '../models/pending_upload.dart'; import '../app_state.dart'; import '../services/localization_service.dart'; import '../dev_config.dart'; import 'advanced_edit_options_sheet.dart'; + class NodeTagSheet extends StatelessWidget { final OsmNode node; final VoidCallback? onEditPressed; @@ -137,6 +140,156 @@ class NodeTagSheet extends StatelessWidget { ); } + // Find the PendingUpload (if any) backing this node, so we can show + // live upload status for pending/edited/deleted nodes. + PendingUpload? pendingUpload; + if (node.tags['_pending_upload'] == 'true') { + pendingUpload = appState.pendingUploads + .firstWhereOrNull((u) => u.tempNodeId == node.id); + } else if (node.tags['_pending_edit'] == 'true' || + node.tags['_pending_deletion'] == 'true') { + pendingUpload = appState.pendingUploads + .firstWhereOrNull((u) => u.originalNodeId == node.id); + } + + void viewInQueue() { + Navigator.pop(context); // Close this sheet first + Navigator.pushNamed(context, '/settings/queue'); + } + + Widget? uploadStatusBanner; + if (!isEditable) { + final IconData statusIcon; + final Color statusColor; + final String statusLabel; + final bool showSpinner; + final bool showQueueButton; + + final state = pendingUpload?.uploadState; + + if (state == null) { + // Fallback: tag says pending but we couldn't find a matching + // queue entry (shouldn't normally happen). + statusIcon = Icons.hourglass_empty; + statusColor = Colors.orange; + statusLabel = locService.t('node.uploadPending'); + showSpinner = false; + showQueueButton = true; + } else { + switch (state) { + case UploadState.pending: + statusIcon = Icons.hourglass_empty; + statusColor = Colors.orange; + statusLabel = locService.t('node.uploadPending'); + showSpinner = false; + showQueueButton = true; + break; + case UploadState.creatingChangeset: + statusIcon = Icons.cloud_upload_outlined; + statusColor = Colors.orange; + statusLabel = locService.t('node.uploadCreatingChangeset'); + showSpinner = true; + showQueueButton = true; + break; + case UploadState.uploading: + statusIcon = Icons.cloud_upload_outlined; + statusColor = Colors.orange; + statusLabel = locService.t('node.uploadUploading'); + showSpinner = true; + showQueueButton = true; + break; + case UploadState.closingChangeset: + statusIcon = Icons.cloud_upload_outlined; + statusColor = Colors.orange; + statusLabel = locService.t('node.uploadClosingChangeset'); + showSpinner = true; + showQueueButton = true; + break; + case UploadState.error: + statusIcon = Icons.error_outline; + statusColor = Colors.red; + statusLabel = locService.t('node.uploadError'); + showSpinner = false; + showQueueButton = true; + break; + case UploadState.complete: + statusIcon = Icons.check_circle_outline; + statusColor = Colors.green; + statusLabel = locService.t('node.uploadComplete'); + showSpinner = false; + showQueueButton = false; + break; + } + } + + uploadStatusBanner = Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + border: Border.all(color: statusColor.withValues(alpha: 0.4)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (showSpinner) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: statusColor, + ), + ) + else + Icon(statusIcon, color: statusColor, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + statusLabel, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (state == UploadState.error && + (pendingUpload?.errorMessage?.isNotEmpty ?? false)) ...[ + const SizedBox(height: 4), + SelectableText( + pendingUpload!.errorMessage!, + style: TextStyle( + color: statusColor.withValues(alpha: 0.9), + fontSize: 12, + ), + ), + ], + if (showQueueButton) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: viewInQueue, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: const Size(0, 28), + foregroundColor: statusColor, + ), + child: Text(locService.t('node.viewInQueue')), + ), + ), + ], + ], + ), + ); + } + + return LayoutBuilder( builder: (context, constraints) { return SafeArea( @@ -151,74 +304,88 @@ class NodeTagSheet extends StatelessWidget { style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), - + + if (uploadStatusBanner != null) uploadStatusBanner, + // Tag list with flexible height constraint + ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context), ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ...node.tags.entries.map( - (e) => Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - e.key, - style: TextStyle( - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Linkify( - onOpen: (link) async { - final uri = Uri.parse(link.url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } else if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')), - ); - } - }, - text: e.value, + child: SelectionArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...node.tags.entries + .where((e) => !e.key.startsWith('_')) // Hide internal bookkeeping tags + .map( + (e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.key, style: TextStyle( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, ), - linkStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, - ), - options: const LinkifyOptions(humanize: false), ), - ), - ], + const SizedBox(width: 8), + Expanded( + child: Linkify( + onOpen: (link) async { + final uri = Uri.parse(link.url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')), + ); + } + }, + text: e.value, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + linkStyle: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + options: const LinkifyOptions(humanize: false), + ), + ), + ], + ), ), ), - ), - ], + + ], + ), ), ), ), + + const SizedBox(height: 16), // First row: View and Advanced buttons Row( mainAxisAlignment: MainAxisAlignment.end, children: [ if (isRealOSMNode) ...[ - TextButton.icon( + OutlinedButton.icon( onPressed: () => viewOnOSM(), - icon: const Icon(Icons.open_in_new, size: 16), + icon: const Icon(Icons.open_in_new, size: 18), label: Text(locService.t('actions.viewOnOSM')), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 36), + ), ), const SizedBox(width: 8), ], + if (isEditable) ...[ OutlinedButton.icon( onPressed: openAdvancedEdit,