feat: improve spaces

- support to show spaces in a list
- add a beautiful animation

This MR makes Spaces much easier to use on desktops and allows to better
find the right space in case they have no avatar.

There will be another MR builting on this work as soon as
https://gitlab.com/famedly/company/frontend/libraries/matrix_api_lite/-/merge_requests/58
is merged.

Signed-off-by: TheOneWithTheBraid <the-one@with-the-braid.cf>
This commit is contained in:
TheOneWithTheBraid 2022-04-02 16:18:36 +02:00
commit 44ffaa1b41
8 changed files with 488 additions and 269 deletions

View file

@ -8,11 +8,13 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:snapping_sheet/snapping_sheet.dart';
import 'package:uni_links/uni_links.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat_list/chat_list_view.dart';
import 'package:fluffychat/pages/chat_list/spaces_bottom_bar.dart';
import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/platform_infos.dart';
@ -40,7 +42,7 @@ class ChatList extends StatefulWidget {
ChatListController createState() => ChatListController();
}
class ChatListController extends State<ChatList> {
class ChatListController extends State<ChatList> with TickerProviderStateMixin {
StreamSubscription? _intentDataStreamSubscription;
StreamSubscription? _intentFileStreamSubscription;
@ -54,6 +56,8 @@ class ChatListController extends State<ChatList> {
return (id == null || !id.stillValid(context)) ? defaultSpacesEntry : id;
}
BoxConstraints? snappingSheetContainerSize;
String? get activeSpaceId => activeSpacesEntry.getSpace(context)?.id;
final ScrollController scrollController = ScrollController();
@ -61,6 +65,10 @@ class ChatListController extends State<ChatList> {
final StreamController<Client> _clientStream = StreamController.broadcast();
SnappingSheetController snappingSheetController = SnappingSheetController();
ScrollController snappingSheetScrollContentController = ScrollController();
Stream<Client> get clientStream => _clientStream.stream;
void _onScroll() {
@ -72,7 +80,10 @@ class ChatListController extends State<ChatList> {
}
}
void setActiveSpacesEntry(BuildContext context, SpacesEntry spaceId) {
void setActiveSpacesEntry(BuildContext context, SpacesEntry? spaceId) {
if (snappingSheetController.currentPosition != kSpacesBottomBarHeight) {
snapBackSpacesSheet();
}
setState(() => _activeSpacesEntry = spaceId);
}
@ -480,6 +491,8 @@ class ChatListController extends State<ChatList> {
VRouter.of(context).to('/rooms');
setState(() {
_activeSpacesEntry = null;
snappingSheetController = SnappingSheetController();
snappingSheetScrollContentController = ScrollController();
selectedRoomIds.clear();
Matrix.of(context).setActiveClient(client);
});
@ -575,6 +588,21 @@ class ChatListController extends State<ChatList> {
void _hackyWebRTCFixForWeb() {
Matrix.of(context).voipPlugin?.context = context;
}
void snapBackSpacesSheet() {
snappingSheetController.snapToPosition(
const SnappingPosition.pixels(
positionPixels: kSpacesBottomBarHeight,
snappingDuration: Duration(milliseconds: 500),
),
);
}
expandSpaces() {
snappingSheetController.snapToPosition(
const SnappingPosition.factor(positionFactor: 0.5),
);
}
}
enum EditBundleAction { addToBundle, removeFromBundle }

View file

@ -8,6 +8,7 @@ import 'package:animations/animations.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:keyboard_shortcuts/keyboard_shortcuts.dart';
import 'package:matrix/matrix.dart';
import 'package:snapping_sheet/snapping_sheet.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/config/app_config.dart';
@ -28,203 +29,240 @@ class ChatListView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<Object?>(
stream: Matrix.of(context).onShareContentChanged.stream,
builder: (_, __) {
final selectMode = controller.selectMode;
return VWidgetGuard(
onSystemPop: (redirector) async {
final selMode = controller.selectMode;
if (selMode != SelectMode.normal) controller.cancelAction();
if (selMode == SelectMode.select) redirector.stopRedirection();
},
child: Scaffold(
appBar: AppBar(
elevation: controller.scrolledToTop ? 0 : null,
actionsIconTheme: IconThemeData(
color: controller.selectedRoomIds.isEmpty
? null
: Theme.of(context).colorScheme.primary,
),
leading: Matrix.of(context).isMultiAccount
? ClientChooserButton(controller)
: selectMode == SelectMode.normal
? null
: IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelAction,
color: Theme.of(context).colorScheme.primary,
),
centerTitle: false,
actions: selectMode == SelectMode.share
stream: Matrix.of(context).onShareContentChanged.stream,
builder: (_, __) {
final selectMode = controller.selectMode;
final showSpaces =
controller.spaces.isNotEmpty && controller.selectedRoomIds.isEmpty;
return VWidgetGuard(
onSystemPop: (redirector) async {
final selMode = controller.selectMode;
if (selMode != SelectMode.normal) controller.cancelAction();
if (selMode == SelectMode.select) redirector.stopRedirection();
},
child: Scaffold(
appBar: AppBar(
elevation: controller.scrolledToTop ? 0 : null,
actionsIconTheme: IconThemeData(
color: controller.selectedRoomIds.isEmpty
? null
: selectMode == SelectMode.select
? [
if (controller.spaces.isNotEmpty)
IconButton(
tooltip: L10n.of(context)!.addToSpace,
icon: const Icon(Icons.group_work_outlined),
onPressed: controller.addOrRemoveToSpace,
),
IconButton(
tooltip: L10n.of(context)!.toggleUnread,
icon: Icon(
controller.anySelectedRoomNotMarkedUnread
? Icons.mark_chat_read_outlined
: Icons.mark_chat_unread_outlined),
onPressed: controller.toggleUnread,
),
IconButton(
tooltip: L10n.of(context)!.toggleFavorite,
icon: Icon(controller.anySelectedRoomNotFavorite
? Icons.push_pin_outlined
: Icons.push_pin),
onPressed: controller.toggleFavouriteRoom,
),
IconButton(
icon: Icon(controller.anySelectedRoomNotMuted
? Icons.notifications_off_outlined
: Icons.notifications_outlined),
tooltip: L10n.of(context)!.toggleMuted,
onPressed: controller.toggleMuted,
),
IconButton(
icon: const Icon(Icons.delete_outlined),
tooltip: L10n.of(context)!.archive,
onPressed: controller.archiveAction,
),
]
: [
KeyBoardShortcuts(
keysToPress: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.keyF
},
onKeysPressed: () =>
VRouter.of(context).to('/search'),
helpLabel: L10n.of(context)!.search,
child: IconButton(
icon: const Icon(Icons.search_outlined),
tooltip: L10n.of(context)!.search,
onPressed: () =>
VRouter.of(context).to('/search'),
),
),
if (selectMode == SelectMode.normal)
IconButton(
icon: const Icon(Icons.camera_alt_outlined),
tooltip: L10n.of(context)!.addToStory,
onPressed: () =>
VRouter.of(context).to('/stories/create'),
),
PopupMenuButton<PopupMenuAction>(
onSelected: controller.onPopupMenuSelect,
itemBuilder: (_) => [
PopupMenuItem(
value: PopupMenuAction.setStatus,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.edit_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.setStatus),
],
),
),
PopupMenuItem(
value: PopupMenuAction.newGroup,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.group_add_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.createNewGroup),
],
),
),
PopupMenuItem(
value: PopupMenuAction.newSpace,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.group_work_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.createNewSpace),
],
),
),
PopupMenuItem(
value: PopupMenuAction.invite,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.share_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.inviteContact),
],
),
),
PopupMenuItem(
value: PopupMenuAction.archive,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.archive_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.archive),
],
),
),
PopupMenuItem(
value: PopupMenuAction.settings,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.settings_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.settings),
],
),
),
],
),
],
title: Text(selectMode == SelectMode.share
? L10n.of(context)!.share
: selectMode == SelectMode.select
? controller.selectedRoomIds.length.toString()
: controller.activeSpaceId == null
? AppConfig.applicationName
: Matrix.of(context)
.client
.getRoomById(controller.activeSpaceId!)!
.displayname),
: Theme.of(context).colorScheme.primary,
),
body: Column(children: [
AnimatedContainer(
height: controller.showChatBackupBanner ? 54 : 0,
duration: const Duration(milliseconds: 300),
clipBehavior: Clip.hardEdge,
curve: Curves.bounceInOut,
decoration: const BoxDecoration(),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: Image.asset(
'assets/backup.png',
fit: BoxFit.contain,
width: 44,
leading: Matrix.of(context).isMultiAccount
? ClientChooserButton(controller)
: selectMode == SelectMode.normal
? null
: IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelAction,
color: Theme.of(context).colorScheme.primary,
),
centerTitle: false,
actions: selectMode == SelectMode.share
? null
: selectMode == SelectMode.select
? [
if (controller.spaces.isNotEmpty)
IconButton(
tooltip: L10n.of(context)!.addToSpace,
icon: const Icon(Icons.group_work_outlined),
onPressed: controller.addOrRemoveToSpace,
),
IconButton(
tooltip: L10n.of(context)!.toggleUnread,
icon: Icon(controller.anySelectedRoomNotMarkedUnread
? Icons.mark_chat_read_outlined
: Icons.mark_chat_unread_outlined),
onPressed: controller.toggleUnread,
),
IconButton(
tooltip: L10n.of(context)!.toggleFavorite,
icon: Icon(controller.anySelectedRoomNotFavorite
? Icons.push_pin_outlined
: Icons.push_pin),
onPressed: controller.toggleFavouriteRoom,
),
IconButton(
icon: Icon(controller.anySelectedRoomNotMuted
? Icons.notifications_off_outlined
: Icons.notifications_outlined),
tooltip: L10n.of(context)!.toggleMuted,
onPressed: controller.toggleMuted,
),
IconButton(
icon: const Icon(Icons.delete_outlined),
tooltip: L10n.of(context)!.archive,
onPressed: controller.archiveAction,
),
]
: [
KeyBoardShortcuts(
keysToPress: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.keyF
},
onKeysPressed: () =>
VRouter.of(context).to('/search'),
helpLabel: L10n.of(context)!.search,
child: IconButton(
icon: const Icon(Icons.search_outlined),
tooltip: L10n.of(context)!.search,
onPressed: () =>
VRouter.of(context).to('/search'),
),
),
if (selectMode == SelectMode.normal)
IconButton(
icon: const Icon(Icons.camera_alt_outlined),
tooltip: L10n.of(context)!.addToStory,
onPressed: () =>
VRouter.of(context).to('/stories/create'),
),
PopupMenuButton<PopupMenuAction>(
onSelected: controller.onPopupMenuSelect,
itemBuilder: (_) => [
PopupMenuItem(
value: PopupMenuAction.setStatus,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.edit_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.setStatus),
],
),
),
PopupMenuItem(
value: PopupMenuAction.newGroup,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.group_add_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.createNewGroup),
],
),
),
PopupMenuItem(
value: PopupMenuAction.newSpace,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.group_work_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.createNewSpace),
],
),
),
PopupMenuItem(
value: PopupMenuAction.invite,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.share_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.inviteContact),
],
),
),
PopupMenuItem(
value: PopupMenuAction.archive,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.archive_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.archive),
],
),
),
PopupMenuItem(
value: PopupMenuAction.settings,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.settings_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.settings),
],
),
),
],
),
],
title: Text(selectMode == SelectMode.share
? L10n.of(context)!.share
: selectMode == SelectMode.select
? controller.selectedRoomIds.length.toString()
: controller.activeSpaceId == null
? AppConfig.applicationName
: Matrix.of(context)
.client
.getRoomById(controller.activeSpaceId!)!
.displayname),
),
body: LayoutBuilder(
builder: (context, size) {
controller.snappingSheetContainerSize = size;
return SnappingSheet(
key: ValueKey(Matrix.of(context).client.userID.toString() +
showSpaces.toString()),
controller: controller.snappingSheetController,
child: Column(
children: [
AnimatedContainer(
height: controller.showChatBackupBanner ? 54 : 0,
duration: const Duration(milliseconds: 300),
clipBehavior: Clip.hardEdge,
curve: Curves.bounceInOut,
decoration: const BoxDecoration(),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: Image.asset(
'assets/backup.png',
fit: BoxFit.contain,
width: 44,
),
title: Text(L10n.of(context)!.setupChatBackupNow),
trailing: const Icon(Icons.chevron_right_outlined),
onTap: controller.firstRunBootstrapAction,
),
),
),
title: Text(L10n.of(context)!.setupChatBackupNow),
trailing: const Icon(Icons.chevron_right_outlined),
onTap: controller.firstRunBootstrapAction,
),
Expanded(child: _ChatListViewBody(controller)),
],
),
),
Expanded(child: _ChatListViewBody(controller)),
]),
floatingActionButton: selectMode == SelectMode.normal
? KeyBoardShortcuts(
initialSnappingPosition: showSpaces
? const SnappingPosition.pixels(
positionPixels: kSpacesBottomBarHeight)
: const SnappingPosition.factor(positionFactor: 0.0),
snappingPositions: showSpaces
? const [
SnappingPosition.pixels(
positionPixels: kSpacesBottomBarHeight),
SnappingPosition.factor(positionFactor: 0.5),
SnappingPosition.factor(positionFactor: 0.9),
]
: [const SnappingPosition.factor(positionFactor: 0.0)],
sheetBelow: showSpaces
? SnappingSheetContent(
childScrollController:
controller.snappingSheetScrollContentController,
draggable: true,
child: SpacesBottomBar(controller),
)
: null,
);
},
),
floatingActionButton: selectMode == SelectMode.normal
? Padding(
padding: showSpaces
? const EdgeInsets.only(bottom: 64.0)
: const EdgeInsets.all(0),
child: KeyBoardShortcuts(
child: FloatingActionButton.extended(
isExtended: controller.scrolledToTop,
onPressed: () =>
@ -239,20 +277,14 @@ class ChatListView extends StatelessWidget {
onKeysPressed: () =>
VRouter.of(context).to('/newprivatechat'),
helpLabel: L10n.of(context)!.newChat,
)
: null,
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ConnectionStatusHeader(),
if (controller.spaces.isNotEmpty &&
controller.selectedRoomIds.isEmpty)
SpacesBottomBar(controller),
],
),
),
);
});
),
)
: null,
bottomNavigationBar: const ConnectionStatusHeader(),
),
);
},
);
}
}

