Files
pyramid/lib/features/spaces/spaces_rail.dart
T
Bernd Steckmeister 25ed765a03 wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 05:47:18 +02:00

536 lines
17 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/matrix_client.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;
ref.read(activeRoomIdProvider.notifier).state =
ref.read(lastRoomPerSpaceProvider)[id];
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: 'Rooms',
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: 'Direct Messages',
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),
),
),
),
),
),
),
);
}
}