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) ──
|
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
|
||||||
container.read(activeRoomIdProvider.notifier).state = null;
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
await _pumpUntil(
|
await _pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
find.text(_groupName),
|
find.text(_groupName),
|
||||||
|
|||||||
@@ -8,9 +8,15 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:integration_test/integration_test.dart';
|
import 'package:integration_test/integration_test.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||||
|
import 'package:pyramid/core/livekit_token.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
|
||||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
|
import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo;
|
||||||
|
import 'package:pyramid/features/group_call/group_call.dart';
|
||||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
|
import 'package:pyramid/layout/mobile_tabs.dart';
|
||||||
import 'package:pyramid/main.dart' as app;
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
|
||||||
import 'support/test_partner.dart';
|
import 'support/test_partner.dart';
|
||||||
@@ -49,12 +55,21 @@ void main() {
|
|||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
phone();
|
phone();
|
||||||
|
|
||||||
|
// Flüssig pumpen (~60 Bilder/s), damit Animationen wie auf dem Gerät
|
||||||
|
// zur Ruhe kommen (pumpFor zeichnet nur alle 100 ms).
|
||||||
|
Future<void> smooth(Duration d) async {
|
||||||
|
final end = DateTime.now().add(d);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async {
|
Future<void> both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async {
|
||||||
phone();
|
phone();
|
||||||
await pumpFor(tester, settle);
|
await smooth(settle);
|
||||||
await _shot(tester, 'p_$name');
|
await _shot(tester, 'p_$name');
|
||||||
desktop();
|
desktop();
|
||||||
await pumpFor(tester, settle);
|
await smooth(settle);
|
||||||
await _shot(tester, 'd_$name');
|
await _shot(tester, 'd_$name');
|
||||||
phone();
|
phone();
|
||||||
await pumpFor(tester, const Duration(milliseconds: 400));
|
await pumpFor(tester, const Duration(milliseconds: 400));
|
||||||
@@ -147,8 +162,13 @@ void main() {
|
|||||||
await _shot(tester, 'p_02_drawer');
|
await _shot(tester, 'p_02_drawer');
|
||||||
await tester.tapAt(const Offset(390, 400));
|
await tester.tapAt(const Offset(390, 400));
|
||||||
await pumpFor(tester, const Duration(seconds: 1));
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
final groupsChip = find.text('Gruppen');
|
||||||
await both('03_raeume');
|
if (groupsChip.evaluate().isNotEmpty) {
|
||||||
|
await tester.tap(groupsChip.first);
|
||||||
|
await both('03_chats_gruppen');
|
||||||
|
await tester.tap(find.text('Alle').first);
|
||||||
|
await pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
container.read(activeSpaceIdProvider.notifier).state = space.id;
|
container.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||||
await both('04_space');
|
await both('04_space');
|
||||||
|
|
||||||
@@ -160,7 +180,7 @@ void main() {
|
|||||||
container.read(activeRoomIdProvider.notifier).state = dm.id;
|
container.read(activeRoomIdProvider.notifier).state = dm.id;
|
||||||
await both('05_dm_chat', settle: const Duration(seconds: 3));
|
await both('05_dm_chat', settle: const Duration(seconds: 3));
|
||||||
final group = me.rooms.firstWhere((r) => r.getLocalizedDisplayname() == 'UI-Test Gruppe');
|
final group = me.rooms.firstWhere((r) => r.getLocalizedDisplayname() == 'UI-Test Gruppe');
|
||||||
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
container.read(activeRoomIdProvider.notifier).state = group.id;
|
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||||
await both('06_gruppe', settle: const Duration(seconds: 3));
|
await both('06_gruppe', settle: const Duration(seconds: 3));
|
||||||
container.read(membersPanelOpenProvider.notifier).state = true;
|
container.read(membersPanelOpenProvider.notifier).state = true;
|
||||||
@@ -216,7 +236,7 @@ void main() {
|
|||||||
await pumpUntil(tester, () => me.getRoomById(callRoomId!)?.membership == Membership.invite);
|
await pumpUntil(tester, () => me.getRoomById(callRoomId!)?.membership == Membership.invite);
|
||||||
await tester.runAsync(() => me.getRoomById(callRoomId!)!.join());
|
await tester.runAsync(() => me.getRoomById(callRoomId!)!.join());
|
||||||
await pumpFor(tester, const Duration(seconds: 3));
|
await pumpFor(tester, const Duration(seconds: 3));
|
||||||
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
container.read(activeRoomIdProvider.notifier).state = callRoomId;
|
container.read(activeRoomIdProvider.notifier).state = callRoomId;
|
||||||
await pumpFor(tester, const Duration(seconds: 2));
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
final voip = container.read(callSignalingProvider);
|
final voip = container.read(callSignalingProvider);
|
||||||
@@ -254,9 +274,124 @@ void main() {
|
|||||||
expect(find.text('Kein Anruf'), findsNothing);
|
expect(find.text('Kein Anruf'), findsNothing);
|
||||||
await both('18_nach_auflegen_durch_gegenseite');
|
await both('18_nach_auflegen_durch_gegenseite');
|
||||||
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
|
||||||
|
// ── 7. Gruppenanruf: Beitreten-Karte, Anruf mit zwei Personen ──
|
||||||
|
// Anwesenheit für alle erlauben (wer darf, ändert die Rechte).
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
for (final c in [me, partner.client]) {
|
||||||
|
final r = c.getRoomById(group.id);
|
||||||
|
if (r != null && r.canChangePowerLevel) {
|
||||||
|
await allowVoicePresenceForAll(c, group.id).catchError((_) {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
Future<void> partnerPresence(bool active) => tester.runAsync(() =>
|
||||||
|
partner.client.setRoomStateWithKey(
|
||||||
|
group.id,
|
||||||
|
'io.pyramid.voice.presence',
|
||||||
|
partner.client.userID!,
|
||||||
|
active
|
||||||
|
? {
|
||||||
|
'active': true,
|
||||||
|
'joined_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'display_name': 'pyramidtest2',
|
||||||
|
}
|
||||||
|
: {'active': false},
|
||||||
|
).then((_) {}));
|
||||||
|
var presenceWritten = false;
|
||||||
|
try {
|
||||||
|
await partnerPresence(true);
|
||||||
|
presenceWritten = true;
|
||||||
|
} catch (e) {
|
||||||
|
logStep('Gegenseite darf keine Anwesenheit schreiben: $e');
|
||||||
|
}
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||||
|
if (presenceWritten) {
|
||||||
|
await pumpUntil(tester, () => find.text('Gruppenanruf läuft').evaluate().isNotEmpty,
|
||||||
|
timeout: const Duration(seconds: 20), what: 'Beitreten-Karte');
|
||||||
|
}
|
||||||
|
await both('19_gruppenanruf_karte', settle: const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// Gegenseite tritt dem LiveKit-Raum wirklich bei (zweite Bubble).
|
||||||
|
lk.Room? partnerLk;
|
||||||
|
try {
|
||||||
|
partnerLk = await tester.runAsync(() async {
|
||||||
|
final t = await LiveKitTokenService.fetch(
|
||||||
|
room: group.id,
|
||||||
|
matrixToken: partner.client.accessToken!,
|
||||||
|
);
|
||||||
|
final r = lk.Room();
|
||||||
|
await r.connect(t.url, t.token);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logStep('Gegenseite konnte LiveKit nicht beitreten: $e');
|
||||||
|
}
|
||||||
|
final lkCall = container.read(voiceChannelProvider);
|
||||||
|
await tester.tap(find.byTooltip('Gruppenanruf beitreten').evaluate().isNotEmpty
|
||||||
|
? find.byTooltip('Gruppenanruf beitreten').first
|
||||||
|
: find.byTooltip('Gruppenanruf').first);
|
||||||
|
await pumpUntil(tester, () => lkCall.isActive && !lkCall.isConnecting,
|
||||||
|
timeout: const Duration(seconds: 30), what: 'Gruppenanruf verbunden');
|
||||||
|
await both('20_gruppenanruf_bubbles', settle: const Duration(seconds: 3));
|
||||||
|
await tester.tap(find.byTooltip('Kachelansicht').first);
|
||||||
|
await both('21_gruppenanruf_kacheln', settle: const Duration(seconds: 2));
|
||||||
|
await tester.tap(find.byTooltip('Bubble-Ansicht').first);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
await both('22_gruppenanruf_minimiert_chat', settle: const Duration(seconds: 2));
|
||||||
|
await tester.runAsync(() => lkCall.hangUp());
|
||||||
|
await tester.runAsync(() async => partnerLk?.disconnect());
|
||||||
|
if (presenceWritten) await partnerPresence(false);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// ── 8. Bubbles mit mehreren Personen (Vorführ-Daten) ──
|
||||||
|
final overlay = Overlay.of(
|
||||||
|
tester.element(find.byType(Scaffold).first),
|
||||||
|
rootOverlay: true,
|
||||||
|
);
|
||||||
|
final demo = OverlayEntry(builder: (_) => const CallBubblesDemo());
|
||||||
|
overlay.insert(demo);
|
||||||
|
await both('23_bubbles_5_personen', settle: const Duration(seconds: 3));
|
||||||
|
demo.remove();
|
||||||
|
final demo7 = OverlayEntry(builder: (_) => const CallBubblesDemo(count: 7));
|
||||||
|
overlay.insert(demo7);
|
||||||
|
await both('24_bubbles_7_personen', settle: const Duration(seconds: 3));
|
||||||
|
demo7.remove();
|
||||||
|
await pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// ── 9. Handy: untere Leiste statt Seitenmenü (Vergleich) ──
|
||||||
|
phone();
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(true));
|
||||||
|
container.read(mobileTabProvider.notifier).state = MobileTab.chats;
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_30_unten_chats');
|
||||||
|
await tester.tap(find.text('Server').last);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_31_unten_server');
|
||||||
|
await tester.tap(find.text(_spaceName).first);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_32_unten_server_kanaele');
|
||||||
|
await tester.tap(find.text('Du').last);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_33_unten_du');
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_34_unten_chat_offen');
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await tester.tap(find.text('Chats').last);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(false));
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
},
|
},
|
||||||
skip: profile == null || accounts == null,
|
skip: profile == null || accounts == null,
|
||||||
timeout: const Timeout(Duration(minutes: 8)),
|
timeout: const Timeout(Duration(minutes: 12)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ enum ModalKind { none, settings, search }
|
|||||||
|
|
||||||
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
||||||
|
|
||||||
|
/// Einstellungen direkt in diesem Bereich öffnen (z. B. aus dem Tab „Du“);
|
||||||
|
/// SettingsModal liest und leert es beim Öffnen.
|
||||||
|
final settingsSectionRequestProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
// ─── Call state ───────────────────────────────────────────────────────────────
|
// ─── Call state ───────────────────────────────────────────────────────────────
|
||||||
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
|
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
|
||||||
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
|
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ class StringSetPref extends StateNotifier<Set<String>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Navigation (Handy) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Handy: untere Leiste (Chats · Server · Du) statt Seitenmenü.
|
||||||
|
/// Standard: Seitenmenü (Bernd 2026-10-09: untere Leiste erst zum Vergleich).
|
||||||
|
final bottomNavProvider =
|
||||||
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('nav_bottom_bar', false));
|
||||||
|
|
||||||
// ── Notification preferences ─────────────────────────────────────────────────
|
// ── Notification preferences ─────────────────────────────────────────────────
|
||||||
|
|
||||||
final notifDesktopProvider =
|
final notifDesktopProvider =
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ class BubbleSim {
|
|||||||
final active = ids.where((id) => bodies.containsKey(id)).toList();
|
final active = ids.where((id) => bodies.containsKey(id)).toList();
|
||||||
double base;
|
double base;
|
||||||
if (active.length <= 1) {
|
if (active.length <= 1) {
|
||||||
base = shortSide * 0.30;
|
base = shortSide * 0.27;
|
||||||
} else {
|
} else {
|
||||||
// Kreise sollen zusammen gut 40 % der Fläche bedecken.
|
// Kreise sollen zusammen gut ein Drittel der Fläche bedecken.
|
||||||
final sumW2 = active.fold<double>(0, (s, id) => s + weight(id) * weight(id));
|
final sumW2 = active.fold<double>(0, (s, id) => s + weight(id) * weight(id));
|
||||||
base = math.sqrt(0.42 * w * h / (math.pi * sumW2));
|
base = math.sqrt(0.36 * w * h / (math.pi * sumW2));
|
||||||
}
|
}
|
||||||
final maxR = shortSide * (active.length <= 2 ? 0.30 : 0.36);
|
final maxR = shortSide * (active.length <= 2 ? 0.27 : 0.36);
|
||||||
for (final b in bodies.values) {
|
for (final b in bodies.values) {
|
||||||
if (b.leaving) {
|
if (b.leaving) {
|
||||||
b.targetR = 0;
|
b.targetR = 0;
|
||||||
|
|||||||
@@ -164,6 +164,17 @@ class _Bubble extends StatelessWidget {
|
|||||||
isScreenShare: data.isScreenShare,
|
isScreenShare: data.isScreenShare,
|
||||||
child: data.coverVideoWidget!,
|
child: data.coverVideoWidget!,
|
||||||
),
|
),
|
||||||
|
const IgnorePointer(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: RadialGradient(
|
||||||
|
center: Alignment(-0.45, -0.55),
|
||||||
|
radius: 0.95,
|
||||||
|
colors: [Color(0x2EFFFFFF), Color(0x00FFFFFF)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (showName)
|
if (showName)
|
||||||
Align(
|
Align(
|
||||||
alignment: const Alignment(0, 0.62),
|
alignment: const Alignment(0, 0.62),
|
||||||
@@ -303,3 +314,49 @@ class _BubbleRingPainter extends CustomPainter {
|
|||||||
@override
|
@override
|
||||||
bool shouldRepaint(_BubbleRingPainter old) => true;
|
bool shouldRepaint(_BubbleRingPainter old) => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vorführ-Ansicht der Bubbles mit erfundenen Teilnehmern – nur für
|
||||||
|
/// Screenshots/Tests (UI-Tour), damit man die Ansicht mit mehreren Personen
|
||||||
|
/// sieht, ohne dass so viele Testkonten anrufen müssen.
|
||||||
|
@visibleForTesting
|
||||||
|
class CallBubblesDemo extends StatelessWidget {
|
||||||
|
final int count;
|
||||||
|
const CallBubblesDemo({super.key, this.count = 5});
|
||||||
|
|
||||||
|
static const _names = ['Du', 'Anna', 'Ben', 'Clara', 'David', 'Ela', 'Finn'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final participants = [
|
||||||
|
for (var i = 0; i < count.clamp(1, _names.length); i++)
|
||||||
|
_ParticipantData(
|
||||||
|
id: 'demo$i',
|
||||||
|
name: _names[i],
|
||||||
|
isLocal: i == 0,
|
||||||
|
isSpeaking: i == 2,
|
||||||
|
isMuted: i == 3,
|
||||||
|
avatarColor: VoiceChannelView._avatarColors[i % VoiceChannelView._avatarColors.length],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return _UnifiedCallView(
|
||||||
|
participants: participants,
|
||||||
|
client: null,
|
||||||
|
startedAt: DateTime.now().subtract(const Duration(minutes: 3, seconds: 12)),
|
||||||
|
isLiveKit: true,
|
||||||
|
displayName: 'Familie',
|
||||||
|
isConnecting: false,
|
||||||
|
onHangUp: () {},
|
||||||
|
onMinimize: () {},
|
||||||
|
onToggleMic: () {},
|
||||||
|
onToggleDeafen: () {},
|
||||||
|
onToggleCam: () {},
|
||||||
|
onToggleScreen: () {},
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
isCameraOff: true,
|
||||||
|
isScreenSharing: false,
|
||||||
|
pt: pt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ class _UnifiedCallView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||||
_CallLayout _mode = _CallLayout.bubbles;
|
/// Zuletzt gewählte Ansicht (Bubbles/Kacheln) – bleibt beim Minimieren
|
||||||
|
/// und für den nächsten Anruf erhalten.
|
||||||
|
static _CallLayout _preferred = _CallLayout.bubbles;
|
||||||
|
_CallLayout _mode = _preferred;
|
||||||
String? _heroId;
|
String? _heroId;
|
||||||
bool _stripCollapsed = false;
|
bool _stripCollapsed = false;
|
||||||
Set<String> _knownScreens = {};
|
Set<String> _knownScreens = {};
|
||||||
@@ -86,7 +89,7 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
|||||||
_knownScreens = screens;
|
_knownScreens = screens;
|
||||||
if (_mode == _CallLayout.hero &&
|
if (_mode == _CallLayout.hero &&
|
||||||
!widget.participants.any((p) => p.id == _heroId && p.hasVideo)) {
|
!widget.participants.any((p) => p.id == _heroId && p.hasVideo)) {
|
||||||
_mode = _CallLayout.bubbles;
|
_mode = _preferred;
|
||||||
_heroId = null;
|
_heroId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +157,7 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
|||||||
_mode = _mode == _CallLayout.bubbles
|
_mode = _mode == _CallLayout.bubbles
|
||||||
? _CallLayout.grid
|
? _CallLayout.grid
|
||||||
: _CallLayout.bubbles;
|
: _CallLayout.bubbles;
|
||||||
|
_preferred = _mode;
|
||||||
_heroId = null;
|
_heroId = null;
|
||||||
}),
|
}),
|
||||||
onMinimize: widget.onMinimize,
|
onMinimize: widget.onMinimize,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:pyramid/core/appearance.dart';
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/features/rooms/rooms_provider.dart'
|
||||||
|
show spaceChannelIdsProvider;
|
||||||
import 'package:pyramid/widgets/spring_press.dart';
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
import 'package:pyramid/features/chat/attachment_dialog.dart';
|
import 'package:pyramid/features/chat/attachment_dialog.dart';
|
||||||
import 'package:pyramid/features/chat/emoji_picker.dart';
|
import 'package:pyramid/features/chat/emoji_picker.dart';
|
||||||
@@ -319,9 +321,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
final mobile = isMobileLayout(size);
|
final mobile = isMobileLayout(size);
|
||||||
|
|
||||||
final roomName = widget.room.getLocalizedDisplayname();
|
final roomName = widget.room.getLocalizedDisplayname();
|
||||||
|
// „#“ nur bei Server-Kanälen; Gruppen heißen wie die Gruppe.
|
||||||
final placeholder = widget.room.isDirectChat
|
final placeholder = widget.room.isDirectChat
|
||||||
? 'Nachricht…'
|
? 'Nachricht…'
|
||||||
: 'Nachricht an #$roomName';
|
: ref.watch(spaceChannelIdsProvider).contains(widget.room.id)
|
||||||
|
? 'Nachricht an #$roomName'
|
||||||
|
: 'Nachricht an $roomName';
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
final name = room?.getLocalizedDisplayname() ?? roomId;
|
final name = room?.getLocalizedDisplayname() ?? roomId;
|
||||||
final isDm = room?.isDirectChat ?? false;
|
final isDm = room?.isDirectChat ?? false;
|
||||||
final isEncrypted = room?.encrypted ?? false;
|
final isEncrypted = room?.encrypted ?? false;
|
||||||
final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name');
|
final topic = room?.topic ?? '';
|
||||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
final isMobile = size.width < 900 || size.height < 500;
|
final isMobile = size.width < 900 || size.height < 500;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class _Header extends ConsumerWidget {
|
|||||||
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
|
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
final isMobile = size.width < 900 || size.height < 500;
|
final isMobile = size.width < 900 || size.height < 500;
|
||||||
|
final bottomNav = ref.watch(bottomNavProvider);
|
||||||
|
|
||||||
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
||||||
Room? activeSpace;
|
Room? activeSpace;
|
||||||
@@ -162,7 +163,18 @@ class _Header extends ConsumerWidget {
|
|||||||
|
|
||||||
Widget controlsRow = Row(
|
Widget controlsRow = Row(
|
||||||
children: [
|
children: [
|
||||||
if (isMobile)
|
if (isMobile && bottomNav) ...[
|
||||||
|
if (isRealSpace)
|
||||||
|
PyrIconBtn(
|
||||||
|
icon: Icon(Icons.arrow_back_rounded,
|
||||||
|
color: hasBanner ? Colors.white : null),
|
||||||
|
tooltip: 'Alle Server',
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(activeSpaceIdProvider.notifier).state = null,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
] else if (isMobile)
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
icon: const Icon(Icons.menu_rounded),
|
icon: const Icon(Icons.menu_rounded),
|
||||||
tooltip: 'Spaces',
|
tooltip: 'Spaces',
|
||||||
@@ -171,7 +183,7 @@ class _Header extends ConsumerWidget {
|
|||||||
else ...[
|
else ...[
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
icon: const Icon(Icons.menu_rounded),
|
icon: const Icon(Icons.menu_rounded),
|
||||||
tooltip: 'Toggle sidebar',
|
tooltip: 'Seitenleiste ein/aus',
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
ref.read(railCollapsedProvider.notifier).update((s) => !s),
|
ref.read(railCollapsedProvider.notifier).update((s) => !s),
|
||||||
),
|
),
|
||||||
@@ -311,7 +323,7 @@ class _Header extends ConsumerWidget {
|
|||||||
height: isRealSpace ? 56 : 52,
|
height: isRealSpace ? 56 : 52,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isRealSpace ? pt.bg0 : pt.bg1,
|
color: isRealSpace && !isMobile ? pt.bg0 : pt.bg1,
|
||||||
border: Border(bottom: BorderSide(color: pt.border)),
|
border: Border(bottom: BorderSide(color: pt.border)),
|
||||||
),
|
),
|
||||||
child: controlsRow,
|
child: controlsRow,
|
||||||
@@ -453,9 +465,13 @@ class _NotificationsButton extends ConsumerWidget {
|
|||||||
class _FilterInput extends StatelessWidget {
|
class _FilterInput extends StatelessWidget {
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
|
final String hint;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
const _FilterInput(
|
const _FilterInput(
|
||||||
{required this.controller, required this.pt, required this.onChanged});
|
{required this.controller,
|
||||||
|
required this.pt,
|
||||||
|
required this.hint,
|
||||||
|
required this.onChanged});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -464,18 +480,17 @@ class _FilterInput extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 8),
|
|
||||||
child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim),
|
|
||||||
),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Chats durchsuchen',
|
hintText: hint,
|
||||||
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||||
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
|
prefixIcon: Icon(Icons.search_rounded, size: 16, color: pt.fgDim),
|
||||||
|
prefixIconConstraints:
|
||||||
|
const BoxConstraints(minWidth: 32, minHeight: 20),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(0, 7, 10, 7),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: pt.bg2,
|
fillColor: pt.bg2,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
|
|||||||
@@ -214,18 +214,27 @@ class _ChatFilterChips extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final chips = [
|
||||||
|
chip(_ChatFilter.all, 'Alle'),
|
||||||
|
chip(_ChatFilter.unread,
|
||||||
|
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
|
||||||
|
chip(_ChatFilter.people, 'Personen'),
|
||||||
|
chip(_ChatFilter.groups, 'Gruppen'),
|
||||||
|
];
|
||||||
|
// Handy: wischbare Zeile; PC (schmale Spalte): umbrechen statt
|
||||||
|
// abschneiden – mit der Maus wischt niemand seitwärts.
|
||||||
|
if (!isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
||||||
|
child: Wrap(runSpacing: 6, children: chips),
|
||||||
|
);
|
||||||
|
}
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 40,
|
height: 40,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
||||||
children: [
|
children: chips,
|
||||||
chip(_ChatFilter.all, 'Alle'),
|
|
||||||
chip(_ChatFilter.unread,
|
|
||||||
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
|
|
||||||
chip(_ChatFilter.people, 'Personen'),
|
|
||||||
chip(_ChatFilter.groups, 'Gruppen'),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -571,6 +580,19 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
|||||||
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
||||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
|
||||||
|
// Handy: Sprachkanal als Kachel mit Teilnehmern (rooms_mobile_tile.dart).
|
||||||
|
if (isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||||
|
return _MobileVoiceTile(
|
||||||
|
room: room,
|
||||||
|
inVoice: inVoice,
|
||||||
|
participants: voiceParticipants,
|
||||||
|
client: client,
|
||||||
|
pt: pt,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
onLongPress: _showContextMenu,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
|
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
|
||||||
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||||
return _MobileRoomTile(
|
return _MobileRoomTile(
|
||||||
|
|||||||
@@ -172,3 +172,103 @@ String? _roomPreview(Room room, String? myUserId) {
|
|||||||
if (room.isDirectChat) return body;
|
if (room.isDirectChat) return body;
|
||||||
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
|
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sprachkanal im Handy-Layout: Kachel wie ein Chat, darunter wer gerade drin
|
||||||
|
/// ist (Avatare + Namen) bzw. „Antippen zum Beitreten“.
|
||||||
|
class _MobileVoiceTile extends StatelessWidget {
|
||||||
|
final Room room;
|
||||||
|
final bool inVoice;
|
||||||
|
final List<Map<String, dynamic>> participants;
|
||||||
|
final Client? client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
|
const _MobileVoiceTile({
|
||||||
|
required this.room,
|
||||||
|
required this.inVoice,
|
||||||
|
required this.participants,
|
||||||
|
required this.client,
|
||||||
|
required this.pt,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final green = pt.online;
|
||||||
|
final names = participants
|
||||||
|
.map((p) => (p['displayName'] as String? ?? '').split(' ').first)
|
||||||
|
.where((n) => n.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
final subtitle = inVoice
|
||||||
|
? 'Du bist drin · antippen zum Verlassen'
|
||||||
|
: participants.isEmpty
|
||||||
|
? 'Sprachkanal · antippen zum Beitreten'
|
||||||
|
: names.length <= 2
|
||||||
|
? '${names.join(' und ')} ${names.length == 1 ? 'ist' : 'sind'} drin'
|
||||||
|
: '${names.take(2).join(', ')} und ${names.length - 2} weitere';
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Container(
|
||||||
|
color: inVoice ? green.withValues(alpha: 0.10) : Colors.transparent,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: _kTileAvatar,
|
||||||
|
height: _kTileAvatar,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: green.withValues(alpha: inVoice ? 0.30 : 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(_kTileAvatar * 0.3),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
participants.isEmpty
|
||||||
|
? Icons.volume_up_rounded
|
||||||
|
: Icons.graphic_eq_rounded,
|
||||||
|
size: 22,
|
||||||
|
color: green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(room.getLocalizedDisplayname(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: participants.isEmpty && !inVoice
|
||||||
|
? pt.fgDim
|
||||||
|
: green,
|
||||||
|
fontSize: 13.5)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (participants.isNotEmpty && client != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
CallAvatarStack(
|
||||||
|
participants: participants,
|
||||||
|
client: client!,
|
||||||
|
ringColor: pt.bg1,
|
||||||
|
size: 26,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
|||||||
});
|
});
|
||||||
final roomsAsync = ref.watch(roomListProvider);
|
final roomsAsync = ref.watch(roomListProvider);
|
||||||
final activeSpaceId = ref.watch(activeSpaceIdProvider);
|
final activeSpaceId = ref.watch(activeSpaceIdProvider);
|
||||||
final call = ref.watch(voiceChannelProvider);
|
final bottomNav = ref.watch(bottomNavProvider) &&
|
||||||
final voip = ref.watch(callSignalingProvider);
|
isMobileLayout(MediaQuery.sizeOf(context));
|
||||||
|
|
||||||
final isRealSpace = activeSpaceId != null &&
|
final isRealSpace = activeSpaceId != null &&
|
||||||
activeSpaceId != 'dms' &&
|
activeSpaceId != 'dms' &&
|
||||||
@@ -87,6 +87,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
|||||||
_FilterInput(
|
_FilterInput(
|
||||||
controller: _filterCtrl,
|
controller: _filterCtrl,
|
||||||
pt: pt,
|
pt: pt,
|
||||||
|
hint: isRealSpace ? 'Kanäle durchsuchen' : 'Chats durchsuchen',
|
||||||
onChanged: (v) => setState(() => _filter = v),
|
onChanged: (v) => setState(() => _filter = v),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -122,52 +123,80 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden.
|
// Call-Dock + Profilbalken. Mit der unteren Leiste (Handy) liegen
|
||||||
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
|
// beide dort (layout/mobile_tabs.dart).
|
||||||
// Konto-Balken nebeneinander statt übereinander.
|
if (!bottomNav) const _PanelFooter(),
|
||||||
Builder(builder: (context) {
|
|
||||||
// Klingeln (noch nicht angenommen) zählt nicht – dafür gibt es
|
|
||||||
// das Annehmen-Fenster (Bernd 2026-10-08).
|
|
||||||
final hasVoipCall = voip.currentCall != null &&
|
|
||||||
voip.currentCall!.state != CallState.kEnded &&
|
|
||||||
voip.currentCall!.state != CallState.kRinging;
|
|
||||||
// Am PC bei offener Anrufansicht nicht doppelt steuern
|
|
||||||
// (UI-Audit S6); am Handy liegt die Anrufansicht ohnehin darüber.
|
|
||||||
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
|
|
||||||
!isMobileLayout(MediaQuery.sizeOf(context));
|
|
||||||
final hasCallDock = !callViewOpen && (call.isActive || hasVoipCall);
|
|
||||||
final isLandscapeCompact =
|
|
||||||
MediaQuery.sizeOf(context).height < 500;
|
|
||||||
|
|
||||||
final callDock = AnimatedSize(
|
|
||||||
duration: const Duration(milliseconds: 220),
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
alignment: Alignment.bottomCenter,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (call.isActive && !callViewOpen) MiniCallWidget(call: call),
|
|
||||||
if (hasVoipCall && !callViewOpen) MiniCallWidget(call: voip),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLandscapeCompact && hasCallDock) {
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Expanded(child: callDock),
|
|
||||||
const Expanded(child: UserPanel()),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [callDock, const UserPanel()],
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Laufender Anruf als kompakte Leiste, animiert ein-/ausgeblendet.
|
||||||
|
class CallDock extends ConsumerWidget {
|
||||||
|
const CallDock({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final call = ref.watch(voiceChannelProvider);
|
||||||
|
final voip = ref.watch(callSignalingProvider);
|
||||||
|
// Klingeln (noch nicht angenommen) zählt nicht – dafür gibt es das
|
||||||
|
// Annehmen-Fenster (Bernd 2026-10-08).
|
||||||
|
final hasVoipCall = voip.currentCall != null &&
|
||||||
|
voip.currentCall!.state != CallState.kEnded &&
|
||||||
|
voip.currentCall!.state != CallState.kRinging;
|
||||||
|
// Am PC bei offener Anrufansicht nicht doppelt steuern (UI-Audit S6); am
|
||||||
|
// Handy liegt die Anrufansicht ohnehin darüber.
|
||||||
|
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
|
||||||
|
!isMobileLayout(MediaQuery.sizeOf(context));
|
||||||
|
return AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (call.isActive && !callViewOpen) MiniCallWidget(call: call),
|
||||||
|
if (hasVoipCall && !callViewOpen) MiniCallWidget(call: voip),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ist gerade ein Anruf im Dock sichtbar?
|
||||||
|
static bool visible(WidgetRef ref, BuildContext context) {
|
||||||
|
final call = ref.watch(voiceChannelProvider);
|
||||||
|
final voip = ref.watch(callSignalingProvider);
|
||||||
|
final hasVoipCall = voip.currentCall != null &&
|
||||||
|
voip.currentCall!.state != CallState.kEnded &&
|
||||||
|
voip.currentCall!.state != CallState.kRinging;
|
||||||
|
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
|
||||||
|
!isMobileLayout(MediaQuery.sizeOf(context));
|
||||||
|
return !callViewOpen && (call.isActive || hasVoipCall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call-Dock immer ÜBER dem Konto-Balken. Im Mobil-Querformat (Höhe knapp)
|
||||||
|
/// liegen beide nebeneinander statt übereinander.
|
||||||
|
class _PanelFooter extends ConsumerWidget {
|
||||||
|
const _PanelFooter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final hasCallDock = CallDock.visible(ref, context);
|
||||||
|
final isLandscapeCompact = MediaQuery.sizeOf(context).height < 500;
|
||||||
|
if (isLandscapeCompact && hasCallDock) {
|
||||||
|
return const Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(child: CallDock()),
|
||||||
|
Expanded(child: UserPanel()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [CallDock(), UserPanel()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import 'package:pyramid/core/appearance.dart';
|
|||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
||||||
import 'package:pyramid/core/room_kinds.dart';
|
import 'package:pyramid/core/room_kinds.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||||
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
|
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
|
||||||
import 'package:pyramid/features/call_ui/mini_call_widget.dart';
|
import 'package:pyramid/features/call_ui/mini_call_widget.dart';
|
||||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
|
import 'package:pyramid/features/group_call/group_call.dart' show CallAvatarStack;
|
||||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
import 'package:pyramid/features/rooms/user_panel.dart';
|
import 'package:pyramid/features/rooms/user_panel.dart';
|
||||||
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
|
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import 'package:pyramid/features/members/members_panel.dart';
|
|||||||
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
||||||
import 'package:pyramid/features/spaces/spaces_rail.dart';
|
import 'package:pyramid/features/spaces/spaces_rail.dart';
|
||||||
import 'package:pyramid/layout/mobile_page_stack.dart';
|
import 'package:pyramid/layout/mobile_page_stack.dart';
|
||||||
|
import 'package:pyramid/layout/mobile_tabs.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
|
||||||
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
import 'package:pyramid/widgets/search_modal.dart';
|
import 'package:pyramid/widgets/search_modal.dart';
|
||||||
@@ -395,11 +397,17 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
: activeRoomId != null
|
: activeRoomId != null
|
||||||
? ChatView(roomId: activeRoomId!)
|
? ChatView(roomId: activeRoomId!)
|
||||||
: null;
|
: null;
|
||||||
|
final bottomNav = ref.watch(bottomNavProvider);
|
||||||
|
final tab = ref.watch(mobileTabProvider);
|
||||||
final body = MobilePageStack(
|
final body = MobilePageStack(
|
||||||
base: const RoomsPanel(),
|
base: bottomNav ? const MobileTabsBase() : const RoomsPanel(),
|
||||||
front: front,
|
front: front,
|
||||||
background: pt.bg1,
|
background: pt.bg1,
|
||||||
);
|
);
|
||||||
|
// Untere Leiste: Zurück führt erst über die Tabs (Server → Übersicht →
|
||||||
|
// Chats), bevor die App geschlossen wird.
|
||||||
|
final tabsAtRoot = !bottomNav ||
|
||||||
|
(tab == MobileTab.chats);
|
||||||
|
|
||||||
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
||||||
final overlayStyle = (pt.isDark
|
final overlayStyle = (pt.isDark
|
||||||
@@ -413,7 +421,10 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
value: overlayStyle,
|
value: overlayStyle,
|
||||||
child: PopScope(
|
child: PopScope(
|
||||||
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
|
canPop: activeRoomId == null &&
|
||||||
|
viewMode == ViewMode.chat &&
|
||||||
|
!membersOpen &&
|
||||||
|
tabsAtRoot,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (!didPop) {
|
if (!didPop) {
|
||||||
if (membersOpen) {
|
if (membersOpen) {
|
||||||
@@ -422,6 +433,8 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
} else if (activeRoomId != null) {
|
} else if (activeRoomId != null) {
|
||||||
ref.read(activeRoomIdProvider.notifier).state = null;
|
ref.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
} else if (bottomNav) {
|
||||||
|
handleMobileTabBack(ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -429,11 +442,13 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
|
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
|
||||||
// geht nahtlos in sie über.
|
// geht nahtlos in sie über.
|
||||||
backgroundColor: pt.bg1,
|
backgroundColor: pt.bg1,
|
||||||
drawer: Drawer(
|
drawer: bottomNav
|
||||||
width: 80,
|
? null
|
||||||
backgroundColor: pt.bg0,
|
: Drawer(
|
||||||
child: const SafeArea(child: SpacesRail()),
|
width: 80,
|
||||||
),
|
backgroundColor: pt.bg0,
|
||||||
|
child: const SafeArea(child: SpacesRail()),
|
||||||
|
),
|
||||||
// SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
|
// SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
|
||||||
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
|
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
|
||||||
body: SafeArea(child: Stack(
|
body: SafeArea(child: Stack(
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/core/room_kinds.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
||||||
|
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||||
|
import 'package:pyramid/widgets/create_join_dialog.dart';
|
||||||
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
|
|
||||||
|
/// Handy-Navigation mit unterer Leiste (Einstellung „Navigation: Untere
|
||||||
|
/// Leiste“, Bernd 2026-10-09 zum Vergleich mit dem Seitenmenü):
|
||||||
|
/// **Chats** (Direktnachrichten + Gruppen), **Server** (Übersicht → Kanäle),
|
||||||
|
/// **Du** (Profil + Einstellungen). Chat und Anruf gleiten wie gewohnt
|
||||||
|
/// darüber; die Leiste liegt darunter und verschwindet mit.
|
||||||
|
enum MobileTab { chats, servers, me }
|
||||||
|
|
||||||
|
final mobileTabProvider = StateProvider<MobileTab>((ref) => MobileTab.chats);
|
||||||
|
|
||||||
|
/// Zuletzt geöffneter Server – „Server“ öffnet wieder dort.
|
||||||
|
final _lastServerProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
|
bool _isRealSpace(String? id) => id != null && id != 'dms' && id != 'rooms';
|
||||||
|
|
||||||
|
/// Wechselt den Tab (und die passende Liste).
|
||||||
|
void selectMobileTab(WidgetRef ref, MobileTab tab) {
|
||||||
|
final current = ref.read(mobileTabProvider);
|
||||||
|
final space = ref.read(activeSpaceIdProvider);
|
||||||
|
if (_isRealSpace(space)) ref.read(_lastServerProvider.notifier).state = space;
|
||||||
|
switch (tab) {
|
||||||
|
case MobileTab.chats:
|
||||||
|
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
case MobileTab.servers:
|
||||||
|
// Nochmal auf „Server“ tippen = zurück zur Übersicht.
|
||||||
|
ref.read(activeSpaceIdProvider.notifier).state =
|
||||||
|
current == MobileTab.servers ? null : ref.read(_lastServerProvider);
|
||||||
|
case MobileTab.me:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ref.read(mobileTabProvider.notifier).state = tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zurück-Taste mit unterer Leiste: Server → Übersicht → Chats.
|
||||||
|
/// Liefert `true`, wenn sie etwas getan hat.
|
||||||
|
bool handleMobileTabBack(WidgetRef ref) {
|
||||||
|
final tab = ref.read(mobileTabProvider);
|
||||||
|
if (tab == MobileTab.servers &&
|
||||||
|
_isRealSpace(ref.read(activeSpaceIdProvider))) {
|
||||||
|
ref.read(activeSpaceIdProvider.notifier).state = null;
|
||||||
|
ref.read(_lastServerProvider.notifier).state = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tab != MobileTab.chats) {
|
||||||
|
selectMobileTab(ref, MobileTab.chats);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grundseite unter Chat/Anruf: Inhalt des Tabs, Anruf-Leiste, Tab-Leiste.
|
||||||
|
class MobileTabsBase extends ConsumerWidget {
|
||||||
|
const MobileTabsBase({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final tab = ref.watch(mobileTabProvider);
|
||||||
|
final space = ref.watch(activeSpaceIdProvider);
|
||||||
|
final Widget content = switch (tab) {
|
||||||
|
MobileTab.chats => const RoomsPanel(key: ValueKey('tab-chats')),
|
||||||
|
MobileTab.servers => _isRealSpace(space)
|
||||||
|
? RoomsPanel(key: ValueKey('tab-server-$space'))
|
||||||
|
: const ServerOverview(key: ValueKey('tab-servers')),
|
||||||
|
MobileTab.me => const MeTab(key: ValueKey('tab-me')),
|
||||||
|
};
|
||||||
|
return ColoredBox(
|
||||||
|
color: pt.bg1,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: motionDuration(context, 220),
|
||||||
|
switchInCurve: Curves.easeOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
transitionBuilder: (child, anim) => FadeTransition(
|
||||||
|
opacity: anim,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween(
|
||||||
|
begin: const Offset(0, 0.015),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(anim),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const CallDock(),
|
||||||
|
const _TabBar(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TabBar extends ConsumerWidget {
|
||||||
|
const _TabBar();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final tab = ref.watch(mobileTabProvider);
|
||||||
|
final chatUnread = ref.watch(dmUnreadCountProvider).valueOrNull ?? 0;
|
||||||
|
final serverUnread = ref.watch(_serverUnreadProvider);
|
||||||
|
|
||||||
|
Widget item(MobileTab t, IconData icon, IconData activeIcon, String label,
|
||||||
|
int badge) {
|
||||||
|
final selected = tab == t;
|
||||||
|
return Expanded(
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
selected: selected,
|
||||||
|
label: label,
|
||||||
|
child: SpringPress(
|
||||||
|
child: InkResponse(
|
||||||
|
onTap: () => selectMobileTab(ref, t),
|
||||||
|
radius: 40,
|
||||||
|
highlightShape: BoxShape.rectangle,
|
||||||
|
containedInkWell: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: motionDuration(context, 240),
|
||||||
|
curve: springyCurve,
|
||||||
|
width: selected ? 60 : 44,
|
||||||
|
height: 30,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? pt.accentSoft : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Icon(selected ? activeIcon : icon,
|
||||||
|
size: 22,
|
||||||
|
color: selected ? pt.accent : pt.fgMuted),
|
||||||
|
),
|
||||||
|
if (badge > 0)
|
||||||
|
Positioned(
|
||||||
|
right: selected ? 6 : 0,
|
||||||
|
top: -3,
|
||||||
|
child: SpringBump(
|
||||||
|
value: badge,
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 18),
|
||||||
|
height: 18,
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 5),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.danger,
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
border: Border.all(color: pt.bg1, width: 2),
|
||||||
|
),
|
||||||
|
child: Text(badge > 99 ? '99+' : '$badge',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: selected ? pt.fg : pt.fgMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight:
|
||||||
|
selected ? FontWeight.w700 : FontWeight.w500)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg1,
|
||||||
|
border: Border(top: BorderSide(color: pt.border)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
item(MobileTab.chats, Icons.chat_bubble_outline_rounded,
|
||||||
|
Icons.chat_bubble_rounded, 'Chats', chatUnread),
|
||||||
|
item(MobileTab.servers, Icons.dns_outlined, Icons.dns_rounded,
|
||||||
|
'Server', serverUnread),
|
||||||
|
item(MobileTab.me, Icons.person_outline_rounded,
|
||||||
|
Icons.person_rounded, 'Du', 0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ungelesene Kanäle über alle Server (für das Abzeichen am „Server“-Tab).
|
||||||
|
final _serverUnreadProvider = Provider<int>((ref) {
|
||||||
|
ref.watch(roomListProvider);
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
if (client == null) return 0;
|
||||||
|
final channels = spaceChannelIds(client);
|
||||||
|
return client.rooms
|
||||||
|
.where((r) =>
|
||||||
|
channels.contains(r.id) &&
|
||||||
|
r.membership == Membership.join &&
|
||||||
|
r.notificationCount > 0)
|
||||||
|
.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Übersicht aller Server als Karten (Tab „Server“).
|
||||||
|
class ServerOverview extends ConsumerWidget {
|
||||||
|
const ServerOverview({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
ref.watch(roomListProvider); // bei jeder Synchronisation neu
|
||||||
|
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_TabHeader(
|
||||||
|
title: 'Server',
|
||||||
|
pt: pt,
|
||||||
|
action: IconButton(
|
||||||
|
tooltip: 'Server erstellen oder beitreten',
|
||||||
|
icon: Icon(Icons.add_rounded, color: pt.fgMuted),
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: context, builder: (_) => const CreateJoinDialog()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: spaces.isEmpty || client == null
|
||||||
|
? _EmptyServers(pt: pt)
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 16),
|
||||||
|
itemCount: spaces.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, i) =>
|
||||||
|
_ServerCard(space: spaces[i], client: client, pt: pt),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerCard extends ConsumerWidget {
|
||||||
|
final Room space;
|
||||||
|
final Client client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _ServerCard(
|
||||||
|
{required this.space, required this.client, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final name = space.getLocalizedDisplayname();
|
||||||
|
final invited = space.membership == Membership.invite;
|
||||||
|
var channels = 0, unread = 0, inVoice = 0;
|
||||||
|
for (final child in space.spaceChildren) {
|
||||||
|
final id = child.roomId;
|
||||||
|
if (id == null) continue;
|
||||||
|
final r = client.getRoomById(id);
|
||||||
|
if (r == null || r.isSpace) continue;
|
||||||
|
channels++;
|
||||||
|
if (r.membership == Membership.join && r.notificationCount > 0) unread++;
|
||||||
|
if (isVoiceChannelRoom(r)) {
|
||||||
|
inVoice += ref.watch(voiceParticipantsProvider(r.id)).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final subtitle = invited
|
||||||
|
? 'Einladung – antippen zum Ansehen'
|
||||||
|
: [
|
||||||
|
channels == 1 ? '1 Kanal' : '$channels Kanäle',
|
||||||
|
if (unread > 0) '$unread ungelesen',
|
||||||
|
].join(' · ');
|
||||||
|
|
||||||
|
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||||
|
Widget fallback(BuildContext _) => Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.accentSoft,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.accent, fontSize: 22, fontWeight: FontWeight.w700)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return SpringPress(
|
||||||
|
pressedScale: 0.97,
|
||||||
|
child: Material(
|
||||||
|
color: pt.bg2,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
onTap: () {
|
||||||
|
ref.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||||
|
ref.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(
|
||||||
|
color: invited ? pt.accent.withAlpha(120) : pt.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
space.avatar == null
|
||||||
|
? Builder(builder: fallback)
|
||||||
|
: MxcAvatar(
|
||||||
|
mxcUri: space.avatar,
|
||||||
|
client: client,
|
||||||
|
size: 52,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
placeholder: fallback,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: unread > 0
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: invited ? pt.accent : pt.fgMuted,
|
||||||
|
fontSize: 13)),
|
||||||
|
if (inVoice > 0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.graphic_eq_rounded,
|
||||||
|
size: 14, color: pt.online),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
inVoice == 1
|
||||||
|
? '1 Person im Sprachkanal'
|
||||||
|
: '$inVoice Personen im Sprachkanal',
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.online,
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.chevron_right_rounded, color: pt.fgDim),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyServers extends StatelessWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _EmptyServers({required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.dns_outlined, size: 40, color: pt.fgDim),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text('Noch keine Server',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 15)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Server sind wie Discord: Kanäle für Text und Sprache.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.fgDim, fontSize: 13)),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: pt.accentSoft,
|
||||||
|
foregroundColor: pt.accent,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.add_rounded, size: 18),
|
||||||
|
label: const Text('Server erstellen oder beitreten'),
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: context, builder: (_) => const CreateJoinDialog()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TabHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final Widget? action;
|
||||||
|
const _TabHeader({required this.title, required this.pt, this.action});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 52,
|
||||||
|
padding: const EdgeInsets.only(left: 18, right: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg1,
|
||||||
|
border: Border(bottom: BorderSide(color: pt.border)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg, fontSize: 17, fontWeight: FontWeight.w700)),
|
||||||
|
),
|
||||||
|
?action,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _myProfileProvider = FutureProvider.autoDispose<Profile?>((ref) async {
|
||||||
|
final client = await ref.watch(matrixClientProvider.future);
|
||||||
|
final id = client.userID;
|
||||||
|
if (id == null) return null;
|
||||||
|
try {
|
||||||
|
return await client.getProfileFromUserId(id);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tab „Du“: eigenes Profil und Einstellungen.
|
||||||
|
class MeTab extends ConsumerWidget {
|
||||||
|
const MeTab({super.key});
|
||||||
|
|
||||||
|
static const _entries = <(String, String, IconData)>[
|
||||||
|
('profile', 'Mein Profil', Icons.person_outline_rounded),
|
||||||
|
('notifications', 'Benachrichtigungen', Icons.notifications_none_rounded),
|
||||||
|
('appearance', 'Erscheinungsbild', Icons.palette_outlined),
|
||||||
|
('voice', 'Sprache & Video', Icons.headphones_rounded),
|
||||||
|
('privacy', 'Datenschutz', Icons.lock_outline_rounded),
|
||||||
|
('sessions', 'Sitzungen', Icons.devices_rounded),
|
||||||
|
('encryption', 'Verschlüsselung', Icons.shield_outlined),
|
||||||
|
('about', 'Über Pyramid', Icons.info_outline_rounded),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
final profile = ref.watch(_myProfileProvider).valueOrNull;
|
||||||
|
final userId = client?.userID ?? '';
|
||||||
|
final name = profile?.displayName ??
|
||||||
|
userId.split(':').first.replaceFirst('@', '');
|
||||||
|
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||||
|
|
||||||
|
void open(String section) {
|
||||||
|
ref.read(settingsSectionRequestProvider.notifier).state = section;
|
||||||
|
ref.read(activeModalProvider.notifier).state = ModalKind.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget fallback(BuildContext _) => Container(
|
||||||
|
width: 76,
|
||||||
|
height: 76,
|
||||||
|
decoration: BoxDecoration(color: pt.accentSoft, shape: BoxShape.circle),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.accent, fontSize: 32, fontWeight: FontWeight.w700)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_TabHeader(title: 'Du', pt: pt),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 16, 12, 16),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg2,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: pt.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
profile?.avatarUrl == null || client == null
|
||||||
|
? Builder(builder: fallback)
|
||||||
|
: MxcAvatar(
|
||||||
|
mxcUri: profile!.avatarUrl,
|
||||||
|
client: client,
|
||||||
|
size: 76,
|
||||||
|
placeholder: fallback,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 2,
|
||||||
|
bottom: 2,
|
||||||
|
child: Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.online,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: pt.bg2, width: 3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(userId,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style:
|
||||||
|
TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: pt.fg,
|
||||||
|
side: BorderSide(color: pt.border),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.edit_rounded, size: 16),
|
||||||
|
label: const Text('Profil bearbeiten'),
|
||||||
|
onPressed: () => open('profile'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg2,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: pt.border),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (var i = 1; i < _entries.length; i++) ...[
|
||||||
|
if (i > 1)
|
||||||
|
Divider(height: 1, indent: 56, color: pt.border),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(_entries[i].$3, color: pt.fgMuted),
|
||||||
|
title: Text(_entries[i].$2,
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 15)),
|
||||||
|
trailing: Icon(Icons.chevron_right_rounded,
|
||||||
|
color: pt.fgDim),
|
||||||
|
onTap: () => open(_entries[i].$1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,26 @@ class _AppearanceSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
if (isMobileLayout(MediaQuery.sizeOf(context))) ...[
|
||||||
|
_SettingsGroup(title: 'Navigation', pt: pt, children: [
|
||||||
|
Consumer(builder: (context, ref, _) {
|
||||||
|
final bottom = ref.watch(bottomNavProvider);
|
||||||
|
return _AdaptiveSettingsRow(
|
||||||
|
title: 'Hauptmenü',
|
||||||
|
desc: 'Seitenmenü (☰) oder Leiste unten mit Chats · Server · Du',
|
||||||
|
pt: pt,
|
||||||
|
control: _SegControl(
|
||||||
|
options: const ['Seitenmenü', 'Untere Leiste'],
|
||||||
|
selected: bottom ? 1 : 0,
|
||||||
|
pt: pt,
|
||||||
|
onSelect: (i) =>
|
||||||
|
ref.read(bottomNavProvider.notifier).set(i == 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
|
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
|
||||||
_SettingsRow(
|
_SettingsRow(
|
||||||
title: 'Eckenrundung',
|
title: 'Eckenrundung',
|
||||||
|
|||||||
@@ -82,6 +82,20 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
('about', 'Über Pyramid', Icons.info_outline_rounded),
|
('about', 'Über Pyramid', Icons.info_outline_rounded),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final requested = ref.read(settingsSectionRequestProvider);
|
||||||
|
if (requested != null) {
|
||||||
|
_section = requested;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
ref.read(settingsSectionRequestProvider.notifier).state = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _titleFor(String id) =>
|
String _titleFor(String id) =>
|
||||||
_categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2;
|
_categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: pyramid
|
name: pyramid
|
||||||
description: A flexible Matrix client with resizable panel layout.
|
description: A flexible Matrix client with resizable panel layout.
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.1.41+42
|
version: 0.1.42+43
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.11.1 <4.0.0"
|
sdk: ">=3.11.1 <4.0.0"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user