OSM message notifications in theory

This commit is contained in:
stopflock
2025-12-02 12:10:09 -06:00
parent f3a5238f50
commit bc03dcbe89
16 changed files with 607 additions and 92 deletions

View File

@@ -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

View File

@@ -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)",

View File

@@ -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<TileProvider> 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<void> login() async {
await _authState.login();
// Check for messages after successful login
if (isLoggedIn) {
checkMessages();
}
}
Future<void> logout() async {
await _authState.logout();
// Clear message state when logging out
clearMessages();
}
Future<void> refreshAuthState() async {
@@ -222,11 +257,33 @@ class AppState extends ChangeNotifier {
Future<void> forceLogin() async {
await _authState.forceLogin();
// Check for messages after successful login
if (isLoggedIn) {
checkMessages();
}
}
Future<bool> validateToken() async {
return await _authState.validateToken();
}
// ---------- Messages Methods ----------
Future<void> 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);

View File

@@ -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...)"
},

View File

@@ -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",

View File

@@ -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...)"
},

View File

@@ -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...)"
},

View File

@@ -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...)"
},

View File

@@ -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...)"
},

View File

@@ -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": " (关闭变更集...)"
},

View File

@@ -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(

View File

@@ -713,11 +713,31 @@ class _HomeScreenState extends State<HomeScreen> 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<AppState>();
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'),
);
},
),
],
),

View File

@@ -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<OSMAccountScreen> createState() => _OSMAccountScreenState();
}
class _OSMAccountScreenState extends State<OSMAccountScreen> {
@override
void initState() {
super.initState();
// Check for messages when screen loads
WidgetsBinding.instance.addPostFrameCallback((_) {
final appState = context.read<AppState>();
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<void> _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')),
),
);
}
}
}
}

View File

@@ -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<AppState>();
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,

View File

@@ -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<int?> 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';
}
}
}

View File

@@ -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<void> 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();
}
}