Files
tajniak81andClaude Opus 5.5 879e22141b Mobile: spaces; /hierarchy always sends children_state
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>
2026-10-18 23:14:40 +02:00

423 lines
12 KiB
Dart

import 'package:flutter/material.dart' hide Visibility;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../screens/room_screen.dart';
import '../screens/space_screen.dart';
import '../state.dart';
/// Start a direct chat, create a room or join one.
Future<void> showNewChatSheet(BuildContext context) =>
showModalBottomSheet<void>(
context: context,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.person_add_alt_1_outlined),
title: const Text('Message someone'),
subtitle: const Text('A direct chat, end-to-end encrypted'),
onTap: () {
Navigator.pop(sheet);
_open(context, const _StartChatDialog());
},
),
ListTile(
leading: const Icon(Icons.group_add_outlined),
title: const Text('New room'),
subtitle: const Text('For a group or a topic'),
onTap: () {
Navigator.pop(sheet);
_open(context, const _CreateRoomDialog());
},
),
ListTile(
leading: const Icon(Icons.workspaces_outline),
title: const Text('New space'),
subtitle: const Text('A group of rooms'),
onTap: () {
Navigator.pop(sheet);
_open(context, const _CreateSpaceDialog());
},
),
ListTile(
leading: const Icon(Icons.login),
title: const Text('Join a room'),
subtitle: const Text('By its address, like #room:example.org'),
onTap: () {
Navigator.pop(sheet);
_open(context, const _JoinRoomDialog());
},
),
const SizedBox(height: 8),
],
),
),
);
Future<void> _open(BuildContext context, Widget dialog) async {
final roomId = await showDialog<String>(
context: context,
builder: (_) => dialog,
);
if (roomId == null || !context.mounted) return;
final container = ProviderScope.containerOf(context, listen: false);
final room = container.read(clientProvider).getRoomById(roomId);
if (room != null && room.isSpace) {
// A space opens as the room list narrowed to it, with its browser on
// top.
container.read(selectedSpaceProvider.notifier).select(roomId);
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => SpaceScreen(spaceId: roomId)));
} else {
Navigator.of(context).push(RoomScreen.route(roomId));
}
}
/// A dialog with one form and a busy state, returning a room ID.
class _FormDialog extends StatefulWidget {
const _FormDialog({
required this.title,
required this.action,
required this.fields,
required this.submit,
});
final String title;
final String action;
final List<Widget> fields;
final Future<String> Function() submit;
@override
State<_FormDialog> createState() => _FormDialogState();
}
class _FormDialogState extends State<_FormDialog> {
bool _busy = false;
String? _error;
Future<void> _go() async {
setState(() {
_busy = true;
_error = null;
});
try {
final roomId = await widget.submit();
if (mounted) Navigator.pop(context, roomId);
} catch (e) {
if (mounted) {
setState(() {
_busy = false;
_error = describeError(e);
});
}
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...widget.fields,
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
],
),
actions: [
TextButton(
onPressed: _busy ? null : () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _busy ? null : _go,
child: _busy
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(widget.action),
),
],
);
}
}
class _StartChatDialog extends ConsumerStatefulWidget {
const _StartChatDialog();
@override
ConsumerState<_StartChatDialog> createState() => _StartChatDialogState();
}
class _StartChatDialogState extends ConsumerState<_StartChatDialog> {
final _userId = TextEditingController();
@override
void dispose() {
_userId.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final client = ref.watch(clientProvider);
return _FormDialog(
title: 'Message someone',
action: 'Start chat',
fields: [
TextField(
controller: _userId,
autofocus: true,
autocorrect: false,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: 'User ID',
hintText: '@alice:${client.userID?.domain ?? 'example.org'}',
),
),
],
submit: () async {
var id = _userId.text.trim();
if (!id.startsWith('@')) id = '@$id';
if (!id.contains(':')) id = '$id:${client.userID!.domain}';
if (!id.isValidMatrixIdStrict()) {
throw const FormatException("That isn't a user ID.");
}
if (id == client.userID) {
throw const FormatException("That's you.");
}
// Check they exist before creating a room for nobody.
await client.getUserProfile(id);
return client.startDirectChat(
id,
enableEncryption: client.encryptionEnabled,
);
},
);
}
}
class _CreateRoomDialog extends ConsumerStatefulWidget {
const _CreateRoomDialog();
@override
ConsumerState<_CreateRoomDialog> createState() => _CreateRoomDialogState();
}
class _CreateRoomDialogState extends ConsumerState<_CreateRoomDialog> {
final _name = TextEditingController();
final _topic = TextEditingController();
bool _public = false;
bool _encrypted = true;
@override
void dispose() {
_name.dispose();
_topic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final client = ref.watch(clientProvider);
return _FormDialog(
title: 'New room',
action: 'Create',
fields: [
TextField(
controller: _name,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 12),
TextField(
controller: _topic,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Topic (optional)'),
),
const SizedBox(height: 8),
StatefulBuilder(
builder: (context, setLocal) => Column(
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Public'),
subtitle: const Text('Anyone can find and join it'),
value: _public,
onChanged: (v) => setLocal(() => _public = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('End-to-end encrypted'),
subtitle: const Text("Can't be turned off later"),
value: _encrypted,
onChanged: client.encryptionEnabled
? (v) => setLocal(() => _encrypted = v)
: null,
),
],
),
),
],
submit: () async {
final name = _name.text.trim();
if (name.isEmpty) throw const FormatException('Give the room a name.');
final topic = _topic.text.trim();
final roomId = await client.createGroupChat(
groupName: name,
enableEncryption: _encrypted && client.encryptionEnabled,
preset: _public
? CreateRoomPreset.publicChat
: CreateRoomPreset.privateChat,
visibility: _public ? Visibility.public : Visibility.private,
initialState: [
if (topic.isNotEmpty)
StateEvent(type: EventTypes.RoomTopic, content: {'topic': topic}),
],
);
return roomId;
},
);
}
}
class _JoinRoomDialog extends ConsumerStatefulWidget {
const _JoinRoomDialog();
@override
ConsumerState<_JoinRoomDialog> createState() => _JoinRoomDialogState();
}
class _JoinRoomDialogState extends ConsumerState<_JoinRoomDialog> {
final _address = TextEditingController();
@override
void dispose() {
_address.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final client = ref.watch(clientProvider);
return _FormDialog(
title: 'Join a room',
action: 'Join',
fields: [
TextField(
controller: _address,
autofocus: true,
autocorrect: false,
decoration: InputDecoration(
labelText: 'Room address',
hintText: '#room:${client.userID?.domain ?? 'example.org'}',
),
),
],
submit: () async {
var address = _address.text.trim();
if (address.isEmpty) {
throw const FormatException('Enter a room address.');
}
if (!address.startsWith('#') && !address.startsWith('!')) {
address = '#$address';
}
if (!address.contains(':')) {
address = '$address:${client.userID!.domain}';
}
final roomId = await client.joinRoom(address);
if (client.getRoomById(roomId)?.membership != Membership.join) {
await client.waitForRoomInSync(roomId, join: true);
}
return roomId;
},
);
}
}
class _CreateSpaceDialog extends ConsumerStatefulWidget {
const _CreateSpaceDialog();
@override
ConsumerState<_CreateSpaceDialog> createState() => _CreateSpaceDialogState();
}
class _CreateSpaceDialogState extends ConsumerState<_CreateSpaceDialog> {
final _name = TextEditingController();
final _topic = TextEditingController();
bool _public = false;
@override
void dispose() {
_name.dispose();
_topic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final client = ref.watch(clientProvider);
return _FormDialog(
title: 'New space',
action: 'Create',
fields: [
TextField(
controller: _name,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 12),
TextField(
controller: _topic,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Topic (optional)'),
),
const SizedBox(height: 8),
StatefulBuilder(
builder: (context, setLocal) => SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Public'),
subtitle: const Text('Anyone can find and join it'),
value: _public,
onChanged: (v) => setLocal(() => _public = v),
),
),
],
submit: () async {
final name = _name.text.trim();
if (name.isEmpty) throw const FormatException('Give the space a name.');
final topic = _topic.text.trim();
final roomId = await client.createRoom(
name: name,
topic: topic.isEmpty ? null : topic,
creationContent: {'type': RoomCreationTypes.mSpace},
preset: _public
? CreateRoomPreset.publicChat
: CreateRoomPreset.privateChat,
visibility: _public ? Visibility.public : Visibility.private,
// A space's own timeline isn't for chatting: only admins post.
powerLevelContentOverride: {'events_default': 100},
);
if (client.getRoomById(roomId)?.membership != Membership.join) {
await client.waitForRoomInSync(roomId, join: true);
}
return roomId;
},
);
}
}