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.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), ), ), ), ), ), ), ); } }