From a808a4f37ef00dde5d0b1c20257a531b20c8c169 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sun, 26 Jul 2026 18:54:53 -0500 Subject: [PATCH] notice about any in-flight submissions when enabling queue pause or offline mode --- lib/app_state.dart | 7 + lib/dev_config.dart | 7 + lib/localizations/de.json | 7 +- lib/localizations/en.json | 7 +- lib/localizations/es.json | 7 +- lib/localizations/fr.json | 7 +- lib/localizations/it.json | 7 +- lib/localizations/nl.json | 7 +- lib/localizations/pl.json | 7 +- lib/localizations/pt.json | 7 +- lib/localizations/tr.json | 7 +- lib/localizations/uk.json | 7 +- lib/localizations/zh.json | 7 +- .../sections/offline_mode_section.dart | 13 +- lib/screens/upload_queue_screen.dart | 21 ++- lib/widgets/in_flight_upload_wait_dialog.dart | 157 ++++++++++++++++++ 16 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 lib/widgets/in_flight_upload_wait_dialog.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index 3d7b653..0ba485c 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -188,6 +188,13 @@ class AppState extends ChangeNotifier { int get pendingCount => _uploadQueueState.pendingCount; List get pendingUploads => _uploadQueueState.pendingUploads; + /// True if any queue item currently has an open changeset that hasn't + /// been closed yet (creating changeset, uploading node, or closing + /// changeset). Used to gate enabling offline mode / pausing the upload + /// queue so in-flight submissions can finish first. + bool get hasInFlightUploads => pendingUploads.any((u) => u.isActivelyProcessing); + + // Suspected location state SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation; bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled; diff --git a/lib/dev_config.dart b/lib/dev_config.dart index f415ac9..8a30769 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -64,6 +64,13 @@ const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5 const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close const double kChangesetCloseBackoffMultiplier = 2.0; +// How long to keep the "Submission Complete" confirmation visible in the +// in-flight upload wait dialog (shown when the user enables offline mode or +// pauses the upload queue while a submission is actively in progress) before +// automatically dismissing and applying the requested setting change. +const Duration kInFlightUploadCompleteDisplayDuration = Duration(seconds: 2); + + // Overpass API configuration const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded) diff --git a/lib/localizations/de.json b/lib/localizations/de.json index e24851c..b503d0c 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -270,8 +270,13 @@ "closingChangeset": " (Changeset schließen...)", "processingPaused": "Warteschlangenverarbeitung pausiert", "pausedDueToOffline": "Upload-Verarbeitung ist pausiert, da der Offline-Modus aktiviert ist.", - "pausedByUser": "Upload-Verarbeitung ist manuell pausiert." + "pausedByUser": "Upload-Verarbeitung ist manuell pausiert.", + "inFlightWaitTitle": "Übermittlung wird abgeschlossen", + "inFlightWaitMessage": "Eine Übermittlung läuft gerade und muss abgeschlossen werden, bevor diese Einstellung angewendet werden kann. Dies sollte nur einen Moment dauern.", + "inFlightCompleteTitle": "Übermittlung Abgeschlossen", + "inFlightCompleteMessage": "Ihre Übermittlung wurde erfolgreich abgeschlossen. Ihre Einstellung wird jetzt angewendet." }, + "tileProviders": { "title": "Kachel-Anbieter", "noProvidersConfigured": "Keine Kachel-Anbieter konfiguriert", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index a7387ba..9fc045d 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -306,8 +306,13 @@ "closingChangeset": " (Closing changeset...)", "processingPaused": "Queue Processing Paused", "pausedDueToOffline": "Upload processing is paused because offline mode is enabled.", - "pausedByUser": "Upload processing is manually paused." + "pausedByUser": "Upload processing is manually paused.", + "inFlightWaitTitle": "Finishing Submission", + "inFlightWaitMessage": "A submission is currently in progress and needs to finish before this setting can be applied. This should only take a moment.", + "inFlightCompleteTitle": "Submission Complete", + "inFlightCompleteMessage": "Your submission finished successfully. Applying your setting now." }, + "tileProviders": { "title": "Tile Providers", "noProvidersConfigured": "No tile providers configured", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 2c329fb..af80917 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -307,8 +307,13 @@ "closingChangeset": " (Cerrando changeset...)", "processingPaused": "Procesamiento de Cola Pausado", "pausedDueToOffline": "El procesamiento de subida está pausado porque el modo sin conexión está habilitado.", - "pausedByUser": "El procesamiento de subida está pausado manualmente." + "pausedByUser": "El procesamiento de subida está pausado manualmente.", + "inFlightWaitTitle": "Finalizando Envío", + "inFlightWaitMessage": "Actualmente hay un envío en curso que debe finalizar antes de que se pueda aplicar esta configuración. Esto solo debería tomar un momento.", + "inFlightCompleteTitle": "Envío Completado", + "inFlightCompleteMessage": "Su envío finalizó exitosamente. Aplicando su configuración ahora." }, + "tileProviders": { "title": "Proveedores de Tiles", "noProvidersConfigured": "No hay proveedores de tiles configurados", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index f8cb8e3..2e5f1e0 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -307,8 +307,13 @@ "closingChangeset": " (Fermeture du changeset...)", "processingPaused": "Traitement de la File d'Attente Interrompu", "pausedDueToOffline": "Le traitement des téléversements est interrompu car le mode hors ligne est activé.", - "pausedByUser": "Le traitement des téléversements est interrompu manuellement." + "pausedByUser": "Le traitement des téléversements est interrompu manuellement.", + "inFlightWaitTitle": "Finalisation de la Soumission", + "inFlightWaitMessage": "Une soumission est actuellement en cours et doit se terminer avant que ce paramètre puisse être appliqué. Cela ne devrait prendre qu'un instant.", + "inFlightCompleteTitle": "Soumission Terminée", + "inFlightCompleteMessage": "Votre soumission s'est terminée avec succès. Application de votre paramètre maintenant." }, + "tileProviders": { "title": "Fournisseurs de Tuiles", "noProvidersConfigured": "Aucun fournisseur de tuiles configuré", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 90d40fa..e58aee6 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -307,8 +307,13 @@ "closingChangeset": " (Chiusura changeset...)", "processingPaused": "Elaborazione Coda Sospesa", "pausedDueToOffline": "L'elaborazione dei caricamenti è sospesa perché la modalità offline è abilitata.", - "pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente." + "pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente.", + "inFlightWaitTitle": "Completamento Invio", + "inFlightWaitMessage": "Un invio è attualmente in corso e deve terminare prima che questa impostazione possa essere applicata. Questo dovrebbe richiedere solo un momento.", + "inFlightCompleteTitle": "Invio Completato", + "inFlightCompleteMessage": "Il tuo invio è terminato con successo. Applicazione della tua impostazione in corso." }, + "tileProviders": { "title": "Fornitori di Tile", "noProvidersConfigured": "Nessun fornitore di tile configurato", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 249a891..36570a2 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -307,8 +307,13 @@ "closingChangeset": " (Changeset sluiten...)", "processingPaused": "Wachtrij Verwerking Gepauzeerd", "pausedDueToOffline": "Upload verwerking is gepauzeerd omdat offline modus is ingeschakeld.", - "pausedByUser": "Upload verwerking is handmatig gepauzeerd." + "pausedByUser": "Upload verwerking is handmatig gepauzeerd.", + "inFlightWaitTitle": "Indiening Wordt Afgerond", + "inFlightWaitMessage": "Er is momenteel een indiening bezig die moet worden afgerond voordat deze instelling kan worden toegepast. Dit zou maar een moment moeten duren.", + "inFlightCompleteTitle": "Indiening Voltooid", + "inFlightCompleteMessage": "Uw indiening is succesvol afgerond. Uw instelling wordt nu toegepast." }, + "tileProviders": { "title": "Tile Providers", "noProvidersConfigured": "Geen tile providers geconfigureerd", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 4c1efd5..7c0e1ab 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -307,8 +307,13 @@ "closingChangeset": " (Zamykanie zestawu zmian...)", "processingPaused": "Przetwarzanie Kolejki Wstrzymane", "pausedDueToOffline": "Przetwarzanie przesyłania jest wstrzymane, ponieważ tryb offline jest włączony.", - "pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane." + "pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane.", + "inFlightWaitTitle": "Kończenie Przesyłania", + "inFlightWaitMessage": "Przesyłanie jest obecnie w toku i musi zostać zakończone, zanim to ustawienie będzie mogło zostać zastosowane. To powinno zająć tylko chwilę.", + "inFlightCompleteTitle": "Przesyłanie Zakończone", + "inFlightCompleteMessage": "Twoje przesyłanie zakończyło się pomyślnie. Stosowanie ustawienia teraz." }, + "tileProviders": { "title": "Dostawcy Kafelków", "noProvidersConfigured": "Brak skonfigurowanych dostawców kafelków", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index eed20d1..c8c4a28 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -307,8 +307,13 @@ "closingChangeset": " (Fechando changeset...)", "processingPaused": "Processamento da Fila Pausado", "pausedDueToOffline": "O processamento de upload está pausado porque o modo offline está habilitado.", - "pausedByUser": "O processamento de upload está pausado manualmente." + "pausedByUser": "O processamento de upload está pausado manualmente.", + "inFlightWaitTitle": "Finalizando Envio", + "inFlightWaitMessage": "Atualmente há um envio em andamento que precisa terminar antes que esta configuração possa ser aplicada. Isso deve levar apenas um momento.", + "inFlightCompleteTitle": "Envio Concluído", + "inFlightCompleteMessage": "Seu envio foi concluído com sucesso. Aplicando sua configuração agora." }, + "tileProviders": { "title": "Provedores de Tiles", "noProvidersConfigured": "Nenhum provedor de tiles configurado", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 7ddafef..50490c0 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -307,8 +307,13 @@ "closingChangeset": " (Değişiklik seti kapatılıyor...)", "processingPaused": "Kuyruk İşleme Duraklatıldı", "pausedDueToOffline": "Çevrimdışı mod etkin olduğu için yükleme işleme duraklatıldı.", - "pausedByUser": "Yükleme işleme manuel olarak duraklatıldı." + "pausedByUser": "Yükleme işleme manuel olarak duraklatıldı.", + "inFlightWaitTitle": "Gönderim Tamamlanıyor", + "inFlightWaitMessage": "Şu anda devam eden bir gönderim var ve bu ayar uygulanmadan önce tamamlanması gerekiyor. Bu sadece bir an sürmelidir.", + "inFlightCompleteTitle": "Gönderim Tamamlandı", + "inFlightCompleteMessage": "Gönderiminiz başarıyla tamamlandı. Ayarınız şimdi uygulanıyor." }, + "tileProviders": { "title": "Döşeme Sağlayıcıları", "noProvidersConfigured": "Döşeme sağlayıcısı yapılandırılmamış", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index ea2b43e..3ecba23 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -307,8 +307,13 @@ "closingChangeset": " (Закриття набору змін...)", "processingPaused": "Обробка Черги Призупинена", "pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.", - "pausedByUser": "Обробка завантаження призупинена вручну." + "pausedByUser": "Обробка завантаження призупинена вручну.", + "inFlightWaitTitle": "Завершення Подання", + "inFlightWaitMessage": "Наразі триває подання, яке потрібно завершити перед застосуванням цього налаштування. Це має зайняти лише мить.", + "inFlightCompleteTitle": "Подання Завершено", + "inFlightCompleteMessage": "Ваше подання успішно завершено. Застосування вашого налаштування зараз." }, + "tileProviders": { "title": "Постачальники Плиток", "noProvidersConfigured": "Постачальників плиток не налаштовано", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index d717b3e..57e286c 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -307,8 +307,13 @@ "closingChangeset": " (关闭变更集...)", "processingPaused": "队列处理已暂停", "pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。", - "pausedByUser": "上传处理已手动暂停。" + "pausedByUser": "上传处理已手动暂停。", + "inFlightWaitTitle": "正在完成提交", + "inFlightWaitMessage": "当前有一个提交正在进行中,需要先完成才能应用此设置。这应该只需要一点时间。", + "inFlightCompleteTitle": "提交完成", + "inFlightCompleteMessage": "您的提交已成功完成。正在应用您的设置。" }, + "tileProviders": { "title": "瓦片提供商", "noProvidersConfigured": "未配置瓦片提供商", diff --git a/lib/screens/settings/sections/offline_mode_section.dart b/lib/screens/settings/sections/offline_mode_section.dart index cd60367..3b6b443 100644 --- a/lib/screens/settings/sections/offline_mode_section.dart +++ b/lib/screens/settings/sections/offline_mode_section.dart @@ -3,6 +3,8 @@ import 'package:provider/provider.dart'; import '../../../app_state.dart'; import '../../../services/offline_area_service.dart'; import '../../../services/localization_service.dart'; +import '../../../widgets/in_flight_upload_wait_dialog.dart'; + class OfflineModeSection extends StatelessWidget { const OfflineModeSection({super.key}); @@ -49,10 +51,17 @@ class OfflineModeSection extends StatelessWidget { } } - // Proceed with the change - await appState.setOfflineMode(value); + // Proceed with the change, respecting any in-flight submission that + // needs to finish before offline mode actually takes effect. + if (!context.mounted) return; + await applyQueueSettingChangeRespectingInFlightUploads( + context: context, + appState: appState, + applyChange: () => appState.setOfflineMode(value), + ); } + @override Widget build(BuildContext context) { return AnimatedBuilder( diff --git a/lib/screens/upload_queue_screen.dart b/lib/screens/upload_queue_screen.dart index 16ca969..47efa6b 100644 --- a/lib/screens/upload_queue_screen.dart +++ b/lib/screens/upload_queue_screen.dart @@ -3,6 +3,8 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../models/pending_upload.dart'; import '../services/localization_service.dart'; +import '../widgets/in_flight_upload_wait_dialog.dart'; + class UploadQueueScreen extends StatelessWidget { const UploadQueueScreen({super.key}); @@ -84,6 +86,22 @@ class UploadQueueScreen extends StatelessWidget { } } + Future _handlePauseQueueChange(BuildContext context, AppState appState, bool value) async { + // Only need to wait for in-flight uploads when turning pause ON. + // Resuming (turning it off) can happen immediately. + if (!value) { + await appState.setPauseQueueProcessing(value); + return; + } + + await applyQueueSettingChangeRespectingInFlightUploads( + context: context, + appState: appState, + applyChange: () => appState.setPauseQueueProcessing(value), + ); + } + + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -166,9 +184,10 @@ class UploadQueueScreen extends StatelessWidget { value: appState.pauseQueueProcessing, onChanged: appState.offlineMode ? null // Disable when offline mode is on - : (value) => appState.setPauseQueueProcessing(value), + : (value) => _handlePauseQueueChange(context, appState, value), ), ), + const SizedBox(height: 16), diff --git a/lib/widgets/in_flight_upload_wait_dialog.dart b/lib/widgets/in_flight_upload_wait_dialog.dart new file mode 100644 index 0000000..a08158a --- /dev/null +++ b/lib/widgets/in_flight_upload_wait_dialog.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +import '../app_state.dart'; +import '../dev_config.dart'; +import '../services/localization_service.dart'; + +/// Applies a queue-pausing settings change (enabling offline mode or pausing +/// the upload queue), but if there's an in-flight submission (a queue item +/// that has already opened a changeset and hasn't closed it yet), first +/// shows a non-dismissible-by-tap-outside dialog explaining that the +/// submission needs to finish. Once it completes, a brief confirmation is +/// shown before the dialog auto-closes and the setting takes effect. +/// +/// If nothing is in-flight, [applyChange] is invoked immediately with no +/// dialog shown at all. +/// +/// The dialog can also be dismissed early by the user tapping "View in +/// Queue", in case something is stuck (e.g. a failed/erroring item) — this +/// lets the user navigate to the queue screen to investigate/clear it out, +/// rather than getting stuck waiting indefinitely. +Future applyQueueSettingChangeRespectingInFlightUploads({ + required BuildContext context, + required AppState appState, + required Future Function() applyChange, +}) async { + if (!appState.hasInFlightUploads) { + await applyChange(); + return; + } + + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => InFlightUploadWaitDialog( + onAllComplete: () { + // Fire and forget - the dialog doesn't need to await this. + applyChange(); + }, + ), + ); +} + +class InFlightUploadWaitDialog extends StatefulWidget { + /// Called exactly once, as soon as no queue items are actively processing. + final VoidCallback onAllComplete; + + const InFlightUploadWaitDialog({super.key, required this.onAllComplete}); + + @override + State createState() => _InFlightUploadWaitDialogState(); +} + +class _InFlightUploadWaitDialogState extends State { + bool _completed = false; + bool _appliedChange = false; + Timer? _autoCloseTimer; + late final AppState _appState; + + @override + void initState() { + super.initState(); + _appState = AppState.instance; + _appState.addListener(_onAppStateChanged); + // Handle the (unlikely) race where the in-flight upload finishes between + // the initial check and this dialog actually mounting. + WidgetsBinding.instance.addPostFrameCallback((_) => _onAppStateChanged()); + } + + void _onAppStateChanged() { + if (!mounted || _completed) return; + if (_appState.hasInFlightUploads) return; + + setState(() { + _completed = true; + }); + + if (!_appliedChange) { + _appliedChange = true; + widget.onAllComplete(); + } + + _autoCloseTimer = Timer(kInFlightUploadCompleteDisplayDuration, () { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + }); + } + + @override + void dispose() { + _appState.removeListener(_onAppStateChanged); + _autoCloseTimer?.cancel(); + super.dispose(); + } + + void _viewInQueue() { + Navigator.of(context, rootNavigator: true).pop(); + Navigator.of(context).pushNamed('/settings/queue'); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) { + final locService = LocalizationService.instance; + + return PopScope( + canPop: false, + child: AlertDialog( + title: Row( + children: [ + Icon( + _completed ? Icons.check_circle : Icons.cloud_upload, + color: _completed ? Colors.green : Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _completed + ? locService.t('queue.inFlightCompleteTitle') + : locService.t('queue.inFlightWaitTitle'), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _completed + ? locService.t('queue.inFlightCompleteMessage') + : locService.t('queue.inFlightWaitMessage'), + ), + const SizedBox(height: 20), + Center( + child: _completed + ? const Icon(Icons.check_circle, size: 40, color: Colors.green) + : const CircularProgressIndicator(), + ), + ], + ), + actions: [ + if (!_completed) + TextButton( + onPressed: _viewInQueue, + child: Text(locService.t('node.viewInQueue')), + ), + ], + ), + ); + }, + ); + } +}