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
@@ -172,3 +172,103 @@ String? _roomPreview(Room room, String? myUserId) {
if (room.isDirectChat) return body;
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
}
/// Sprachkanal im Handy-Layout: Kachel wie ein Chat, darunter wer gerade drin
/// ist (Avatare + Namen) bzw. „Antippen zum Beitreten“.
class _MobileVoiceTile extends StatelessWidget {
final Room room;
final bool inVoice;
final List<Map<String, dynamic>> participants;
final Client? client;
final PyramidTheme pt;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _MobileVoiceTile({
required this.room,
required this.inVoice,
required this.participants,
required this.client,
required this.pt,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
final green = pt.online;
final names = participants
.map((p) => (p['displayName'] as String? ?? '').split(' ').first)
.where((n) => n.isNotEmpty)
.toList();
final subtitle = inVoice
? 'Du bist drin · antippen zum Verlassen'
: participants.isEmpty
? 'Sprachkanal · antippen zum Beitreten'
: names.length <= 2
? '${names.join(' und ')} ${names.length == 1 ? 'ist' : 'sind'} drin'
: '${names.take(2).join(', ')} und ${names.length - 2} weitere';
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onLongPress: onLongPress,
child: Container(
color: inVoice ? green.withValues(alpha: 0.10) : Colors.transparent,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
child: Row(
children: [
Container(
width: _kTileAvatar,
height: _kTileAvatar,
decoration: BoxDecoration(
color: green.withValues(alpha: inVoice ? 0.30 : 0.14),
borderRadius: BorderRadius.circular(_kTileAvatar * 0.3),
),
child: Icon(
participants.isEmpty
? Icons.volume_up_rounded
: Icons.graphic_eq_rounded,
size: 22,
color: green,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(room.getLocalizedDisplayname(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight: FontWeight.w600)),
const SizedBox(height: 3),
Text(subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: participants.isEmpty && !inVoice
? pt.fgDim
: green,
fontSize: 13.5)),
],
),
),
if (participants.isNotEmpty && client != null) ...[
const SizedBox(width: 8),
CallAvatarStack(
participants: participants,
client: client!,
ringColor: pt.bg1,
size: 26,
),
],
],
),
),
);
}
}