Files
warp/apps/mobile/lib/screens/settings_screen.dart
tajniak81andClaude Opus 5.5 34b2f51a33
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
Mobile: show the server's display name in Settings → About
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>
2026-11-02 08:51:01 +01:00

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),
),
);
}