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:
co-authored by
Claude Opus 5.5
parent
de24053167
commit
391ebf0ffa
@@ -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),
|
||||
|
||||
@@ -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<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 {
|
||||
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<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,
|
||||
timeout: const Timeout(Duration(minutes: 8)),
|
||||
timeout: const Timeout(Duration(minutes: 12)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ enum ModalKind { none, settings, search }
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
|
||||
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
final notifDesktopProvider =
|
||||
|
||||
@@ -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<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) {
|
||||
if (b.leaving) {
|
||||
b.targetR = 0;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> _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,
|
||||
|
||||
@@ -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<ChatComposer> {
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> 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(
|
||||
|
||||
@@ -214,18 +214,27 @@ class _ChatFilterChips extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
||||
children: [
|
||||
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: chips,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -571,6 +580,19 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
||||
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
||||
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(
|
||||
|
||||
@@ -172,3 +172,103 @@ String? _roomPreview(Room room, String? myUserId) {
|
||||
if (room.isDirectChat) return body;
|
||||
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
|
||||
}
|
||||
|
||||
/// Sprachkanal im Handy-Layout: Kachel wie ein Chat, darunter wer gerade drin
|
||||
/// ist (Avatare + Namen) bzw. „Antippen zum Beitreten“.
|
||||
class _MobileVoiceTile extends StatelessWidget {
|
||||
final Room room;
|
||||
final bool inVoice;
|
||||
final List<Map<String, dynamic>> participants;
|
||||
final Client? client;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
const _MobileVoiceTile({
|
||||
required this.room,
|
||||
required this.inVoice,
|
||||
required this.participants,
|
||||
required this.client,
|
||||
required this.pt,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final green = pt.online;
|
||||
final names = participants
|
||||
.map((p) => (p['displayName'] as String? ?? '').split(' ').first)
|
||||
.where((n) => n.isNotEmpty)
|
||||
.toList();
|
||||
final subtitle = inVoice
|
||||
? 'Du bist drin · antippen zum Verlassen'
|
||||
: participants.isEmpty
|
||||
? 'Sprachkanal · antippen zum Beitreten'
|
||||
: names.length <= 2
|
||||
? '${names.join(' und ')} ${names.length == 1 ? 'ist' : 'sind'} drin'
|
||||
: '${names.take(2).join(', ')} und ${names.length - 2} weitere';
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Container(
|
||||
color: inVoice ? green.withValues(alpha: 0.10) : Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: _kTileAvatar,
|
||||
height: _kTileAvatar,
|
||||
decoration: BoxDecoration(
|
||||
color: green.withValues(alpha: inVoice ? 0.30 : 0.14),
|
||||
borderRadius: BorderRadius.circular(_kTileAvatar * 0.3),
|
||||
),
|
||||
child: Icon(
|
||||
participants.isEmpty
|
||||
? Icons.volume_up_rounded
|
||||
: Icons.graphic_eq_rounded,
|
||||
size: 22,
|
||||
color: green,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(room.getLocalizedDisplayname(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 3),
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: participants.isEmpty && !inVoice
|
||||
? pt.fgDim
|
||||
: green,
|
||||
fontSize: 13.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (participants.isNotEmpty && client != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
CallAvatarStack(
|
||||
participants: participants,
|
||||
client: client!,
|
||||
ringColor: pt.bg1,
|
||||
size: 26,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
});
|
||||
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<RoomsPanel> {
|
||||
_FilterInput(
|
||||
controller: _filterCtrl,
|
||||
pt: pt,
|
||||
hint: isRealSpace ? 'Kanäle durchsuchen' : 'Chats durchsuchen',
|
||||
onChanged: (v) => setState(() => _filter = v),
|
||||
),
|
||||
Expanded(
|
||||
@@ -122,24 +123,33 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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).
|
||||
// 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.
|
||||
// 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(
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
@@ -151,23 +161,42 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 Row(
|
||||
return const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: callDock),
|
||||
const Expanded(child: UserPanel()),
|
||||
Expanded(child: CallDock()),
|
||||
Expanded(child: UserPanel()),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
return const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [callDock, const UserPanel()],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
children: [CallDock(), UserPanel()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<SystemUiOverlayStyle>(
|
||||
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,7 +442,9 @@ class _MobileShell extends ConsumerWidget {
|
||||
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
|
||||
// geht nahtlos in sie über.
|
||||
backgroundColor: pt.bg1,
|
||||
drawer: Drawer(
|
||||
drawer: bottomNav
|
||||
? null
|
||||
: Drawer(
|
||||
width: 80,
|
||||
backgroundColor: pt.bg0,
|
||||
child: const SafeArea(child: SpacesRail()),
|
||||
|
||||
@@ -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),
|
||||
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',
|
||||
|
||||
@@ -82,6 +82,20 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||||
('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;
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user