diff --git a/integration_test/mobile_ui_screens_test.dart b/integration_test/mobile_ui_screens_test.dart index f053e32..95f4974 100644 --- a/integration_test/mobile_ui_screens_test.dart +++ b/integration_test/mobile_ui_screens_test.dart @@ -225,7 +225,7 @@ void main() { // ── Gruppenraum (Namen + Avatare in den Sprechblasen) ── container.read(activeRoomIdProvider.notifier).state = null; - container.read(activeSpaceIdProvider.notifier).state = 'rooms'; + container.read(activeSpaceIdProvider.notifier).state = 'dms'; await _pumpUntil( tester, find.text(_groupName), diff --git a/integration_test/ui_audit_tour_test.dart b/integration_test/ui_audit_tour_test.dart index 8ac9bc1..1642d80 100644 --- a/integration_test/ui_audit_tour_test.dart +++ b/integration_test/ui_audit_tour_test.dart @@ -8,9 +8,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:matrix/matrix.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/settings_prefs.dart' show bottomNavProvider; 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/layout/mobile_tabs.dart'; import 'package:pyramid/main.dart' as app; import 'support/test_partner.dart'; @@ -49,12 +55,21 @@ void main() { addTearDown(tester.view.reset); phone(); + // Flüssig pumpen (~60 Bilder/s), damit Animationen wie auf dem Gerät + // zur Ruhe kommen (pumpFor zeichnet nur alle 100 ms). + Future smooth(Duration d) async { + final end = DateTime.now().add(d); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 16)); + } + } + Future both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async { phone(); - await pumpFor(tester, settle); + await smooth(settle); await _shot(tester, 'p_$name'); desktop(); - await pumpFor(tester, settle); + await smooth(settle); await _shot(tester, 'd_$name'); phone(); await pumpFor(tester, const Duration(milliseconds: 400)); @@ -147,8 +162,13 @@ void main() { await _shot(tester, 'p_02_drawer'); await tester.tapAt(const Offset(390, 400)); await pumpFor(tester, const Duration(seconds: 1)); - container.read(activeSpaceIdProvider.notifier).state = 'rooms'; - await both('03_raeume'); + final groupsChip = find.text('Gruppen'); + 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; await both('04_space'); @@ -160,7 +180,7 @@ void main() { container.read(activeRoomIdProvider.notifier).state = dm.id; await both('05_dm_chat', settle: const Duration(seconds: 3)); 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; await both('06_gruppe', settle: const Duration(seconds: 3)); container.read(membersPanelOpenProvider.notifier).state = true; @@ -216,7 +236,7 @@ void main() { await pumpUntil(tester, () => me.getRoomById(callRoomId!)?.membership == Membership.invite); await tester.runAsync(() => me.getRoomById(callRoomId!)!.join()); 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; await pumpFor(tester, const Duration(seconds: 2)); final voip = container.read(callSignalingProvider); @@ -254,9 +274,124 @@ void main() { expect(find.text('Kein Anruf'), findsNothing); await both('18_nach_auflegen_durch_gegenseite'); 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 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, - timeout: const Timeout(Duration(minutes: 8)), + timeout: const Timeout(Duration(minutes: 12)), ); } diff --git a/lib/core/app_state.dart b/lib/core/app_state.dart index 1ec05fa..f6c80a0 100644 --- a/lib/core/app_state.dart +++ b/lib/core/app_state.dart @@ -69,6 +69,10 @@ enum ModalKind { none, settings, search } final activeModalProvider = StateProvider((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((ref) => null); + // ─── Call state ─────────────────────────────────────────────────────────────── // Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen: // `callSignalingProvider` (features/call_signaling/call_signaling_service.dart) diff --git a/lib/core/settings_prefs.dart b/lib/core/settings_prefs.dart index bf5ebdc..b6e4345 100644 --- a/lib/core/settings_prefs.dart +++ b/lib/core/settings_prefs.dart @@ -68,6 +68,13 @@ class StringSetPref extends StateNotifier> { } } +// ── Navigation (Handy) ─────────────────────────────────────────────────────── + +/// Handy: untere Leiste (Chats · Server · Du) statt Seitenmenü. +/// Standard: Seitenmenü (Bernd 2026-10-09: untere Leiste erst zum Vergleich). +final bottomNavProvider = + StateNotifierProvider((ref) => BoolPref('nav_bottom_bar', false)); + // ── Notification preferences ───────────────────────────────────────────────── final notifDesktopProvider = diff --git a/lib/features/call_ui/bubble_physics.dart b/lib/features/call_ui/bubble_physics.dart index e366452..dbdf3fb 100644 --- a/lib/features/call_ui/bubble_physics.dart +++ b/lib/features/call_ui/bubble_physics.dart @@ -70,13 +70,13 @@ class BubbleSim { final active = ids.where((id) => bodies.containsKey(id)).toList(); double base; if (active.length <= 1) { - base = shortSide * 0.30; + base = shortSide * 0.27; } else { - // Kreise sollen zusammen gut 40 % der Fläche bedecken. + // Kreise sollen zusammen gut ein Drittel der Fläche bedecken. final sumW2 = active.fold(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) { if (b.leaving) { b.targetR = 0; diff --git a/lib/features/call_ui/view/bubble_stage.dart b/lib/features/call_ui/view/bubble_stage.dart index 4db1f70..a21ae80 100644 --- a/lib/features/call_ui/view/bubble_stage.dart +++ b/lib/features/call_ui/view/bubble_stage.dart @@ -164,6 +164,17 @@ class _Bubble extends StatelessWidget { isScreenShare: data.isScreenShare, 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) Align( alignment: const Alignment(0, 0.62), @@ -303,3 +314,49 @@ class _BubbleRingPainter extends CustomPainter { @override 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, + ); + } +} diff --git a/lib/features/call_ui/view/call_view.dart b/lib/features/call_ui/view/call_view.dart index 95852f7..2479e32 100644 --- a/lib/features/call_ui/view/call_view.dart +++ b/lib/features/call_ui/view/call_view.dart @@ -54,7 +54,10 @@ class _UnifiedCallView extends StatefulWidget { } 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; bool _stripCollapsed = false; Set _knownScreens = {}; @@ -86,7 +89,7 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> { _knownScreens = screens; if (_mode == _CallLayout.hero && !widget.participants.any((p) => p.id == _heroId && p.hasVideo)) { - _mode = _CallLayout.bubbles; + _mode = _preferred; _heroId = null; } } @@ -154,6 +157,7 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> { _mode = _mode == _CallLayout.bubbles ? _CallLayout.grid : _CallLayout.bubbles; + _preferred = _mode; _heroId = null; }), onMinimize: widget.onMinimize, diff --git a/lib/features/chat/chat_composer.dart b/lib/features/chat/chat_composer.dart index 58f6ade..a13eecf 100644 --- a/lib/features/chat/chat_composer.dart +++ b/lib/features/chat/chat_composer.dart @@ -10,6 +10,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/appearance.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/features/chat/attachment_dialog.dart'; import 'package:pyramid/features/chat/emoji_picker.dart'; @@ -319,9 +321,12 @@ class _ChatComposerState extends ConsumerState { final mobile = isMobileLayout(size); final roomName = widget.room.getLocalizedDisplayname(); + // „#“ nur bei Server-Kanälen; Gruppen heißen wie die Gruppe. final placeholder = widget.room.isDirectChat ? 'Nachricht…' - : 'Nachricht an #$roomName'; + : ref.watch(spaceChannelIdsProvider).contains(widget.room.id) + ? 'Nachricht an #$roomName' + : 'Nachricht an $roomName'; return SafeArea( child: Column( diff --git a/lib/features/chat/view/chat_header.dart b/lib/features/chat/view/chat_header.dart index 6f3a542..4e62dfb 100644 --- a/lib/features/chat/view/chat_header.dart +++ b/lib/features/chat/view/chat_header.dart @@ -26,7 +26,7 @@ class _ChatHeader extends ConsumerWidget { final name = room?.getLocalizedDisplayname() ?? roomId; final isDm = room?.isDirectChat ?? 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 size = MediaQuery.sizeOf(context); final isMobile = size.width < 900 || size.height < 500; diff --git a/lib/features/rooms/panel/rooms_header.dart b/lib/features/rooms/panel/rooms_header.dart index c832deb..c0541a8 100644 --- a/lib/features/rooms/panel/rooms_header.dart +++ b/lib/features/rooms/panel/rooms_header.dart @@ -15,6 +15,7 @@ class _Header extends ConsumerWidget { final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms'; final size = MediaQuery.sizeOf(context); final isMobile = size.width < 900 || size.height < 500; + final bottomNav = ref.watch(bottomNavProvider); final spaces = ref.watch(spacesProvider).valueOrNull ?? []; Room? activeSpace; @@ -162,7 +163,18 @@ class _Header extends ConsumerWidget { Widget controlsRow = Row( 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( icon: const Icon(Icons.menu_rounded), tooltip: 'Spaces', @@ -171,7 +183,7 @@ class _Header extends ConsumerWidget { else ...[ PyrIconBtn( icon: const Icon(Icons.menu_rounded), - tooltip: 'Toggle sidebar', + tooltip: 'Seitenleiste ein/aus', onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s), ), @@ -311,7 +323,7 @@ class _Header extends ConsumerWidget { height: isRealSpace ? 56 : 52, padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( - color: isRealSpace ? pt.bg0 : pt.bg1, + color: isRealSpace && !isMobile ? pt.bg0 : pt.bg1, border: Border(bottom: BorderSide(color: pt.border)), ), child: controlsRow, @@ -453,9 +465,13 @@ class _NotificationsButton extends ConsumerWidget { class _FilterInput extends StatelessWidget { final TextEditingController controller; final PyramidTheme pt; + final String hint; final ValueChanged onChanged; const _FilterInput( - {required this.controller, required this.pt, required this.onChanged}); + {required this.controller, + required this.pt, + required this.hint, + required this.onChanged}); @override Widget build(BuildContext context) { @@ -464,18 +480,17 @@ class _FilterInput extends StatelessWidget { child: Stack( alignment: Alignment.centerLeft, children: [ - Padding( - padding: const EdgeInsets.only(left: 8), - child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim), - ), TextField( controller: controller, onChanged: onChanged, style: TextStyle(color: pt.fg, fontSize: 13), decoration: InputDecoration( - hintText: 'Chats durchsuchen', + hintText: hint, 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, fillColor: pt.bg2, border: OutlineInputBorder( diff --git a/lib/features/rooms/panel/rooms_list.dart b/lib/features/rooms/panel/rooms_list.dart index cc25e3d..e511502 100644 --- a/lib/features/rooms/panel/rooms_list.dart +++ b/lib/features/rooms/panel/rooms_list.dart @@ -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( height: 40, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.fromLTRB(4, 2, 4, 6), - children: [ - chip(_ChatFilter.all, 'Alle'), - chip(_ChatFilter.unread, - unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'), - chip(_ChatFilter.people, 'Personen'), - chip(_ChatFilter.groups, 'Gruppen'), - ], + children: chips, ), ); } @@ -571,6 +580,19 @@ class _RoomItemState extends ConsumerState<_RoomItem> { final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : >[]; 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). if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) { return _MobileRoomTile( diff --git a/lib/features/rooms/panel/rooms_mobile_tile.dart b/lib/features/rooms/panel/rooms_mobile_tile.dart index cc4b2b2..0f2ab6d 100644 --- a/lib/features/rooms/panel/rooms_mobile_tile.dart +++ b/lib/features/rooms/panel/rooms_mobile_tile.dart @@ -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> 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, + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/rooms/panel/rooms_panel_core.dart b/lib/features/rooms/panel/rooms_panel_core.dart index e96a132..359ddfe 100644 --- a/lib/features/rooms/panel/rooms_panel_core.dart +++ b/lib/features/rooms/panel/rooms_panel_core.dart @@ -64,8 +64,8 @@ class _RoomsPanelState extends ConsumerState { }); final roomsAsync = ref.watch(roomListProvider); final activeSpaceId = ref.watch(activeSpaceIdProvider); - final call = ref.watch(voiceChannelProvider); - final voip = ref.watch(callSignalingProvider); + final bottomNav = ref.watch(bottomNavProvider) && + isMobileLayout(MediaQuery.sizeOf(context)); final isRealSpace = activeSpaceId != null && activeSpaceId != 'dms' && @@ -87,6 +87,7 @@ class _RoomsPanelState extends ConsumerState { _FilterInput( controller: _filterCtrl, pt: pt, + hint: isRealSpace ? 'Kanäle durchsuchen' : 'Chats durchsuchen', onChanged: (v) => setState(() => _filter = v), ), Expanded( @@ -122,52 +123,80 @@ class _RoomsPanelState extends ConsumerState { ), ), ), - // Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden. - // Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und - // Konto-Balken nebeneinander statt übereinander. - 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()], - ); - }), + // Call-Dock + Profilbalken. Mit der unteren Leiste (Handy) liegen + // beide dort (layout/mobile_tabs.dart). + if (!bottomNav) const _PanelFooter(), ], ), ); } } + +/// 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()], + ); + } +} diff --git a/lib/features/rooms/rooms_panel.dart b/lib/features/rooms/rooms_panel.dart index a5da318..2d365ac 100644 --- a/lib/features/rooms/rooms_panel.dart +++ b/lib/features/rooms/rooms_panel.dart @@ -11,11 +11,13 @@ import 'package:pyramid/core/appearance.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/notification_content.dart' show notificationBody; 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/features/rooms/rooms_provider.dart'; 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_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/rooms/user_panel.dart'; import 'package:pyramid/features/spaces/space_admin_dialog.dart'; diff --git a/lib/layout/app_shell.dart b/lib/layout/app_shell.dart index 19fe9b8..2037efe 100644 --- a/lib/layout/app_shell.dart +++ b/lib/layout/app_shell.dart @@ -18,6 +18,8 @@ import 'package:pyramid/features/members/members_panel.dart'; import 'package:pyramid/features/rooms/rooms_panel.dart'; import 'package:pyramid/features/spaces/spaces_rail.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/mxc_image.dart'; import 'package:pyramid/widgets/search_modal.dart'; @@ -395,11 +397,17 @@ class _MobileShell extends ConsumerWidget { : activeRoomId != null ? ChatView(roomId: activeRoomId!) : null; + final bottomNav = ref.watch(bottomNavProvider); + final tab = ref.watch(mobileTabProvider); final body = MobilePageStack( - base: const RoomsPanel(), + base: bottomNav ? const MobileTabsBase() : const RoomsPanel(), front: front, 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). final overlayStyle = (pt.isDark @@ -413,7 +421,10 @@ class _MobileShell extends ConsumerWidget { return AnnotatedRegion( value: overlayStyle, child: PopScope( - canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen, + canPop: activeRoomId == null && + viewMode == ViewMode.chat && + !membersOpen && + tabsAtRoot, onPopInvokedWithResult: (didPop, _) { if (!didPop) { if (membersOpen) { @@ -422,6 +433,8 @@ class _MobileShell extends ConsumerWidget { ref.read(viewModeProvider.notifier).state = ViewMode.chat; } else if (activeRoomId != 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 // geht nahtlos in sie über. backgroundColor: pt.bg1, - drawer: Drawer( - width: 80, - backgroundColor: pt.bg0, - child: const SafeArea(child: SpacesRail()), - ), + drawer: bottomNav + ? null + : Drawer( + width: 80, + backgroundColor: pt.bg0, + child: const SafeArea(child: SpacesRail()), + ), // SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur // Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart). body: SafeArea(child: Stack( diff --git a/lib/layout/mobile_tabs.dart b/lib/layout/mobile_tabs.dart new file mode 100644 index 0000000..8ac68a8 --- /dev/null +++ b/lib/layout/mobile_tabs.dart @@ -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((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), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 277955c..c2d4c9e 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -169,6 +169,26 @@ class _AppearanceSection extends StatelessWidget { ), ]), 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: [ _SettingsRow( title: 'Eckenrundung', diff --git a/lib/widgets/settings_modal.dart b/lib/widgets/settings_modal.dart index 6e5efa6..5776860 100644 --- a/lib/widgets/settings_modal.dart +++ b/lib/widgets/settings_modal.dart @@ -82,6 +82,20 @@ class _SettingsModalState extends ConsumerState { ('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) => _categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2; diff --git a/pubspec.yaml b/pubspec.yaml index dd8553e..7eade84 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pyramid description: A flexible Matrix client with resizable panel layout. publish_to: none -version: 0.1.41+42 +version: 0.1.42+43 environment: sdk: ">=3.11.1 <4.0.0"