Files
pyramid/lib/features/spaces/spaces_rail.dart
T
Bernd SteckmeisterandClaude Opus 5.5 87b472a373 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]>
2026-10-09 11:35:40 +02:00

555 lines
18 KiB
Dart

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';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_logo.dart';
class SpacesRail extends ConsumerWidget {
const SpacesRail({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
final activeSpaceId = ref.watch(activeSpaceIdProvider);
final forceJoined = ref.watch(forceJoinedSpacesProvider);
final dmUnread = ref.watch(dmUnreadCountProvider).valueOrNull ?? 0;
void selectSpace(String? id) {
// Save current room for the space we're leaving
final currentRoom = ref.read(activeRoomIdProvider);
final currentSpace = ref.read(activeSpaceIdProvider);
if (currentRoom != null) {
final updated = Map<String?, String?>.from(ref.read(lastRoomPerSpaceProvider));
updated[currentSpace] = currentRoom;
ref.read(lastRoomPerSpaceProvider.notifier).state = updated;
}
// Switch space and restore last visited room for the new space
ref.read(activeSpaceIdProvider.notifier).state = 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();
}
}
return Container(
width: 72,
color: pt.bg0,
child: Column(
children: [
const SizedBox(height: 12),
_HomeButton(
pt: pt,
active: activeSpaceId == 'dms' || activeSpaceId == null,
unreadCount: dmUnread,
onTap: () => selectSpace('dms'),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
width: 32,
height: 2,
decoration: BoxDecoration(
color: pt.border,
borderRadius: BorderRadius.circular(2),
),
),
),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
_VirtualSpaceItem(
id: 'rooms',
label: 'R',
color: const Color(0xFFF59E0B),
name: 'Räume',
active: activeSpaceId == 'rooms',
unread: 0,
pt: pt,
onTap: () => selectSpace('rooms'),
),
...spaces.map((space) => Padding(
padding: const EdgeInsets.only(top: 6),
child: _SpaceItem(
space: space,
active: activeSpaceId == space.id,
isInvite: space.membership == Membership.invite &&
!forceJoined.contains(space.id),
pt: pt,
onTap: () => selectSpace(space.id),
),
)),
const SizedBox(height: 8),
_AddButton(pt: pt),
const SizedBox(height: 8),
],
),
),
const SizedBox(height: 8),
_SettingsButton(pt: pt),
const SizedBox(height: 12),
],
),
);
}
}
class _SettingsButton extends ConsumerWidget {
final PyramidTheme pt;
const _SettingsButton({required this.pt});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: IconButton(
icon: Icon(Icons.settings_outlined, color: pt.fgDim),
onPressed: () {
ref.read(activeModalProvider.notifier).state = ModalKind.settings;
final scaffold = Scaffold.maybeOf(context);
if (scaffold != null && scaffold.isDrawerOpen) {
scaffold.closeDrawer();
}
},
),
);
}
}
class _HomeButton extends StatefulWidget {
final PyramidTheme pt;
final bool active;
final int unreadCount;
final VoidCallback onTap;
const _HomeButton({
required this.pt,
required this.active,
required this.unreadCount,
required this.onTap,
});
@override
State<_HomeButton> createState() => _HomeButtonState();
}
class _HomeButtonState extends State<_HomeButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Stack(
clipBehavior: Clip.none,
children: [
if (widget.active)
Positioned(
left: -12,
top: 10,
child: Container(
width: 4,
height: 28,
decoration: BoxDecoration(
color: pt.accent,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(3),
bottomRight: Radius.circular(3),
),
),
),
),
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack,
transform: _hovered
? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05))
: Matrix4.identity(),
child: Tooltip(
message: 'Direktnachrichten',
waitDuration: const Duration(milliseconds: 400),
preferBelow: false,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [pt.accent, Color.fromARGB(255,
(pt.accent.r * 255 * 0.85).round().clamp(0, 255),
(pt.accent.g * 255 * 0.85).round().clamp(0, 255),
(pt.accent.b * 255 * 0.7).round().clamp(0, 255),
)],
),
borderRadius: BorderRadius.circular(
widget.active ? pt.rLg * 0.6 : pt.rLg,
),
boxShadow: widget.active
? [BoxShadow(color: pt.accentGlow, blurRadius: 16, offset: const Offset(0, 4))]
: null,
),
child: Center(child: PyramidLogo(size: 28, color: Colors.white)),
),
if (widget.unreadCount > 0)
Positioned(
right: -4,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
decoration: BoxDecoration(
color: pt.danger,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: pt.bg0, width: 1.5),
),
child: Center(
child: Text(
widget.unreadCount > 99 ? '99+' : '${widget.unreadCount}',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w700),
),
),
),
),
],
),
),
),
),
),
],
);
}
}
class _VirtualSpaceItem extends StatefulWidget {
final String id;
final String label;
final Color color;
final String name;
final bool active;
final int unread;
final PyramidTheme pt;
final VoidCallback onTap;
const _VirtualSpaceItem({
required this.id,
required this.label,
required this.color,
required this.name,
required this.active,
required this.unread,
required this.pt,
required this.onTap,
});
@override
State<_VirtualSpaceItem> createState() => _VirtualSpaceItemState();
}
class _VirtualSpaceItemState extends State<_VirtualSpaceItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final active = widget.active;
final radius = active
? pt.rLg * 0.6
: _hovered ? pt.rLg * 0.7 : pt.rLg;
return Center(
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
// Active indicator
if (active)
Positioned(
left: -18,
top: 10,
child: Container(
width: 4,
height: 28,
decoration: BoxDecoration(
color: pt.accent,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(3),
bottomRight: Radius.circular(3),
),
),
),
),
Tooltip(
message: widget.name,
waitDuration: const Duration(milliseconds: 400),
preferBelow: false,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack,
width: 48,
height: 48,
decoration: BoxDecoration(
color: _hovered ? pt.bg3 : pt.bg2,
borderRadius: BorderRadius.circular(radius),
),
child: Center(
child: PyramidGlyph(
size: 30,
color: widget.color,
label: widget.label,
),
),
),
),
],
),
),
),
);
}
}
class _SpaceItem extends ConsumerStatefulWidget {
final Room space;
final bool active;
final bool isInvite;
final PyramidTheme pt;
final VoidCallback onTap;
const _SpaceItem({
required this.space,
required this.active,
this.isInvite = false,
required this.pt,
required this.onTap,
});
@override
ConsumerState<_SpaceItem> createState() => _SpaceItemState();
}
class _SpaceItemState extends ConsumerState<_SpaceItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final active = widget.active;
final name = widget.space.getLocalizedDisplayname();
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
final radius = active ? pt.rLg * 0.6 : _hovered ? pt.rLg * 0.7 : pt.rLg;
final client = ref.watch(matrixClientProvider).valueOrNull;
final avatarUri = widget.space.avatar;
Widget avatarChild;
if (client != null && avatarUri != null) {
avatarChild = MxcAvatar(
mxcUri: avatarUri,
client: client,
size: 48,
borderRadius: BorderRadius.circular(radius),
placeholder: (_) => _SpaceInitial(letter: letter, pt: pt, radius: radius),
);
} else {
avatarChild = _SpaceInitial(letter: letter, pt: pt, radius: radius);
}
return Center(
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
onSecondaryTap: widget.isInvite
? null
: () => showDialog(
context: context,
builder: (_) => SpaceEditDialog(space: widget.space),
),
onLongPress: widget.isInvite
? null
: () => showDialog(
context: context,
builder: (_) => SpaceEditDialog(space: widget.space),
),
child: Stack(
clipBehavior: Clip.none,
children: [
if (active)
Positioned(
left: -18,
top: 10,
child: Container(
width: 4,
height: 28,
decoration: BoxDecoration(
color: pt.accent,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(3),
bottomRight: Radius.circular(3),
),
),
),
),
Tooltip(
message: name,
waitDuration: const Duration(milliseconds: 400),
preferBelow: false,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack,
width: 48,
height: 48,
decoration: BoxDecoration(
color: _hovered ? pt.bg3 : pt.bg2,
borderRadius: BorderRadius.circular(radius),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: avatarChild,
),
),
),
if (widget.isInvite)
Positioned(
right: -2,
top: -2,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: pt.accent,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: pt.bg0, width: 1.5),
),
child: const Text(
'NEU',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.w800,
letterSpacing: 0.3),
),
),
),
],
),
),
),
);
}
}
class _SpaceInitial extends StatelessWidget {
final String letter;
final PyramidTheme pt;
final double radius;
const _SpaceInitial({required this.letter, required this.pt, required this.radius});
@override
Widget build(BuildContext context) {
return Center(
child: Text(letter, style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
);
}
}
class _AddButton extends StatefulWidget {
final PyramidTheme pt;
const _AddButton({required this.pt});
@override
State<_AddButton> createState() => _AddButtonState();
}
class _AddButtonState extends State<_AddButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Center(
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: () => showDialog(
context: context,
builder: (_) => const CreateJoinDialog(),
),
child: Tooltip(
message: 'Create · Join · Discover',
waitDuration: const Duration(milliseconds: 400),
preferBelow: false,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack,
width: 48,
height: 48,
decoration: BoxDecoration(
color: _hovered ? pt.accentSoft : pt.bg2,
borderRadius: BorderRadius.circular(_hovered ? pt.rLg * 0.7 : pt.rLg),
border: Border.all(
color: _hovered ? pt.accent : pt.borderStrong,
width: 1.5,
style: BorderStyle.solid,
),
),
child: Center(
child: AnimatedRotation(
turns: _hovered ? 0.25 : 0,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack,
child: Icon(Icons.add, color: pt.accent, size: 22),
),
),
),
),
),
),
);
}
}