25ed765a03
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
536 lines
17 KiB
Dart
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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|