add changeset comment tracking and message notification bubble on startup

This commit is contained in:
stopflock
2026-08-24 18:03:03 -05:00
parent bdc5399c9f
commit 0e359f2aa9
18 changed files with 443 additions and 84 deletions
+17
View File
@@ -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<void> 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<bool> needsReauthForMessages() async {
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -272,7 +272,12 @@
"reauthExplanation": "Ви повинні оновити вашу автентифікацію, щоб отримувати сповіщення про повідомлення OSM через додаток.",
"reauthBenefit": "Це дозволить показувати точки сповіщень, коли у вас є непрочитані повідомлення в OpenStreetMap.",
"reauthNow": "Зробити Зараз",
"reauthLater": "Пізніше"
"newChangesetComments": "Новий Коментар до Набору Змін",
"newChangesetCommentsSubtitle": "Хтось прокоментував один або декілька ваших наборів змін",
"reauthLater": "Пізніше",
"unreadNotificationsTitle": "У Вас Є Непрочитані Сповіщення",
"unreadNotificationsMessage": "У вас є непрочитані повідомлення OSM та/або нові коментарі до ваших наборів змін.",
"unreadNotificationsView": "Переглянути Зараз"
},
"queue": {
"title": "Черга Завантаження",
+6 -1
View File
@@ -272,7 +272,12 @@
"reauthExplanation": "您必须刷新身份验证才能通过应用接收 OSM 消息通知。",
"reauthBenefit": "这将在您在 OpenStreetMap 上有未读消息时启用通知点。",
"reauthNow": "现在执行",
"reauthLater": "稍后"
"newChangesetComments": "新的变更集评论",
"newChangesetCommentsSubtitle": "有人评论了您的一个或多个变更集",
"reauthLater": "稍后",
"unreadNotificationsTitle": "您有未读通知",
"unreadNotificationsMessage": "您有未读的 OSM 消息和/或对您变更集的新评论。",
"unreadNotificationsView": "立即查看"
},
"queue": {
"title": "上传队列",
+35 -17
View File
@@ -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<HomeScreen> 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<HomeScreen> 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<HomeScreen> 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<HomeScreen> with TickerProviderStateMixin {
final appState = context.watch<AppState>();
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'),
);
+37 -29
View File
@@ -128,38 +128,46 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
},
),
// 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
+3 -17
View File
@@ -117,23 +117,9 @@ class SettingsScreen extends StatelessWidget {
final appState = context.watch<AppState>();
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')),
@@ -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<int?> 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<void> 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<Map<int, int>?> _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 = <int, int>{};
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<Map<int, int>> _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<String, dynamic>;
return decoded.map((k, v) => MapEntry(int.parse(k), v as int));
} catch (e) {
return {};
}
}
Future<void> _saveSeenCounts(UploadMode uploadMode, Map<int, int> 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';
}
}
}
+49 -10
View File
@@ -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<void> 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] as int?;
final unreadChangesetId = results[1] as int?;
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<void> 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();
}
}
}
@@ -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')),
),
],
);
}
}