mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Enabling push still fails on iOS with getAPNSToken() returning null, so capture the APNs registration result natively to find out why. AppDelegate now overrides didRegister/didFailToRegister, logs the outcome, and exposes it over a oott/push_diagnostics method channel (super still calls through so Firebase swizzling is unaffected). apnsRegistrationStatus() reads that channel, and the settings push toggle now shows Apple's actual rejection reason in the error message when enabling fails, so it can be diagnosed without a Mac to read the device console. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
320 lines
10 KiB
Dart
320 lines
10 KiB
Dart
import 'package:encrypter/encrypter/xor.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
|
||
import '../main.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';
|
||
import 'backend_config_dialog.dart';
|
||
|
||
class Settings extends StatefulWidget {
|
||
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();
|
||
}
|
||
|
||
class _SettingsState extends State<Settings> {
|
||
String _baseUrl = '';
|
||
String _apiKey = '';
|
||
bool _apiKeyVisible = false;
|
||
bool _isFirstRun = false;
|
||
late String _selectedTheme;
|
||
late final PushService _pushService;
|
||
bool _pushEnabled = false;
|
||
bool _pushBusy = false;
|
||
// Whether the backend delivers notifications via push. The per-device push
|
||
// toggle only makes sense then, so it stays hidden until this is confirmed.
|
||
bool _pushMethodActive = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_readConnectionFromPrefs();
|
||
_isFirstRun = _baseUrl.isEmpty;
|
||
_selectedTheme = context.read<AppState>().themeKey;
|
||
_pushService = widget.pushService ?? FirebasePushService();
|
||
_pushEnabled = PrefUtil.getValue('push_enabled', false) as bool;
|
||
_loadConfig();
|
||
// First run / unconfigured: open the connection dialog immediately and keep
|
||
// it open (non-dismissible) until the user saves a working configuration.
|
||
if (_isFirstRun) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) _openConfigDialog(dismissible: false);
|
||
});
|
||
}
|
||
}
|
||
|
||
void _readConnectionFromPrefs() {
|
||
_baseUrl = PrefUtil.getValue('base_url', '') as String;
|
||
_apiKey = XOR().xorDecode(PrefUtil.getValue('api_key', '') as String);
|
||
}
|
||
|
||
// Learn the backend's notification method so the push toggle is only shown
|
||
// when the backend actually delivers via push. Failures (e.g. the backend is
|
||
// unreachable, as on first run) just leave the toggle hidden.
|
||
Future<void> _loadConfig() async {
|
||
try {
|
||
final config = await BackendAPI.instance.getConfig();
|
||
if (!mounted) return;
|
||
setState(() => _pushMethodActive = config.notificationMethod == 'push');
|
||
} catch (e) {
|
||
debugPrint('Failed to load backend config: $e');
|
||
}
|
||
}
|
||
|
||
// Opens the backend connection dialog and, on save, refreshes the screen with
|
||
// the new connection details and re-reads the backend config.
|
||
Future<void> _openConfigDialog({bool dismissible = true}) async {
|
||
final saved = await showBackendConfigDialog(
|
||
context,
|
||
dismissible: dismissible,
|
||
);
|
||
if (!mounted || !saved) return;
|
||
setState(() {
|
||
_isFirstRun = false;
|
||
_readConnectionFromPrefs();
|
||
});
|
||
_loadConfig();
|
||
}
|
||
|
||
// 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);
|
||
// On iOS, surface the native APNs registration reason (if any) so the
|
||
// failure can be diagnosed without a Mac to read the device console.
|
||
final apnsStatus = await apnsRegistrationStatus();
|
||
if (!mounted) return;
|
||
UISnackbars.showError(
|
||
context,
|
||
apnsStatus != null
|
||
? 'Could not enable push. $apnsStatus'
|
||
: '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);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
final textTheme = Theme.of(context).textTheme;
|
||
|
||
return SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (_isFirstRun) ...[
|
||
Card(
|
||
color: colorScheme.secondaryContainer,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(Insets.lg),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.waving_hand_outlined,
|
||
color: colorScheme.onSecondaryContainer,
|
||
),
|
||
const SizedBox(width: Insets.md),
|
||
Expanded(
|
||
child: Text(
|
||
'Welcome to OOTT! Point the app at your server’s API '
|
||
'to get started.',
|
||
style: textTheme.bodyMedium?.copyWith(
|
||
color: colorScheme.onSecondaryContainer,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: Insets.lg),
|
||
],
|
||
_buildConnectionSummary(context),
|
||
const SizedBox(height: Insets.lg),
|
||
_buildAppSettings(context),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// Read-only summary of the backend connection with a link to reconfigure it.
|
||
Widget _buildConnectionSummary(BuildContext context) {
|
||
final textTheme = Theme.of(context).textTheme;
|
||
final configured = _baseUrl.isNotEmpty;
|
||
|
||
return Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(Insets.lg),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
'Backend connection',
|
||
style: textTheme.titleLarge,
|
||
),
|
||
),
|
||
TextButton.icon(
|
||
onPressed: () => _openConfigDialog(),
|
||
icon: const Icon(Icons.edit_outlined),
|
||
label: const Text('Re-configure'),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: Insets.sm),
|
||
_buildReadOnlyField(
|
||
context,
|
||
label: 'Base URL',
|
||
value: configured ? _baseUrl : 'Not configured yet',
|
||
),
|
||
const SizedBox(height: Insets.md),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Expanded(
|
||
child: _buildReadOnlyField(
|
||
context,
|
||
label: 'API key',
|
||
value: !configured
|
||
? 'Not configured yet'
|
||
: (_apiKeyVisible ? _apiKey : '••••••••'),
|
||
),
|
||
),
|
||
if (configured)
|
||
IconButton(
|
||
icon: Icon(
|
||
_apiKeyVisible ? Icons.visibility : Icons.visibility_off,
|
||
),
|
||
onPressed: () =>
|
||
setState(() => _apiKeyVisible = !_apiKeyVisible),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// App-level preferences (theme, per-device push) that apply immediately.
|
||
Widget _buildAppSettings(BuildContext context) {
|
||
final textTheme = Theme.of(context).textTheme;
|
||
|
||
return Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(Insets.lg),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('App settings', style: textTheme.titleLarge),
|
||
const SizedBox(height: Insets.md),
|
||
DropdownButtonFormField<String>(
|
||
initialValue: _selectedTheme,
|
||
decoration: const InputDecoration(
|
||
border: UnderlineInputBorder(),
|
||
labelText: 'Theme',
|
||
),
|
||
items: const [
|
||
DropdownMenuItem(
|
||
value: 'catppuccin_mocha',
|
||
child: Text('Catppuccin Mocha'),
|
||
),
|
||
DropdownMenuItem(
|
||
value: 'gruvbox_dark',
|
||
child: Text('Gruvbox Dark'),
|
||
),
|
||
],
|
||
// Applies immediately; the theme persists itself, no Save needed.
|
||
onChanged: (value) {
|
||
if (value == null) return;
|
||
setState(() => _selectedTheme = value);
|
||
context.read<AppState>().setTheme(value);
|
||
},
|
||
),
|
||
if (_pushService.isSupported && _pushMethodActive) ...[
|
||
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,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildReadOnlyField(
|
||
BuildContext context, {
|
||
required String label,
|
||
required String value,
|
||
}) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
final textTheme = Theme.of(context).textTheme;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: textTheme.bodySmall?.copyWith(
|
||
color: colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: Insets.xs),
|
||
Text(value, style: textTheme.bodyLarge),
|
||
],
|
||
);
|
||
}
|
||
}
|