diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 4aaf63a0..d11cd56d 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -92,8 +92,12 @@ abstract class AppRoutes { FluffyThemes.isColumnMode(context) && state.fullPath?.startsWith('/rooms/settings') == false ? TwoColumnLayout( + displayNavigationRail: + state.path?.startsWith('/rooms/settings') != true, mainView: ChatList( activeChat: state.pathParameters['roomid'], + displayNavigationRail: + state.path?.startsWith('/rooms/settings') != true, ), sideView: child, ) @@ -171,6 +175,7 @@ abstract class AppRoutes { ? TwoColumnLayout( mainView: const Settings(), sideView: child, + displayNavigationRail: false, ) : child, ), diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 9974a9e7..80a84fa4 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -182,10 +182,17 @@ class ChatView extends StatelessWidget { tooltip: L10n.of(context)!.close, color: Theme.of(context).colorScheme.primary, ) - : UnreadRoomsBadge( - filter: (r) => r.id != controller.roomId, - badgePosition: BadgePosition.topEnd(end: 8, top: 4), - child: const Center(child: BackButton()), + : StreamBuilder( + stream: Matrix.of(context) + .client + .onSync + .stream + .where((syncUpdate) => syncUpdate.hasRoomUpdate), + builder: (context, _) => UnreadRoomsBadge( + filter: (r) => r.id != controller.roomId, + badgePosition: BadgePosition.topEnd(end: 8, top: 4), + child: const Center(child: BackButton()), + ), ), titleSpacing: 0, title: ChatAppBarTitle(controller), diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 8ee1870b..d4f6067e 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -73,10 +73,12 @@ extension LocalizedActiveFilter on ActiveFilter { class ChatList extends StatefulWidget { static BuildContext? contextForVoip; final String? activeChat; + final bool displayNavigationRail; const ChatList({ super.key, required this.activeChat, + this.displayNavigationRail = false, }); @override @@ -667,7 +669,11 @@ class ChatListController extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.navigate_next_outlined), + Avatar( + mxContent: space.avatar, + size: Avatar.defaultSize / 2, + name: space.getLocalizedDisplayname(), + ), const SizedBox(width: 12), Expanded( child: Text( diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index 4b17ad6b..e596440b 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -168,7 +168,8 @@ class ChatListViewBody extends StatelessWidget { ActiveFilter.allChats, ActiveFilter.unread, ActiveFilter.groups, - if (spaceDelegateCandidates.isNotEmpty) + if (spaceDelegateCandidates.isNotEmpty && + !controller.widget.displayNavigationRail) ActiveFilter.spaces, ] .map( diff --git a/lib/pages/chat_list/chat_list_item.dart b/lib/pages/chat_list/chat_list_item.dart index a146a076..f704c41b 100644 --- a/lib/pages/chat_list/chat_list_item.dart +++ b/lib/pages/chat_list/chat_list_item.dart @@ -171,12 +171,12 @@ class ChatListItem extends StatelessWidget { ), ), Positioned( - top: -2, - right: -2, + top: 0, + right: 0, child: AnimatedScale( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, - scale: listTileHovered ? 1.1 : 1.0, + scale: listTileHovered ? 1.0 : 0.0, child: Material( color: backgroundColor, borderRadius: BorderRadius.circular(16), diff --git a/lib/pages/chat_list/chat_list_view.dart b/lib/pages/chat_list/chat_list_view.dart index 93fa8575..5bb6687a 100644 --- a/lib/pages/chat_list/chat_list_view.dart +++ b/lib/pages/chat_list/chat_list_view.dart @@ -5,7 +5,12 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; +import 'package:fluffychat/pages/chat_list/navi_rail_item.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/avatar.dart'; import '../../widgets/matrix.dart'; import 'chat_list_body.dart'; @@ -35,32 +40,113 @@ class ChatListView extends StatelessWidget { return; } }, - child: GestureDetector( - onTap: FocusManager.instance.primaryFocus?.unfocus, - excludeFromSemantics: true, - behavior: HitTestBehavior.translucent, - child: Scaffold( - body: ChatListViewBody(controller), - floatingActionButton: KeyBoardShortcuts( - keysToPress: { - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.keyN, - }, - onKeysPressed: () => context.go('/rooms/newprivatechat'), - helpLabel: L10n.of(context)!.newChat, - child: - selectMode == SelectMode.normal && !controller.isSearchMode - ? FloatingActionButton.extended( - onPressed: controller.addChatAction, - icon: const Icon(Icons.add_outlined), - label: Text( - L10n.of(context)!.chat, - overflow: TextOverflow.fade, + child: Row( + children: [ + if (FluffyThemes.isColumnMode(context) && + controller.widget.displayNavigationRail) ...[ + Builder( + builder: (context) { + final allSpaces = Matrix.of(context) + .client + .rooms + .where((room) => room.isSpace); + final rootSpaces = allSpaces + .where( + (space) => !allSpaces.any( + (parentSpace) => parentSpace.spaceChildren + .any((child) => child.roomId == space.id), + ), + ) + .toList(); + + return SizedBox( + width: FluffyThemes.navRailWidth, + child: ListView.builder( + scrollDirection: Axis.vertical, + itemCount: rootSpaces.length + 2, + itemBuilder: (context, i) { + if (i == 0) { + return NaviRailItem( + isSelected: controller.activeSpaceId == null, + onTap: controller.clearActiveSpace, + icon: const Icon(Icons.forum_outlined), + selectedIcon: const Icon(Icons.forum), + toolTip: L10n.of(context)!.chats, + unreadBadgeFilter: (room) => true, + ); + } + i--; + if (i == rootSpaces.length) { + return NaviRailItem( + isSelected: false, + onTap: () => context.go('/rooms/newspace'), + icon: const Icon(Icons.add), + toolTip: L10n.of(context)!.createNewSpace, + ); + } + final space = rootSpaces[i]; + final displayname = + rootSpaces[i].getLocalizedDisplayname( + MatrixLocals(L10n.of(context)!), + ); + final spaceChildrenIds = + space.spaceChildren.map((c) => c.roomId).toSet(); + return NaviRailItem( + toolTip: displayname, + isSelected: controller.activeSpaceId == space.id, + onTap: () => + controller.setActiveSpace(rootSpaces[i].id), + unreadBadgeFilter: (room) => + spaceChildrenIds.contains(room.id), + icon: Avatar( + mxContent: rootSpaces[i].avatar, + name: displayname, + size: 32, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius / 4, + ), ), - ) - : const SizedBox.shrink(), + ); + }, + ), + ); + }, + ), + Container( + color: Theme.of(context).dividerColor, + width: 1, + ), + ], + Expanded( + child: GestureDetector( + onTap: FocusManager.instance.primaryFocus?.unfocus, + excludeFromSemantics: true, + behavior: HitTestBehavior.translucent, + child: Scaffold( + body: ChatListViewBody(controller), + floatingActionButton: KeyBoardShortcuts( + keysToPress: { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.keyN, + }, + onKeysPressed: () => context.go('/rooms/newprivatechat'), + helpLabel: L10n.of(context)!.newChat, + child: selectMode == SelectMode.normal && + !controller.isSearchMode + ? FloatingActionButton.extended( + onPressed: controller.addChatAction, + icon: const Icon(Icons.add_outlined), + label: Text( + L10n.of(context)!.chat, + overflow: TextOverflow.fade, + ), + ) + : const SizedBox.shrink(), + ), + ), + ), ), - ), + ], ), ); }, diff --git a/lib/pages/chat_list/nav_rail_item.dart b/lib/pages/chat_list/nav_rail_item.dart new file mode 100644 index 00000000..d09659f8 --- /dev/null +++ b/lib/pages/chat_list/nav_rail_item.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import '../../config/themes.dart'; + +class NaviRailItem extends StatefulWidget { + final String toolTip; + final bool isSelected; + final void Function() onTap; + final Widget icon; + final Widget? selectedIcon; + + const NaviRailItem({ + required this.toolTip, + required this.isSelected, + required this.onTap, + required this.icon, + this.selectedIcon, + super.key, + }); + + @override + State createState() => _NaviRailItemState(); +} + +class _NaviRailItemState extends State { + bool _hovered = false; + + void _onHover(bool hover) { + if (hover == _hovered) return; + setState(() { + _hovered = hover; + }); + } + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(AppConfig.borderRadius); + return SizedBox( + height: 64, + width: 64, + child: Stack( + children: [ + Positioned( + top: 16, + bottom: 16, + left: 0, + child: AnimatedContainer( + width: widget.isSelected ? 4 : 0, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(90), + bottomRight: Radius.circular(90), + ), + ), + ), + ), + Center( + child: AnimatedScale( + scale: _hovered ? 1.2 : 1.0, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + child: Material( + borderRadius: borderRadius, + color: widget.isSelected + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surface, + child: Tooltip( + message: widget.toolTip, + child: InkWell( + borderRadius: borderRadius, + onTap: widget.onTap, + onHover: _onHover, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 8.0, + ), + child: widget.isSelected + ? widget.selectedIcon ?? widget.icon + : widget.icon, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/chat_list/navi_rail_item.dart b/lib/pages/chat_list/navi_rail_item.dart index d09659f8..66ad7c04 100644 --- a/lib/pages/chat_list/navi_rail_item.dart +++ b/lib/pages/chat_list/navi_rail_item.dart @@ -1,14 +1,20 @@ import 'package:flutter/material.dart'; +import 'package:badges/badges.dart'; +import 'package:matrix/matrix.dart'; + import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; +import 'package:fluffychat/widgets/unread_rooms_badge.dart'; import '../../config/themes.dart'; -class NaviRailItem extends StatefulWidget { +class NaviRailItem extends StatelessWidget { final String toolTip; final bool isSelected; final void Function() onTap; final Widget icon; final Widget? selectedIcon; + final bool Function(Room)? unreadBadgeFilter; const NaviRailItem({ required this.toolTip, @@ -16,80 +22,78 @@ class NaviRailItem extends StatefulWidget { required this.onTap, required this.icon, this.selectedIcon, + this.unreadBadgeFilter, super.key, }); - - @override - State createState() => _NaviRailItemState(); -} - -class _NaviRailItemState extends State { - bool _hovered = false; - - void _onHover(bool hover) { - if (hover == _hovered) return; - setState(() { - _hovered = hover; - }); - } - @override Widget build(BuildContext context) { final borderRadius = BorderRadius.circular(AppConfig.borderRadius); - return SizedBox( - height: 64, - width: 64, - child: Stack( - children: [ - Positioned( - top: 16, - bottom: 16, - left: 0, - child: AnimatedContainer( - width: widget.isSelected ? 4 : 0, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: const BorderRadius.only( - topRight: Radius.circular(90), - bottomRight: Radius.circular(90), - ), - ), - ), - ), - Center( - child: AnimatedScale( - scale: _hovered ? 1.2 : 1.0, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - child: Material( - borderRadius: borderRadius, - color: widget.isSelected - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.surface, - child: Tooltip( - message: widget.toolTip, - child: InkWell( - borderRadius: borderRadius, - onTap: widget.onTap, - onHover: _onHover, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 8.0, - ), - child: widget.isSelected - ? widget.selectedIcon ?? widget.icon - : widget.icon, + final icon = isSelected ? selectedIcon ?? this.icon : this.icon; + final unreadBadgeFilter = this.unreadBadgeFilter; + return HoverBuilder( + builder: (context, hovered) { + return SizedBox( + height: 64, + width: 64, + child: Stack( + children: [ + Positioned( + top: 16, + bottom: 16, + left: 0, + child: AnimatedContainer( + width: isSelected ? 4 : 0, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(90), + bottomRight: Radius.circular(90), ), ), ), ), - ), + Center( + child: AnimatedScale( + scale: hovered ? 1.2 : 1.0, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + child: Material( + borderRadius: borderRadius, + color: isSelected + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surface, + child: Tooltip( + message: toolTip, + child: InkWell( + borderRadius: borderRadius, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 8.0, + ), + child: unreadBadgeFilter == null + ? icon + : UnreadRoomsBadge( + filter: unreadBadgeFilter, + badgePosition: BadgePosition.topEnd( + top: -12, + end: -8, + ), + child: icon, + ), + ), + ), + ), + ), + ), + ), + ], ), - ], - ), + ); + }, ); } } diff --git a/lib/widgets/layouts/two_column_layout.dart b/lib/widgets/layouts/two_column_layout.dart index c270f120..a6f4c8bd 100644 --- a/lib/widgets/layouts/two_column_layout.dart +++ b/lib/widgets/layouts/two_column_layout.dart @@ -3,11 +3,13 @@ import 'package:flutter/material.dart'; class TwoColumnLayout extends StatelessWidget { final Widget mainView; final Widget sideView; + final bool displayNavigationRail; const TwoColumnLayout({ super.key, required this.mainView, required this.sideView, + required this.displayNavigationRail, }); @override Widget build(BuildContext context) { @@ -18,7 +20,7 @@ class TwoColumnLayout extends StatelessWidget { Container( clipBehavior: Clip.antiAlias, decoration: const BoxDecoration(), - width: 384.0, + width: 360.0 + (displayNavigationRail ? 64 : 0), child: mainView, ), Container( diff --git a/lib/widgets/unread_rooms_badge.dart b/lib/widgets/unread_rooms_badge.dart index 00669176..5270c0db 100644 --- a/lib/widgets/unread_rooms_badge.dart +++ b/lib/widgets/unread_rooms_badge.dart @@ -19,41 +19,32 @@ class UnreadRoomsBadge extends StatelessWidget { @override Widget build(BuildContext context) { - return StreamBuilder( - stream: Matrix.of(context) - .client - .onSync - .stream - .where((syncUpdate) => syncUpdate.hasRoomUpdate), - builder: (context, _) { - final unreadCount = Matrix.of(context) - .client - .rooms - .where(filter) - .where((r) => (r.isUnread || r.membership == Membership.invite)) - .length; - return b.Badge( - badgeStyle: b.BadgeStyle( - badgeColor: Theme.of(context).colorScheme.primary, - elevation: 4, - borderSide: BorderSide( - color: Theme.of(context).colorScheme.surface, - width: 2, - ), - ), - badgeContent: Text( - unreadCount.toString(), - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimary, - fontSize: 12, - ), - ), - showBadge: unreadCount != 0, - badgeAnimation: const b.BadgeAnimation.scale(), - position: badgePosition ?? b.BadgePosition.bottomEnd(), - child: child, - ); - }, + final unreadCount = Matrix.of(context) + .client + .rooms + .where(filter) + .where((r) => (r.isUnread || r.membership == Membership.invite)) + .length; + return b.Badge( + badgeStyle: b.BadgeStyle( + badgeColor: Theme.of(context).colorScheme.primary, + elevation: 4, + borderSide: BorderSide( + color: Theme.of(context).colorScheme.surface, + width: 2, + ), + ), + badgeContent: Text( + unreadCount.toString(), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontSize: 12, + ), + ), + showBadge: unreadCount != 0, + badgeAnimation: const b.BadgeAnimation.scale(), + position: badgePosition ?? b.BadgePosition.bottomEnd(), + child: child, ); } } diff --git a/linux/my_application.cc b/linux/my_application.cc index 0abe77c6..c185bcd7 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -60,7 +60,7 @@ static void my_application_activate(GApplication* application) { gtk_window_set_title(window, "FluffyChat"); } - gtk_window_set_default_size(window, 800, 600); + gtk_window_set_default_size(window, 864, 680); gtk_widget_show(GTK_WIDGET(window)); g_autoptr(FlDartProject) project = fl_dart_project_new();