feat(ui): untere Leiste (Chats/Server/Du) als Option, Server-Uebersicht, Du-Tab, Feinschliff

- Einstellung 'Navigation: Seitenmenue | Untere Leiste' (Handy, Standard
  bleibt Seitenmenue): Tabs Chats/Server/Du, Server-Uebersicht als Karten,
  Du-Tab mit Profil + Einstellungen, Zurueck-Taste ueber die Tabs
- CallDock als eigenes Widget (Anrufleiste auch ueber der Tab-Leiste)
- Sprachkanaele am Handy als Kachel mit 'wer ist drin'
- Suchfeld mit Lupe, 'Kanaele durchsuchen' im Server, Filter-Chips am PC
  umbrechend, '#' nur bei Server-Kanaelen, englische Reste entfernt
- Anrufansicht merkt sich Bubbles/Kacheln, Bubbles mit leichtem Glanz
- UI-Tour: Gruppenanruf (echter zweiter LiveKit-Teilnehmer), Bubbles mit
  5/7 Personen, untere Leiste; Version 0.1.42

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-10 14:22:02 +02:00
co-authored by Claude Opus 5.5
parent de24053167
commit 391ebf0ffa
19 changed files with 1139 additions and 87 deletions
+623
View File
@@ -0,0 +1,623 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/room_kinds.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_panel.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/widgets/create_join_dialog.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/spring_press.dart';
/// Handy-Navigation mit unterer Leiste (Einstellung „Navigation: Untere
/// Leiste“, Bernd 2026-10-09 zum Vergleich mit dem Seitenmenü):
/// **Chats** (Direktnachrichten + Gruppen), **Server** (Übersicht → Kanäle),
/// **Du** (Profil + Einstellungen). Chat und Anruf gleiten wie gewohnt
/// darüber; die Leiste liegt darunter und verschwindet mit.
enum MobileTab { chats, servers, me }
final mobileTabProvider = StateProvider<MobileTab>((ref) => MobileTab.chats);
/// Zuletzt geöffneter Server – „Server“ öffnet wieder dort.
final _lastServerProvider = StateProvider<String?>((ref) => null);
bool _isRealSpace(String? id) => id != null && id != 'dms' && id != 'rooms';
/// Wechselt den Tab (und die passende Liste).
void selectMobileTab(WidgetRef ref, MobileTab tab) {
final current = ref.read(mobileTabProvider);
final space = ref.read(activeSpaceIdProvider);
if (_isRealSpace(space)) ref.read(_lastServerProvider.notifier).state = space;
switch (tab) {
case MobileTab.chats:
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
case MobileTab.servers:
// Nochmal auf „Server“ tippen = zurück zur Übersicht.
ref.read(activeSpaceIdProvider.notifier).state =
current == MobileTab.servers ? null : ref.read(_lastServerProvider);
case MobileTab.me:
break;
}
ref.read(mobileTabProvider.notifier).state = tab;
}
/// Zurück-Taste mit unterer Leiste: Server → Übersicht → Chats.
/// Liefert `true`, wenn sie etwas getan hat.
bool handleMobileTabBack(WidgetRef ref) {
final tab = ref.read(mobileTabProvider);
if (tab == MobileTab.servers &&
_isRealSpace(ref.read(activeSpaceIdProvider))) {
ref.read(activeSpaceIdProvider.notifier).state = null;
ref.read(_lastServerProvider.notifier).state = null;
return true;
}
if (tab != MobileTab.chats) {
selectMobileTab(ref, MobileTab.chats);
return true;
}
return false;
}
/// Grundseite unter Chat/Anruf: Inhalt des Tabs, Anruf-Leiste, Tab-Leiste.
class MobileTabsBase extends ConsumerWidget {
const MobileTabsBase({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final tab = ref.watch(mobileTabProvider);
final space = ref.watch(activeSpaceIdProvider);
final Widget content = switch (tab) {
MobileTab.chats => const RoomsPanel(key: ValueKey('tab-chats')),
MobileTab.servers => _isRealSpace(space)
? RoomsPanel(key: ValueKey('tab-server-$space'))
: const ServerOverview(key: ValueKey('tab-servers')),
MobileTab.me => const MeTab(key: ValueKey('tab-me')),
};
return ColoredBox(
color: pt.bg1,
child: Column(
children: [
Expanded(
child: AnimatedSwitcher(
duration: motionDuration(context, 220),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween(
begin: const Offset(0, 0.015),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: content,
),
),
const CallDock(),
const _TabBar(),
],
),
);
}
}
class _TabBar extends ConsumerWidget {
const _TabBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final tab = ref.watch(mobileTabProvider);
final chatUnread = ref.watch(dmUnreadCountProvider).valueOrNull ?? 0;
final serverUnread = ref.watch(_serverUnreadProvider);
Widget item(MobileTab t, IconData icon, IconData activeIcon, String label,
int badge) {
final selected = tab == t;
return Expanded(
child: Semantics(
button: true,
selected: selected,
label: label,
child: SpringPress(
child: InkResponse(
onTap: () => selectMobileTab(ref, t),
radius: 40,
highlightShape: BoxShape.rectangle,
containedInkWell: false,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
clipBehavior: Clip.none,
children: [
AnimatedContainer(
duration: motionDuration(context, 240),
curve: springyCurve,
width: selected ? 60 : 44,
height: 30,
decoration: BoxDecoration(
color: selected ? pt.accentSoft : Colors.transparent,
borderRadius: BorderRadius.circular(999),
),
child: Icon(selected ? activeIcon : icon,
size: 22,
color: selected ? pt.accent : pt.fgMuted),
),
if (badge > 0)
Positioned(
right: selected ? 6 : 0,
top: -3,
child: SpringBump(
value: badge,
child: Container(
constraints: const BoxConstraints(minWidth: 18),
height: 18,
padding:
const EdgeInsets.symmetric(horizontal: 5),
alignment: Alignment.center,
decoration: BoxDecoration(
color: pt.danger,
borderRadius: BorderRadius.circular(9),
border: Border.all(color: pt.bg1, width: 2),
),
child: Text(badge > 99 ? '99+' : '$badge',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700)),
),
),
),
],
),
const SizedBox(height: 3),
Text(label,
style: TextStyle(
color: selected ? pt.fg : pt.fgMuted,
fontSize: 12,
fontWeight:
selected ? FontWeight.w700 : FontWeight.w500)),
],
),
),
),
),
),
);
}
return Container(
decoration: BoxDecoration(
color: pt.bg1,
border: Border(top: BorderSide(color: pt.border)),
),
padding: const EdgeInsets.fromLTRB(8, 4, 8, 6),
child: Row(
children: [
item(MobileTab.chats, Icons.chat_bubble_outline_rounded,
Icons.chat_bubble_rounded, 'Chats', chatUnread),
item(MobileTab.servers, Icons.dns_outlined, Icons.dns_rounded,
'Server', serverUnread),
item(MobileTab.me, Icons.person_outline_rounded,
Icons.person_rounded, 'Du', 0),
],
),
);
}
}
/// Ungelesene Kanäle über alle Server (für das Abzeichen am „Server“-Tab).
final _serverUnreadProvider = Provider<int>((ref) {
ref.watch(roomListProvider);
final client = ref.watch(matrixClientProvider).valueOrNull;
if (client == null) return 0;
final channels = spaceChannelIds(client);
return client.rooms
.where((r) =>
channels.contains(r.id) &&
r.membership == Membership.join &&
r.notificationCount > 0)
.length;
});
/// Übersicht aller Server als Karten (Tab „Server“).
class ServerOverview extends ConsumerWidget {
const ServerOverview({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
ref.watch(roomListProvider); // bei jeder Synchronisation neu
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
final client = ref.watch(matrixClientProvider).valueOrNull;
return Column(
children: [
_TabHeader(
title: 'Server',
pt: pt,
action: IconButton(
tooltip: 'Server erstellen oder beitreten',
icon: Icon(Icons.add_rounded, color: pt.fgMuted),
onPressed: () => showDialog(
context: context, builder: (_) => const CreateJoinDialog()),
),
),
Expanded(
child: spaces.isEmpty || client == null
? _EmptyServers(pt: pt)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 16),
itemCount: spaces.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) =>
_ServerCard(space: spaces[i], client: client, pt: pt),
),
),
],
);
}
}
class _ServerCard extends ConsumerWidget {
final Room space;
final Client client;
final PyramidTheme pt;
const _ServerCard(
{required this.space, required this.client, required this.pt});
@override
Widget build(BuildContext context, WidgetRef ref) {
final name = space.getLocalizedDisplayname();
final invited = space.membership == Membership.invite;
var channels = 0, unread = 0, inVoice = 0;
for (final child in space.spaceChildren) {
final id = child.roomId;
if (id == null) continue;
final r = client.getRoomById(id);
if (r == null || r.isSpace) continue;
channels++;
if (r.membership == Membership.join && r.notificationCount > 0) unread++;
if (isVoiceChannelRoom(r)) {
inVoice += ref.watch(voiceParticipantsProvider(r.id)).length;
}
}
final subtitle = invited
? 'Einladung – antippen zum Ansehen'
: [
channels == 1 ? '1 Kanal' : '$channels Kanäle',
if (unread > 0) '$unread ungelesen',
].join(' · ');
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
Widget fallback(BuildContext _) => Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: pt.accentSoft,
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.center,
child: Text(initial,
style: TextStyle(
color: pt.accent, fontSize: 22, fontWeight: FontWeight.w700)),
);
return SpringPress(
pressedScale: 0.97,
child: Material(
color: pt.bg2,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () {
ref.read(activeSpaceIdProvider.notifier).state = space.id;
ref.read(activeRoomIdProvider.notifier).state = null;
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: invited ? pt.accent.withAlpha(120) : pt.border),
),
child: Row(
children: [
space.avatar == null
? Builder(builder: fallback)
: MxcAvatar(
mxcUri: space.avatar,
client: client,
size: 52,
borderRadius: BorderRadius.circular(16),
placeholder: fallback,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: pt.fg,
fontSize: 16,
fontWeight: unread > 0
? FontWeight.w700
: FontWeight.w600)),
const SizedBox(height: 2),
Text(subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: invited ? pt.accent : pt.fgMuted,
fontSize: 13)),
if (inVoice > 0) ...[
const SizedBox(height: 4),
Row(children: [
Icon(Icons.graphic_eq_rounded,
size: 14, color: pt.online),
const SizedBox(width: 4),
Text(
inVoice == 1
? '1 Person im Sprachkanal'
: '$inVoice Personen im Sprachkanal',
style: TextStyle(
color: pt.online,
fontSize: 12.5,
fontWeight: FontWeight.w600)),
]),
],
],
),
),
Icon(Icons.chevron_right_rounded, color: pt.fgDim),
],
),
),
),
),
);
}
}
class _EmptyServers extends StatelessWidget {
final PyramidTheme pt;
const _EmptyServers({required this.pt});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.dns_outlined, size: 40, color: pt.fgDim),
const SizedBox(height: 10),
Text('Noch keine Server',
style: TextStyle(color: pt.fgMuted, fontSize: 15)),
const SizedBox(height: 4),
Text('Server sind wie Discord: Kanäle für Text und Sprache.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgDim, fontSize: 13)),
const SizedBox(height: 14),
FilledButton.tonalIcon(
style: FilledButton.styleFrom(
backgroundColor: pt.accentSoft,
foregroundColor: pt.accent,
),
icon: const Icon(Icons.add_rounded, size: 18),
label: const Text('Server erstellen oder beitreten'),
onPressed: () => showDialog(
context: context, builder: (_) => const CreateJoinDialog()),
),
],
),
),
);
}
}
class _TabHeader extends StatelessWidget {
final String title;
final PyramidTheme pt;
final Widget? action;
const _TabHeader({required this.title, required this.pt, this.action});
@override
Widget build(BuildContext context) {
return Container(
height: 52,
padding: const EdgeInsets.only(left: 18, right: 6),
decoration: BoxDecoration(
color: pt.bg1,
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Row(
children: [
Expanded(
child: Text(title,
style: TextStyle(
color: pt.fg, fontSize: 17, fontWeight: FontWeight.w700)),
),
?action,
],
),
);
}
}
final _myProfileProvider = FutureProvider.autoDispose<Profile?>((ref) async {
final client = await ref.watch(matrixClientProvider.future);
final id = client.userID;
if (id == null) return null;
try {
return await client.getProfileFromUserId(id);
} catch (_) {
return null;
}
});
/// Tab „Du“: eigenes Profil und Einstellungen.
class MeTab extends ConsumerWidget {
const MeTab({super.key});
static const _entries = <(String, String, IconData)>[
('profile', 'Mein Profil', Icons.person_outline_rounded),
('notifications', 'Benachrichtigungen', Icons.notifications_none_rounded),
('appearance', 'Erscheinungsbild', Icons.palette_outlined),
('voice', 'Sprache & Video', Icons.headphones_rounded),
('privacy', 'Datenschutz', Icons.lock_outline_rounded),
('sessions', 'Sitzungen', Icons.devices_rounded),
('encryption', 'Verschlüsselung', Icons.shield_outlined),
('about', 'Über Pyramid', Icons.info_outline_rounded),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final client = ref.watch(matrixClientProvider).valueOrNull;
final profile = ref.watch(_myProfileProvider).valueOrNull;
final userId = client?.userID ?? '';
final name = profile?.displayName ??
userId.split(':').first.replaceFirst('@', '');
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
void open(String section) {
ref.read(settingsSectionRequestProvider.notifier).state = section;
ref.read(activeModalProvider.notifier).state = ModalKind.settings;
}
Widget fallback(BuildContext _) => Container(
width: 76,
height: 76,
decoration: BoxDecoration(color: pt.accentSoft, shape: BoxShape.circle),
alignment: Alignment.center,
child: Text(initial,
style: TextStyle(
color: pt.accent, fontSize: 32, fontWeight: FontWeight.w700)),
);
return Column(
children: [
_TabHeader(title: 'Du', pt: pt),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(12, 16, 12, 16),
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: pt.border),
),
child: Row(
children: [
Stack(
clipBehavior: Clip.none,
children: [
profile?.avatarUrl == null || client == null
? Builder(builder: fallback)
: MxcAvatar(
mxcUri: profile!.avatarUrl,
client: client,
size: 76,
placeholder: fallback,
),
Positioned(
right: 2,
bottom: 2,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: pt.online,
shape: BoxShape.circle,
border: Border.all(color: pt.bg2, width: 3),
),
),
),
],
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: pt.fg,
fontSize: 19,
fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(userId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
TextStyle(color: pt.fgMuted, fontSize: 13)),
const SizedBox(height: 10),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: pt.fg,
side: BorderSide(color: pt.border),
shape: const StadiumBorder(),
visualDensity: VisualDensity.compact,
),
icon: const Icon(Icons.edit_rounded, size: 16),
label: const Text('Profil bearbeiten'),
onPressed: () => open('profile'),
),
],
),
),
],
),
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: pt.border),
),
clipBehavior: Clip.antiAlias,
child: Material(
type: MaterialType.transparency,
child: Column(
children: [
for (var i = 1; i < _entries.length; i++) ...[
if (i > 1)
Divider(height: 1, indent: 56, color: pt.border),
ListTile(
leading: Icon(_entries[i].$3, color: pt.fgMuted),
title: Text(_entries[i].$2,
style: TextStyle(color: pt.fg, fontSize: 15)),
trailing: Icon(Icons.chevron_right_rounded,
color: pt.fgDim),
onTap: () => open(_entries[i].$1),
),
],
],
),
),
),
],
),
),
],
);
}
}