Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions lib/features/settings/providers/announcements_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'package:mostro/src/rust/api/announcements.dart' as announcements_api;

/// The Announcements toggle (specs/006-announcement-channel §7).
///
/// Unlike its four neighbours, which are `shared_preferences` keys, this one
/// lives in the Rust settings table: the value decides whether Rust opens a
/// relay subscription, and the authority for that is on the side that opens
/// it. Off means the subscription is closed at the tap, not that arriving
/// notices are hidden.

/// The Rust side of the toggle. Injected so screen tests never reach the
/// bridge.
class AnnouncementsBridge {
const AnnouncementsBridge();

Future<bool> enabled() => announcements_api.getAnnouncementsEnabled();

Future<void> setEnabled(bool enabled) =>
announcements_api.setAnnouncementsEnabled(enabled: enabled);
}

final announcementsBridgeProvider = Provider<AnnouncementsBridge>(
(ref) => const AnnouncementsBridge(),
);

class AnnouncementsEnabledNotifier extends StateNotifier<bool> {
AnnouncementsEnabledNotifier({required AnnouncementsBridge bridge})
: _bridge = bridge,
// On until Rust says otherwise: the persisted default (§7).
super(true) {
_load();
}

final AnnouncementsBridge _bridge;

/// Taps so far. A load that answers after one read the setting before its
/// write, so its value is older than the screen's; a failed write reloads
/// only when no newer tap has replaced it.
int _taps = 0;

/// Writes go one after another: two quick taps otherwise race to the
/// store, and the older one can land last.
Future<void> _writes = Future.value();

Future<void> _load() async {
try {
final enabled = await _bridge.enabled();
if (mounted && _taps == 0) state = enabled;
} catch (e) {
debugPrint('[announcements] reading the toggle failed: $e');
}
}

/// Applies the change to the UI first and persists after, so the toggle
/// answers the tap; a failed write is reported by returning false and the
/// toggle going back to what Rust holds.
Future<bool> set(bool value) {
final tap = ++_taps;
final before = state;
state = value;
final write = _writes.then((_) => _write(value, tap, before));
_writes = write.then((_) {});
return write;
}

Future<bool> _write(bool value, int tap, bool before) async {
try {
await _bridge.setEnabled(value);
return true;
} catch (e) {
debugPrint('[announcements] saving the toggle failed: $e');
if (mounted && tap == _taps) await _reload(tap, before);
return false;
}
}

/// After a refused write the screen shows a value Rust never took. What it
/// showed before the tap may be stale too — the load can still be in
/// flight — so Rust is asked again; a tap that lands meanwhile wins.
Future<void> _reload(int tap, bool before) async {
bool enabled;
try {
enabled = await _bridge.enabled();
} catch (e) {
debugPrint('[announcements] re-reading the toggle failed: $e');
enabled = before;
}
if (mounted && tap == _taps) state = enabled;
}
}

