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 showNewChatSheet(BuildContext context) => showModalBottomSheet( 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 _open(BuildContext context, Widget dialog) async { final roomId = await showDialog( 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 fields; final Future Function() submit; @override State<_FormDialog> createState() => _FormDialogState(); } class _FormDialogState extends State<_FormDialog> { bool _busy = false; String? _error; Future _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; }, ); } }