View file

@ -1,53 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:salomon_bottom_bar/salomon_bottom_bar.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/spaces_drawer.dart';
import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
const kSpacesBottomBarHeight = 56.0;
class SpacesBottomBar extends StatelessWidget {
final ChatListController controller;
const SpacesBottomBar(this.controller, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final foundIndex = controller.spacesEntries.indexWhere(
(se) => spacesEntryRoughEquivalence(se, controller.activeSpacesEntry));
final currentIndex = foundIndex == -1 ? 0 : foundIndex;
return Material(
color: Theme.of(context).appBarTheme.backgroundColor,
color: Theme.of(context).navigationBarTheme.backgroundColor,
elevation: 6,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppConfig.borderRadius)),
clipBehavior: Clip.hardEdge,
child: SafeArea(
child: StreamBuilder<Object>(
stream: Matrix.of(context).client.onSync.stream.where((sync) =>
(sync.rooms?.join?.values.any((r) =>
r.state?.any((s) => s.type.startsWith('m.space')) ??
false) ??
false) ||
(sync.rooms?.leave?.isNotEmpty ?? false)),
builder: (context, snapshot) {
return Container(
height: 56,
alignment: Alignment.center,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SalomonBottomBar(
itemPadding: const EdgeInsets.all(8),
currentIndex: currentIndex,
onTap: (i) => controller.setActiveSpacesEntry(
stream: Matrix.of(context).client.onSync.stream.where((sync) =>
(sync.rooms?.join?.values.any((r) =>
r.state?.any((s) => s.type.startsWith('m.space')) ??
false) ??
false) ||
(sync.rooms?.leave?.isNotEmpty ?? false)),
builder: (context, snapshot) {
return SingleChildScrollView(
controller: controller.snappingSheetScrollContentController,
child: AnimatedBuilder(
child: _SpacesBottomNavigation(controller: controller),
builder: (context, child) {
if (controller.snappingSheetContainerSize == null) {
return child!;
}
final rawPosition =
controller.snappingSheetController.currentPosition;
final position = rawPosition /
controller.snappingSheetContainerSize!.maxHeight;
if (rawPosition <= kSpacesBottomBarHeight) {
return child!;
} else if (position >= 0.5) {
return SpacesDrawer(controller: controller);
} else {
final normalized = (rawPosition - kSpacesBottomBarHeight) /
(controller.snappingSheetContainerSize!.maxHeight -
kSpacesBottomBarHeight) *
2;
var boxHeight = (1 - normalized) * kSpacesBottomBarHeight;
if (boxHeight < 0) boxHeight = 0;
return Column(
children: [
SizedBox(
height: boxHeight,
child: ClipRect(
clipBehavior: Clip.hardEdge,
child: Opacity(
opacity: 1 - normalized, child: child!)),
),
Opacity(
opacity: normalized,
child: SpacesDrawer(controller: controller),
),
],
);
}
},
animation: controller.snappingSheetController,
),
);
},
),
),
);
}
}
class _SpacesBottomNavigation extends StatelessWidget {
final ChatListController controller;
const _SpacesBottomNavigation({Key? key, required this.controller})
: super(key: key);
@override
Widget build(BuildContext context) {
final currentIndex = controller.activeSpaceId == null
? 1
: controller.spaces
.indexWhere((space) => controller.activeSpaceId == space.id) +
2;
return Container(
height: 56,
alignment: Alignment.center,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SalomonBottomBar(
itemPadding: const EdgeInsets.all(8),
currentIndex: currentIndex,
onTap: (i) => i == 0
? controller.expandSpaces()
: i == 1
? controller.setActiveSpacesEntry(
context,
null,
)
: controller.setActiveSpacesEntry(
context,
controller.spacesEntries[i],
),
selectedItemColor: Theme.of(context).colorScheme.primary,
items: controller.spacesEntries
.map((entry) => _buildSpacesEntryUI(context, entry))
.toList(),
),
),
);
}),
selectedItemColor: Theme.of(context).colorScheme.primary,
items: [
SalomonBottomBarItem(
icon: const Icon(Icons.keyboard_arrow_up),
title: Text(L10n.of(context)!.showSpaces),
),
...controller.spacesEntries
.map((space) => _buildSpacesEntryUI(context, space))
.toList(),
],
),
),
);
}