final announcementsEnabledProvider =
StateNotifierProvider<AnnouncementsEnabledNotifier, bool>(
(ref) => AnnouncementsEnabledNotifier(
bridge: ref.watch(announcementsBridgeProvider),
),
);
156 changes: 113 additions & 43 deletions lib/features/settings/screens/notification_settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:mostro/core/app_routes.dart';
import 'package:mostro/core/order_book_palette.dart';
import 'package:mostro/core/settings_palette.dart';
import 'package:mostro/features/settings/models/push_status_line.dart';
import 'package:mostro/features/settings/providers/announcements_provider.dart';
import 'package:mostro/features/settings/providers/notification_permission_provider.dart';
import 'package:mostro/features/settings/providers/notification_prefs_provider.dart';
import 'package:mostro/features/settings/providers/push_settings_provider.dart';
Expand All @@ -28,6 +29,12 @@ import 'package:mostro/shared/widgets/redesign_app_bar.dart';
///
/// The event rows gate the in-app cards, not the push: a push carries no
/// type to filter on (docs/PUSH_NOTIFICATIONS.md §9.1).
///
/// A third card holds the Announcements toggle (specs/006 §7): the project's
/// signed notices, read from the relays. Off closes that subscription at the
/// tap, which is why its value lives in Rust rather than in `shared_preferences`
/// like the four rows above it — and why the OS permission does not gate it:
/// an announcement posts no system notification.
class NotificationSettingsScreen extends ConsumerWidget {
const NotificationSettingsScreen({super.key});

Expand Down Expand Up @@ -125,6 +132,19 @@ class NotificationSettingsScreen extends ConsumerWidget {
),
),
const SizedBox(height: settingsGroupGap),
Padding(
padding: const EdgeInsets.fromLTRB(2, 0, 2, 14),
child: Text(
l10n.announcementsGroupSubtitle,
style: TextStyle(
fontSize: 12,
height: 1.5,
color: book.textSecondary,
),
),
),
const SettingsGroup(rows: [_AnnouncementsRow()]),
const SizedBox(height: settingsGroupGap),
SettingsFootnote(
icon: Icons.lock_outline,
// Relevant in a privacy app: pushes travel through Google/Apple.
Expand Down Expand Up @@ -188,7 +208,7 @@ class _PushMasterRowState extends ConsumerState<_PushMasterRow> {
value
? Icons.notifications_active_outlined
: Icons.notifications_off_outlined,
size: 17,
size: 16,
color: value ? book.limeIcon : book.textTertiary,
),
const SizedBox(width: 12),
Expand Down Expand Up @@ -320,7 +340,7 @@ class _UnsupportedPlatformRow extends StatelessWidget {
children: [
Icon(
Icons.notifications_off_outlined,
size: 17,
size: 16,
color: book.textTertiary,
),
const SizedBox(width: 12),
Expand Down Expand Up @@ -355,12 +375,99 @@ class _EventRow extends ConsumerWidget {
final bool value;
final bool enabled;

@override
Widget build(BuildContext context, WidgetRef ref) {
final (title, description) = _copy(AppLocalizations.of(context), event);
return _ToggleRow(
icon: icon,
title: title,
description: description,
value: value,
onChanged: enabled ? (next) => _set(context, ref, next) : null,
);
}

Future<void> _set(BuildContext context, WidgetRef ref, bool next) async {
final ok = await ref
.read(notificationPrefsProvider.notifier)
.setEvent(event, next);
if (!ok && context.mounted) _saveFailed(context);
}

static (String, String) _copy(
AppLocalizations l10n,
NotificationEvent event,
) => switch (event) {
NotificationEvent.tradeUpdates => (
l10n.notifTradeUpdatesTitle,
l10n.notifTradeUpdatesSubtitle,
),
NotificationEvent.newMessages => (
l10n.notifNewMessagesTitle,
l10n.notifNewMessagesSubtitle,
),
NotificationEvent.paymentAlerts => (
l10n.notifPaymentAlertsTitle,
l10n.notifPaymentAlertsSubtitle,
),
NotificationEvent.disputeUpdates => (
l10n.notifDisputeUpdatesTitle,
l10n.notifDisputeUpdatesSubtitle,
),
};
}

/// The Announcements toggle (specs/006 §7), persisted by Rust: off closes
/// the relay subscription at the tap. Never gated by the OS permission.
class _AnnouncementsRow extends ConsumerWidget {
const _AnnouncementsRow();

@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final book = OrderBookPalette.of(context);
final (title, description) = _copy(l10n, event);
return _ToggleRow(
icon: Icons.campaign_outlined,
title: l10n.notifAnnouncementsTitle,
description: l10n.notifAnnouncementsSubtitle,
value: ref.watch(announcementsEnabledProvider),
onChanged: (next) => _set(context, ref, next),
);
}

Future<void> _set(BuildContext context, WidgetRef ref, bool next) async {
final ok = await ref.read(announcementsEnabledProvider.notifier).set(next);
if (!ok && context.mounted) _saveFailed(context);
}
}

void _saveFailed(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).notificationPrefSaveFailed),
),
);
}

/// One row of a settings group: a glyph, a 13/600 title over an 11/400
/// description, and a toggle. [onChanged] null disables the toggle.
class _ToggleRow extends StatelessWidget {
const _ToggleRow({
required this.icon,
required this.title,
required this.description,
required this.value,
required this.onChanged,
});

final IconData icon;
final String title;
final String description;
final bool value;
final ValueChanged<bool>? onChanged;

@override
Widget build(BuildContext context) {
final book = OrderBookPalette.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
child: Row(
Expand All @@ -370,7 +477,7 @@ class _EventRow extends ConsumerWidget {
children: [
Icon(
icon,
size: 17,
size: 16,
color: value ? book.limeIcon : book.textTertiary,
),
const SizedBox(width: 12),
Expand Down Expand Up @@ -402,49 +509,12 @@ class _EventRow extends ConsumerWidget {
MostroToggle(
value: value,
semanticLabel: title,
onChanged: enabled ? (next) => _set(context, ref, next) : null,
onChanged: onChanged,
),
],
),
);
}

Future<void> _set(BuildContext context, WidgetRef ref, bool next) async {
final ok = await ref
.read(notificationPrefsProvider.notifier)
.setEvent(event, next);
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).notificationPrefSaveFailed,
),
),
);
}
}

