fix(ui): UX-Audit – Sackgassen, tote Knöpfe, englische Reste; Screenshot-Tour

Bernds Wunsch 2026-10-08: UI grundsätzlich prüfen (Navigation, Sackgassen,
Anruf-Logik). Audit + Konzept in docs/UI_AUDIT.md, belegt mit neuer
Screenshot-Tour integration_test/ui_audit_tour_test.dart (35 Zustände,
Handy + PC, zwei Testkonten, Test-Space mit Sprachkanal).

Sofort behoben:
- S1/S2: Gegenseite legt auf / Anruf scheitert / Sprachkanal bricht ab →
  automatisch zurück zum Chat mit Hinweis (Fehler sichtbar statt still);
  leere Anrufansicht deutsch mit Zurück-Knopf (vorher englische Sackgasse
  ohne Zurück am Handy).
- S4: toter „Threads“-Knopf entfernt. S5: Space betreten (PC) öffnet
  letzten bzw. ersten Textkanal. S6: keine doppelte Anrufsteuerung am PC.
- Annehmen-Fenster mit Anrufer-Avatar und Anrufart; eigene Kachel mit
  eigenem Initial statt „D“; Mitgliederliste rund mit Profilbild und „(Du)“;
  Tastaturkürzel nur am PC; englische Texte in Anruf/Leerzuständen deutsch.
- Tests: Fehlerempfänger des Testrahmens nach app.main() zurück (vorher
  verschwanden Fehlermeldungen), Lesebestätigungs-Test im Wegwerf-Raum statt
  in der DM, robustere Schritte; test.ps1 räumt liegengebliebene
  Test-Prozesse weg; gemeinsame Hilfen integration_test/support/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-09 11:35:40 +02:00
