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
+1 -1
View File
@@ -225,7 +225,7 @@ void main() {
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ── // ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
container.read(activeRoomIdProvider.notifier).state = null; container.read(activeRoomIdProvider.notifier).state = null;
container.read(activeSpaceIdProvider.notifier).state = 'rooms'; container.read(activeSpaceIdProvider.notifier).state = 'dms';
await _pumpUntil( await _pumpUntil(
tester, tester,
find.text(_groupName), find.text(_groupName),
+142 -7
View File
@@ -8,9 +8,15 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:pyramid/core/livekit_token.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo;
import 'package:pyramid/features/group_call/group_call.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart'; import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/layout/mobile_tabs.dart';
import 'package:pyramid/main.dart' as app; import 'package:pyramid/main.dart' as app;
import 'support/test_partner.dart'; import 'support/test_partner.dart';
@@ -49,12 +55,21 @@ void main() {
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
phone(); phone();
// Flüssig pumpen (~60 Bilder/s), damit Animationen wie auf dem Gerät
// zur Ruhe kommen (pumpFor zeichnet nur alle 100 ms).
Future<void> smooth(Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 16));
}
}
Future<void> both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async { Future<void> both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async {
phone(); phone();
await pumpFor(tester, settle); await smooth(settle);
await _shot(tester, 'p_$name'); await _shot(tester, 'p_$name');
desktop(); desktop();
await pumpFor(tester, settle); await smooth(settle);
await _shot(tester, 'd_$name'); await _shot(tester, 'd_$name');
phone(); phone();
await pumpFor(tester, const Duration(milliseconds: 400)); await pumpFor(tester, const Duration(milliseconds: 400));
@@ -147,8 +162,13 @@ void main() {
await _shot(tester, 'p_02_drawer'); await _shot(tester, 'p_02_drawer');
await tester.tapAt(const Offset(390, 400)); await tester.tapAt(const Offset(390, 400));
await pumpFor(tester, const Duration(seconds: 1)); await pumpFor(tester, const Duration(seconds: 1));
container.read(activeSpaceIdProvider.notifier).state = 'rooms'; final groupsChip = find.text('Gruppen');
await both('03_raeume'); if (groupsChip.evaluate().isNotEmpty) {
await tester.tap(groupsChip.first);
await both('03_chats_gruppen');
await tester.tap(find.text('Alle').first);
await pumpFor(tester, const Duration(milliseconds: 500));
}
container.read(activeSpaceIdProvider.notifier).state = space.id; container.read(activeSpaceIdProvider.notifier).state = space.id;
await both('04_space'); await both('04_space');
@@ -160,7 +180,7 @@ void main() {
container.read(activeRoomIdProvider.notifier).state = dm.id; container.read(activeRoomIdProvider.notifier).state = dm.id;
await both('05_dm_chat', settle: const Duration(seconds: 3)); await both('05_dm_chat', settle: const Duration(seconds: 3));
final group = me.rooms.firstWhere((r) => r.getLocalizedDisplayname() == 'UI-Test Gruppe'); final group = me.rooms.firstWhere((r) => r.getLocalizedDisplayname() == 'UI-Test Gruppe');
container.read(activeSpaceIdProvider.notifier).state = 'rooms'; container.read(activeSpaceIdProvider.notifier).state = 'dms';
container.read(activeRoomIdProvider.notifier).state = group.id; container.read(activeRoomIdProvider.notifier).state = group.id;
await both('06_gruppe', settle: const Duration(seconds: 3)); await both('06_gruppe', settle: const Duration(seconds: 3));
container.read(membersPanelOpenProvider.notifier).state = true; container.read(membersPanelOpenProvider.notifier).state = true;
@@ -216,7 +236,7 @@ void main() {
await pumpUntil(tester, () => me.getRoomById(callRoomId!)?.membership == Membership.invite); await pumpUntil(tester, () => me.getRoomById(callRoomId!)?.membership == Membership.invite);
await tester.runAsync(() => me.getRoomById(callRoomId!)!.join()); await tester.runAsync(() => me.getRoomById(callRoomId!)!.join());
await pumpFor(tester, const Duration(seconds: 3)); await pumpFor(tester, const Duration(seconds: 3));
container.read(activeSpaceIdProvider.notifier).state = 'rooms'; container.read(activeSpaceIdProvider.notifier).state = 'dms';
container.read(activeRoomIdProvider.notifier).state = callRoomId; container.read(activeRoomIdProvider.notifier).state = callRoomId;
await pumpFor(tester, const Duration(seconds: 2)); await pumpFor(tester, const Duration(seconds: 2));
final voip = container.read(callSignalingProvider); final voip = container.read(callSignalingProvider);
@@ -254,9 +274,124 @@ void main() {
expect(find.text('Kein Anruf'), findsNothing); expect(find.text('Kein Anruf'), findsNothing);
await both('18_nach_auflegen_durch_gegenseite'); await both('18_nach_auflegen_durch_gegenseite');
container.read(viewModeProvider.notifier).state = ViewMode.chat; container.read(viewModeProvider.notifier).state = ViewMode.chat;
// ── 7. Gruppenanruf: Beitreten-Karte, Anruf mit zwei Personen ──
// Anwesenheit für alle erlauben (wer darf, ändert die Rechte).
await tester.runAsync(() async {
for (final c in [me, partner.client]) {
final r = c.getRoomById(group.id);
if (r != null && r.canChangePowerLevel) {
await allowVoicePresenceForAll(c, group.id).catchError((_) {});
break;
}
}
});
await pumpFor(tester, const Duration(seconds: 2));
Future<void> partnerPresence(bool active) => tester.runAsync(() =>
partner.client.setRoomStateWithKey(
group.id,
'io.pyramid.voice.presence',
partner.client.userID!,
active
? {
'active': true,
'joined_at': DateTime.now().millisecondsSinceEpoch,
'display_name': 'pyramidtest2',
}
: {'active': false},
).then((_) {}));
var presenceWritten = false;
try {
await partnerPresence(true);
presenceWritten = true;
} catch (e) {
logStep('Gegenseite darf keine Anwesenheit schreiben: $e');
}
container.read(activeSpaceIdProvider.notifier).state = 'dms';
container.read(activeRoomIdProvider.notifier).state = group.id;
if (presenceWritten) {
await pumpUntil(tester, () => find.text('Gruppenanruf läuft').evaluate().isNotEmpty,
timeout: const Duration(seconds: 20), what: 'Beitreten-Karte');
}
await both('19_gruppenanruf_karte', settle: const Duration(seconds: 2));
// Gegenseite tritt dem LiveKit-Raum wirklich bei (zweite Bubble).
lk.Room? partnerLk;
try {
partnerLk = await tester.runAsync(() async {
final t = await LiveKitTokenService.fetch(
room: group.id,
matrixToken: partner.client.accessToken!,
);
final r = lk.Room();
await r.connect(t.url, t.token);
return r;
});
} catch (e) {
logStep('Gegenseite konnte LiveKit nicht beitreten: $e');
}
final lkCall = container.read(voiceChannelProvider);
await tester.tap(find.byTooltip('Gruppenanruf beitreten').evaluate().isNotEmpty
? find.byTooltip('Gruppenanruf beitreten').first
: find.byTooltip('Gruppenanruf').first);
await pumpUntil(tester, () => lkCall.isActive && !lkCall.isConnecting,
timeout: const Duration(seconds: 30), what: 'Gruppenanruf verbunden');
await both('20_gruppenanruf_bubbles', settle: const Duration(seconds: 3));
await tester.tap(find.byTooltip('Kachelansicht').first);
await both('21_gruppenanruf_kacheln', settle: const Duration(seconds: 2));
await tester.tap(find.byTooltip('Bubble-Ansicht').first);
await pumpFor(tester, const Duration(seconds: 1));
container.read(viewModeProvider.notifier).state = ViewMode.chat;
await both('22_gruppenanruf_minimiert_chat', settle: const Duration(seconds: 2));
await tester.runAsync(() => lkCall.hangUp());
await tester.runAsync(() async => partnerLk?.disconnect());
if (presenceWritten) await partnerPresence(false);
await pumpFor(tester, const Duration(seconds: 2));
// ── 8. Bubbles mit mehreren Personen (Vorführ-Daten) ──
final overlay = Overlay.of(
tester.element(find.byType(Scaffold).first),
rootOverlay: true,
);
final demo = OverlayEntry(builder: (_) => const CallBubblesDemo());
overlay.insert(demo);
await both('23_bubbles_5_personen', settle: const Duration(seconds: 3));
demo.remove();
final demo7 = OverlayEntry(builder: (_) => const CallBubblesDemo(count: 7));
overlay.insert(demo7);
await both('24_bubbles_7_personen', settle: const Duration(seconds: 3));
demo7.remove();
await pumpFor(tester, const Duration(milliseconds: 500));
// ── 9. Handy: untere Leiste statt Seitenmenü (Vergleich) ──
phone();
container.read(activeRoomIdProvider.notifier).state = null;
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(true));
container.read(mobileTabProvider.notifier).state = MobileTab.chats;
container.read(activeSpaceIdProvider.notifier).state = 'dms';
await pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, 'p_30_unten_chats');
await tester.tap(find.text('Server').last);
await pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, 'p_31_unten_server');
await tester.tap(find.text(_spaceName).first);
await pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, 'p_32_unten_server_kanaele');
await tester.tap(find.text('Du').last);
await pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, 'p_33_unten_du');
container.read(activeRoomIdProvider.notifier).state = group.id;
await pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, 'p_34_unten_chat_offen');
container.read(activeRoomIdProvider.notifier).state = null;
await pumpFor(tester, const Duration(seconds: 1));
await tester.tap(find.text('Chats').last);
await pumpFor(tester, const Duration(seconds: 1));
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(false));
await pumpFor(tester, const Duration(seconds: 1));
}, },
skip: profile == null || accounts == null, skip: profile == null || accounts == null,
timeout: const Timeout(Duration(minutes: 8)), timeout: const Timeout(Duration(minutes: 12)),
); );
} }
+4
View File
@@ -69,6 +69,10 @@ enum ModalKind { none, settings, search }
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none); final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
/// Einstellungen direkt in diesem Bereich öffnen (z. B. aus dem Tab „Du“);
/// SettingsModal liest und leert es beim Öffnen.
final settingsSectionRequestProvider = StateProvider<String?>((ref) => null);
// ─── Call state ─────────────────────────────────────────────────────────────── // ─── Call state ───────────────────────────────────────────────────────────────
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen: // Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart) // `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
+7
View File
@@ -68,6 +68,13 @@ class StringSetPref extends StateNotifier<Set<String>> {
} }
} }
// ── Navigation (Handy) ───────────────────────────────────────────────────────
/// Handy: untere Leiste (Chats · Server · Du) statt Seitenmenü.
/// Standard: Seitenmenü (Bernd 2026-10-09: untere Leiste erst zum Vergleich).
final bottomNavProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('nav_bottom_bar', false));
// ── Notification preferences ───────────────────────────────────────────────── // ── Notification preferences ─────────────────────────────────────────────────
final notifDesktopProvider = final notifDesktopProvider =
+4 -4
View File
@@ -70,13 +70,13 @@ class BubbleSim {
final active = ids.where((id) => bodies.containsKey(id)).toList(); final active = ids.where((id) => bodies.containsKey(id)).toList();
double base; double base;
if (active.length <= 1) { if (active.length <= 1) {
base = shortSide * 0.30; base = shortSide * 0.27;
} else { } else {
// Kreise sollen zusammen gut 40 % der Fläche bedecken. // Kreise sollen zusammen gut ein Drittel der Fläche bedecken.
final sumW2 = active.fold<double>(0, (s, id) => s + weight(id) * weight(id)); final sumW2 = active.fold<double>(0, (s, id) => s + weight(id) * weight(id));
base = math.sqrt(0.42 * w * h / (math.pi * sumW2)); base = math.sqrt(0.36 * w * h / (math.pi * sumW2));
} }
final maxR = shortSide * (active.length <= 2 ? 0.30 : 0.36); final maxR = shortSide * (active.length <= 2 ? 0.27 : 0.36);
for (final b in bodies.values) { for (final b in bodies.values) {
if (b.leaving) { if (b.leaving) {
b.targetR = 0; b.targetR = 0;
@@ -164,6 +164,17 @@ class _Bubble extends StatelessWidget {
isScreenShare: data.isScreenShare, isScreenShare: data.isScreenShare,
child: data.coverVideoWidget!, child: data.coverVideoWidget!,
), ),
const IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment(-0.45, -0.55),
radius: 0.95,
colors: [Color(0x2EFFFFFF), Color(0x00FFFFFF)],
),
),
),
),
if (showName) if (showName)
Align( Align(
alignment: const Alignment(0, 0.62), alignment: const Alignment(0, 0.62),
@@ -303,3 +314,49 @@ class _BubbleRingPainter extends CustomPainter {
@override @override
bool shouldRepaint(_BubbleRingPainter old) => true; bool shouldRepaint(_BubbleRingPainter old) => true;
} }
/// Vorführ-Ansicht der Bubbles mit erfundenen Teilnehmern – nur für
/// Screenshots/Tests (UI-Tour), damit man die Ansicht mit mehreren Personen
/// sieht, ohne dass so viele Testkonten anrufen müssen.
@visibleForTesting
class CallBubblesDemo extends StatelessWidget {
final int count;
const CallBubblesDemo({super.key, this.count = 5});
static const _names = ['Du', 'Anna', 'Ben', 'Clara', 'David', 'Ela', 'Finn'];
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final participants = [
for (var i = 0; i < count.clamp(1, _names.length); i++)
_ParticipantData(
id: 'demo$i',
name: _names[i],
isLocal: i == 0,
isSpeaking: i == 2,
isMuted: i == 3,
avatarColor: VoiceChannelView._avatarColors[i % VoiceChannelView._avatarColors.length],
),
];
return _UnifiedCallView(
participants: participants,
client: null,
startedAt: DateTime.now().subtract(const Duration(minutes: 3, seconds: 12)),
isLiveKit: true,
displayName: 'Familie',
isConnecting: false,
onHangUp: () {},
onMinimize: () {},
onToggleMic: () {},
onToggleDeafen: () {},
onToggleCam: () {},
onToggleScreen: () {},
isMuted: false,
isDeafened: false,
isCameraOff: true,
isScreenSharing: false,
pt: pt,
);
}
}
+6 -2
View File
@@ -54,7 +54,10 @@ class _UnifiedCallView extends StatefulWidget {
} }
class _UnifiedCallViewState extends State<_UnifiedCallView> { class _UnifiedCallViewState extends State<_UnifiedCallView> {
_CallLayout _mode = _CallLayout.bubbles; /// Zuletzt gewählte Ansicht (Bubbles/Kacheln) – bleibt beim Minimieren
/// und für den nächsten Anruf erhalten.
static _CallLayout _preferred = _CallLayout.bubbles;
_CallLayout _mode = _preferred;
String? _heroId; String? _heroId;
bool _stripCollapsed = false; bool _stripCollapsed = false;
Set<String> _knownScreens = {}; Set<String> _knownScreens = {};
@@ -86,7 +89,7 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
_knownScreens = screens; _knownScreens = screens;
if (_mode == _CallLayout.hero && if (_mode == _CallLayout.hero &&
!widget.participants.any((p) => p.id == _heroId && p.hasVideo)) { !widget.participants.any((p) => p.id == _heroId && p.hasVideo)) {
_mode = _CallLayout.bubbles; _mode = _preferred;
_heroId = null; _heroId = null;
} }
} }
@@ -154,6 +157,7 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
_mode = _mode == _CallLayout.bubbles _mode = _mode == _CallLayout.bubbles
? _CallLayout.grid ? _CallLayout.grid
: _CallLayout.bubbles; : _CallLayout.bubbles;
_preferred = _mode;
_heroId = null; _heroId = null;
}), }),
onMinimize: widget.onMinimize, onMinimize: widget.onMinimize,
+6 -1
View File
@@ -10,6 +10,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/appearance.dart'; import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart'
show spaceChannelIdsProvider;
import 'package:pyramid/widgets/spring_press.dart'; import 'package:pyramid/widgets/spring_press.dart';
import 'package:pyramid/features/chat/attachment_dialog.dart'; import 'package:pyramid/features/chat/attachment_dialog.dart';
import 'package:pyramid/features/chat/emoji_picker.dart'; import 'package:pyramid/features/chat/emoji_picker.dart';
@@ -319,9 +321,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
final mobile = isMobileLayout(size); final mobile = isMobileLayout(size);
final roomName = widget.room.getLocalizedDisplayname(); final roomName = widget.room.getLocalizedDisplayname();
// „#“ nur bei Server-Kanälen; Gruppen heißen wie die Gruppe.
final placeholder = widget.room.isDirectChat final placeholder = widget.room.isDirectChat
? 'Nachricht…' ? 'Nachricht…'
: 'Nachricht an #$roomName'; : ref.watch(spaceChannelIdsProvider).contains(widget.room.id)
? 'Nachricht an #$roomName'
: 'Nachricht an $roomName';
return SafeArea( return SafeArea(
child: Column( child: Column(
+1 -1
View File
@@ -26,7 +26,7 @@ class _ChatHeader extends ConsumerWidget {
final name = room?.getLocalizedDisplayname() ?? roomId; final name = room?.getLocalizedDisplayname() ?? roomId;
final isDm = room?.isDirectChat ?? false; final isDm = room?.isDirectChat ?? false;
final isEncrypted = room?.encrypted ?? false; final isEncrypted = room?.encrypted ?? false;
final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name'); final topic = room?.topic ?? '';
final client = ref.watch(matrixClientProvider).valueOrNull; final client = ref.watch(matrixClientProvider).valueOrNull;
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500; final isMobile = size.width < 900 || size.height < 500;
+25 -10
View File
@@ -15,6 +15,7 @@ class _Header extends ConsumerWidget {
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms'; final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500; final isMobile = size.width < 900 || size.height < 500;
final bottomNav = ref.watch(bottomNavProvider);
final spaces = ref.watch(spacesProvider).valueOrNull ?? []; final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
Room? activeSpace; Room? activeSpace;
@@ -162,7 +163,18 @@ class _Header extends ConsumerWidget {
Widget controlsRow = Row( Widget controlsRow = Row(
children: [ children: [
if (isMobile) if (isMobile && bottomNav) ...[
if (isRealSpace)
PyrIconBtn(
icon: Icon(Icons.arrow_back_rounded,
color: hasBanner ? Colors.white : null),
tooltip: 'Alle Server',
onPressed: () =>
ref.read(activeSpaceIdProvider.notifier).state = null,
)
else
const SizedBox(width: 2),
] else if (isMobile)
PyrIconBtn( PyrIconBtn(
icon: const Icon(Icons.menu_rounded), icon: const Icon(Icons.menu_rounded),
tooltip: 'Spaces', tooltip: 'Spaces',
@@ -171,7 +183,7 @@ class _Header extends ConsumerWidget {
else ...[ else ...[
PyrIconBtn( PyrIconBtn(
icon: const Icon(Icons.menu_rounded), icon: const Icon(Icons.menu_rounded),
tooltip: 'Toggle sidebar', tooltip: 'Seitenleiste ein/aus',
onPressed: () => onPressed: () =>
ref.read(railCollapsedProvider.notifier).update((s) => !s), ref.read(railCollapsedProvider.notifier).update((s) => !s),
), ),
@@ -311,7 +323,7 @@ class _Header extends ConsumerWidget {
height: isRealSpace ? 56 : 52, height: isRealSpace ? 56 : 52,
padding: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isRealSpace ? pt.bg0 : pt.bg1, color: isRealSpace && !isMobile ? pt.bg0 : pt.bg1,
border: Border(bottom: BorderSide(color: pt.border)), border: Border(bottom: BorderSide(color: pt.border)),
), ),
child: controlsRow, child: controlsRow,
@@ -453,9 +465,13 @@ class _NotificationsButton extends ConsumerWidget {
class _FilterInput extends StatelessWidget { class _FilterInput extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
final PyramidTheme pt; final PyramidTheme pt;
final String hint;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
const _FilterInput( const _FilterInput(
{required this.controller, required this.pt, required this.onChanged}); {required this.controller,
required this.pt,
required this.hint,
required this.onChanged});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -464,18 +480,17 @@ class _FilterInput extends StatelessWidget {
child: Stack( child: Stack(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
children: [ children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim),
),
TextField( TextField(
controller: controller, controller: controller,
onChanged: onChanged, onChanged: onChanged,
style: TextStyle(color: pt.fg, fontSize: 13), style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Chats durchsuchen', hintText: hint,
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7), prefixIcon: Icon(Icons.search_rounded, size: 16, color: pt.fgDim),
prefixIconConstraints:
const BoxConstraints(minWidth: 32, minHeight: 20),
contentPadding: const EdgeInsets.fromLTRB(0, 7, 10, 7),
filled: true, filled: true,
fillColor: pt.bg2, fillColor: pt.bg2,
border: OutlineInputBorder( border: OutlineInputBorder(
+29 -7
View File
@@ -214,18 +214,27 @@ class _ChatFilterChips extends StatelessWidget {
); );
} }
final chips = [
chip(_ChatFilter.all, 'Alle'),
chip(_ChatFilter.unread,
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
chip(_ChatFilter.people, 'Personen'),
chip(_ChatFilter.groups, 'Gruppen'),
];
// Handy: wischbare Zeile; PC (schmale Spalte): umbrechen statt
// abschneiden – mit der Maus wischt niemand seitwärts.
if (!isMobileLayout(MediaQuery.sizeOf(context))) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
child: Wrap(runSpacing: 6, children: chips),
);
}
return SizedBox( return SizedBox(
height: 40, height: 40,
child: ListView( child: ListView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6), padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
children: [ children: chips,
chip(_ChatFilter.all, 'Alle'),
chip(_ChatFilter.unread,
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
chip(_ChatFilter.people, 'Personen'),
chip(_ChatFilter.groups, 'Gruppen'),
],
), ),
); );
} }
@@ -571,6 +580,19 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[]; final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
final client = ref.watch(matrixClientProvider).valueOrNull; final client = ref.watch(matrixClientProvider).valueOrNull;
// Handy: Sprachkanal als Kachel mit Teilnehmern (rooms_mobile_tile.dart).
if (isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
return _MobileVoiceTile(
room: room,
inVoice: inVoice,
participants: voiceParticipants,
client: client,
pt: pt,
onTap: widget.onTap,
onLongPress: _showContextMenu,
);
}
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart). // Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) { if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
return _MobileRoomTile( return _MobileRoomTile(
@@ -172,3 +172,103 @@ String? _roomPreview(Room room, String? myUserId) {
if (room.isDirectChat) return body; if (room.isDirectChat) return body;
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $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,
),
],
],
),
),
);
}
}
+75 -46
View File
@@ -64,8 +64,8 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
}); });
final roomsAsync = ref.watch(roomListProvider); final roomsAsync = ref.watch(roomListProvider);
final activeSpaceId = ref.watch(activeSpaceIdProvider); final activeSpaceId = ref.watch(activeSpaceIdProvider);
final call = ref.watch(voiceChannelProvider); final bottomNav = ref.watch(bottomNavProvider) &&
final voip = ref.watch(callSignalingProvider); isMobileLayout(MediaQuery.sizeOf(context));
final isRealSpace = activeSpaceId != null && final isRealSpace = activeSpaceId != null &&
activeSpaceId != 'dms' && activeSpaceId != 'dms' &&
@@ -87,6 +87,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
_FilterInput( _FilterInput(
controller: _filterCtrl, controller: _filterCtrl,
pt: pt, pt: pt,
hint: isRealSpace ? 'Kanäle durchsuchen' : 'Chats durchsuchen',
onChanged: (v) => setState(() => _filter = v), onChanged: (v) => setState(() => _filter = v),
), ),
Expanded( Expanded(
@@ -122,52 +123,80 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
), ),
), ),
), ),
// Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden. // Call-Dock + Profilbalken. Mit der unteren Leiste (Handy) liegen
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und // beide dort (layout/mobile_tabs.dart).
// Konto-Balken nebeneinander statt übereinander. if (!bottomNav) const _PanelFooter(),
Builder(builder: (context) {
// Klingeln (noch nicht angenommen) zählt nicht – dafür gibt es
// das Annehmen-Fenster (Bernd 2026-10-08).
final hasVoipCall = voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded &&
voip.currentCall!.state != CallState.kRinging;
// Am PC bei offener Anrufansicht nicht doppelt steuern
// (UI-Audit S6); am Handy liegt die Anrufansicht ohnehin darüber.
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
!isMobileLayout(MediaQuery.sizeOf(context));
final hasCallDock = !callViewOpen && (call.isActive || hasVoipCall);
final isLandscapeCompact =
MediaQuery.sizeOf(context).height < 500;
final callDock = AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (call.isActive && !callViewOpen) MiniCallWidget(call: call),
if (hasVoipCall && !callViewOpen) MiniCallWidget(call: voip),
],
),
);
if (isLandscapeCompact && hasCallDock) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: callDock),
const Expanded(child: UserPanel()),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [callDock, const UserPanel()],
);
}),
], ],
), ),
); );
} }
} }
/// Laufender Anruf als kompakte Leiste, animiert ein-/ausgeblendet.
class CallDock extends ConsumerWidget {
const CallDock({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final call = ref.watch(voiceChannelProvider);
final voip = ref.watch(callSignalingProvider);
// Klingeln (noch nicht angenommen) zählt nicht – dafür gibt es das
// Annehmen-Fenster (Bernd 2026-10-08).
final hasVoipCall = voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded &&
voip.currentCall!.state != CallState.kRinging;
// Am PC bei offener Anrufansicht nicht doppelt steuern (UI-Audit S6); am
// Handy liegt die Anrufansicht ohnehin darüber.
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
!isMobileLayout(MediaQuery.sizeOf(context));
return AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (call.isActive && !callViewOpen) MiniCallWidget(call: call),
if (hasVoipCall && !callViewOpen) MiniCallWidget(call: voip),
],
),
);
}
/// Ist gerade ein Anruf im Dock sichtbar?
static bool visible(WidgetRef ref, BuildContext context) {
final call = ref.watch(voiceChannelProvider);
final voip = ref.watch(callSignalingProvider);
final hasVoipCall = voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded &&
voip.currentCall!.state != CallState.kRinging;
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
!isMobileLayout(MediaQuery.sizeOf(context));
return !callViewOpen && (call.isActive || hasVoipCall);
}
}
/// Call-Dock immer ÜBER dem Konto-Balken. Im Mobil-Querformat (Höhe knapp)
/// liegen beide nebeneinander statt übereinander.
class _PanelFooter extends ConsumerWidget {
const _PanelFooter();
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasCallDock = CallDock.visible(ref, context);
final isLandscapeCompact = MediaQuery.sizeOf(context).height < 500;
if (isLandscapeCompact && hasCallDock) {
return const Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: CallDock()),
Expanded(child: UserPanel()),
],
);
}
return const Column(
mainAxisSize: MainAxisSize.min,
children: [CallDock(), UserPanel()],
);
}
}
+2
View File
@@ -11,11 +11,13 @@ import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart' show notificationBody; import 'package:pyramid/core/notification_content.dart' show notificationBody;
import 'package:pyramid/core/room_kinds.dart'; import 'package:pyramid/core/room_kinds.dart';
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart'; import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite; import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
import 'package:pyramid/features/call_ui/mini_call_widget.dart'; import 'package:pyramid/features/call_ui/mini_call_widget.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/group_call/group_call.dart' show CallAvatarStack;
import 'package:pyramid/features/voice_channel/voice_channel_service.dart'; import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/features/rooms/user_panel.dart'; import 'package:pyramid/features/rooms/user_panel.dart';
import 'package:pyramid/features/spaces/space_admin_dialog.dart'; import 'package:pyramid/features/spaces/space_admin_dialog.dart';
+22 -7
View File
@@ -18,6 +18,8 @@ import 'package:pyramid/features/members/members_panel.dart';
import 'package:pyramid/features/rooms/rooms_panel.dart'; import 'package:pyramid/features/rooms/rooms_panel.dart';
import 'package:pyramid/features/spaces/spaces_rail.dart'; import 'package:pyramid/features/spaces/spaces_rail.dart';
import 'package:pyramid/layout/mobile_page_stack.dart'; import 'package:pyramid/layout/mobile_page_stack.dart';
import 'package:pyramid/layout/mobile_tabs.dart';
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
import 'package:pyramid/widgets/spring_press.dart' show motionDuration; import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/search_modal.dart'; import 'package:pyramid/widgets/search_modal.dart';
@@ -395,11 +397,17 @@ class _MobileShell extends ConsumerWidget {
: activeRoomId != null : activeRoomId != null
? ChatView(roomId: activeRoomId!) ? ChatView(roomId: activeRoomId!)
: null; : null;
final bottomNav = ref.watch(bottomNavProvider);
final tab = ref.watch(mobileTabProvider);
final body = MobilePageStack( final body = MobilePageStack(
base: const RoomsPanel(), base: bottomNav ? const MobileTabsBase() : const RoomsPanel(),
front: front, front: front,
background: pt.bg1, background: pt.bg1,
); );
// Untere Leiste: Zurück führt erst über die Tabs (Server → Übersicht →
// Chats), bevor die App geschlossen wird.
final tabsAtRoot = !bottomNav ||
(tab == MobileTab.chats);
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt). // Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
final overlayStyle = (pt.isDark final overlayStyle = (pt.isDark
@@ -413,7 +421,10 @@ class _MobileShell extends ConsumerWidget {
return AnnotatedRegion<SystemUiOverlayStyle>( return AnnotatedRegion<SystemUiOverlayStyle>(
value: overlayStyle, value: overlayStyle,
child: PopScope( child: PopScope(
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen, canPop: activeRoomId == null &&
viewMode == ViewMode.chat &&
!membersOpen &&
tabsAtRoot,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (!didPop) { if (!didPop) {
if (membersOpen) { if (membersOpen) {
@@ -422,6 +433,8 @@ class _MobileShell extends ConsumerWidget {
ref.read(viewModeProvider.notifier).state = ViewMode.chat; ref.read(viewModeProvider.notifier).state = ViewMode.chat;
} else if (activeRoomId != null) { } else if (activeRoomId != null) {
ref.read(activeRoomIdProvider.notifier).state = null; ref.read(activeRoomIdProvider.notifier).state = null;
} else if (bottomNav) {
handleMobileTabBack(ref);
} }
} }
}, },
@@ -429,11 +442,13 @@ class _MobileShell extends ConsumerWidget {
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste // bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
// geht nahtlos in sie über. // geht nahtlos in sie über.
backgroundColor: pt.bg1, backgroundColor: pt.bg1,
drawer: Drawer( drawer: bottomNav
width: 80, ? null
backgroundColor: pt.bg0, : Drawer(
child: const SafeArea(child: SpacesRail()), width: 80,
), backgroundColor: pt.bg0,
child: const SafeArea(child: SpacesRail()),
),
// SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur // SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart). // Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
body: SafeArea(child: Stack( body: SafeArea(child: Stack(
+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),
),
],
],
),
),
),
],
),
),
],
);
}
}
@@ -169,6 +169,26 @@ class _AppearanceSection extends StatelessWidget {
), ),
]), ]),
const SizedBox(height: 20), const SizedBox(height: 20),
if (isMobileLayout(MediaQuery.sizeOf(context))) ...[
_SettingsGroup(title: 'Navigation', pt: pt, children: [
Consumer(builder: (context, ref, _) {
final bottom = ref.watch(bottomNavProvider);
return _AdaptiveSettingsRow(
title: 'Hauptmenü',
desc: 'Seitenmenü (☰) oder Leiste unten mit Chats · Server · Du',
pt: pt,
control: _SegControl(
options: const ['Seitenmenü', 'Untere Leiste'],
selected: bottom ? 1 : 0,
pt: pt,
onSelect: (i) =>
ref.read(bottomNavProvider.notifier).set(i == 1),
),
);
}),
]),
const SizedBox(height: 20),
],
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [ _SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
_SettingsRow( _SettingsRow(
title: 'Eckenrundung', title: 'Eckenrundung',
+14
View File
@@ -82,6 +82,20 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
('about', 'Über Pyramid', Icons.info_outline_rounded), ('about', 'Über Pyramid', Icons.info_outline_rounded),
]; ];
@override
void initState() {
super.initState();
final requested = ref.read(settingsSectionRequestProvider);
if (requested != null) {
_section = requested;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ref.read(settingsSectionRequestProvider.notifier).state = null;
}
});
}
}
String _titleFor(String id) => String _titleFor(String id) =>
_categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2; _categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2;
+1 -1
View File
@@ -1,7 +1,7 @@
name: pyramid name: pyramid
description: A flexible Matrix client with resizable panel layout. description: A flexible Matrix client with resizable panel layout.
publish_to: none publish_to: none
version: 0.1.41+42 version: 0.1.42+43
environment: environment:
sdk: ">=3.11.1 <4.0.0" sdk: ">=3.11.1 <4.0.0"