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]>
555 lines
18 KiB
Dart
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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|