mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-08-30 22:50:50 +02:00
add changeset comment tracking and message notification bubble on startup
This commit is contained in:
@@ -176,6 +176,8 @@ class AppState extends ChangeNotifier {
|
|||||||
// Messages state
|
// Messages state
|
||||||
int? get unreadMessageCount => _messagesState.unreadCount;
|
int? get unreadMessageCount => _messagesState.unreadCount;
|
||||||
bool get hasUnreadMessages => _messagesState.hasUnreadMessages;
|
bool get hasUnreadMessages => _messagesState.hasUnreadMessages;
|
||||||
|
bool get hasUnreadChangesetComments => _messagesState.hasUnreadChangesetComments;
|
||||||
|
bool get hasUnreadNotifications => _messagesState.hasUnreadNotifications;
|
||||||
bool get isCheckingMessages => _messagesState.isChecking;
|
bool get isCheckingMessages => _messagesState.isChecking;
|
||||||
|
|
||||||
// Tile provider state
|
// Tile provider state
|
||||||
@@ -331,6 +333,7 @@ class AppState extends ChangeNotifier {
|
|||||||
final accessToken = await _authState.getAccessToken();
|
final accessToken = await _authState.getAccessToken();
|
||||||
await _messagesState.checkMessages(
|
await _messagesState.checkMessages(
|
||||||
accessToken: accessToken,
|
accessToken: accessToken,
|
||||||
|
username: isLoggedIn ? username : null,
|
||||||
uploadMode: uploadMode,
|
uploadMode: uploadMode,
|
||||||
forceRefresh: forceRefresh,
|
forceRefresh: forceRefresh,
|
||||||
);
|
);
|
||||||
@@ -340,10 +343,24 @@ class AppState extends ChangeNotifier {
|
|||||||
return _messagesState.getMessagesUrl(uploadMode);
|
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() {
|
void clearMessages() {
|
||||||
_messagesState.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
|
/// Check if the current OAuth token has required scopes for message notifications
|
||||||
/// Returns true if re-authentication is needed
|
/// Returns true if re-authentication is needed
|
||||||
Future<bool> needsReauthForMessages() async {
|
Future<bool> needsReauthForMessages() async {
|
||||||
|
|||||||
@@ -235,7 +235,12 @@
|
|||||||
"reauthExplanation": "Sie müssen Ihre Authentifizierung aktualisieren, um OSM-Nachrichtenbenachrichtigungen über die App zu erhalten.",
|
"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.",
|
"reauthBenefit": "Dies ermöglicht Benachrichtigungspunkte, wenn Sie ungelesene Nachrichten auf OpenStreetMap haben.",
|
||||||
"reauthNow": "Jetzt machen",
|
"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": {
|
"queue": {
|
||||||
"title": "Upload-Warteschlange",
|
"title": "Upload-Warteschlange",
|
||||||
|
|||||||
@@ -267,11 +267,16 @@
|
|||||||
"viewMessages": "View Messages on OSM",
|
"viewMessages": "View Messages on OSM",
|
||||||
"unreadMessagesCount": "You have {} unread messages",
|
"unreadMessagesCount": "You have {} unread messages",
|
||||||
"noUnreadMessages": "No unread messages",
|
"noUnreadMessages": "No unread messages",
|
||||||
|
"newChangesetComments": "New Changeset Comment",
|
||||||
|
"newChangesetCommentsSubtitle": "Someone commented on one or more of your changesets",
|
||||||
"reauthRequired": "Refresh Authentication",
|
"reauthRequired": "Refresh Authentication",
|
||||||
"reauthExplanation": "You must refresh your authentication to receive OSM message notifications through the app.",
|
"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.",
|
"reauthBenefit": "This will enable notification dots when you have unread messages on OpenStreetMap.",
|
||||||
"reauthNow": "Do That Now",
|
"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": {
|
"queue": {
|
||||||
"title": "Upload Queue",
|
"title": "Upload Queue",
|
||||||
|
|||||||
@@ -272,7 +272,12 @@
|
|||||||
"reauthExplanation": "Debes actualizar tu autenticación para recibir notificaciones de mensajes OSM a través de la aplicación.",
|
"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.",
|
"reauthBenefit": "Esto habilitará puntos de notificación cuando tengas mensajes sin leer en OpenStreetMap.",
|
||||||
"reauthNow": "Hazlo Ahora",
|
"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": {
|
"queue": {
|
||||||
"title": "Cola de Subida",
|
"title": "Cola de Subida",
|
||||||
|
|||||||
@@ -272,7 +272,12 @@
|
|||||||
"reauthExplanation": "Vous devez actualiser votre authentification pour recevoir des notifications de messages OSM via l'application.",
|
"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.",
|
"reauthBenefit": "Cela activera les points de notification lorsque vous avez des messages non lus sur OpenStreetMap.",
|
||||||
"reauthNow": "Le Faire Maintenant",
|
"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": {
|
"queue": {
|
||||||
"title": "File de Téléchargement",
|
"title": "File de Téléchargement",
|
||||||
|
|||||||
@@ -272,7 +272,12 @@
|
|||||||
"reauthExplanation": "Devi aggiornare la tua autenticazione per ricevere notifiche di messaggi OSM tramite l'app.",
|
"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.",
|
"reauthBenefit": "Questo abiliterà i punti di notifica quando hai messaggi non letti su OpenStreetMap.",
|
||||||
"reauthNow": "Fallo Ora",
|
"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": {
|
"queue": {
|
||||||
"title": "Coda di Upload",
|
"title": "Coda di Upload",
|
||||||
|
|||||||
@@ -272,7 +272,12 @@
|
|||||||
"reauthExplanation": "U moet uw authenticatie verversen om OSM bericht meldingen te ontvangen via de app.",
|
"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.",
|
"reauthBenefit": "Dit zal melding stippen inschakelen wanneer u ongelezen berichten heeft op OpenStreetMap.",
|
||||||
"reauthNow": "Doe Dat Nu",
|
"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": {
|
"queue": {
|
||||||
"title": "Upload Wachtrij",
|
"title": "Upload Wachtrij",
|
||||||
|
|||||||
@@ -272,7 +272,12 @@
|
|||||||
"reauthExplanation": "Musisz odświeżyć uwierzytelnienie, aby otrzymywać powiadomienia o wiadomościach OSM przez aplikację.",
|
"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.",
|
"reauthBenefit": "To włączy kropki powiadomień, gdy masz nieprzeczytane wiadomości w OpenStreetMap.",
|
||||||
"reauthNow": "Zrób To Teraz",
|
"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": {
|
"queue": {
|
||||||
"title": "Kolejka Przesyłania",
|
"title": "Kolejka Przesyłania",
|
||||||
|
|||||||
@@ -272,7 +272,12 @@
|
|||||||
"reauthExplanation": "Você deve atualizar sua autenticação para receber notificações de mensagens OSM através do aplicativo.",
|
"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.",
|
"reauthBenefit": "Isso habilitará pontos de notificação quando você tiver mensagens não lidas no OpenStreetMap.",
|
||||||
"reauthNow": "Fazer Agora",
|
"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": {
|
"queue": {
|
||||||
"title": "Fila de Upload",
|
"title": "Fila de Upload",
|
||||||
|
|||||||
@@ -272,7 +272,12 @@
|
|||||||
"reauthExplanation": "Uygulama üzerinden OSM mesaj bildirimlerini alabilmek için kimlik doğrulamanızı yenilemeniz gerekir.",
|
"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.",
|
"reauthBenefit": "Bu, OpenStreetMap'te okunmamış mesajlarınız olduğunda bildirim noktalarını etkinleştirecek.",
|
||||||
"reauthNow": "Şimdi Yap",
|
"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": {
|
"queue": {
|
||||||
"title": "Yükleme Kuyruğu",
|
"title": "Yükleme Kuyruğu",
|
||||||
|
|||||||
@@ -272,7 +272,12 @@
|
|||||||
"reauthExplanation": "Ви повинні оновити вашу автентифікацію, щоб отримувати сповіщення про повідомлення OSM через додаток.",
|
"reauthExplanation": "Ви повинні оновити вашу автентифікацію, щоб отримувати сповіщення про повідомлення OSM через додаток.",
|
||||||
"reauthBenefit": "Це дозволить показувати точки сповіщень, коли у вас є непрочитані повідомлення в OpenStreetMap.",
|
"reauthBenefit": "Це дозволить показувати точки сповіщень, коли у вас є непрочитані повідомлення в OpenStreetMap.",
|
||||||
"reauthNow": "Зробити Зараз",
|
"reauthNow": "Зробити Зараз",
|
||||||
"reauthLater": "Пізніше"
|
"newChangesetComments": "Новий Коментар до Набору Змін",
|
||||||
|
"newChangesetCommentsSubtitle": "Хтось прокоментував один або декілька ваших наборів змін",
|
||||||
|
"reauthLater": "Пізніше",
|
||||||
|
"unreadNotificationsTitle": "У Вас Є Непрочитані Сповіщення",
|
||||||
|
"unreadNotificationsMessage": "У вас є непрочитані повідомлення OSM та/або нові коментарі до ваших наборів змін.",
|
||||||
|
"unreadNotificationsView": "Переглянути Зараз"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"title": "Черга Завантаження",
|
"title": "Черга Завантаження",
|
||||||
|
|||||||
@@ -272,7 +272,12 @@
|
|||||||
"reauthExplanation": "您必须刷新身份验证才能通过应用接收 OSM 消息通知。",
|
"reauthExplanation": "您必须刷新身份验证才能通过应用接收 OSM 消息通知。",
|
||||||
"reauthBenefit": "这将在您在 OpenStreetMap 上有未读消息时启用通知点。",
|
"reauthBenefit": "这将在您在 OpenStreetMap 上有未读消息时启用通知点。",
|
||||||
"reauthNow": "现在执行",
|
"reauthNow": "现在执行",
|
||||||
"reauthLater": "稍后"
|
"newChangesetComments": "新的变更集评论",
|
||||||
|
"newChangesetCommentsSubtitle": "有人评论了您的一个或多个变更集",
|
||||||
|
"reauthLater": "稍后",
|
||||||
|
"unreadNotificationsTitle": "您有未读通知",
|
||||||
|
"unreadNotificationsMessage": "您有未读的 OSM 消息和/或对您变更集的新评论。",
|
||||||
|
"unreadNotificationsView": "立即查看"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"title": "上传队列",
|
"title": "上传队列",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import '../widgets/search_bar.dart';
|
|||||||
import '../widgets/suspected_location_sheet.dart';
|
import '../widgets/suspected_location_sheet.dart';
|
||||||
import '../widgets/welcome_dialog.dart';
|
import '../widgets/welcome_dialog.dart';
|
||||||
import '../widgets/changelog_dialog.dart';
|
import '../widgets/changelog_dialog.dart';
|
||||||
|
import '../widgets/unread_notifications_dialog.dart';
|
||||||
import '../models/osm_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../models/suspected_location.dart';
|
import '../models/suspected_location.dart';
|
||||||
import '../models/search_result.dart';
|
import '../models/search_result.dart';
|
||||||
@@ -54,6 +55,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
// Track popup display to avoid showing multiple times
|
// Track popup display to avoid showing multiple times
|
||||||
bool _hasCheckedForPopup = false;
|
bool _hasCheckedForPopup = false;
|
||||||
|
|
||||||
|
// Track whether we've shown the unread notifications popup this session
|
||||||
|
bool _hasShownUnreadNotificationsPopup = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.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() {
|
void _onStartRoute() {
|
||||||
_navigationCoordinator.startRoute(
|
_navigationCoordinator.startRoute(
|
||||||
context: context,
|
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,
|
// Auto-focus a node's details sheet right after it was submitted/edited/deleted,
|
||||||
// behind kAutoOpenNodeSheetAfterSubmit (pending A/B testing/team feedback).
|
// behind kAutoOpenNodeSheetAfterSubmit (pending A/B testing/team feedback).
|
||||||
if (kAutoOpenNodeSheetAfterSubmit) {
|
if (kAutoOpenNodeSheetAfterSubmit) {
|
||||||
@@ -510,23 +542,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
return IconButton(
|
return IconButton(
|
||||||
tooltip: LocalizationService.instance.settings,
|
tooltip: LocalizationService.instance.settings,
|
||||||
icon: Stack(
|
icon: Badge(
|
||||||
children: [
|
isLabelVisible: appState.hasUnreadNotifications,
|
||||||
const Icon(Icons.settings),
|
child: 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
// Messages button - only show when not in simulate mode
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Stack(
|
leading: Badge(
|
||||||
children: [
|
isLabelVisible: appState.hasUnreadMessages,
|
||||||
const Icon(Icons.message),
|
label: Text('${appState.unreadMessageCount ?? 0}'),
|
||||||
if (appState.hasUnreadMessages)
|
child: const Icon(Icons.message),
|
||||||
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')),
|
title: Text(locService.t('auth.viewMessages')),
|
||||||
subtitle: Text(appState.hasUnreadMessages
|
subtitle: Text(appState.hasUnreadMessages
|
||||||
|
|||||||
@@ -117,23 +117,9 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Stack(
|
leading: Badge(
|
||||||
children: [
|
isLabelVisible: appState.hasUnreadNotifications,
|
||||||
const Icon(Icons.account_circle),
|
child: 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
title: Text(locService.t('auth.osmAccountTitle')),
|
title: Text(locService.t('auth.osmAccountTitle')),
|
||||||
subtitle: Text(locService.t('auth.osmAccountSubtitle')),
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/osm_messages_service.dart';
|
import '../services/osm_messages_service.dart';
|
||||||
|
import '../services/changeset_comments_service.dart';
|
||||||
import 'settings_state.dart';
|
import 'settings_state.dart';
|
||||||
|
|
||||||
/// State management for OSM message notifications
|
/// State management for OSM message and changeset comment notifications
|
||||||
class MessagesState extends ChangeNotifier {
|
class MessagesState extends ChangeNotifier {
|
||||||
final OSMMessagesService _messagesService = OSMMessagesService();
|
final OSMMessagesService _messagesService = OSMMessagesService();
|
||||||
|
final ChangesetCommentsService _changesetCommentsService = ChangesetCommentsService();
|
||||||
|
|
||||||
int? _unreadCount;
|
int? _unreadCount;
|
||||||
|
int? _unreadChangesetId; // ID of a changeset with unread comments, if any
|
||||||
bool _isChecking = false;
|
bool _isChecking = false;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
int? get unreadCount => _unreadCount;
|
int? get unreadCount => _unreadCount;
|
||||||
bool get hasUnreadMessages => (_unreadCount ?? 0) > 0;
|
bool get hasUnreadMessages => (_unreadCount ?? 0) > 0;
|
||||||
|
bool get hasUnreadChangesetComments => _unreadChangesetId != null;
|
||||||
|
bool get hasUnreadNotifications => hasUnreadMessages || hasUnreadChangesetComments;
|
||||||
bool get isChecking => _isChecking;
|
bool get isChecking => _isChecking;
|
||||||
|
|
||||||
/// Check for unread messages
|
/// Check for unread messages and changeset comments
|
||||||
Future<void> checkMessages({
|
Future<void> checkMessages({
|
||||||
required String? accessToken,
|
required String? accessToken,
|
||||||
|
required String? username,
|
||||||
required UploadMode uploadMode,
|
required UploadMode uploadMode,
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -26,18 +32,29 @@ class MessagesState extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final count = await _messagesService.getUnreadMessageCount(
|
final results = await Future.wait([
|
||||||
|
_messagesService.getUnreadMessageCount(
|
||||||
accessToken: accessToken,
|
accessToken: accessToken,
|
||||||
uploadMode: uploadMode,
|
uploadMode: uploadMode,
|
||||||
forceRefresh: forceRefresh,
|
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;
|
_unreadCount = count;
|
||||||
|
_unreadChangesetId = unreadChangesetId;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle errors - messages are not critical
|
// Silently handle errors - notifications are not critical
|
||||||
debugPrint('MessagesState: Error checking messages: $e');
|
debugPrint('MessagesState: Error checking messages: $e');
|
||||||
} finally {
|
} finally {
|
||||||
_isChecking = false;
|
_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
|
/// Get the URL to view messages
|
||||||
String getMessagesUrl(UploadMode uploadMode) {
|
String getMessagesUrl(UploadMode uploadMode) {
|
||||||
return _messagesService.getMessagesUrl(uploadMode);
|
return _messagesService.getMessagesUrl(uploadMode);
|
||||||
@@ -53,7 +90,9 @@ class MessagesState extends ChangeNotifier {
|
|||||||
/// Clear message state (when user logs out or changes mode)
|
/// Clear message state (when user logs out or changes mode)
|
||||||
void clearMessages() {
|
void clearMessages() {
|
||||||
_unreadCount = null;
|
_unreadCount = null;
|
||||||
|
_unreadChangesetId = null;
|
||||||
_messagesService.clearCache();
|
_messagesService.clearCache();
|
||||||
|
_changesetCommentsService.clearCache();
|
||||||
notifyListeners();
|
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')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user