diff --git a/lib/app_state.dart b/lib/app_state.dart index 0ba485c..90fbb4f 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -176,6 +176,8 @@ class AppState extends ChangeNotifier { // Messages state int? get unreadMessageCount => _messagesState.unreadCount; bool get hasUnreadMessages => _messagesState.hasUnreadMessages; + bool get hasUnreadChangesetComments => _messagesState.hasUnreadChangesetComments; + bool get hasUnreadNotifications => _messagesState.hasUnreadNotifications; bool get isCheckingMessages => _messagesState.isChecking; // Tile provider state @@ -331,6 +333,7 @@ class AppState extends ChangeNotifier { final accessToken = await _authState.getAccessToken(); await _messagesState.checkMessages( accessToken: accessToken, + username: isLoggedIn ? username : null, uploadMode: uploadMode, forceRefresh: forceRefresh, ); @@ -340,10 +343,24 @@ class AppState extends ChangeNotifier { return _messagesState.getMessagesUrl(uploadMode); } + /// URL of the specific changeset with unread comments, on OSM's website, + /// or null if there are no unread changeset comments. + String? getUnreadChangesetUrl() { + return _messagesState.getUnreadChangesetUrl(uploadMode); + } + void clearMessages() { _messagesState.clearMessages(); } + /// Mark changeset comments as read (called when user views the changeset on OSM) + Future markChangesetCommentsRead() async { + await _messagesState.markChangesetCommentsRead( + username: isLoggedIn ? username : null, + uploadMode: uploadMode, + ); + } + /// Check if the current OAuth token has required scopes for message notifications /// Returns true if re-authentication is needed Future needsReauthForMessages() async { diff --git a/lib/localizations/de.json b/lib/localizations/de.json index b503d0c..3d61c9e 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -235,7 +235,12 @@ "reauthExplanation": "Sie müssen Ihre Authentifizierung aktualisieren, um OSM-Nachrichtenbenachrichtigungen über die App zu erhalten.", "reauthBenefit": "Dies ermöglicht Benachrichtigungspunkte, wenn Sie ungelesene Nachrichten auf OpenStreetMap haben.", "reauthNow": "Jetzt machen", - "reauthLater": "Später" + "newChangesetComments": "Neuer Changeset-Kommentar", + "newChangesetCommentsSubtitle": "Jemand hat einen oder mehrere Ihrer Changesets kommentiert", + "reauthLater": "Später", + "unreadNotificationsTitle": "Sie haben ungelesene Benachrichtigungen", + "unreadNotificationsMessage": "Sie haben ungelesene OSM-Nachrichten und/oder neue Kommentare zu Ihren Changesets.", + "unreadNotificationsView": "Jetzt ansehen" }, "queue": { "title": "Upload-Warteschlange", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 9fc045d..69e7cc8 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -267,11 +267,16 @@ "viewMessages": "View Messages on OSM", "unreadMessagesCount": "You have {} unread messages", "noUnreadMessages": "No unread messages", + "newChangesetComments": "New Changeset Comment", + "newChangesetCommentsSubtitle": "Someone commented on one or more of your changesets", "reauthRequired": "Refresh Authentication", "reauthExplanation": "You must refresh your authentication to receive OSM message notifications through the app.", "reauthBenefit": "This will enable notification dots when you have unread messages on OpenStreetMap.", "reauthNow": "Do That Now", - "reauthLater": "Later" + "reauthLater": "Later", + "unreadNotificationsTitle": "You Have Unread Notifications", + "unreadNotificationsMessage": "You have unread OSM messages and/or new comments on your changesets.", + "unreadNotificationsView": "View Now" }, "queue": { "title": "Upload Queue", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index af80917..aee455d 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -272,7 +272,12 @@ "reauthExplanation": "Debes actualizar tu autenticación para recibir notificaciones de mensajes OSM a través de la aplicación.", "reauthBenefit": "Esto habilitará puntos de notificación cuando tengas mensajes sin leer en OpenStreetMap.", "reauthNow": "Hazlo Ahora", - "reauthLater": "Más Tarde" + "newChangesetComments": "Nuevo Comentario en Changeset", + "newChangesetCommentsSubtitle": "Alguien comentó en uno o más de tus changesets", + "reauthLater": "Más Tarde", + "unreadNotificationsTitle": "Tienes Notificaciones Sin Leer", + "unreadNotificationsMessage": "Tienes mensajes de OSM sin leer y/o nuevos comentarios en tus changesets.", + "unreadNotificationsView": "Ver Ahora" }, "queue": { "title": "Cola de Subida", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 2e5f1e0..03db05e 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -272,7 +272,12 @@ "reauthExplanation": "Vous devez actualiser votre authentification pour recevoir des notifications de messages OSM via l'application.", "reauthBenefit": "Cela activera les points de notification lorsque vous avez des messages non lus sur OpenStreetMap.", "reauthNow": "Le Faire Maintenant", - "reauthLater": "Plus Tard" + "newChangesetComments": "Nouveau Commentaire de Changeset", + "newChangesetCommentsSubtitle": "Quelqu'un a commenté un ou plusieurs de vos changesets", + "reauthLater": "Plus Tard", + "unreadNotificationsTitle": "Vous Avez des Notifications Non Lues", + "unreadNotificationsMessage": "Vous avez des messages OSM non lus et/ou de nouveaux commentaires sur vos changesets.", + "unreadNotificationsView": "Voir Maintenant" }, "queue": { "title": "File de Téléchargement", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index e58aee6..bd41dee 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -272,7 +272,12 @@ "reauthExplanation": "Devi aggiornare la tua autenticazione per ricevere notifiche di messaggi OSM tramite l'app.", "reauthBenefit": "Questo abiliterà i punti di notifica quando hai messaggi non letti su OpenStreetMap.", "reauthNow": "Fallo Ora", - "reauthLater": "Più Tardi" + "newChangesetComments": "Nuovo Commento al Changeset", + "newChangesetCommentsSubtitle": "Qualcuno ha commentato uno o più dei tuoi changeset", + "reauthLater": "Più Tardi", + "unreadNotificationsTitle": "Hai Notifiche Non Lette", + "unreadNotificationsMessage": "Hai messaggi OSM non letti e/o nuovi commenti sui tuoi changeset.", + "unreadNotificationsView": "Visualizza Ora" }, "queue": { "title": "Coda di Upload", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 36570a2..d36a25f 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -272,7 +272,12 @@ "reauthExplanation": "U moet uw authenticatie verversen om OSM bericht meldingen te ontvangen via de app.", "reauthBenefit": "Dit zal melding stippen inschakelen wanneer u ongelezen berichten heeft op OpenStreetMap.", "reauthNow": "Doe Dat Nu", - "reauthLater": "Later" + "newChangesetComments": "Nieuwe Changeset-opmerking", + "newChangesetCommentsSubtitle": "Iemand heeft commentaar gegeven op een of meer van uw changesets", + "reauthLater": "Later", + "unreadNotificationsTitle": "U Heeft Ongelezen Meldingen", + "unreadNotificationsMessage": "U heeft ongelezen OSM-berichten en/of nieuwe opmerkingen op uw changesets.", + "unreadNotificationsView": "Nu Bekijken" }, "queue": { "title": "Upload Wachtrij", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 7c0e1ab..30dac55 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -272,7 +272,12 @@ "reauthExplanation": "Musisz odświeżyć uwierzytelnienie, aby otrzymywać powiadomienia o wiadomościach OSM przez aplikację.", "reauthBenefit": "To włączy kropki powiadomień, gdy masz nieprzeczytane wiadomości w OpenStreetMap.", "reauthNow": "Zrób To Teraz", - "reauthLater": "Później" + "newChangesetComments": "Nowy Komentarz do Zestawu Zmian", + "newChangesetCommentsSubtitle": "Ktoś skomentował jeden lub więcej Twoich zestawów zmian", + "reauthLater": "Później", + "unreadNotificationsTitle": "Masz Nieprzeczytane Powiadomienia", + "unreadNotificationsMessage": "Masz nieprzeczytane wiadomości OSM i/lub nowe komentarze do Twoich zestawów zmian.", + "unreadNotificationsView": "Zobacz Teraz" }, "queue": { "title": "Kolejka Przesyłania", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index c8c4a28..0ce4615 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -272,7 +272,12 @@ "reauthExplanation": "Você deve atualizar sua autenticação para receber notificações de mensagens OSM através do aplicativo.", "reauthBenefit": "Isso habilitará pontos de notificação quando você tiver mensagens não lidas no OpenStreetMap.", "reauthNow": "Fazer Agora", - "reauthLater": "Mais Tarde" + "newChangesetComments": "Novo Comentário no Changeset", + "newChangesetCommentsSubtitle": "Alguém comentou em um ou mais dos seus changesets", + "reauthLater": "Mais Tarde", + "unreadNotificationsTitle": "Você Tem Notificações Não Lidas", + "unreadNotificationsMessage": "Você tem mensagens OSM não lidas e/ou novos comentários nos seus changesets.", + "unreadNotificationsView": "Ver Agora" }, "queue": { "title": "Fila de Upload", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 50490c0..e7fb0e1 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -272,7 +272,12 @@ "reauthExplanation": "Uygulama üzerinden OSM mesaj bildirimlerini alabilmek için kimlik doğrulamanızı yenilemeniz gerekir.", "reauthBenefit": "Bu, OpenStreetMap'te okunmamış mesajlarınız olduğunda bildirim noktalarını etkinleştirecek.", "reauthNow": "Şimdi Yap", - "reauthLater": "Sonra" + "newChangesetComments": "Yeni Değişiklik Kümesi Yorumu", + "newChangesetCommentsSubtitle": "Biri değişiklik kümelerinizden birine veya birkaçına yorum yaptı", + "reauthLater": "Sonra", + "unreadNotificationsTitle": "Okunmamış Bildirimleriniz Var", + "unreadNotificationsMessage": "Okunmamış OSM mesajlarınız ve/veya değişiklik kümelerinize yapılan yeni yorumlar var.", + "unreadNotificationsView": "Şimdi Görüntüle" }, "queue": { "title": "Yükleme Kuyruğu", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index 3ecba23..22cf206 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -272,7 +272,12 @@ "reauthExplanation": "Ви повинні оновити вашу автентифікацію, щоб отримувати сповіщення про повідомлення OSM через додаток.", "reauthBenefit": "Це дозволить показувати точки сповіщень, коли у вас є непрочитані повідомлення в OpenStreetMap.", "reauthNow": "Зробити Зараз", - "reauthLater": "Пізніше" + "newChangesetComments": "Новий Коментар до Набору Змін", + "newChangesetCommentsSubtitle": "Хтось прокоментував один або декілька ваших наборів змін", + "reauthLater": "Пізніше", + "unreadNotificationsTitle": "У Вас Є Непрочитані Сповіщення", + "unreadNotificationsMessage": "У вас є непрочитані повідомлення OSM та/або нові коментарі до ваших наборів змін.", + "unreadNotificationsView": "Переглянути Зараз" }, "queue": { "title": "Черга Завантаження", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 57e286c..c7aa918 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -272,7 +272,12 @@ "reauthExplanation": "您必须刷新身份验证才能通过应用接收 OSM 消息通知。", "reauthBenefit": "这将在您在 OpenStreetMap 上有未读消息时启用通知点。", "reauthNow": "现在执行", - "reauthLater": "稍后" + "newChangesetComments": "新的变更集评论", + "newChangesetCommentsSubtitle": "有人评论了您的一个或多个变更集", + "reauthLater": "稍后", + "unreadNotificationsTitle": "您有未读通知", + "unreadNotificationsMessage": "您有未读的 OSM 消息和/或对您变更集的新评论。", + "unreadNotificationsView": "立即查看" }, "queue": { "title": "上传队列", diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 60d9ca8..06ebb24 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -18,6 +18,7 @@ import '../widgets/search_bar.dart'; import '../widgets/suspected_location_sheet.dart'; import '../widgets/welcome_dialog.dart'; import '../widgets/changelog_dialog.dart'; +import '../widgets/unread_notifications_dialog.dart'; import '../models/osm_node.dart'; import '../models/suspected_location.dart'; import '../models/search_result.dart'; @@ -53,6 +54,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { // Track popup display to avoid showing multiple times bool _hasCheckedForPopup = false; + + // Track whether we've shown the unread notifications popup this session + bool _hasShownUnreadNotificationsPopup = false; @override void initState() { @@ -247,6 +251,21 @@ class _HomeScreenState extends State with TickerProviderStateMixin { } } + void _showUnreadNotificationsDialog() { + if (!mounted) return; + showDialog( + context: context, + builder: (context) => UnreadNotificationsDialog( + onView: () { + Navigator.of(context).pushNamed('/settings/osm-account'); + }, + onDismiss: () { + // Just dismiss - badges remain visible until the user views them. + }, + ), + ); + } + void _onStartRoute() { _navigationCoordinator.startRoute( context: context, @@ -457,6 +476,19 @@ class _HomeScreenState extends State with TickerProviderStateMixin { }); } + // Show a one-time popup this session if the user has unread messages + // and/or unread changeset comments. Gated on _hasCheckedForPopup so it + // doesn't race with the welcome/changelog dialog above. + if (_hasCheckedForPopup && + !_hasShownUnreadNotificationsPopup && + appState.isLoggedIn && + appState.hasUnreadNotifications) { + _hasShownUnreadNotificationsPopup = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _showUnreadNotificationsDialog(); + }); + } + // Auto-focus a node's details sheet right after it was submitted/edited/deleted, // behind kAutoOpenNodeSheetAfterSubmit (pending A/B testing/team feedback). if (kAutoOpenNodeSheetAfterSubmit) { @@ -510,23 +542,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { final appState = context.watch(); return IconButton( tooltip: LocalizationService.instance.settings, - icon: Stack( - children: [ - const Icon(Icons.settings), - if (appState.hasUnreadMessages) - Positioned( - right: 0, - top: 0, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.error, - shape: BoxShape.circle, - ), - ), - ), - ], + icon: Badge( + isLabelVisible: appState.hasUnreadNotifications, + child: const Icon(Icons.settings), ), onPressed: () => Navigator.pushNamed(context, '/settings'), ); diff --git a/lib/screens/osm_account_screen.dart b/lib/screens/osm_account_screen.dart index 8e95ddb..85cce6e 100644 --- a/lib/screens/osm_account_screen.dart +++ b/lib/screens/osm_account_screen.dart @@ -128,38 +128,46 @@ class _OSMAccountScreenState extends State { }, ), + // Unread changeset comments - links directly to the + // specific changeset with the new comment, and only + // appears when there is one. + if (appState.hasUnreadChangesetComments) ...[ + const Divider(), + ListTile( + leading: Badge( + isLabelVisible: true, + child: const Icon(Icons.comment), + ), + title: Text(locService.t('auth.newChangesetComments')), + subtitle: Text(locService.t('auth.newChangesetCommentsSubtitle')), + trailing: const Icon(Icons.open_in_new), + onTap: () async { + final changesetUrl = appState.getUnreadChangesetUrl(); + if (changesetUrl == null) return; + final url = Uri.parse(changesetUrl); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + // The user has now had the opportunity to see the + // new comment on OSM's website - clear our badge. + appState.markChangesetCommentsRead(); + } 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: 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, - ), - ), - ), - ], + leading: Badge( + isLabelVisible: appState.hasUnreadMessages, + label: Text('${appState.unreadMessageCount ?? 0}'), + child: const Icon(Icons.message), ), title: Text(locService.t('auth.viewMessages')), subtitle: Text(appState.hasUnreadMessages diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 620acaf..63b055a 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -117,23 +117,9 @@ class SettingsScreen extends StatelessWidget { final appState = context.watch(); return ListTile( - leading: Stack( - children: [ - const Icon(Icons.account_circle), - if (appState.hasUnreadMessages) - Positioned( - right: 0, - top: 0, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.error, - shape: BoxShape.circle, - ), - ), - ), - ], + leading: Badge( + isLabelVisible: appState.hasUnreadNotifications, + child: const Icon(Icons.account_circle), ), title: Text(locService.t('auth.osmAccountTitle')), subtitle: Text(locService.t('auth.osmAccountSubtitle')), diff --git a/lib/services/changeset_comments_service.dart b/lib/services/changeset_comments_service.dart new file mode 100644 index 0000000..48e8241 --- /dev/null +++ b/lib/services/changeset_comments_service.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../state/settings_state.dart'; +import 'http_client.dart'; + +/// Tracks unread comments on the user's own changesets. +/// +/// The OSM API has no "read" concept for changeset comments, so we track it +/// ourselves: we remember the `comments_count` we last saw for each of the +/// user's recent changesets, and flag a changeset as having unread comments +/// if it now has a higher count than what we last recorded. +/// +/// This only looks at the user's most recent 100 changesets (the OSM API's +/// max page size, and it doesn't support simple offset paging). If a +/// changeset receives a new comment and then ages out of that window +/// entirely before the next check, we will miss it - an accepted limitation +/// of polling a bounded recent-activity window rather than a real +/// notification feed. +class ChangesetCommentsService { + static const _cacheDuration = Duration(minutes: 5); + final _client = UserAgentClient(); + + DateTime? _lastCheck; + int? _lastResult; // ID of changeset with unread comments, or null if none + bool _lastResultIsSet = false; // whether _lastResult holds a valid cached value + UploadMode? _lastMode; + + /// Returns the ID of a changeset with more comments now than the last + /// time [markRead] was called for this upload mode, or null if there are + /// none (or if not logged in, in simulate mode, or on error). When + /// multiple changesets have new comments, returns the most recent one + /// (the API returns changesets newest-first). + Future getUnreadChangesetId({ + required String? username, + required UploadMode uploadMode, + bool forceRefresh = false, + }) async { + if (uploadMode == UploadMode.simulate) return null; + if (username == null || username.isEmpty) return null; + + if (!forceRefresh && + _lastResultIsSet && + _lastMode == uploadMode && + _lastCheck != null && + DateTime.now().difference(_lastCheck!) < _cacheDuration) { + return _lastResult; + } + + try { + final current = await _fetchCommentCounts(username, uploadMode); + if (current == null) return null; + + final seen = await _loadSeenCounts(uploadMode); + // current is built from the API response, which is ordered + // newest-first, so the first match found is the most recent one. + int? unreadId; + for (final entry in current.entries) { + if (entry.value > (seen[entry.key] ?? 0)) { + unreadId = entry.key; + break; + } + } + + _lastCheck = DateTime.now(); + _lastResult = unreadId; + _lastResultIsSet = true; + _lastMode = uploadMode; + + return unreadId; + } catch (e) { + return null; + } + } + + /// Mark all currently-visible changeset comments as read by saving the + /// current comment counts as the new baseline. + Future markRead({ + required String? username, + required UploadMode uploadMode, + }) async { + if (uploadMode == UploadMode.simulate) return; + if (username == null || username.isEmpty) return; + + try { + final current = await _fetchCommentCounts(username, uploadMode); + if (current == null) return; + + await _saveSeenCounts(uploadMode, current); + + // Update in-memory cache so getUnreadChangesetId() doesn't need to + // re-fetch immediately after marking read. + _lastCheck = DateTime.now(); + _lastResult = null; + _lastResultIsSet = true; + _lastMode = uploadMode; + } catch (e) { + // Best-effort - if this fails the badge will just persist until the + // next successful mark-read attempt. + } + } + + /// Clear cached state (useful on logout or upload mode change). + void clearCache() { + _lastCheck = null; + _lastResult = null; + _lastResultIsSet = false; + _lastMode = null; + } + + /// Build the URL to the given changeset on the OSM website for the given + /// upload mode. + String getChangesetUrl(int changesetId, UploadMode uploadMode) { + switch (uploadMode) { + case UploadMode.production: + return 'https://www.openstreetmap.org/changeset/$changesetId'; + case UploadMode.sandbox: + return 'https://master.apis.dev.openstreetmap.org/changeset/$changesetId'; + case UploadMode.simulate: + return 'https://www.openstreetmap.org/changeset/$changesetId'; + } + } + + /// Fetches the user's most recent changesets and returns a map of + /// changeset ID -> comments_count. Returns null on any error. + Future?> _fetchCommentCounts( + String username, + UploadMode uploadMode, + ) async { + final apiHost = _getApiHost(uploadMode); + final response = await _client.get( + Uri.parse('$apiHost/api/0.6/changesets.json?display_name=${Uri.encodeQueryComponent(username)}'), + ); + + if (response.statusCode != 200) return null; + + final data = jsonDecode(response.body); + final changesets = data['changesets']; + if (changesets is! List) return null; + + final result = {}; + for (final cs in changesets) { + final id = cs['id']; + final count = cs['comments_count']; + if (id is int && count is int) { + result[id] = count; + } + } + return result; + } + + Future> _loadSeenCounts(UploadMode uploadMode) async { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString(_prefsKey(uploadMode)); + if (json == null) return {}; + try { + final decoded = jsonDecode(json) as Map; + return decoded.map((k, v) => MapEntry(int.parse(k), v as int)); + } catch (e) { + return {}; + } + } + + Future _saveSeenCounts(UploadMode uploadMode, Map counts) async { + final prefs = await SharedPreferences.getInstance(); + final encoded = jsonEncode(counts.map((k, v) => MapEntry(k.toString(), v))); + await prefs.setString(_prefsKey(uploadMode), encoded); + } + + String _prefsKey(UploadMode uploadMode) => + 'changeset_comments_seen_${uploadMode.name}'; + + 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'; + } + } +} diff --git a/lib/state/messages_state.dart b/lib/state/messages_state.dart index ea67351..ef53fa9 100644 --- a/lib/state/messages_state.dart +++ b/lib/state/messages_state.dart @@ -1,22 +1,28 @@ import 'package:flutter/material.dart'; import '../services/osm_messages_service.dart'; +import '../services/changeset_comments_service.dart'; import 'settings_state.dart'; -/// State management for OSM message notifications +/// State management for OSM message and changeset comment notifications class MessagesState extends ChangeNotifier { final OSMMessagesService _messagesService = OSMMessagesService(); + final ChangesetCommentsService _changesetCommentsService = ChangesetCommentsService(); int? _unreadCount; + int? _unreadChangesetId; // ID of a changeset with unread comments, if any bool _isChecking = false; // Getters int? get unreadCount => _unreadCount; bool get hasUnreadMessages => (_unreadCount ?? 0) > 0; + bool get hasUnreadChangesetComments => _unreadChangesetId != null; + bool get hasUnreadNotifications => hasUnreadMessages || hasUnreadChangesetComments; bool get isChecking => _isChecking; - /// Check for unread messages + /// Check for unread messages and changeset comments Future checkMessages({ required String? accessToken, + required String? username, required UploadMode uploadMode, bool forceRefresh = false, }) async { @@ -26,18 +32,29 @@ class MessagesState extends ChangeNotifier { notifyListeners(); try { - final count = await _messagesService.getUnreadMessageCount( - accessToken: accessToken, - uploadMode: uploadMode, - forceRefresh: forceRefresh, - ); + final results = await Future.wait([ + _messagesService.getUnreadMessageCount( + accessToken: accessToken, + uploadMode: uploadMode, + forceRefresh: forceRefresh, + ), + _changesetCommentsService.getUnreadChangesetId( + username: username, + uploadMode: uploadMode, + forceRefresh: forceRefresh, + ), + ]); - if (_unreadCount != count) { + final count = results[0]; + final unreadChangesetId = results[1]; + + if (_unreadCount != count || _unreadChangesetId != unreadChangesetId) { _unreadCount = count; + _unreadChangesetId = unreadChangesetId; notifyListeners(); } } catch (e) { - // Silently handle errors - messages are not critical + // Silently handle errors - notifications are not critical debugPrint('MessagesState: Error checking messages: $e'); } finally { _isChecking = false; @@ -45,6 +62,26 @@ class MessagesState extends ChangeNotifier { } } + /// URL of the changeset with unread comments, on OSM's website, or null + /// if there are no unread changeset comments. + String? getUnreadChangesetUrl(UploadMode uploadMode) { + final id = _unreadChangesetId; + if (id == null) return null; + return _changesetCommentsService.getChangesetUrl(id, uploadMode); + } + + /// Mark changeset comments as read (called when user views the changeset on OSM) + Future markChangesetCommentsRead({ + required String? username, + required UploadMode uploadMode, + }) async { + await _changesetCommentsService.markRead(username: username, uploadMode: uploadMode); + if (_unreadChangesetId != null) { + _unreadChangesetId = null; + notifyListeners(); + } + } + /// Get the URL to view messages String getMessagesUrl(UploadMode uploadMode) { return _messagesService.getMessagesUrl(uploadMode); @@ -53,7 +90,9 @@ class MessagesState extends ChangeNotifier { /// Clear message state (when user logs out or changes mode) void clearMessages() { _unreadCount = null; + _unreadChangesetId = null; _messagesService.clearCache(); + _changesetCommentsService.clearCache(); notifyListeners(); } -} \ No newline at end of file +} diff --git a/lib/widgets/unread_notifications_dialog.dart b/lib/widgets/unread_notifications_dialog.dart new file mode 100644 index 0000000..c89d848 --- /dev/null +++ b/lib/widgets/unread_notifications_dialog.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import '../services/localization_service.dart'; + +/// Dialog shown once per session when the user has unread OSM messages +/// and/or unread changeset comments, prompting them to view their OSM +/// account details. +class UnreadNotificationsDialog extends StatelessWidget { + final VoidCallback onView; + final VoidCallback onDismiss; + + const UnreadNotificationsDialog({ + super.key, + required this.onView, + required this.onDismiss, + }); + + @override + Widget build(BuildContext context) { + final locService = LocalizationService.instance; + + return AlertDialog( + title: Row( + children: [ + Icon( + Icons.notifications_active_outlined, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text(locService.t('auth.unreadNotificationsTitle')), + ), + ], + ), + content: Text(locService.t('auth.unreadNotificationsMessage')), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onDismiss(); + }, + child: Text(locService.t('auth.reauthLater')), + ), + FilledButton.icon( + onPressed: () { + Navigator.of(context).pop(); + onView(); + }, + icon: const Icon(Icons.arrow_forward, size: 18), + label: Text(locService.t('auth.unreadNotificationsView')), + ), + ], + ); + } +}