co-authored by Claude Opus 5.5
parent 842dd61031
commit 87b472a373
22 changed files with 934 additions and 378 deletions
+8
View File
@@ -0,0 +1,8 @@
import 'package:matrix/matrix.dart';
/// Raumtyp der Pyramid-Sprachkanäle (creation_content.type).
const voiceChannelRoomType = 'io.pyramid.voice_channel';
/// Ist [room] ein Sprachkanal (statt eines Text-Chats)?
bool isVoiceChannelRoom(Room room) =>
room.getState(EventTypes.RoomCreate)?.content['type'] == voiceChannelRoomType;
+2 -2
View File
@@ -49,7 +49,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
final call = widget.call;
final String displayName = isVoip
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unknown')
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unbekannt')
: call.displayName;
final bool isConnecting = isVoip ? false : call.isConnecting;
@@ -160,7 +160,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
const _LiveDot(),
const SizedBox(width: 6),
Text(
isConnecting ? 'CONNECTING...' : 'VOICE CONNECTED',
isConnecting ? 'CONNECTING...' : (isVoip ? 'ANRUF LÄUFT' : (call.isVoiceChannel ? 'SPRACHKANAL' : 'GRUPPENANRUF')),
style: TextStyle(
color: pt.online,
fontSize: 10,
+14 -10
View File
@@ -14,7 +14,7 @@ class _BigAvatar extends StatelessWidget {
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
shape: BoxShape.circle,
),
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))),
child: Center(child: Text(data.initial, style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))),
),
);
}
@@ -34,7 +34,7 @@ class _SmallAvatar extends StatelessWidget {
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
shape: BoxShape.circle,
),
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
child: Center(child: Text(data.initial, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
),
);
}
@@ -127,14 +127,12 @@ class _NoCall extends StatelessWidget {
child: Row(
children: [
PyrIconBtn(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Toggle sidebar',
onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s),
icon: const Icon(Icons.arrow_back_rounded),
tooltip: 'Zurück',
onPressed: () => ref.read(viewModeProvider.notifier).state = ViewMode.chat,
),
const SizedBox(width: 8),
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text('No active call', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
Text('Kein Anruf', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
],
),
),
@@ -143,9 +141,15 @@ class _NoCall extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('𐃏', style: TextStyle(fontSize: 48, color: pt.fgDim.withAlpha(100))),
Icon(Icons.call_end_rounded, size: 48, color: pt.fgDim),
const SizedBox(height: 12),
Text('Join a voice room to start a call', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
Text('Gerade läuft kein Anruf.', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.read(viewModeProvider.notifier).state = ViewMode.chat,
style: FilledButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg),
child: const Text('Zurück zum Chat'),
),
],
),
),
+1 -1
View File
@@ -193,7 +193,7 @@ class _CallHeader extends StatelessWidget {
const Spacer(),
PyrIconBtn(
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
tooltip: isGridView ? 'Hero mode' : 'Grid mode',
tooltip: isGridView ? 'Großansicht' : 'Kachelansicht',
onPressed: onToggleView,
),
const SizedBox(width: 4),
@@ -12,6 +12,13 @@ class _ParticipantData {
final Color avatarColor;
final dynamic originalParticipant;
final double aspectRatio;
/// Woraus der Anfangsbuchstabe im Avatar gebildet wird (Standard: [name]).
final String? initialFrom;
String get initial {
final src = (initialFrom ?? name).replaceFirst('@', '').trim();
return src.isEmpty ? '?' : src.characters.first.toUpperCase();
}
const _ParticipantData({
required this.id,
@@ -24,6 +31,7 @@ class _ParticipantData {
this.avatarColor = Colors.grey,
this.originalParticipant,
this.aspectRatio = 16.0 / 9.0,
this.initialFrom,
});
}
@@ -148,7 +156,7 @@ class VoiceChannelView extends ConsumerWidget {
final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty;
participants.add(_ParticipantData(
id: 'remote',
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Gegenseite',
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: false,
isSpeaking: voip.isRemoteSpeaking,
@@ -164,6 +172,7 @@ class VoiceChannelView extends ConsumerWidget {
participants.add(_ParticipantData(
id: 'local',
name: 'Du',
initialFrom: voip.currentCall?.room.client.userID?.split(':').first,
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: voip.isMicMuted,
isSpeaking: voip.isLocalSpeaking,
+4 -10
View File
@@ -111,13 +111,13 @@ class _ChatHeader extends ConsumerWidget {
PyrIconBtn(
size: 28,
icon: const Icon(Icons.phone_rounded),
tooltip: 'Voice call',
tooltip: 'Anrufen',
onPressed: () => _startCall(ref, room, cam: false),
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Video call',
tooltip: 'Videoanruf',
onPressed: () => _startCall(ref, room, cam: true),
),
if (size.width > 700)
@@ -129,12 +129,6 @@ class _ChatHeader extends ConsumerWidget {
),
],
if (size.width > 800) ...[
PyrIconBtn(
size: 28,
icon: const Icon(Icons.inbox_rounded),
tooltip: 'Threads',
onPressed: () {},
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.push_pin_rounded),
@@ -557,13 +551,13 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
PyrIconBtn(
size: 28,
icon: const Icon(Icons.phone_rounded),
tooltip: 'Voice call',
tooltip: 'Anrufen',
onPressed: () => _startCall(cam: false),
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Video call',
tooltip: 'Videoanruf',
onPressed: () => _startCall(cam: true),
),
if (size.width > 700)
@@ -65,7 +65,7 @@ class _MessageList extends StatelessWidget {
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
const SizedBox(height: 16),
Text(
'No messages yet',
'Noch keine Nachrichten – schreib die erste!',
style: TextStyle(color: pt.fgMuted, fontSize: 14),
),
],
+1 -1
View File
@@ -120,7 +120,7 @@ class NoChatSelected extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'Choose from the sidebar on the left',
'Links findest du alle Chats und Räume.',
style: TextStyle(color: pt.fgDim, fontSize: 13),
),
],
+1 -1
View File
@@ -535,7 +535,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
),
error: (e, _) => Center(
child: Text(
'Could not load messages',
'Nachrichten konnten nicht geladen werden.',
style: TextStyle(color: pt.fgDim, fontSize: 14),
),
),
+15 -11
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_logo.dart';
class MembersPanel extends ConsumerWidget {
@@ -94,7 +95,7 @@ class MembersPanel extends ConsumerWidget {
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: Text(
'No members loaded',
'Keine Mitglieder geladen',
style: TextStyle(color: pt.fgDim, fontSize: 12),
),
),
@@ -177,19 +178,22 @@ class _MemberItemState extends State<_MemberItem> {
opacity: widget.isOffline ? 0.5 : 1,
child: Stack(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Center(
// Rund wie überall sonst, mit echtem Profilbild.
MxcAvatar(
mxcUri: member.avatarUrl,
client: member.room.client,
size: 32,
borderRadius: BorderRadius.circular(16),
placeholder: (_) => Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
alignment: Alignment.center,
child: Text(
initial,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
@@ -214,7 +218,7 @@ class _MemberItemState extends State<_MemberItem> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
member.id == member.room.client.userID ? '$name (Du)' : name,
style: TextStyle(
color: widget.isOffline ? pt.fgMuted : pt.fg,
fontSize: 13,
@@ -112,7 +112,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
),
),
error: (e, _) => Center(
child: Text('Error',
child: Text('Fehler',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
),
data: (rooms) => _RoomsList(
@@ -136,7 +136,11 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
final hasVoipCall = voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded &&
voip.currentCall!.state != CallState.kRinging;
final hasCallDock = call.isActive || hasVoipCall;
// 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;
@@ -147,8 +151,8 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (call.isActive) MiniCallWidget(call: call),
if (hasVoipCall) MiniCallWidget(call: voip),
if (call.isActive && !callViewOpen) MiniCallWidget(call: call),
if (hasVoipCall && !callViewOpen) MiniCallWidget(call: voip),
],
),
);
+21 -2
View File
@@ -2,7 +2,9 @@ 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/appearance.dart' show isMobileLayout;
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_provider.dart';
import 'package:pyramid/widgets/create_join_dialog.dart';
@@ -32,8 +34,25 @@ class SpacesRail extends ConsumerWidget {
}
// Switch space and restore last visited room for the new space
ref.read(activeSpaceIdProvider.notifier).state = id;
ref.read(activeRoomIdProvider.notifier).state =
ref.read(lastRoomPerSpaceProvider)[id];
var target = ref.read(lastRoomPerSpaceProvider)[id];
// Am PC nicht auf „Wähle einen Chat aus“ landen: ersten Textkanal des
// Spaces öffnen (UI-Audit S5). Am Handy bleibt die Kanalliste sichtbar.
if (target == null &&
id != null &&
id != 'dms' &&
id != 'rooms' &&
!isMobileLayout(MediaQuery.sizeOf(context))) {
final client = ref.read(matrixClientProvider).valueOrNull;
final space = client?.getRoomById(id);
for (final child in space?.spaceChildren ?? []) {
final r = child.roomId == null ? null : client!.getRoomById(child.roomId!);
if (r != null && r.membership == Membership.join && !isVoiceChannelRoom(r)) {
target = r.id;
break;
}
}
}
ref.read(activeRoomIdProvider.notifier).state = target;
final scaffold = Scaffold.maybeOf(context);
if (scaffold != null && scaffold.isDrawerOpen) {
scaffold.closeDrawer();
+68 -8
View File
@@ -19,6 +19,7 @@ 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/widgets/spring_press.dart' show motionDuration;
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/search_modal.dart';
import 'package:pyramid/widgets/settings_modal.dart';
import 'package:pyramid/widgets/share_target_dialog.dart';
@@ -151,6 +152,44 @@ class _AppShellState extends ConsumerState<AppShell>
ref.read(activeModalProvider.notifier).state = ModalKind.none;
}
bool _hadVoipCall = false;
bool _hadLiveKit = false;
/// Anruf ist vorbei: Anrufansicht verlassen (sofern kein anderer Anruf
/// mehr läuft) und kurz Bescheid geben.
void _leaveCallView(String message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
_leaveCallViewNow(message);
} catch (e, s) {
debugPrint('[AppShell] Anrufansicht verlassen: $e $s');
}
});
}
void _leaveCallViewNow(String message) {
{
if (!mounted) return;
final voip = ref.read(callSignalingProvider);
final lk = ref.read(voiceChannelProvider);
final stillInCall = (voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded) ||
lk.isActive ||
lk.isConnecting;
if (stillInCall) return;
if (ref.read(viewModeProvider) == ViewMode.voice) {
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
ScaffoldMessenger.maybeOf(context)
?..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
));
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
@@ -164,6 +203,12 @@ class _AppShellState extends ConsumerState<AppShell>
// würde der Nutzer permanent zurück in die Call-UI gezwungen und könnte
// sie nie minimieren.
ref.listen(callSignalingProvider, (prev, next) {
// Anruf vorbei (aufgelegt, abgelehnt, gescheitert) → nicht auf der
// leeren Anrufansicht stehen lassen (UI-Audit S1, 2026-10-08).
final hasCall = next.currentCall != null &&
next.currentCall!.state != CallState.kEnded;
if (_hadVoipCall && !hasCall) _leaveCallView('Anruf beendet');
_hadVoipCall = hasCall;
final wasConnected = _wasVoipConnected;
final isConnected = next.currentCall != null &&
next.currentCall!.state == CallState.kConnected;
@@ -176,6 +221,12 @@ class _AppShellState extends ConsumerState<AppShell>
});
ref.listen(voiceChannelProvider, (prev, next) {
final lkActive = next.isActive || next.isConnecting;
if (_hadLiveKit && !lkActive) {
// Fehler (z. B. Verbindung verloren) sichtbar machen statt still.
_leaveCallView(next.error ?? (next.isVoiceChannel ? 'Sprachkanal verlassen' : 'Anruf beendet'));
}
_hadLiveKit = lkActive;
// Only auto-switch to voice view for video/audio calls, NOT voice channels
// (voice channels should stay in background with a mini bar)
final wasActive = _wasLiveKitActive;
@@ -519,18 +570,27 @@ class _IncomingCallOverlay extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: pt.accent,
shape: BoxShape.circle,
MxcAvatar(
mxcUri: call.room.avatar,
client: call.room.client,
size: 88,
placeholder: (_) => Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: pt.accent,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
call.room.getLocalizedDisplayname().characters.firstOrNull?.toUpperCase() ?? '?',
style: const TextStyle(color: Colors.white, fontSize: 36, fontWeight: FontWeight.w700),
),
),
child: const Icon(Icons.person_rounded, size: 40, color: Colors.white),
),
const SizedBox(height: 24),
Text(
'Eingehender Anruf',
call.type == CallType.kVideo ? 'Eingehender Videoanruf' : 'Eingehender Anruf',
style: TextStyle(color: pt.fgDim, fontSize: 13),
),
const SizedBox(height: 8),
+1 -1
View File
@@ -214,7 +214,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
for (final c in _categories)
for (final c in _categories.where((c) => c.$1 != 'keys'))
_MobileCategoryTile(
label: c.$2,
icon: c.$3,