View file

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'chat_list.dart';
class SpacesDrawer extends StatelessWidget {
final ChatListController controller;
const SpacesDrawer({Key? key, required this.controller}) : super(key: key);
@override
Widget build(BuildContext context) {
final currentIndex = controller.activeSpaceId == null
? 0
: controller.spaces
.indexWhere((space) => controller.activeSpaceId == space.id) +
1;
final Map<SpacesEntry, dynamic> spaceHierarchy =
Map.fromEntries(controller.spacesEntries.map((e) => MapEntry(e, null)));
// TODO(TheOeWithTheBraid): wait for space hierarchy https://gitlab.com/famedly/company/frontend/libraries/matrix_api_lite/-/merge_requests/58
return WillPopScope(
onWillPop: () async {
controller.snapBackSpacesSheet();
return false;
},
child: ListView.builder(
shrinkWrap: true,
itemCount: spaceHierarchy.length,
itemBuilder: (BuildContext context, int index) {
if (index == 0) {
return ListTile(
selected: currentIndex == index,
leading: const Icon(Icons.keyboard_arrow_down),
title: Text(L10n.of(context)!.allChats),
onTap: () => controller.setActiveSpacesEntry(
context,
null,
),
);
} else {
final space = spaceHierarchy.keys.toList()[index];
final room = space.getSpace(context)!;
return ListTile(
selected: currentIndex == index,
leading: Avatar(
mxContent: room.avatar,
name: space.getName(context),
size: 24,
fontSize: 12,
),
title: Text(space.getName(context)),
subtitle: room.topic.isEmpty
? null
: Tooltip(
message: room.topic,
child: Text(
room.topic.replaceAll('\n', ' '),
softWrap: false,
overflow: TextOverflow.fade,
),
),
onTap: () => controller.setActiveSpacesEntry(
context,
space,
),
trailing: IconButton(
icon: const Icon(Icons.edit),
tooltip: L10n.of(context)!.edit,
onPressed: () => controller.editSpace(context, room.id),
),
);
}
},
),
);
}
}