selectable/copyable node tags, pending submission status on details sheet, "view on osm" button styling

This commit is contained in:
stopflock
2026-07-25 19:27:11 -05:00
parent 3e2c9c5b9e
commit 78f1ef58de
12 changed files with 310 additions and 55 deletions
+9 -1
View File
@@ -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...",
+9 -1
View File
@@ -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...",
+9 -1
View File
@@ -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...",
+9 -1
View File
@@ -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...",
+9 -1
View File
@@ -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...",
+9 -1
View File
@@ -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...",
+9 -1
View File
@@ -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...",
+9 -1
View File
@@ -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...",
+9 -1
View File
@@ -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...",
+9 -1
View File
@@ -153,8 +153,16 @@
"deleteReasonCustom": "Власний..",
"deleteReasonHint": "Опишіть причину...",
"mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти."
"mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти.",
"uploadPending": "Завантаження очікує",
"uploadCreatingChangeset": "Створення набору змін...",
"uploadUploading": "Завантаження...",
"uploadClosingChangeset": "Закриття набору змін...",
"uploadError": "Помилка завантаження",
"uploadComplete": "Завантаження завершено",
"viewInQueue": "Переглянути в Черзі"
},
"addNode": {
"profile": "Профіль",
"selectProfile": "Оберіть профіль...",
+9 -1
View File
@@ -153,8 +153,16 @@
"deleteReasonCustom": "自定义..",
"deleteReasonHint": "请描述原因...",
"mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。"
"mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。",
"uploadPending": "上传待处理",
"uploadCreatingChangeset": "正在创建变更集...",
"uploadUploading": "正在上传...",
"uploadClosingChangeset": "正在关闭变更集...",
"uploadError": "上传失败",
"uploadComplete": "上传完成",
"viewInQueue": "在队列中查看"
},
"addNode": {
"profile": "配置文件",
"selectProfile": "选择配置文件...",
+211 -44
View File
@@ -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,