CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
CI / Desktop client (ubuntu-24.04) (push) Canceled after 0s
CI / Desktop client (windows-latest) (push) Canceled after 0s
The display name an admin sets was only on the admin API, so the app
could not show it. /capabilities now carries it as io.warp.server
({"display_name": ...}, left out while there is none), and the About
row's title is that name, falling back to "Homeserver".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
462 lines
15 KiB
Dart
462 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:local_auth/local_auth.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../matrix/client.dart';
|
|
import '../matrix/notifications.dart';
|
|
import '../matrix/push.dart';
|
|
import '../state.dart';
|
|
import '../theme.dart';
|
|
import '../widgets/app_lock.dart';
|
|
import '../widgets/avatar.dart';
|
|
import 'encryption_screen.dart';
|
|
import 'sessions_screen.dart';
|
|
|
|
class SettingsScreen extends ConsumerStatefulWidget {
|
|
const SettingsScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
|
}
|
|
|
|
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|
late Future<Profile> _profile;
|
|
late Future<String?> _distributor;
|
|
late final Future<bool> _canLock = canConfirmIdentity();
|
|
late final Future<String?> _serverName = serverDisplayName(
|
|
ref.read(clientProvider),
|
|
);
|
|
bool _busy = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_profile = ref.read(clientProvider).fetchOwnProfile();
|
|
_distributor = Push.currentDistributor();
|
|
}
|
|
|
|
void _snack(String text) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
|
}
|
|
|
|
Future<void> _run(Future<void> Function() action) async {
|
|
setState(() => _busy = true);
|
|
try {
|
|
await action();
|
|
} catch (e) {
|
|
_snack(describeError(e));
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
void _reloadProfile() {
|
|
final client = ref.read(clientProvider);
|
|
setState(() {
|
|
_profile = client.getProfileFromUserId(
|
|
client.userID!,
|
|
maxCacheAge: Duration.zero,
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<void> _editName(String current) async {
|
|
final controller = TextEditingController(text: current);
|
|
final name = await showDialog<String>(
|
|
context: context,
|
|
builder: (dialog) => AlertDialog(
|
|
title: const Text('Display name'),
|
|
content: TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
textCapitalization: TextCapitalization.words,
|
|
onSubmitted: (v) => Navigator.pop(dialog, v),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialog),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(dialog, controller.text),
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (name == null || name.trim().isEmpty) return;
|
|
final client = ref.read(clientProvider);
|
|
await _run(
|
|
() => client.setProfileField(client.userID!, 'displayname', {
|
|
'displayname': name.trim(),
|
|
}),
|
|
);
|
|
_reloadProfile();
|
|
}
|
|
|
|
Future<void> _editAvatar() async {
|
|
final picked = await ImagePicker().pickImage(
|
|
source: ImageSource.gallery,
|
|
maxWidth: 1024,
|
|
maxHeight: 1024,
|
|
imageQuality: 90,
|
|
);
|
|
if (picked == null) return;
|
|
final client = ref.read(clientProvider);
|
|
final bytes = await picked.readAsBytes();
|
|
await _run(
|
|
() => client.setAvatar(MatrixImageFile(bytes: bytes, name: picked.name)),
|
|
);
|
|
_reloadProfile();
|
|
}
|
|
|
|
Future<void> _togglePush(bool on) async {
|
|
if (!on) {
|
|
await _run(Push.disable);
|
|
setState(() {
|
|
_distributor = Push.currentDistributor();
|
|
});
|
|
return;
|
|
}
|
|
await Notifications.requestPermission();
|
|
final options = await Push.distributors();
|
|
if (!mounted) return;
|
|
if (options.isEmpty) {
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (dialog) => AlertDialog(
|
|
title: const Text('Install a push app'),
|
|
content: const Text(
|
|
'Warp receives push notifications through UnifiedPush, without '
|
|
'Google services. Install a distributor such as ntfy, then turn '
|
|
'this on again.\n\nWithout one, you are notified while Warp is '
|
|
'open or running in the background.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialog),
|
|
child: const Text('Not now'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
Navigator.pop(dialog);
|
|
launchUrl(
|
|
Uri.parse('https://unifiedpush.org/users/distributors/'),
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
},
|
|
child: const Text('Find one'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
String? choice = options.length == 1 ? options.single : null;
|
|
choice ??= await showDialog<String>(
|
|
context: context,
|
|
builder: (dialog) => SimpleDialog(
|
|
title: const Text('Push through'),
|
|
children: [
|
|
for (final d in options)
|
|
SimpleDialogOption(
|
|
onPressed: () => Navigator.pop(dialog, d),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(d),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (choice == null) return;
|
|
final distributor = choice;
|
|
await _run(() async {
|
|
await Push.enable(distributor: distributor);
|
|
});
|
|
setState(() {
|
|
_distributor = Push.currentDistributor();
|
|
});
|
|
}
|
|
|
|
/// Either way it asks first, so nobody holding an unlocked phone turns
|
|
/// the lock off, and nobody turns it on without a working way to unlock.
|
|
Future<void> _toggleLock(bool on) async {
|
|
try {
|
|
final confirmed = await confirmIdentity(
|
|
on ? 'Turn on app lock' : 'Turn off app lock',
|
|
);
|
|
if (confirmed) ref.read(appLockProvider.notifier).set(on);
|
|
} on LocalAuthException catch (e) {
|
|
_snack(describeAuthError(e));
|
|
} catch (e) {
|
|
_snack(describeError(e));
|
|
}
|
|
}
|
|
|
|
Future<void> _signOut() async {
|
|
final client = ref.read(clientProvider);
|
|
final hasBackup = client.encryption?.keyManager.enabled == true;
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialog) => AlertDialog(
|
|
title: const Text('Sign out?'),
|
|
content: Text(
|
|
hasBackup
|
|
? "This session's keys are deleted from this phone. To read "
|
|
'your encrypted messages after you sign in again, you '
|
|
'need your recovery key or another verified session.'
|
|
: "This session's keys are deleted from this phone, and "
|
|
"encryption isn't set up for your account: encrypted "
|
|
"messages only this session can read will be lost.",
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialog, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(backgroundColor: context.warp.danger),
|
|
onPressed: () => Navigator.pop(dialog, true),
|
|
child: const Text('Sign out'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true) return;
|
|
await _run(() async {
|
|
await Push.forget();
|
|
await Notifications.clearAll();
|
|
try {
|
|
await client.logout();
|
|
} catch (e) {
|
|
// The session may already be gone on the server; sign out here
|
|
// regardless.
|
|
Logs().w('Logout request failed, clearing locally', e);
|
|
await client.clear();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final client = ref.watch(clientProvider);
|
|
final themeMode = ref.watch(themeModeProvider);
|
|
final c = context.warp;
|
|
final text = Theme.of(context).textTheme;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Settings')),
|
|
body: AbsorbPointer(
|
|
absorbing: _busy,
|
|
child: ListView(
|
|
children: [
|
|
if (_busy) const LinearProgressIndicator(minHeight: 2),
|
|
FutureBuilder<Profile>(
|
|
future: _profile,
|
|
builder: (context, snapshot) {
|
|
final profile = snapshot.data;
|
|
final name = profile?.displayName ?? client.userID!.localpart!;
|
|
return Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Row(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: _editAvatar,
|
|
child: Stack(
|
|
children: [
|
|
Avatar(
|
|
name: name,
|
|
seed: client.userID!,
|
|
mxc: profile?.avatarUrl,
|
|
size: 72,
|
|
),
|
|
Positioned(
|
|
right: 0,
|
|
bottom: 0,
|
|
child: CircleAvatar(
|
|
radius: 12,
|
|
backgroundColor: c.tealStrong,
|
|
child: Icon(
|
|
Icons.photo_camera,
|
|
size: 14,
|
|
color: c.onTeal,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
name,
|
|
style: text.titleLarge,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Change display name',
|
|
icon: const Icon(
|
|
Icons.edit_outlined,
|
|
size: 18,
|
|
),
|
|
onPressed: () => _editName(name),
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
client.userID!,
|
|
style: text.bodySmall?.copyWith(
|
|
fontFamily: fontMono,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const Divider(),
|
|
_Header('Security'),
|
|
ListTile(
|
|
leading: const Icon(Icons.key_outlined),
|
|
title: const Text('Encryption'),
|
|
subtitle: Text(_encryptionSummary(client)),
|
|
onTap: () => Navigator.of(context).push(
|
|
MaterialPageRoute(builder: (_) => const EncryptionScreen()),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.devices_outlined),
|
|
title: const Text('Sessions'),
|
|
subtitle: const Text('Where you are signed in'),
|
|
onTap: () => Navigator.of(
|
|
context,
|
|
).push(MaterialPageRoute(builder: (_) => const SessionsScreen())),
|
|
),
|
|
FutureBuilder<bool>(
|
|
future: _canLock,
|
|
builder: (context, snapshot) {
|
|
final available = snapshot.data ?? false;
|
|
return SwitchListTile(
|
|
secondary: const Icon(Icons.fingerprint),
|
|
title: const Text('App lock'),
|
|
subtitle: Text(
|
|
!snapshot.hasData
|
|
? ''
|
|
: available
|
|
? 'Unlock Warp with your fingerprint, face or '
|
|
'screen lock'
|
|
: 'Set a screen lock on this phone to use it',
|
|
),
|
|
value: ref.watch(appLockProvider),
|
|
onChanged: available ? _toggleLock : null,
|
|
);
|
|
},
|
|
),
|
|
const Divider(),
|
|
_Header('Notifications'),
|
|
FutureBuilder<String?>(
|
|
future: _distributor,
|
|
builder: (context, snapshot) {
|
|
final distributor = snapshot.data;
|
|
return SwitchListTile(
|
|
secondary: const Icon(Icons.notifications_active_outlined),
|
|
title: const Text('Push notifications'),
|
|
subtitle: Text(
|
|
distributor == null
|
|
? 'Off: notified only while Warp runs'
|
|
: 'Through $distributor',
|
|
),
|
|
value: distributor != null,
|
|
onChanged: _togglePush,
|
|
);
|
|
},
|
|
),
|
|
const Divider(),
|
|
_Header('Appearance'),
|
|
RadioGroup<ThemeMode>(
|
|
groupValue: themeMode,
|
|
onChanged: (m) {
|
|
if (m != null) ref.read(themeModeProvider.notifier).set(m);
|
|
},
|
|
child: Column(
|
|
children: [
|
|
for (final (mode, label) in [
|
|
(ThemeMode.system, 'System'),
|
|
(ThemeMode.light, 'Light'),
|
|
(ThemeMode.dark, 'Dark'),
|
|
])
|
|
RadioListTile<ThemeMode>(value: mode, title: Text(label)),
|
|
],
|
|
),
|
|
),
|
|
const Divider(),
|
|
_Header('About'),
|
|
FutureBuilder<String?>(
|
|
future: _serverName,
|
|
builder: (context, snap) => ListTile(
|
|
leading: const Icon(Icons.dns_outlined),
|
|
title: Text(snap.data ?? 'Homeserver'),
|
|
subtitle: Text(client.homeserver?.toString() ?? ''),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.smartphone),
|
|
title: const Text('This session'),
|
|
subtitle: Text(
|
|
'${client.deviceName ?? deviceDisplayName} · '
|
|
'${client.deviceID}',
|
|
),
|
|
),
|
|
const Divider(),
|
|
ListTile(
|
|
leading: Icon(Icons.logout, color: c.danger),
|
|
title: Text('Sign out', style: TextStyle(color: c.danger)),
|
|
onTap: _signOut,
|
|
),
|
|
const SizedBox(height: 32),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _encryptionSummary(Client client) {
|
|
final encryption = client.encryption;
|
|
if (encryption == null) return 'Not available';
|
|
if (!encryption.crossSigning.enabled) return 'Not set up';
|
|
if (client.isUnknownSession) return 'This session is not verified';
|
|
return 'Set up · this session is verified';
|
|
}
|
|
}
|
|
|
|
class _Header extends StatelessWidget {
|
|
const _Header(this.text);
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
|
child: Text(
|
|
text,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelLarge?.copyWith(color: context.warp.tealText),
|
|
),
|
|
);
|
|
}
|