mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Implement push notifications Phase 1 (FCM + project relay)
Backend (Rust): - push_tokens migration, model (PushToken/PushPlatform), and db layer (upsert/list/delete/delete_many) - PUT/DELETE /api/push_tokens endpoints wired into the router + OpenAPI - "push" notification method: relay sender (reqwest) that forwards only the sanitized title/body and prunes dead tokens, plus settings with a default relay_url - 164 tests pass, clippy clean Relay (push_relay/, TypeScript Firebase Cloud Function): - POST /v1/push (firebase-admin sendEach + per-token status mapping), GET /healthz, payload validation, per-IP Firestore rate limiting - Jest tests + README documenting the manual project-owned setup Frontend (Flutter): - oott_api_push.dart (register/unregister), push_service.dart behind a PushService abstraction, and a per-device push toggle in settings - firebase_core/firebase_messaging/flutter_local_notifications deps - 145 tests pass, dart analyze clean Dev shell: - add nodejs_22 to the Nix dev shell so the relay tests/build run locally Remaining (manual, project-owned): create the Firebase project, deploy the relay and set the real default_relay_url, add native Firebase config, and test on real devices. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fa54361198
commit
4f3fe10332
@@ -7,10 +7,15 @@ import '../theme/app_colors.dart';
|
||||
import '../theme/dimens.dart';
|
||||
import '../utils/oott_api.dart';
|
||||
import '../utils/pref_utils.dart';
|
||||
import '../utils/push_service.dart';
|
||||
import '../utils/ui_snackbars.dart';
|
||||
|
||||
class Settings extends StatefulWidget {
|
||||
const Settings({super.key});
|
||||
const Settings({super.key, this.pushService});
|
||||
|
||||
/// Injectable so widget tests can supply a fake; defaults to the real
|
||||
/// FCM-backed service.
|
||||
final PushService? pushService;
|
||||
|
||||
@override
|
||||
State<Settings> createState() => _SettingsState();
|
||||
@@ -24,6 +29,9 @@ class _SettingsState extends State<Settings> {
|
||||
bool _connectionModified = false;
|
||||
bool _isFirstRun = false;
|
||||
late String _selectedTheme;
|
||||
late final PushService _pushService;
|
||||
bool _pushEnabled = false;
|
||||
bool _pushBusy = false;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@@ -36,6 +44,8 @@ class _SettingsState extends State<Settings> {
|
||||
PrefUtil.getValue('api_key', '') as String,
|
||||
);
|
||||
_selectedTheme = context.read<AppState>().themeKey;
|
||||
_pushService = widget.pushService ?? FirebasePushService();
|
||||
_pushEnabled = PrefUtil.getValue('push_enabled', false) as bool;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -70,6 +80,50 @@ class _SettingsState extends State<Settings> {
|
||||
}
|
||||
}
|
||||
|
||||
// Enable or disable push on this specific device. The toggle reflects user
|
||||
// intent (persisted), but enabling can still fail if the OS permission is
|
||||
// declined, in which case the switch falls back to off.
|
||||
Future<void> _togglePush(bool value) async {
|
||||
setState(() => _pushBusy = true);
|
||||
try {
|
||||
if (value) {
|
||||
final enabled = await _pushService.enable();
|
||||
if (!mounted) return;
|
||||
if (enabled) {
|
||||
await PrefUtil.setValue('push_enabled', true);
|
||||
if (!mounted) return;
|
||||
setState(() => _pushEnabled = true);
|
||||
UISnackbars.showSuccess(
|
||||
context,
|
||||
'Push notifications enabled on this device',
|
||||
);
|
||||
} else {
|
||||
setState(() => _pushEnabled = false);
|
||||
UISnackbars.showError(
|
||||
context,
|
||||
'Could not enable push. Check notification permission for OOTT.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await _pushService.disable();
|
||||
if (!mounted) return;
|
||||
await PrefUtil.setValue('push_enabled', false);
|
||||
if (!mounted) return;
|
||||
setState(() => _pushEnabled = false);
|
||||
UISnackbars.showSuccess(
|
||||
context,
|
||||
'Push notifications disabled on this device',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to update push settings: $e');
|
||||
if (!mounted) return;
|
||||
UISnackbars.showError(context, 'Failed to update push settings');
|
||||
} finally {
|
||||
if (mounted) setState(() => _pushBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
try {
|
||||
@@ -191,6 +245,18 @@ class _SettingsState extends State<Settings> {
|
||||
if (value != null) setState(() => _selectedTheme = value);
|
||||
},
|
||||
),
|
||||
if (_pushService.isSupported) ...[
|
||||
const SizedBox(height: Insets.sm),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Push notifications on this device'),
|
||||
subtitle: const Text(
|
||||
'Receive alerts on this device even when the app is closed.',
|
||||
),
|
||||
value: _pushEnabled,
|
||||
onChanged: _pushBusy ? null : _togglePush,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Insets.lg),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
|
||||
Reference in New Issue
Block a user