Files
warp/apps/mobile/lib/widgets/new_chat_sheet.dart
T
tajniak81andClaude Opus 5.5 3356089c13 Android app (apps/mobile) with Flutter
apps/mobile is a Matrix client for Android on the famedly Matrix Dart SDK,
with vodozemac (compiled from Rust during the build) for end-to-end
encryption. It speaks the plain Client-Server API and uses the web
client's design tokens, fonts and symbol.

- Sign in by server name (.well-known), homeserver address or full user ID;
  sign up with the dummy and registration token stages. Sessions survive
  restarts: SQLite in private storage, credentials also in the Keystore,
  both excluded from backups and device transfers.
- Room list with unread and mention counts, invites, search and filters.
  Timeline with grouping, day separators, notices, history on scroll,
  links, images and files (authenticated media). Sending text, photos,
  camera shots and files, with Sending/Sent/Read/Not sent and Retry.
  Typing, receipts, deleting. New rooms and DMs, encrypted by default;
  joining by address; room info with members, invite, rename, topic,
  notification level, leave.
- Encryption: one-step setup behind a recovery key, verifying a new session
  with the recovery key (restoring the backup) or another session, emoji
  verification of people and sessions, reset. Undecryptable messages say
  why. Session list with verify and sign out.
- Notifications while running, and push through UnifiedPush (no Firebase):
  the distributor's endpoint becomes an event_id_only pusher, the gateway
  is discovered, and a push starts the app headless to fetch, decrypt and
  show the event.

Make targets: mobile (release APKs per ABI into bin/), mobile-run,
mobile-test. Kotlin incremental compilation is off (Windows, project and
pub cache on different drives), and Tink's JVM artifact is excluded in
favour of tink-android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:45:06 +02:00

330 lines
9.3 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 '../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.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) {
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;
},
);
}
}