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((ref) => MobileTab.chats); /// Zuletzt geöffneter Server – „Server“ öffnet wieder dort. final _lastServerProvider = StateProvider((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((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((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), ), ], ], ), ), ), ], ), ), ], ); } }