static (String, String) _copy(
AppLocalizations l10n,
NotificationEvent event,
) => switch (event) {
NotificationEvent.tradeUpdates => (
l10n.notifTradeUpdatesTitle,
l10n.notifTradeUpdatesSubtitle,
),
NotificationEvent.newMessages => (
l10n.notifNewMessagesTitle,
l10n.notifNewMessagesSubtitle,
),
NotificationEvent.paymentAlerts => (
l10n.notifPaymentAlertsTitle,
l10n.notifPaymentAlertsSubtitle,
),
NotificationEvent.disputeUpdates => (
l10n.notifDisputeUpdatesTitle,
l10n.notifDisputeUpdatesSubtitle,
),
};
}

// ── Permission banners ────────────────────────────────────────────────────────
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,9 @@
"notifPaymentAlertsSubtitle": "Bestätigungen und Fehler von Lightning-Zahlungen",
"notifDisputeUpdatesTitle": "Streitfall-Updates",
"notifDisputeUpdatesSubtitle": "Schlichter-Aktionen und Streitbeilegungen",
"notifAnnouncementsTitle": "Ankündigungen",
"notifAnnouncementsSubtitle": "Versionen, Ausfälle und Relay-Änderungen, signiert vom Mostro-Projekt",
"announcementsGroupSubtitle": "Ankündigungen werden von den Relays gelesen und nie beantwortet: Nichts über dich wird gesendet.",
"searchCurrenciesHint": "Währungen suchen…",
"noCurrenciesFoundMessage": "Keine Währungen gefunden",
"shareLogsTooltip": "Protokolle teilen",
Expand Down
12 changes: 12 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1714,6 +1714,18 @@
"@notifDisputeUpdatesSubtitle": {
"description": "Subtitle of the dispute updates notification toggle"
},
"notifAnnouncementsTitle": "Announcements",
"@notifAnnouncementsTitle": {
"description": "Title of the Announcements toggle in notification settings (specs/006)"
},
"notifAnnouncementsSubtitle": "Releases, outages and relay changes, signed by the Mostro project",
"@notifAnnouncementsSubtitle": {
"description": "Subtitle of the Announcements toggle"
},
"announcementsGroupSubtitle": "Announcements are read from the relays and never answered: nothing about you is sent.",
"@announcementsGroupSubtitle": {
"description": "Line above the Announcements toggle: the channel is one-way, nothing is reported back"
},
"searchCurrenciesHint": "Search currencies…",
"@searchCurrenciesHint": {
"description": "Hint text of the currency search field"
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,9 @@
"notifPaymentAlertsSubtitle": "Confirmaciones y fallos de pagos Lightning",
"notifDisputeUpdatesTitle": "Actualizaciones de disputas",
"notifDisputeUpdatesSubtitle": "Acciones de los resolutores y resoluciones de disputas",
"notifAnnouncementsTitle": "Anuncios",
"notifAnnouncementsSubtitle": "Versiones, caídas y cambios de relays, firmados por el proyecto Mostro",
"announcementsGroupSubtitle": "Los anuncios se leen de los relays y nunca se responden: no se envía nada sobre ti.",
"searchCurrenciesHint": "Buscar monedas…",
"noCurrenciesFoundMessage": "No se encontraron monedas",
"shareLogsTooltip": "Compartir registros",
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,9 @@
"notifPaymentAlertsSubtitle": "Confirmations et échecs de paiements Lightning",
"notifDisputeUpdatesTitle": "Mises à jour des litiges",
"notifDisputeUpdatesSubtitle": "Actions des médiateurs et résolutions de litiges",
"notifAnnouncementsTitle": "Annonces",
"notifAnnouncementsSubtitle": "Versions, pannes et changements de relais, signés par le projet Mostro",
"announcementsGroupSubtitle": "Les annonces sont lues depuis les relais et jamais répondues : rien vous concernant n'est envoyé.",
"searchCurrenciesHint": "Rechercher des devises…",
"noCurrenciesFoundMessage": "Aucune devise trouvée",
"shareLogsTooltip": "Partager les journaux",
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_it.arb
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,9 @@
"notifPaymentAlertsSubtitle": "Conferme ed errori dei pagamenti Lightning",
"notifDisputeUpdatesTitle": "Aggiornamenti dispute",
"notifDisputeUpdatesSubtitle": "Azioni dei mediatori e risoluzioni delle dispute",
"notifAnnouncementsTitle": "Annunci",
"notifAnnouncementsSubtitle": "Versioni, interruzioni e cambi di relay, firmati dal progetto Mostro",
"announcementsGroupSubtitle": "Gli annunci vengono letti dai relay e mai risposti: non viene inviato nulla su di te.",
"searchCurrenciesHint": "Cerca valute…",
"noCurrenciesFoundMessage": "Nessuna valuta trovata",
"shareLogsTooltip": "Condividi i log",
Expand Down
Loading
Loading