Files
pyramid/lib/layout/mobile_tabs.dart
Bernd SteckmeisterandClaude Opus 5.5 391ebf0ffa 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]>
2026-10-10 14:22:02 +02:00

624 lines
23 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
),
],
],
),
),
),
],
),
),
],
);
}
}