From bc03dcbe89d00fd52c6e41832acc672ceb1d53f7 Mon Sep 17 00:00:00 2001 From: stopflock Date: Tue, 2 Dec 2025 12:10:09 -0600 Subject: [PATCH] OSM message notifications in theory --- README.md | 2 - assets/changelog.json | 3 + lib/app_state.dart | 67 +++++++ lib/localizations/de.json | 11 +- lib/localizations/en.json | 10 +- lib/localizations/es.json | 11 +- lib/localizations/fr.json | 11 +- lib/localizations/it.json | 11 +- lib/localizations/pt.json | 11 +- lib/localizations/zh.json | 11 +- lib/screens/about_screen.dart | 64 ------- lib/screens/home_screen.dart | 30 ++- lib/screens/osm_account_screen.dart | 256 ++++++++++++++++++++++++- lib/screens/settings_screen.dart | 39 +++- lib/services/osm_messages_service.dart | 103 ++++++++++ lib/state/messages_state.dart | 59 ++++++ 16 files changed, 607 insertions(+), 92 deletions(-) create mode 100644 lib/services/osm_messages_service.dart create mode 100644 lib/state/messages_state.dart diff --git a/README.md b/README.md index 1556a6e..33307ba 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,6 @@ cp lib/keys.dart.example lib/keys.dart ## Roadmap ### Needed Bugfixes -- Messages notification -- Move "delete OSM acct" button to OSM page - Remove potentially wrong FOVs from default profiles - Download area zoom goes too far - Update node cache to reflect cleared queue entries diff --git a/assets/changelog.json b/assets/changelog.json index 38f3c6f..bfb02bb 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -5,6 +5,9 @@ "• NEW: Enhanced upload error handling - failures in each stage (create changeset, upload node, close changeset) are now handled appropriately", "• NEW: Improved upload status display - shows 'Creating changeset...', 'Uploading...', and 'Closing changeset...' with time remaining for changeset close", "• NEW: Error message details - tap the error icon (!) on failed uploads to see exactly what went wrong and at which stage", + "• NEW: OSM message notifications - notification dots appear on Settings button and OSM Account section when you have unread messages on OpenStreetMap", + "• NEW: 'View Messages on OSM' button - easily access your OSM messages from within the app (respects current upload destination)", + "• IMPROVED: Moved 'Delete OSM Account' link from About page to OSM Account page - now only appears when logged in and respects current upload destination (production vs sandbox)", "• IMPROVED: Proper 59-minute changeset window handling - node submission and changeset closing share the same timer from successful changeset creation", "• IMPROVED: Step 2 failures (node operations) retry indefinitely within the 59-minute window instead of giving up after 3 attempts", "• IMPROVED: Changeset close retry logic - continues trying for up to 59 minutes, then trusts OSM auto-close (never errors out once node is submitted)", diff --git a/lib/app_state.dart b/lib/app_state.dart index 1fb3f9b..bf26143 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; @@ -19,6 +20,7 @@ import 'services/profile_service.dart'; import 'widgets/proximity_warning_dialog.dart'; import 'dev_config.dart'; import 'state/auth_state.dart'; +import 'state/messages_state.dart'; import 'state/navigation_state.dart'; import 'state/operator_profile_state.dart'; import 'state/profile_state.dart'; @@ -39,6 +41,7 @@ class AppState extends ChangeNotifier { // State modules late final AuthState _authState; + late final MessagesState _messagesState; late final NavigationState _navigationState; late final OperatorProfileState _operatorProfileState; late final ProfileState _profileState; @@ -49,10 +52,12 @@ class AppState extends ChangeNotifier { late final UploadQueueState _uploadQueueState; bool _isInitialized = false; + Timer? _messageCheckTimer; AppState() { instance = this; _authState = AuthState(); + _messagesState = MessagesState(); _navigationState = NavigationState(); _operatorProfileState = OperatorProfileState(); _profileState = ProfileState(); @@ -64,6 +69,7 @@ class AppState extends ChangeNotifier { // Set up state change listeners _authState.addListener(_onStateChanged); + _messagesState.addListener(_onStateChanged); _navigationState.addListener(_onStateChanged); _operatorProfileState.addListener(_onStateChanged); _profileState.addListener(_onStateChanged); @@ -141,6 +147,11 @@ class AppState extends ChangeNotifier { bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled; int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance; + // Messages state + int? get unreadMessageCount => _messagesState.unreadCount; + bool get hasUnreadMessages => _messagesState.hasUnreadMessages; + bool get isCheckingMessages => _messagesState.isChecking; + // Tile provider state List get tileProviders => _settingsState.tileProviders; TileType? get selectedTileType => _settingsState.selectedTileType; @@ -204,16 +215,40 @@ class AppState extends ChangeNotifier { _startUploader(); _isInitialized = true; + + // Start periodic message checking + _startMessageCheckTimer(); + notifyListeners(); } + + void _startMessageCheckTimer() { + _messageCheckTimer?.cancel(); + + // Check messages every 10 minutes when logged in + _messageCheckTimer = Timer.periodic( + const Duration(minutes: 10), + (timer) { + if (isLoggedIn) { + checkMessages(); + } + }, + ); + } // ---------- Auth Methods ---------- Future login() async { await _authState.login(); + // Check for messages after successful login + if (isLoggedIn) { + checkMessages(); + } } Future logout() async { await _authState.logout(); + // Clear message state when logging out + clearMessages(); } Future refreshAuthState() async { @@ -222,11 +257,33 @@ class AppState extends ChangeNotifier { Future forceLogin() async { await _authState.forceLogin(); + // Check for messages after successful login + if (isLoggedIn) { + checkMessages(); + } } Future validateToken() async { return await _authState.validateToken(); } + + // ---------- Messages Methods ---------- + Future checkMessages({bool forceRefresh = false}) async { + final accessToken = await _authState.getAccessToken(); + await _messagesState.checkMessages( + accessToken: accessToken, + uploadMode: uploadMode, + forceRefresh: forceRefresh, + ); + } + + String getMessagesUrl() { + return _messagesState.getMessagesUrl(uploadMode); + } + + void clearMessages() { + _messagesState.clearMessages(); + } // ---------- Profile Methods ---------- void toggleProfile(NodeProfile p, bool e) { @@ -440,6 +497,14 @@ class AppState extends ChangeNotifier { await _settingsState.setUploadMode(mode); await _authState.onUploadModeChanged(mode); + + // Clear and re-check messages for new mode + clearMessages(); + if (isLoggedIn) { + // Don't await - let it run in background + checkMessages(); + } + _startUploader(); // Restart uploader with new mode } @@ -557,7 +622,9 @@ class AppState extends ChangeNotifier { @override void dispose() { + _messageCheckTimer?.cancel(); _authState.removeListener(_onStateChanged); + _messagesState.removeListener(_onStateChanged); _navigationState.removeListener(_onStateChanged); _operatorProfileState.removeListener(_onStateChanged); _profileState.removeListener(_onStateChanged); diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 07ae02d..ee07e4a 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -175,7 +175,15 @@ "deleteAccountSubtitle": "Ihr OpenStreetMap-Konto verwalten", "deleteAccountExplanation": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die OpenStreetMap-Website besuchen. Dies entfernt dauerhaft Ihr OSM-Konto und alle zugehörigen Daten.", "deleteAccountWarning": "Warnung: Diese Aktion kann nicht rückgängig gemacht werden und löscht Ihr OSM-Konto dauerhaft.", - "goToOSM": "Zu OpenStreetMap gehen" + "goToOSM": "Zu OpenStreetMap gehen", + "accountManagement": "Kontoverwaltung", + "accountManagementDescription": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die entsprechende OpenStreetMap-Website besuchen. Dadurch werden Ihr Konto und alle zugehörigen Daten dauerhaft gelöscht.", + "currentDestinationProduction": "Derzeit verbunden mit: Produktions-OpenStreetMap", + "currentDestinationSandbox": "Derzeit verbunden mit: Sandbox-OpenStreetMap", + "currentDestinationSimulate": "Derzeit im: Simulationsmodus (kein echtes Konto)", + "viewMessages": "Nachrichten auf OSM anzeigen", + "unreadMessagesCount": "Sie haben {} ungelesene Nachrichten", + "noUnreadMessages": "Keine ungelesenen Nachrichten" }, "queue": { "title": "Upload-Warteschlange", @@ -205,6 +213,7 @@ "retryUpload": "Upload wiederholen", "clearAll": "Alle Löschen", "errorDetails": "Fehlerdetails", + "creatingChangeset": " (Changeset erstellen...)", "uploading": " (Uploading...)", "closingChangeset": " (Changeset schließen...)" }, diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 2e55781..05953a5 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -207,7 +207,15 @@ "deleteAccountSubtitle": "Manage your OpenStreetMap account", "deleteAccountExplanation": "To delete your OpenStreetMap account, you'll need to visit the OpenStreetMap website. This will permanently remove your OSM account and all associated data.", "deleteAccountWarning": "Warning: This action cannot be undone and will permanently delete your OSM account.", - "goToOSM": "Go to OpenStreetMap" + "goToOSM": "Go to OpenStreetMap", + "accountManagement": "Account Management", + "accountManagementDescription": "To delete your OpenStreetMap account, you'll need to visit the appropriate OpenStreetMap website. This will permanently remove your account and all associated data.", + "currentDestinationProduction": "Currently connected to: Production OpenStreetMap", + "currentDestinationSandbox": "Currently connected to: Sandbox OpenStreetMap", + "currentDestinationSimulate": "Currently in: Simulate mode (no real account)", + "viewMessages": "View Messages on OSM", + "unreadMessagesCount": "You have {} unread messages", + "noUnreadMessages": "No unread messages" }, "queue": { "title": "Upload Queue", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index fb5a690..abd53b4 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -207,7 +207,15 @@ "deleteAccountSubtitle": "Gestiona tu cuenta de OpenStreetMap", "deleteAccountExplanation": "Para eliminar tu cuenta de OpenStreetMap, necesitarás visitar el sitio web de OpenStreetMap. Esto eliminará permanentemente tu cuenta OSM y todos los datos asociados.", "deleteAccountWarning": "Advertencia: Esta acción no se puede deshacer y eliminará permanentemente tu cuenta OSM.", - "goToOSM": "Ir a OpenStreetMap" + "goToOSM": "Ir a OpenStreetMap", + "accountManagement": "Gestión de Cuenta", + "accountManagementDescription": "Para eliminar su cuenta de OpenStreetMap, debe visitar el sitio web de OpenStreetMap correspondiente. Esto eliminará permanentemente su cuenta y todos los datos asociados.", + "currentDestinationProduction": "Actualmente conectado a: OpenStreetMap de Producción", + "currentDestinationSandbox": "Actualmente conectado a: OpenStreetMap Sandbox", + "currentDestinationSimulate": "Actualmente en: Modo de simulación (sin cuenta real)", + "viewMessages": "Ver Mensajes en OSM", + "unreadMessagesCount": "Tienes {} mensajes sin leer", + "noUnreadMessages": "No hay mensajes sin leer" }, "queue": { "title": "Cola de Subida", @@ -237,6 +245,7 @@ "retryUpload": "Reintentar subida", "clearAll": "Limpiar Todo", "errorDetails": "Detalles del Error", + "creatingChangeset": " (Creando changeset...)", "uploading": " (Subiendo...)", "closingChangeset": " (Cerrando changeset...)" }, diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index a706388..d521e95 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -207,7 +207,15 @@ "deleteAccountSubtitle": "Gérez votre compte OpenStreetMap", "deleteAccountExplanation": "Pour supprimer votre compte OpenStreetMap, vous devrez visiter le site web OpenStreetMap. Cela supprimera définitivement votre compte OSM et toutes les données associées.", "deleteAccountWarning": "Attention : Cette action ne peut pas être annulée et supprimera définitivement votre compte OSM.", - "goToOSM": "Aller à OpenStreetMap" + "goToOSM": "Aller à OpenStreetMap", + "accountManagement": "Gestion de Compte", + "accountManagementDescription": "Pour supprimer votre compte OpenStreetMap, vous devez visiter le site Web OpenStreetMap approprié. Cela supprimera définitivement votre compte et toutes les données associées.", + "currentDestinationProduction": "Actuellement connecté à : OpenStreetMap de Production", + "currentDestinationSandbox": "Actuellement connecté à : OpenStreetMap Sandbox", + "currentDestinationSimulate": "Actuellement en : Mode simulation (pas de compte réel)", + "viewMessages": "Voir les Messages sur OSM", + "unreadMessagesCount": "Vous avez {} messages non lus", + "noUnreadMessages": "Aucun message non lu" }, "queue": { "title": "File de Téléchargement", @@ -237,6 +245,7 @@ "retryUpload": "Réessayer téléchargement", "clearAll": "Tout Vider", "errorDetails": "Détails de l'Erreur", + "creatingChangeset": " (Création du changeset...)", "uploading": " (Téléchargement...)", "closingChangeset": " (Fermeture du changeset...)" }, diff --git a/lib/localizations/it.json b/lib/localizations/it.json index deea3f9..fdbef0e 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -207,7 +207,15 @@ "deleteAccountSubtitle": "Gestisci il tuo account OpenStreetMap", "deleteAccountExplanation": "Per eliminare il tuo account OpenStreetMap, dovrai visitare il sito web di OpenStreetMap. Questo rimuoverà permanentemente il tuo account OSM e tutti i dati associati.", "deleteAccountWarning": "Attenzione: Questa azione non può essere annullata e eliminerà permanentemente il tuo account OSM.", - "goToOSM": "Vai a OpenStreetMap" + "goToOSM": "Vai a OpenStreetMap", + "accountManagement": "Gestione Account", + "accountManagementDescription": "Per eliminare il tuo account OpenStreetMap, devi visitare il sito web OpenStreetMap appropriato. Questo rimuoverà permanentemente il tuo account e tutti i dati associati.", + "currentDestinationProduction": "Attualmente connesso a: OpenStreetMap di Produzione", + "currentDestinationSandbox": "Attualmente connesso a: OpenStreetMap Sandbox", + "currentDestinationSimulate": "Attualmente in: Modalità simulazione (nessun account reale)", + "viewMessages": "Visualizza Messaggi su OSM", + "unreadMessagesCount": "Hai {} messaggi non letti", + "noUnreadMessages": "Nessun messaggio non letto" }, "queue": { "title": "Coda di Upload", @@ -237,6 +245,7 @@ "retryUpload": "Riprova upload", "clearAll": "Pulisci Tutto", "errorDetails": "Dettagli dell'Errore", + "creatingChangeset": " (Creazione changeset...)", "uploading": " (Caricamento...)", "closingChangeset": " (Chiusura changeset...)" }, diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 2f4721c..766feb6 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -207,7 +207,15 @@ "deleteAccountSubtitle": "Gerencie sua conta OpenStreetMap", "deleteAccountExplanation": "Para excluir sua conta OpenStreetMap, você precisará visitar o site do OpenStreetMap. Isso removerá permanentemente sua conta OSM e todos os dados associados.", "deleteAccountWarning": "Aviso: Esta ação não pode ser desfeita e excluirá permanentemente sua conta OSM.", - "goToOSM": "Ir para OpenStreetMap" + "goToOSM": "Ir para OpenStreetMap", + "accountManagement": "Gerenciamento de Conta", + "accountManagementDescription": "Para excluir sua conta do OpenStreetMap, você deve visitar o site do OpenStreetMap apropriado. Isso removerá permanentemente sua conta e todos os dados associados.", + "currentDestinationProduction": "Atualmente conectado a: OpenStreetMap de Produção", + "currentDestinationSandbox": "Atualmente conectado a: OpenStreetMap Sandbox", + "currentDestinationSimulate": "Atualmente em: Modo de simulação (sem conta real)", + "viewMessages": "Ver Mensagens no OSM", + "unreadMessagesCount": "Você tem {} mensagens não lidas", + "noUnreadMessages": "Nenhuma mensagem não lida" }, "queue": { "title": "Fila de Upload", @@ -237,6 +245,7 @@ "retryUpload": "Tentar upload novamente", "clearAll": "Limpar Tudo", "errorDetails": "Detalhes do Erro", + "creatingChangeset": " (Criando changeset...)", "uploading": " (Enviando...)", "closingChangeset": " (Fechando changeset...)" }, diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 7447f3f..3d77592 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -207,7 +207,15 @@ "deleteAccountSubtitle": "管理您的 OpenStreetMap 账户", "deleteAccountExplanation": "要删除您的 OpenStreetMap 账户,您需要访问 OpenStreetMap 网站。这将永久删除您的 OSM 账户和所有相关数据。", "deleteAccountWarning": "警告:此操作无法撤销,将永久删除您的 OSM 账户。", - "goToOSM": "前往 OpenStreetMap" + "goToOSM": "前往 OpenStreetMap", + "accountManagement": "账户管理", + "accountManagementDescription": "要删除您的 OpenStreetMap 账户,您需要访问相应的 OpenStreetMap 网站。这将永久删除您的账户和所有相关数据。", + "currentDestinationProduction": "当前连接到:生产环境 OpenStreetMap", + "currentDestinationSandbox": "当前连接到:沙盒环境 OpenStreetMap", + "currentDestinationSimulate": "当前处于:模拟模式(无真实账户)", + "viewMessages": "在 OSM 上查看消息", + "unreadMessagesCount": "您有 {} 条未读消息", + "noUnreadMessages": "没有未读消息" }, "queue": { "title": "上传队列", @@ -237,6 +245,7 @@ "retryUpload": "重试上传", "clearAll": "全部清空", "errorDetails": "错误详情", + "creatingChangeset": " (创建变更集...)", "uploading": " (上传中...)", "closingChangeset": " (关闭变更集...)" }, diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 767a868..d7e7a0b 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -101,75 +101,11 @@ class AboutScreen extends StatelessWidget { _buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'), const SizedBox(height: 8), _buildLinkText(context, 'Contact', 'https://deflock.me/contact'), - const SizedBox(height: 24), - - // Divider for account management section - Divider( - color: Theme.of(context).dividerColor.withOpacity(0.3), - ), - const SizedBox(height: 16), - - // Account deletion link (less prominent) - _buildAccountDeletionLink(context), ], ); } - Widget _buildAccountDeletionLink(BuildContext context) { - final locService = LocalizationService.instance; - - return GestureDetector( - onTap: () => _showDeleteAccountDialog(context, locService), - child: Text( - locService.t('auth.deleteAccount'), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error.withOpacity(0.7), - decoration: TextDecoration.underline, - ), - textAlign: TextAlign.center, - ), - ); - } - void _showDeleteAccountDialog(BuildContext context, LocalizationService locService) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(locService.t('auth.deleteAccount')), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(locService.t('auth.deleteAccountExplanation')), - const SizedBox(height: 12), - Text( - locService.t('auth.deleteAccountWarning'), - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.error, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(locService.t('actions.cancel')), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - _launchUrl('https://www.openstreetmap.org/account/deletion', context); - }, - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.error, - ), - child: Text(locService.t('auth.goToOSM')), - ), - ], - ), - ); - } Widget _buildLinkText(BuildContext context, String text, String url) { return GestureDetector( diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 2a12359..007011b 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -713,11 +713,31 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ), AnimatedBuilder( animation: LocalizationService.instance, - builder: (context, child) => IconButton( - tooltip: LocalizationService.instance.settings, - icon: const Icon(Icons.settings), - onPressed: () => Navigator.pushNamed(context, '/settings'), - ), + builder: (context, child) { + final appState = context.watch(); + return IconButton( + tooltip: LocalizationService.instance.settings, + icon: Stack( + children: [ + const Icon(Icons.settings), + if (appState.hasUnreadMessages && appState.uploadMode != UploadMode.simulate) + Positioned( + right: 0, + top: 0, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + onPressed: () => Navigator.pushNamed(context, '/settings'), + ); + }, ), ], ), diff --git a/lib/screens/osm_account_screen.dart b/lib/screens/osm_account_screen.dart index 8a9c3d7..285c46e 100644 --- a/lib/screens/osm_account_screen.dart +++ b/lib/screens/osm_account_screen.dart @@ -4,12 +4,28 @@ import 'package:url_launcher/url_launcher.dart'; import '../app_state.dart'; import '../services/localization_service.dart'; import '../dev_config.dart'; -import '../state/settings_state.dart'; import '../screens/settings/sections/upload_mode_section.dart'; -class OSMAccountScreen extends StatelessWidget { +class OSMAccountScreen extends StatefulWidget { const OSMAccountScreen({super.key}); + @override + State createState() => _OSMAccountScreenState(); +} + +class _OSMAccountScreenState extends State { + @override + void initState() { + super.initState(); + // Check for messages when screen loads + WidgetsBinding.instance.addPostFrameCallback((_) { + final appState = context.read(); + if (appState.isLoggedIn) { + appState.checkMessages(); + } + }); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -90,14 +106,68 @@ class OSMAccountScreen extends StatelessWidget { }, ), - const Divider(), + // Only show OSM website buttons when not in simulate mode + if (appState.uploadMode != UploadMode.simulate) ...[ + const Divider(), + ListTile( + leading: const Icon(Icons.history), + title: Text(locService.t('auth.viewMyEdits')), + subtitle: Text(locService.t('auth.viewMyEditsSubtitle')), + trailing: const Icon(Icons.open_in_new), + onTap: () async { + final url = Uri.parse('https://openstreetmap.org/user/${Uri.encodeComponent(appState.username)}/history'); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))), + ); + } + } + }, + ), + + // Messages button - only show when not in simulate mode + const Divider(), ListTile( - leading: const Icon(Icons.history), - title: Text(locService.t('auth.viewMyEdits')), - subtitle: Text(locService.t('auth.viewMyEditsSubtitle')), + leading: Stack( + children: [ + const Icon(Icons.message), + if (appState.hasUnreadMessages) + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + shape: BoxShape.circle, + ), + constraints: const BoxConstraints( + minWidth: 12, + minHeight: 12, + ), + child: Text( + '${appState.unreadMessageCount}', + style: TextStyle( + color: Theme.of(context).colorScheme.onError, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + title: Text(locService.t('auth.viewMessages')), + subtitle: Text(appState.hasUnreadMessages + ? locService.t('auth.unreadMessagesCount', params: [appState.unreadMessageCount.toString()]) + : locService.t('auth.noUnreadMessages')), trailing: const Icon(Icons.open_in_new), onTap: () async { - final url = Uri.parse('https://openstreetmap.org/user/${Uri.encodeComponent(appState.username)}/history'); + final url = Uri.parse(appState.getMessagesUrl()); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } else { @@ -109,6 +179,7 @@ class OSMAccountScreen extends StatelessWidget { } }, ), + ], ], ], ), @@ -167,10 +238,181 @@ class OSMAccountScreen extends StatelessWidget { ), ), ), + + // Account deletion section - only show when logged in + if (appState.isLoggedIn) ...[ + const SizedBox(height: 16), + _buildAccountDeletionSection(context, appState), + ], ], ), ); }, ); } + + Widget _buildAccountDeletionSection(BuildContext context, AppState appState) { + final locService = LocalizationService.instance; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + locService.t('auth.accountManagement'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + locService.t('auth.accountManagementDescription'), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + + // Show current upload destination + Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), + borderRadius: BorderRadius.circular(8.0), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _getCurrentDestinationText(locService, appState.uploadMode), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Delete account button + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _showDeleteAccountDialog(context, locService, appState.uploadMode), + icon: const Icon(Icons.delete_outline), + label: Text(locService.t('auth.deleteAccount')), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + side: BorderSide(color: Theme.of(context).colorScheme.error.withOpacity(0.5)), + ), + ), + ), + ], + ), + ), + ); + } + + String _getCurrentDestinationText(LocalizationService locService, UploadMode uploadMode) { + switch (uploadMode) { + case UploadMode.production: + return locService.t('auth.currentDestinationProduction'); + case UploadMode.sandbox: + return locService.t('auth.currentDestinationSandbox'); + case UploadMode.simulate: + return locService.t('auth.currentDestinationSimulate'); + } + } + + void _showDeleteAccountDialog(BuildContext context, LocalizationService locService, UploadMode uploadMode) { + final deleteUrl = _getDeleteAccountUrl(uploadMode); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(locService.t('auth.deleteAccount')), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(locService.t('auth.deleteAccountExplanation')), + const SizedBox(height: 12), + Text( + locService.t('auth.deleteAccountWarning'), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.error, + ), + ), + const SizedBox(height: 12), + // Show which account will be deleted + Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1), + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: Theme.of(context).colorScheme.error.withOpacity(0.3), + ), + ), + child: Text( + _getCurrentDestinationText(locService, uploadMode), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(locService.t('actions.cancel')), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _launchDeleteAccountUrl(deleteUrl, context, locService); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(locService.t('auth.goToOSM')), + ), + ], + ), + ); + } + + String _getDeleteAccountUrl(UploadMode uploadMode) { + switch (uploadMode) { + case UploadMode.production: + return 'https://www.openstreetmap.org/account/deletion'; + case UploadMode.sandbox: + return 'https://master.apis.dev.openstreetmap.org/account/deletion'; + case UploadMode.simulate: + // For simulate mode, just go to production since it's not a real account + return 'https://www.openstreetmap.org/account/deletion'; + } + } + + Future _launchDeleteAccountUrl(String url, BuildContext context, LocalizationService locService) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite')), + ), + ); + } + } + } } \ No newline at end of file diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 1a17105..f63e48a 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../app_state.dart'; import '../services/localization_service.dart'; import '../services/version_service.dart'; import '../dev_config.dart'; @@ -23,13 +25,7 @@ class SettingsScreen extends StatelessWidget { ), children: [ // OpenStreetMap Account - _buildNavigationTile( - context, - icon: Icons.account_circle, - title: locService.t('auth.osmAccountTitle'), - subtitle: locService.t('auth.osmAccountSubtitle'), - onTap: () => Navigator.pushNamed(context, '/settings/osm-account'), - ), + _buildOSMAccountTile(context, locService), const Divider(), // Upload Queue @@ -117,6 +113,35 @@ class SettingsScreen extends StatelessWidget { ); } + Widget _buildOSMAccountTile(BuildContext context, LocalizationService locService) { + final appState = context.watch(); + + return ListTile( + leading: Stack( + children: [ + const Icon(Icons.account_circle), + if (appState.hasUnreadMessages && appState.uploadMode != UploadMode.simulate) + Positioned( + right: 0, + top: 0, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + title: Text(locService.t('auth.osmAccountTitle')), + subtitle: Text(locService.t('auth.osmAccountSubtitle')), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () => Navigator.pushNamed(context, '/settings/osm-account'), + ); + } + Widget _buildNavigationTile( BuildContext context, { required IconData icon, diff --git a/lib/services/osm_messages_service.dart b/lib/services/osm_messages_service.dart new file mode 100644 index 0000000..3627d12 --- /dev/null +++ b/lib/services/osm_messages_service.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../state/settings_state.dart'; + +/// Service for checking OSM user messages +class OSMMessagesService { + static const _messageCheckCacheDuration = Duration(minutes: 5); + + DateTime? _lastCheck; + int? _lastUnreadCount; + UploadMode? _lastMode; + + /// Get the number of unread messages for the current user + /// Returns null if not logged in, on error, or in simulate mode + Future getUnreadMessageCount({ + required String? accessToken, + required UploadMode uploadMode, + bool forceRefresh = false, + }) async { + // No messages in simulate mode + if (uploadMode == UploadMode.simulate) { + return null; + } + + // No access token means not logged in + if (accessToken == null || accessToken.isEmpty) { + return null; + } + + // Check cache unless forced refresh or mode changed + if (!forceRefresh && + _lastCheck != null && + _lastUnreadCount != null && + _lastMode == uploadMode && + DateTime.now().difference(_lastCheck!) < _messageCheckCacheDuration) { + return _lastUnreadCount; + } + + try { + final apiHost = _getApiHost(uploadMode); + final response = await http.get( + Uri.parse('$apiHost/api/0.6/user/details.json'), + headers: {'Authorization': 'Bearer $accessToken'}, + ); + + if (response.statusCode != 200) { + return null; + } + + final data = jsonDecode(response.body); + final user = data['user']; + if (user == null) return null; + + // OSM API returns message counts in user details + final messages = user['messages']; + if (messages == null) return null; + + // Get unread count + final unreadCount = messages['unread']?['count'] ?? 0; + + // Update cache + _lastCheck = DateTime.now(); + _lastUnreadCount = unreadCount; + _lastMode = uploadMode; + + return unreadCount; + + } catch (e) { + // Don't throw - just return null on any error + return null; + } + } + + /// Get the URL to view messages on OSM website + String getMessagesUrl(UploadMode uploadMode) { + switch (uploadMode) { + case UploadMode.production: + return 'https://www.openstreetmap.org/messages/inbox'; + case UploadMode.sandbox: + return 'https://master.apis.dev.openstreetmap.org/messages/inbox'; + case UploadMode.simulate: + return 'https://www.openstreetmap.org/messages/inbox'; + } + } + + /// Clear the cache (useful when user logs out or changes mode) + void clearCache() { + _lastCheck = null; + _lastUnreadCount = null; + _lastMode = null; + } + + String _getApiHost(UploadMode uploadMode) { + switch (uploadMode) { + case UploadMode.production: + return 'https://api.openstreetmap.org'; + case UploadMode.sandbox: + return 'https://api06.dev.openstreetmap.org'; + case UploadMode.simulate: + return 'https://api.openstreetmap.org'; + } + } +} \ No newline at end of file diff --git a/lib/state/messages_state.dart b/lib/state/messages_state.dart new file mode 100644 index 0000000..ea67351 --- /dev/null +++ b/lib/state/messages_state.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import '../services/osm_messages_service.dart'; +import 'settings_state.dart'; + +/// State management for OSM message notifications +class MessagesState extends ChangeNotifier { + final OSMMessagesService _messagesService = OSMMessagesService(); + + int? _unreadCount; + bool _isChecking = false; + + // Getters + int? get unreadCount => _unreadCount; + bool get hasUnreadMessages => (_unreadCount ?? 0) > 0; + bool get isChecking => _isChecking; + + /// Check for unread messages + Future checkMessages({ + required String? accessToken, + required UploadMode uploadMode, + bool forceRefresh = false, + }) async { + if (_isChecking) return; // Prevent concurrent checks + + _isChecking = true; + notifyListeners(); + + try { + final count = await _messagesService.getUnreadMessageCount( + accessToken: accessToken, + uploadMode: uploadMode, + forceRefresh: forceRefresh, + ); + + if (_unreadCount != count) { + _unreadCount = count; + notifyListeners(); + } + } catch (e) { + // Silently handle errors - messages are not critical + debugPrint('MessagesState: Error checking messages: $e'); + } finally { + _isChecking = false; + notifyListeners(); + } + } + + /// Get the URL to view messages + String getMessagesUrl(UploadMode uploadMode) { + return _messagesService.getMessagesUrl(uploadMode); + } + + /// Clear message state (when user logs out or changes mode) + void clearMessages() { + _unreadCount = null; + _messagesService.clearCache(); + notifyListeners(); + } +} \ No newline at end of file