Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Christian Pauly
e7af925f2a feat: New navigation design 2022-08-31 16:38:08 +02:00
10 changed files with 495 additions and 355 deletions

View file

@ -1,13 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/platform_infos.dart';
import '../widgets/matrix.dart';
import 'app_config.dart'; import 'app_config.dart';
abstract class FluffyThemes { abstract class FluffyThemes {
static const double columnWidth = 360.0; static const double columnWidth = 360.0;
static bool isColumnModeByWidth(double width) => width > columnWidth * 2 + 64;
static bool isColumnMode(BuildContext context) => static bool isColumnMode(BuildContext context) =>
MediaQuery.of(context).size.width > columnWidth * 2; isColumnModeByWidth(MediaQuery.of(context).size.width);
static bool getDisplayNavigationRail(BuildContext context) =>
VRouter.of(context).path.startsWith('/rooms') &&
(Matrix.of(context).client.rooms.any((room) => room.isSpace) ||
AppConfig.separateChatTypes);
static const fallbackTextStyle = TextStyle( static const fallbackTextStyle = TextStyle(
fontFamily: 'Roboto', fontFamily: 'Roboto',

View file

@ -13,10 +13,12 @@ import 'package:uni_links/uni_links.dart';
import 'package:vrouter/vrouter.dart'; import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.dart';
import 'package:fluffychat/pages/chat_list/spaces_entry.dart'; import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
import 'package:fluffychat/utils/famedlysdk_store.dart'; import 'package:fluffychat/utils/famedlysdk_store.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart';
import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/space_navigator.dart'; import 'package:fluffychat/utils/space_navigator.dart';
import '../../../utils/account_bundles.dart'; import '../../../utils/account_bundles.dart';
@ -30,7 +32,11 @@ import '../settings_account/settings_account.dart';
import 'package:fluffychat/utils/tor_stub.dart' import 'package:fluffychat/utils/tor_stub.dart'
if (dart.library.html) 'package:tor_detector_web/tor_detector_web.dart'; if (dart.library.html) 'package:tor_detector_web/tor_detector_web.dart';
enum SelectMode { normal, share, select } enum SelectMode {
normal,
share,
select,
}
enum PopupMenuAction { enum PopupMenuAction {
settings, settings,
@ -41,6 +47,13 @@ enum PopupMenuAction {
archive, archive,
} }
enum ActiveFilter {
allChats,
groups,
messages,
spaces,
}
class ChatList extends StatefulWidget { class ChatList extends StatefulWidget {
const ChatList({Key? key}) : super(key: key); const ChatList({Key? key}) : super(key: key);
@ -58,6 +71,92 @@ class ChatListController extends State<ChatList>
SpacesEntry? _activeSpacesEntry; SpacesEntry? _activeSpacesEntry;
bool get displayNavigationBar =>
!FluffyThemes.isColumnMode(context) &&
(spaces.isNotEmpty || AppConfig.separateChatTypes);
int get selectedIndex {
switch (activeFilter) {
case ActiveFilter.allChats:
return 0;
case ActiveFilter.groups:
return 0;
case ActiveFilter.messages:
return 1;
case ActiveFilter.spaces:
return AppConfig.separateChatTypes ? 2 : 1;
}
}
void onDestinationSelected(int? i) {
switch (i) {
case 0:
if (AppConfig.separateChatTypes) {
setState(() {
activeFilter = ActiveFilter.groups;
});
} else {
setState(() {
activeFilter = ActiveFilter.allChats;
});
}
break;
case 1:
if (AppConfig.separateChatTypes) {
setState(() {
activeFilter = ActiveFilter.messages;
});
} else {
setState(() {
activeFilter = ActiveFilter.spaces;
});
}
break;
case 2:
setState(() {
activeFilter = ActiveFilter.spaces;
});
break;
}
}
ActiveFilter activeFilter = AppConfig.separateChatTypes
? ActiveFilter.messages
: ActiveFilter.allChats;
List<Room> get filteredRooms {
final rooms = Matrix.of(context).client.rooms;
switch (activeFilter) {
case ActiveFilter.allChats:
return rooms
.where((room) =>
!room.isSpace && room.spaceParents.isEmpty && !room.isStoryRoom)
.toList();
case ActiveFilter.groups:
return rooms
.where((room) =>
!room.isSpace &&
room.spaceParents.isEmpty &&
!room.isDirectChat &&
!room.isStoryRoom)
.toList();
case ActiveFilter.messages:
return rooms
.where((room) =>
!room.isSpace &&
room.spaceParents.isEmpty &&
room.isDirectChat &&
!room.isStoryRoom)
.toList();
case ActiveFilter.spaces:
return rooms
.where((room) =>
(room.isSpace || room.spaceParents.isNotEmpty) &&
!room.isStoryRoom)
.toList();
}
}
bool isSearchMode = false; bool isSearchMode = false;
Future<QueryPublicRoomsResponse>? publicRoomsResponse; Future<QueryPublicRoomsResponse>? publicRoomsResponse;
String? searchServer; String? searchServer;
@ -154,6 +253,7 @@ class ChatListController extends State<ChatList>
bool isTorBrowser = false; bool isTorBrowser = false;
@Deprecated('')
SpacesEntry get activeSpacesEntry { SpacesEntry get activeSpacesEntry {
final id = _activeSpacesEntry; final id = _activeSpacesEntry;
return (id == null || !id.stillValid(context)) ? defaultSpacesEntry : id; return (id == null || !id.stillValid(context)) ? defaultSpacesEntry : id;

View file

@ -17,6 +17,7 @@ import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/profile_bottom_sheet.dart'; import 'package:fluffychat/widgets/profile_bottom_sheet.dart';
import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; import 'package:fluffychat/widgets/public_room_bottom_sheet.dart';
import '../../utils/stream_extension.dart'; import '../../utils/stream_extension.dart';
import '../../widgets/connection_status_header.dart';
import '../../widgets/matrix.dart'; import '../../widgets/matrix.dart';
import 'spaces_hierarchy_proposal.dart'; import 'spaces_hierarchy_proposal.dart';
@ -57,146 +58,148 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
Widget child; Widget child;
if (widget.controller.waitForFirstSync && if (widget.controller.waitForFirstSync &&
Matrix.of(context).client.prevBatch != null) { Matrix.of(context).client.prevBatch != null) {
final rooms = widget.controller.activeSpacesEntry.getRooms(context); final rooms = widget.controller.filteredRooms;
final displayStoriesHeader = widget.controller.activeSpacesEntry final displayStoriesHeader = {
.shouldShowStoriesHeader(context) || ActiveFilter.allChats,
rooms.isEmpty; ActiveFilter.messages,
}.contains(widget.controller.activeFilter);
child = ListView.builder( child = ListView.builder(
key: ValueKey(Matrix.of(context).client.userID.toString() + key: ValueKey(Matrix.of(context).client.userID.toString() +
widget.controller.activeSpaceId.toString() + widget.controller.activeSpaceId.toString() +
widget.controller.activeSpacesEntry.runtimeType.toString()), widget.controller.activeFilter.toString()),
controller: widget.controller.scrollController, controller: widget.controller.scrollController,
// add +1 space below in order to properly scroll below the spaces bar // add +1 space below in order to properly scroll below the spaces bar
itemCount: rooms.length + (displayStoriesHeader ? 2 : 1), itemCount: rooms.length + 2,
itemBuilder: (BuildContext context, int i) { itemBuilder: (BuildContext context, int i) {
if (displayStoriesHeader) { if (i == 0) {
if (i == 0) { return Column(
return Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ SpaceRoomListTopBar(widget.controller),
SpaceRoomListTopBar(widget.controller), if (roomSearchResult != null) ...[
if (roomSearchResult != null) ...[ SearchTitle(
SearchTitle( title: L10n.of(context)!.publicRooms,
title: L10n.of(context)!.publicRooms, icon: const Icon(Icons.explore_outlined),
icon: const Icon(Icons.explore_outlined), ),
), AnimatedContainer(
AnimatedContainer( height: roomSearchResult.chunk.isEmpty ? 0 : 106,
height: roomSearchResult.chunk.isEmpty ? 0 : 106, duration: const Duration(milliseconds: 250),
duration: const Duration(milliseconds: 250), clipBehavior: Clip.hardEdge,
clipBehavior: Clip.hardEdge, decoration: const BoxDecoration(),
decoration: const BoxDecoration(), child: ListView.builder(
child: ListView.builder( scrollDirection: Axis.horizontal,
scrollDirection: Axis.horizontal, itemCount: roomSearchResult.chunk.length,
itemCount: roomSearchResult.chunk.length, itemBuilder: (context, i) => _SearchItem(
itemBuilder: (context, i) => _SearchItem( title: roomSearchResult.chunk[i].name ??
title: roomSearchResult.chunk[i].name ?? roomSearchResult
roomSearchResult .chunk[i].canonicalAlias?.localpart ??
.chunk[i].canonicalAlias?.localpart ?? L10n.of(context)!.group,
L10n.of(context)!.group, avatar: roomSearchResult.chunk[i].avatarUrl,
avatar: roomSearchResult.chunk[i].avatarUrl, onPressed: () => showModalBottomSheet(
onPressed: () => showModalBottomSheet( context: context,
context: context, builder: (c) => PublicRoomBottomSheet(
builder: (c) => PublicRoomBottomSheet( roomAlias:
roomAlias: roomSearchResult.chunk[i].canonicalAlias ??
roomSearchResult.chunk[i].canonicalAlias ?? roomSearchResult.chunk[i].roomId,
roomSearchResult.chunk[i].roomId, outerContext: context,
outerContext: context, chunk: roomSearchResult.chunk[i],
chunk: roomSearchResult.chunk[i],
),
), ),
), ),
), ),
), ),
], ),
if (userSearchResult != null) ...[ ],
SearchTitle( if (userSearchResult != null) ...[
title: L10n.of(context)!.users, SearchTitle(
icon: const Icon(Icons.group_outlined), title: L10n.of(context)!.users,
), icon: const Icon(Icons.group_outlined),
AnimatedContainer( ),
height: userSearchResult.results.isEmpty ? 0 : 106, AnimatedContainer(
duration: const Duration(milliseconds: 250), height: userSearchResult.results.isEmpty ? 0 : 106,
clipBehavior: Clip.hardEdge, duration: const Duration(milliseconds: 250),
decoration: const BoxDecoration(), clipBehavior: Clip.hardEdge,
child: ListView.builder( decoration: const BoxDecoration(),
scrollDirection: Axis.horizontal, child: ListView.builder(
itemCount: userSearchResult.results.length, scrollDirection: Axis.horizontal,
itemBuilder: (context, i) => _SearchItem( itemCount: userSearchResult.results.length,
title: userSearchResult.results[i].displayName ?? itemBuilder: (context, i) => _SearchItem(
userSearchResult.results[i].userId.localpart ?? title: userSearchResult.results[i].displayName ??
L10n.of(context)!.unknownDevice, userSearchResult.results[i].userId.localpart ??
avatar: userSearchResult.results[i].avatarUrl, L10n.of(context)!.unknownDevice,
onPressed: () => showModalBottomSheet( avatar: userSearchResult.results[i].avatarUrl,
context: context, onPressed: () => showModalBottomSheet(
builder: (c) => ProfileBottomSheet( context: context,
userId: userSearchResult.results[i].userId, builder: (c) => ProfileBottomSheet(
outerContext: context, userId: userSearchResult.results[i].userId,
), outerContext: context,
), ),
), ),
), ),
), ),
], ),
if (widget.controller.isSearchMode) ],
SearchTitle( if (widget.controller.isSearchMode)
title: L10n.of(context)!.stories, SearchTitle(
icon: const Icon(Icons.camera_alt_outlined), title: L10n.of(context)!.stories,
), icon: const Icon(Icons.camera_alt_outlined),
),
if (displayStoriesHeader)
StoriesHeader( StoriesHeader(
filter: widget.controller.searchController.text, filter: widget.controller.searchController.text,
), ),
AnimatedContainer( const ConnectionStatusHeader(),
height: widget.controller.isTorBrowser ? 64 : 0, AnimatedContainer(
duration: const Duration(milliseconds: 300), height: widget.controller.isTorBrowser ? 64 : 0,
clipBehavior: Clip.hardEdge, duration: const Duration(milliseconds: 300),
curve: Curves.bounceInOut, clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(), curve: Curves.bounceInOut,
child: Material( decoration: const BoxDecoration(),
color: Theme.of(context).colorScheme.surface, child: Material(
child: ListTile( color: Theme.of(context).colorScheme.surface,
leading: const Icon(Icons.vpn_key), child: ListTile(
title: Text(L10n.of(context)!.dehydrateTor), leading: const Icon(Icons.vpn_key),
subtitle: Text(L10n.of(context)!.dehydrateTorLong), title: Text(L10n.of(context)!.dehydrateTor),
trailing: const Icon(Icons.chevron_right_outlined), subtitle: Text(L10n.of(context)!.dehydrateTorLong),
onTap: widget.controller.dehydrate, trailing: const Icon(Icons.chevron_right_outlined),
), onTap: widget.controller.dehydrate,
), ),
), ),
if (widget.controller.isSearchMode) ),
SearchTitle( if (widget.controller.isSearchMode)
title: L10n.of(context)!.chats, SearchTitle(
icon: const Icon(Icons.chat_outlined), title: L10n.of(context)!.chats,
), icon: const Icon(Icons.chat_outlined),
if (rooms.isEmpty && !widget.controller.isSearchMode) ),
Column( if (rooms.isEmpty && !widget.controller.isSearchMode)
key: const ValueKey(null), Column(
mainAxisAlignment: MainAxisAlignment.center, key: const ValueKey(null),
mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ mainAxisSize: MainAxisSize.min,
Image.asset( children: <Widget>[
'assets/private_chat_wallpaper.png', Image.asset(
width: 160, 'assets/private_chat_wallpaper.png',
height: 160, width: 160,
), height: 160,
Center( ),
child: Text( Center(
L10n.of(context)!.startYourFirstChat, child: Text(
textAlign: TextAlign.start, L10n.of(context)!.startYourFirstChat,
style: const TextStyle( textAlign: TextAlign.start,
color: Colors.grey, style: const TextStyle(
fontSize: 16, color: Colors.grey,
), fontSize: 16,
), ),
), ),
const SizedBox(height: 16), ),
], const SizedBox(height: 16),
), ],
], ),
); ],
} );
i--;
} }
i--;
if (i >= rooms.length) { if (i >= rooms.length) {
return SpacesHierarchyProposals( return SpacesHierarchyProposals(
space: widget.controller.activeSpacesEntry.getSpace(context)?.id, space: widget.controller.activeSpacesEntry.getSpace(context)?.id,

View file

@ -1,85 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/spaces_drawer.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../config/app_config.dart';
class ChatListDrawer extends StatelessWidget {
final ChatListController controller;
const ChatListDrawer(this.controller, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => Drawer(
child: SafeArea(
child: Column(
children: [
ListTile(
leading: const CircleAvatar(
radius: Avatar.defaultSize / 2,
backgroundImage: AssetImage('assets/logo.png'),
),
title: Text(AppConfig.applicationName),
trailing: Icon(
Icons.adaptive.share_outlined,
color: Theme.of(context).colorScheme.onBackground,
),
onTap: () {
Scaffold.of(context).closeDrawer();
FluffyShare.share(
L10n.of(context)!.inviteText(
Matrix.of(context).client.userID!,
'https://matrix.to/#/${Matrix.of(context).client.userID}?client=im.fluffychat'),
context);
},
),
const Divider(thickness: 1),
Expanded(
child: SpacesDrawer(
controller: controller,
),
),
const Divider(thickness: 1),
ListTile(
leading: Icon(
Icons.group_add_outlined,
color: Theme.of(context).colorScheme.onBackground,
),
title: Text(L10n.of(context)!.createNewGroup),
onTap: () {
Scaffold.of(context).closeDrawer();
VRouter.of(context).to('/newgroup');
},
),
ListTile(
leading: Icon(
Icons.group_work_outlined,
color: Theme.of(context).colorScheme.onBackground,
),
title: Text(L10n.of(context)!.createNewSpace),
onTap: () {
Scaffold.of(context).closeDrawer();
VRouter.of(context).to('/newspace');
},
),
ListTile(
leading: Icon(
Icons.settings_outlined,
color: Theme.of(context).colorScheme.onBackground,
),
title: Text(L10n.of(context)!.settings),
onTap: () {
Scaffold.of(context).closeDrawer();
VRouter.of(context).to('/settings');
},
),
],
),
),
);
}

View file

@ -1,12 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/client_chooser_button.dart'; import 'package:fluffychat/pages/chat_list/client_chooser_button.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
final ChatListController controller; final ChatListController controller;
@ -53,68 +51,22 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
borderRadius: borderRadius:
BorderRadius.circular(AppConfig.borderRadius), BorderRadius.circular(AppConfig.borderRadius),
), ),
hintText: controller.activeSpacesEntry.getName(context), hintText: L10n.of(context)!.search,
prefixIcon: Padding( floatingLabelBehavior: FloatingLabelBehavior.never,
padding: const EdgeInsets.only( prefixIcon: controller.isSearchMode
left: 8.0, ? IconButton(
right: 4, tooltip: L10n.of(context)!.cancel,
), icon: const Icon(Icons.close_outlined),
child: controller.isSearchMode onPressed: controller.cancelSearch,
? IconButton( color: Theme.of(context).colorScheme.onBackground,
tooltip: L10n.of(context)!.cancel, )
icon: const Icon(Icons.close_outlined), : Icon(
onPressed: controller.cancelSearch, Icons.search_outlined,
color: color: Theme.of(context).colorScheme.onBackground,
Theme.of(context).colorScheme.onBackground, ),
) suffixIcon: SizedBox(
: IconButton( width: 0,
onPressed: Scaffold.of(context).openDrawer, child: ClientChooserButton(controller),
icon: Icon(
Icons.menu,
color: Theme.of(context)
.colorScheme
.onBackground,
),
),
),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: controller.isSearchMode
? [
if (controller.isSearching)
const CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
TextButton(
onPressed: controller.setServer,
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 12),
),
child: Text(
controller.searchServer ??
Matrix.of(context)
.client
.homeserver!
.host,
maxLines: 2,
),
),
]
: [
IconButton(
icon: Icon(
Icons.camera_alt_outlined,
color: Theme.of(context)
.colorScheme
.onBackground,
),
tooltip: L10n.of(context)!.addToStory,
onPressed: () =>
VRouter.of(context).to('/stories/create'),
),
ClientChooserButton(controller),
const SizedBox(width: 12),
],
), ),
), ),
), ),

View file

@ -5,9 +5,9 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; import 'package:keyboard_shortcuts/keyboard_shortcuts.dart';
import 'package:vrouter/vrouter.dart'; import 'package:vrouter/vrouter.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/chat_list.dart';
import 'package:fluffychat/pages/chat_list/chat_list_drawer.dart';
import 'package:fluffychat/widgets/connection_status_header.dart';
import '../../widgets/matrix.dart'; import '../../widgets/matrix.dart';
import 'chat_list_body.dart'; import 'chat_list_body.dart';
import 'chat_list_header.dart'; import 'chat_list_header.dart';
@ -18,6 +18,29 @@ class ChatListView extends StatelessWidget {
const ChatListView(this.controller, {Key? key}) : super(key: key); const ChatListView(this.controller, {Key? key}) : super(key: key);
List<NavigationDestination> getNavigationDestinations(BuildContext context) =>
[
if (AppConfig.separateChatTypes) ...[
NavigationDestination(
icon: const Icon(Icons.group_outlined),
label: L10n.of(context)!.groups,
),
NavigationDestination(
icon: const Icon(Icons.chat_bubble_outline),
label: L10n.of(context)!.messages,
),
] else
NavigationDestination(
icon: const Icon(Icons.chat_bubble),
label: L10n.of(context)!.allChats,
),
if (controller.spaces.isNotEmpty)
const NavigationDestination(
icon: Icon(Icons.group_work_outlined),
label: 'Spaces',
),
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return StreamBuilder<Object?>( return StreamBuilder<Object?>(
@ -30,24 +53,56 @@ class ChatListView extends StatelessWidget {
if (selMode != SelectMode.normal) controller.cancelAction(); if (selMode != SelectMode.normal) controller.cancelAction();
if (selMode == SelectMode.select) redirector.stopRedirection(); if (selMode == SelectMode.select) redirector.stopRedirection();
}, },
child: Scaffold( child: Row(
appBar: ChatListHeader(controller: controller), children: [
body: ChatListViewBody(controller), if (FluffyThemes.isColumnMode(context) &&
drawer: ChatListDrawer(controller), FluffyThemes.getDisplayNavigationRail(context)) ...[
bottomNavigationBar: const ConnectionStatusHeader(), NavigationRail(
floatingActionButton: selectMode == SelectMode.normal selectedIndex: controller.selectedIndex,
? KeyBoardShortcuts( onDestinationSelected: controller.onDestinationSelected,
keysToPress: { labelType: NavigationRailLabelType.all,
LogicalKeyboardKey.controlLeft, destinations: getNavigationDestinations(context)
LogicalKeyboardKey.keyN .map(
}, (destination) => NavigationRailDestination(
onKeysPressed: () => icon: destination.icon,
VRouter.of(context).to('/newprivatechat'), label: Text(destination.label),
helpLabel: L10n.of(context)!.newChat, ),
child: )
StartChatFloatingActionButton(controller: controller), .toList(),
) ),
: null, Container(
color: Theme.of(context).dividerColor,
width: 1,
),
],
Expanded(
child: Scaffold(
appBar: ChatListHeader(controller: controller),
body: ChatListViewBody(controller),
bottomNavigationBar: controller.displayNavigationBar
? NavigationBar(
selectedIndex: controller.selectedIndex,
onDestinationSelected:
controller.onDestinationSelected,
destinations: getNavigationDestinations(context),
)
: null,
floatingActionButton: selectMode == SelectMode.normal
? KeyBoardShortcuts(
keysToPress: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.keyN
},
onKeysPressed: () =>
VRouter.of(context).to('/newprivatechat'),
helpLabel: L10n.of(context)!.newChat,
child: StartChatFloatingActionButton(
controller: controller),
)
: null,
),
),
],
), ),
); );
}, },

View file

@ -4,9 +4,11 @@ import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; import 'package:keyboard_shortcuts/keyboard_shortcuts.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/matrix.dart';
import '../../utils/fluffy_share.dart';
import 'chat_list.dart'; import 'chat_list.dart';
class ClientChooserButton extends StatelessWidget { class ClientChooserButton extends StatelessWidget {
@ -23,6 +25,60 @@ class ClientChooserButton extends StatelessWidget {
? -1 ? -1
: 1); : 1);
return <PopupMenuEntry<Object>>[ return <PopupMenuEntry<Object>>[
PopupMenuItem(
value: SettingsAction.newStory,
child: Row(
children: [
const Icon(Icons.camera_outlined),
const SizedBox(width: 18),
Text(L10n.of(context)!.yourStory),
],
),
),
PopupMenuItem(
value: SettingsAction.newGroup,
child: Row(
children: [
const Icon(Icons.group_add_outlined),
const SizedBox(width: 18),
Text(L10n.of(context)!.createNewGroup),
],
),
),
PopupMenuItem(
value: SettingsAction.newSpace,
child: Row(
children: [
const Icon(Icons.group_work_outlined),
const SizedBox(width: 18),
Text(L10n.of(context)!.createNewSpace),
],
),
),
PopupMenuItem(
value: SettingsAction.invite,
child: Row(
children: [
Icon(Icons.adaptive.share_outlined),
const SizedBox(width: 18),
Text(L10n.of(context)!.inviteContact),
],
),
),
PopupMenuItem(
value: SettingsAction.settings,
child: Row(
children: [
const Icon(Icons.settings_outlined),
const SizedBox(width: 18),
Text(L10n.of(context)!.settings),
],
),
),
const PopupMenuItem(
value: null,
child: Divider(height: 1),
),
for (final bundle in bundles) ...[ for (final bundle in bundles) ...[
if (matrix.accountBundles[bundle]!.length != 1 || if (matrix.accountBundles[bundle]!.length != 1 ||
matrix.accountBundles[bundle]!.single!.userID != bundle) matrix.accountBundles[bundle]!.single!.userID != bundle)
@ -80,7 +136,7 @@ class ClientChooserButton extends StatelessWidget {
.toList(), .toList(),
], ],
PopupMenuItem( PopupMenuItem(
value: AddAccountAction.addAccount, value: SettingsAction.addAccount,
child: Row( child: Row(
children: [ children: [
const Icon(Icons.person_add_outlined), const Icon(Icons.person_add_outlined),
@ -98,42 +154,55 @@ class ClientChooserButton extends StatelessWidget {
int clientCount = 0; int clientCount = 0;
matrix.accountBundles.forEach((key, value) => clientCount += value.length); matrix.accountBundles.forEach((key, value) => clientCount += value.length);
return Center( return FutureBuilder<Profile>(
child: FutureBuilder<Profile>( future: matrix.client.fetchOwnProfile(),
future: matrix.client.fetchOwnProfile(), builder: (context, snapshot) => Stack(
builder: (context, snapshot) => Stack( alignment: Alignment.center,
alignment: Alignment.center, children: [
children: [ ...List.generate(
...List.generate( clientCount,
clientCount, (index) => KeyBoardShortcuts(
(index) => KeyBoardShortcuts( keysToPress: _buildKeyboardShortcut(index + 1),
keysToPress: _buildKeyboardShortcut(index + 1), helpLabel: L10n.of(context)!.switchToAccount(index + 1),
helpLabel: L10n.of(context)!.switchToAccount(index + 1), onKeysPressed: () => _handleKeyboardShortcut(
onKeysPressed: () => _handleKeyboardShortcut(matrix, index), matrix,
child: Container(), index,
context,
),
child: Container(),
),
),
KeyBoardShortcuts(
keysToPress: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.tab
},
helpLabel: L10n.of(context)!.nextAccount,
onKeysPressed: () => _nextAccount(matrix, context),
child: Container(),
),
KeyBoardShortcuts(
keysToPress: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.tab
},
helpLabel: L10n.of(context)!.previousAccount,
onKeysPressed: () => _previousAccount(matrix, context),
child: Container(),
),
Theme(
data: Theme.of(context).copyWith(
useMaterial3: false,
popupMenuTheme: PopupMenuThemeData(
color: Theme.of(context).colorScheme.background,
textStyle: TextStyle(
color: Theme.of(context).colorScheme.onBackground),
elevation: Theme.of(context).appBarTheme.scrolledUnderElevation,
), ),
), ),
KeyBoardShortcuts( child: PopupMenuButton<Object>(
keysToPress: { onSelected: (o) => _clientSelected(o, context),
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.tab
},
helpLabel: L10n.of(context)!.nextAccount,
onKeysPressed: () => _nextAccount(matrix),
child: Container(),
),
KeyBoardShortcuts(
keysToPress: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.tab
},
helpLabel: L10n.of(context)!.previousAccount,
onKeysPressed: () => _previousAccount(matrix),
child: Container(),
),
PopupMenuButton<Object>(
onSelected: _clientSelected,
itemBuilder: _bundleMenuItems, itemBuilder: _bundleMenuItems,
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
@ -147,8 +216,8 @@ class ClientChooserButton extends StatelessWidget {
), ),
), ),
), ),
], ),
), ],
), ),
); );
} }
@ -164,17 +233,46 @@ class ClientChooserButton extends StatelessWidget {
} }
} }
void _clientSelected(Object object) { void _clientSelected(
Object object,
BuildContext context,
) {
if (object is Client) { if (object is Client) {
controller.setActiveClient(object); controller.setActiveClient(object);
} else if (object is String) { } else if (object is String) {
controller.setActiveBundle(object); controller.setActiveBundle(object);
} else if (object == AddAccountAction.addAccount) { } else if (object is SettingsAction) {
controller.addAccountAction(); switch (object) {
case SettingsAction.addAccount:
VRouter.of(context).to('/settings/account');
break;
case SettingsAction.newStory:
VRouter.of(context).to('/stories/create');
break;
case SettingsAction.newGroup:
VRouter.of(context).to('/newgroup');
break;
case SettingsAction.newSpace:
VRouter.of(context).to('/newspace');
break;
case SettingsAction.invite:
FluffyShare.share(
L10n.of(context)!.inviteText(Matrix.of(context).client.userID!,
'https://matrix.to/#/${Matrix.of(context).client.userID}?client=im.fluffychat'),
context);
break;
case SettingsAction.settings:
VRouter.of(context).to('/settings');
break;
}
} }
} }
void _handleKeyboardShortcut(MatrixState matrix, int index) { void _handleKeyboardShortcut(
MatrixState matrix,
int index,
BuildContext context,
) {
final bundles = matrix.accountBundles.keys.toList() final bundles = matrix.accountBundles.keys.toList()
..sort((a, b) => a!.isValidMatrixId == b!.isValidMatrixId ..sort((a, b) => a!.isValidMatrixId == b!.isValidMatrixId
? 0 ? 0
@ -186,20 +284,20 @@ class ClientChooserButton extends StatelessWidget {
int clientCount = 0; int clientCount = 0;
matrix.accountBundles matrix.accountBundles
.forEach((key, value) => clientCount += value.length); .forEach((key, value) => clientCount += value.length);
_handleKeyboardShortcut(matrix, clientCount); _handleKeyboardShortcut(matrix, clientCount, context);
} }
for (final bundleName in bundles) { for (final bundleName in bundles) {
final bundle = matrix.accountBundles[bundleName]; final bundle = matrix.accountBundles[bundleName];
if (bundle != null) { if (bundle != null) {
if (index < bundle.length) { if (index < bundle.length) {
return _clientSelected(bundle[index]!); return _clientSelected(bundle[index]!, context);
} else { } else {
index -= bundle.length; index -= bundle.length;
} }
} }
} }
// if index too high, restarting from 0 // if index too high, restarting from 0
_handleKeyboardShortcut(matrix, 0); _handleKeyboardShortcut(matrix, 0, context);
} }
int? _shortcutIndexOfClient(MatrixState matrix, Client client) { int? _shortcutIndexOfClient(MatrixState matrix, Client client) {
@ -223,17 +321,24 @@ class ClientChooserButton extends StatelessWidget {
return null; return null;
} }
void _nextAccount(MatrixState matrix) { void _nextAccount(MatrixState matrix, BuildContext context) {
final client = matrix.client; final client = matrix.client;
final lastIndex = _shortcutIndexOfClient(matrix, client); final lastIndex = _shortcutIndexOfClient(matrix, client);
_handleKeyboardShortcut(matrix, lastIndex! + 1); _handleKeyboardShortcut(matrix, lastIndex! + 1, context);
} }
void _previousAccount(MatrixState matrix) { void _previousAccount(MatrixState matrix, BuildContext context) {
final client = matrix.client; final client = matrix.client;
final lastIndex = _shortcutIndexOfClient(matrix, client); final lastIndex = _shortcutIndexOfClient(matrix, client);
_handleKeyboardShortcut(matrix, lastIndex! - 1); _handleKeyboardShortcut(matrix, lastIndex! - 1, context);
} }
} }
enum AddAccountAction { addAccount } enum SettingsAction {
addAccount,
newStory,
newGroup,
newSpace,
invite,
settings,
}

View file

@ -16,14 +16,8 @@ extension ClientStoriesExtension on Client {
room.unsafeGetUserFromMemoryOrFallback(room.directChatMatrixID!)) room.unsafeGetUserFromMemoryOrFallback(room.directChatMatrixID!))
.toList(); .toList();
List<Room> get storiesRooms => rooms List<Room> get storiesRooms =>
.where((room) => rooms.where((room) => room.isStoryRoom).toList();
room
.getState(EventTypes.RoomCreate)
?.content
.tryGet<String>('type') ==
storiesRoomType)
.toList();
Future<List<User>> getUndecidedContactsForStories(Room? storiesRoom) async { Future<List<User>> getUndecidedContactsForStories(Room? storiesRoom) async {
if (storiesRoom == null) return contacts; if (storiesRoom == null) return contacts;
@ -96,3 +90,9 @@ extension ClientStoriesExtension on Client {
.toList()); .toList());
} }
} }
extension StoryRoom on Room {
bool get isStoryRoom =>
getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') ==
ClientStoriesExtension.storiesRoomType;
}

View file

@ -62,18 +62,14 @@ class FluffyChatAppState extends State<FluffyChatApp> {
initial: AdaptiveThemeMode.system, initial: AdaptiveThemeMode.system,
builder: (theme, darkTheme) => LayoutBuilder( builder: (theme, darkTheme) => LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
const maxColumns = 3; final isColumnMode =
var newColumns = FluffyThemes.isColumnModeByWidth(constraints.maxWidth);
(constraints.maxWidth / FluffyThemes.columnWidth).floor(); if (isColumnMode != columnMode) {
if (newColumns > maxColumns) newColumns = maxColumns; Logs().v('Set Column Mode = $isColumnMode');
columnMode ??= newColumns > 1;
_router ??= GlobalKey<VRouterState>();
if (columnMode != newColumns > 1) {
Logs().v('Set Column Mode = $columnMode');
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() { setState(() {
_initialUrl = _router?.currentState?.url; _initialUrl = _router?.currentState?.url;
columnMode = newColumns > 1; columnMode = isColumnMode;
_router = GlobalKey<VRouterState>(); _router = GlobalKey<VRouterState>();
}); });
}); });

View file

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../config/themes.dart';
class TwoColumnLayout extends StatelessWidget { class TwoColumnLayout extends StatelessWidget {
final Widget mainView; final Widget mainView;
final Widget sideView; final Widget sideView;
@ -18,7 +20,8 @@ class TwoColumnLayout extends StatelessWidget {
Container( Container(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(), decoration: const BoxDecoration(),
width: 360.0, width: 360.0 +
(FluffyThemes.getDisplayNavigationRail(context) ? 64 : 0),
child: mainView, child: mainView,
), ),
Container( Container(