feat: menu de contexto em bottom sheet, painel de anexos, perfil e feed do mural
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

- Salas (chat, canais, murais e salas de espacos) abrem bottom sheet com grid de acoes
- Botao + da barra de digitacao abre painel de anexos em grid de 3 colunas que empurra a caixa de texto
- Modal do perfil permite editar nome/status e ler QR code (mobile)
- Alterar status atualiza localmente via extensao do SDK matrix
- Mural pre-carrega historico para exibir todas as midias em dispositivos sem cache
This commit is contained in:
2026-09-21 14:30:47 -03:00
parent 762178d9f9
commit 5a72c71cfc
14 changed files with 861 additions and 584 deletions

View File

@@ -4,255 +4,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:async/async.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart' hide Result;
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/tatu_share.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:tatchat/widgets/self_profile_dialog.dart';
import 'chat_list.dart';
/// The header avatar of the active account. Tapping it opens the own profile
/// dialog. Account switching and the former avatar popup actions live in the
/// navigation drawer (Sidebar).
class ClientChooserButton extends StatelessWidget {
final ChatListController controller;
const ClientChooserButton(this.controller, {super.key});
List<PopupMenuEntry<Object>> _bundleMenuItems(BuildContext context) {
final matrix = Matrix.of(context);
final bundles = matrix.accountBundles.keys.toList()
..sort(
(a, b) => a!.isValidMatrixIdStrict() == b!.isValidMatrixIdStrict()
? 0
: a.isValidMatrixIdStrict() && !b.isValidMatrixIdStrict()
? -1
: 1,
);
if (FluffyThemes.isColumnMode(context)) {
// In column mode the settings/actions live in the navigation sidebar,
// so the avatar popup only offers the account switcher.
return <PopupMenuEntry<Object>>[
..._bundleEntries(context, matrix, bundles),
PopupMenuItem(
value: SettingsAction.addAccount,
child: Row(
children: [
const Icon(Icons.person_add_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).addAccount),
],
),
),
];
}
return <PopupMenuEntry<Object>>[
PopupMenuItem(
value: SettingsAction.newGroup,
child: Row(
children: [
const Icon(Icons.group_add_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).createGroup),
],
),
),
PopupMenuItem(
value: SettingsAction.setStatus,
child: Row(
children: [
const Icon(Icons.edit_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).setStatus),
],
),
),
PopupMenuItem(
value: SettingsAction.invite,
child: Row(
children: [
Icon(Icons.adaptive.share_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).inviteContact),
],
),
),
PopupMenuItem(
value: SettingsAction.archive,
child: Row(
children: [
const Icon(Icons.archive_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).archive),
],
),
),
PopupMenuItem(
value: SettingsAction.apps,
child: Row(
children: [
const Icon(Icons.apps_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).apps),
],
),
),
PopupMenuItem(
value: SettingsAction.settings,
child: Row(
children: [
const Icon(Icons.settings_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).settings),
],
),
),
const PopupMenuDivider(),
..._bundleEntries(context, matrix, bundles),
PopupMenuItem(
value: SettingsAction.addAccount,
child: Row(
children: [
const Icon(Icons.person_add_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).addAccount),
],
),
),
];
}
List<PopupMenuEntry<Object>> _bundleEntries(
BuildContext context,
MatrixState matrix,
List<String?> bundles,
) {
return <PopupMenuEntry<Object>>[
for (final bundle in bundles) ...[
if (matrix.accountBundles[bundle]!.length != 1 ||
matrix.accountBundles[bundle]!.single!.userID != bundle)
PopupMenuItem(
value: null,
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
Text(
bundle!,
style: TextStyle(
color: Theme.of(context).textTheme.titleMedium!.color,
fontSize: 14,
),
),
const Divider(height: 1),
],
),
),
...matrix.accountBundles[bundle]!
.whereType<Client>()
.where((client) => client.isLogged())
.map(
(client) => PopupMenuItem(
value: client,
child: FutureBuilder<Profile?>(
future: client.fetchOwnProfile(),
builder: (context, snapshot) {
final displayname =
snapshot.data?.displayName ?? client.userID!.localpart!;
return Row(
key: ValueKey('switch_account_$displayname'),
children: [
Avatar(
mxContent: snapshot.data?.avatarUrl,
name: displayname,
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Text(
displayname,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => controller.editBundlesForAccount(
client.userID,
bundle,
),
),
],
);
},
),
),
),
],
];
}
@override
Widget build(BuildContext context) {
final matrix = Matrix.of(context);
final client = Result(() => matrix.client).asValue?.value;
var clientCount = 0;
matrix.accountBundles.forEach((key, value) => clientCount += value.length);
return FutureBuilder<Profile>(
future: client?.isLogged() == true ? client?.fetchOwnProfile() : null,
builder: (context, snapshot) => Material(
clipBehavior: Clip.hardEdge,
borderRadius: BorderRadius.circular(99),
color: Colors.transparent,
child: PopupMenuButton<Object>(
key: Key('accounts_and_settings_buttons'),
tooltip: L10n.of(context).accountsAndSettings,
onSelected: (o) => _clientSelected(o, context),
itemBuilder: _bundleMenuItems,
icon: Avatar(
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ?? client?.userID?.localpart,
size: 32,
child: Tooltip(
message: L10n.of(context).accountsAndSettings,
child: InkWell(
key: const Key('accounts_and_settings_buttons'),
onTap: () => showSelfProfileDialog(context),
child: Avatar(
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ?? client?.userID?.localpart,
size: 32,
),
),
),
),
);
}
Future<void> _clientSelected(Object object, BuildContext context) async {
if (object is Client) {
controller.setActiveClient(object);
} else if (object is String) {
controller.setActiveBundle(object);
} else if (object is SettingsAction) {
switch (object) {
case SettingsAction.addAccount:
if (!context.mounted) return;
context.go('/rooms/settings/addaccount');
break;
case SettingsAction.newGroup:
context.go('/rooms/newgroup');
break;
case SettingsAction.invite:
TatuShare.shareInviteLink(context);
break;
case SettingsAction.settings:
context.go('/rooms/settings');
break;
case SettingsAction.archive:
context.go('/rooms/archive');
break;
case SettingsAction.apps:
context.go('/rooms/apps');
break;
case SettingsAction.setStatus:
controller.setStatus();
break;
}
}
}
}
enum SettingsAction { addAccount, newGroup, setStatus, invite, settings, archive, apps }