Mobile: - The room list's title picks a space: All chats, or one space, which narrows the list to its rooms (and its subspaces' rooms). - A space browser over /hierarchy: the rooms of a space and of its subspaces, joined or joinable, with Join; for those who may, adding an existing room or a new one to the space. - New space in the new-chat sheet; invites to spaces show with the other invites and open the space when joined. - Fixes several setState calls that were handed a Future (reloading the members, sessions, push distributor, threads), which Flutter rejects. Server: /hierarchy left out children_state for a room without children. The spec requires it on every room (an empty list), and SpaceRoom's omitempty dropped empty lists too, despite its comment. The famedly Dart SDK fails to parse such a response. The client API now wraps each room so the field is always written; the federation response, which wants it left out of the children it lists, is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
418 lines
14 KiB
Dart
418 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:image_picker/image_picker.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/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;
|
|
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();
|
|
});
|
|
}
|
|
|
|
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())),
|
|
),
|
|
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'),
|
|
ListTile(
|
|
leading: const Icon(Icons.dns_outlined),
|
|
title: const Text('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),
|
|
),
|
|
);
|
|
}
|