notice about any in-flight submissions when enabling queue pause or offline mode

This commit is contained in:
stopflock
2026-07-26 18:54:53 -05:00
parent 0887d1ef44
commit a808a4f37e
16 changed files with 268 additions and 14 deletions
+7
View File
@@ -188,6 +188,13 @@ class AppState extends ChangeNotifier {
int get pendingCount => _uploadQueueState.pendingCount;
List<PendingUpload> 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;
+7
View File
@@ -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)
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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é",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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ış",
+6 -1
View File
@@ -307,8 +307,13 @@
"closingChangeset": " (Закриття набору змін...)",
"processingPaused": "Обробка Черги Призупинена",
"pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.",
"pausedByUser": "Обробка завантаження призупинена вручну."
"pausedByUser": "Обробка завантаження призупинена вручну.",
"inFlightWaitTitle": "Завершення Подання",
"inFlightWaitMessage": "Наразі триває подання, яке потрібно завершити перед застосуванням цього налаштування. Це має зайняти лише мить.",
"inFlightCompleteTitle": "Подання Завершено",
"inFlightCompleteMessage": "Ваше подання успішно завершено. Застосування вашого налаштування зараз."
},
"tileProviders": {
"title": "Постачальники Плиток",
"noProvidersConfigured": "Постачальників плиток не налаштовано",
+6 -1
View File
@@ -307,8 +307,13 @@
"closingChangeset": " (关闭变更集...)",
"processingPaused": "队列处理已暂停",
"pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。",
"pausedByUser": "上传处理已手动暂停。"
"pausedByUser": "上传处理已手动暂停。",
"inFlightWaitTitle": "正在完成提交",
"inFlightWaitMessage": "当前有一个提交正在进行中,需要先完成才能应用此设置。这应该只需要一点时间。",
"inFlightCompleteTitle": "提交完成",
"inFlightCompleteMessage": "您的提交已成功完成。正在应用您的设置。"
},
"tileProviders": {
"title": "瓦片提供商",
"noProvidersConfigured": "未配置瓦片提供商",
@@ -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(
+20 -1
View File
@@ -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<void> _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),
@@ -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<void> applyQueueSettingChangeRespectingInFlightUploads({
required BuildContext context,
required AppState appState,
required Future<void> Function() applyChange,
}) async {
if (!appState.hasInFlightUploads) {
await applyChange();
return;
}
await showDialog<void>(
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<InFlightUploadWaitDialog> createState() => _InFlightUploadWaitDialogState();
}
class _InFlightUploadWaitDialogState extends State<InFlightUploadWaitDialog> {
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')),
),
],
),
);
},
);
}
}