feat: New material 3 design

This commit is contained in:
Christian Pauly 2022-07-07 18:50:13 +02:00
commit ccfda5bc96
28 changed files with 817 additions and 1307 deletions

View file

@ -14,9 +14,9 @@ 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/famedlysdk_store.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import '../../../utils/account_bundles.dart';
import '../../main.dart';
@ -52,6 +52,97 @@ class ChatListController extends State<ChatList> with TickerProviderStateMixin {
SpacesEntry? _activeSpacesEntry;
bool isSearchMode = false;
Future<QueryPublicRoomsResponse>? publicRoomsResponse;
String? searchServer;
Timer? _coolDown;
SearchUserDirectoryResponse? userSearchResult;
QueryPublicRoomsResponse? roomSearchResult;
bool isSearching = false;
static const String _serverStoreNamespace = 'im.fluffychat.search.server';
void setServer() async {
final newServer = await showTextInputDialog(
useRootNavigator: false,
title: L10n.of(context)!.changeTheHomeserver,
context: context,
okLabel: L10n.of(context)!.ok,
cancelLabel: L10n.of(context)!.cancel,
textFields: [
DialogTextField(
prefixText: 'https://',
hintText: Matrix.of(context).client.homeserver?.host,
initialText: searchServer,
keyboardType: TextInputType.url,
autocorrect: false)
]);
if (newServer == null) return;
Store().setItem(_serverStoreNamespace, newServer.single);
setState(() {
searchServer = newServer.single;
});
onSearchEnter(searchController.text);
}
final TextEditingController searchController = TextEditingController();
void _search() async {
final client = Matrix.of(context).client;
if (!isSearching) {
setState(() {
isSearching = true;
});
}
SearchUserDirectoryResponse? userSearchResult;
QueryPublicRoomsResponse? roomSearchResult;
try {
roomSearchResult = await client.queryPublicRooms(
server: searchServer,
filter: PublicRoomQueryFilter(genericSearchTerm: searchController.text),
limit: 20,
);
userSearchResult = await client.searchUserDirectory(
searchController.text,
limit: 20,
);
} catch (e, s) {
Logs().w('Searching has crashed', e, s);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e.toLocalizedString(context),
),
),
);
}
setState(() {
isSearching = false;
this.roomSearchResult = roomSearchResult;
this.userSearchResult = userSearchResult;
});
}
void onSearchEnter(String text) {
if (text.isEmpty) {
cancelSearch();
return;
}
setState(() {
isSearchMode = true;
});
_coolDown?.cancel();
_coolDown = Timer(const Duration(milliseconds: 500), _search);
}
void cancelSearch() => setState(() {
searchController.clear();
isSearchMode = false;
roomSearchResult = userSearchResult = null;
isSearching = false;
});
SpacesEntry get activeSpacesEntry {
final id = _activeSpacesEntry;
return (id == null || !id.stillValid(context)) ? defaultSpacesEntry : id;
@ -72,6 +163,8 @@ class ChatListController extends State<ChatList> with TickerProviderStateMixin {
Stream<Client> get clientStream => _clientStream.stream;
void addAccountAction() => VRouter.of(context).to('/settings/account/add');
void _onScroll() {
final newScrolledToTop = scrollController.position.pixels <= 0;
if (newScrolledToTop != scrolledToTop) {
@ -82,12 +175,7 @@ class ChatListController extends State<ChatList> with TickerProviderStateMixin {
}
void setActiveSpacesEntry(BuildContext context, SpacesEntry? spaceId) {
if ((snappingSheetController.isAttached
? snappingSheetController.currentPosition
: 0) !=
kSpacesBottomBarHeight) {
snapBackSpacesSheet();
}
Scaffold.of(context).closeDrawer();
setState(() => _activeSpacesEntry = spaceId);
}
@ -212,6 +300,10 @@ class ChatListController extends State<ChatList> with TickerProviderStateMixin {
scrollController.addListener(_onScroll);
_waitForFirstSync();
_hackyWebRTCFixForWeb();
WidgetsBinding.instance.addPostFrameCallback((_) async {
searchServer = await Store().getItem(_serverStoreNamespace);
});
super.initState();
}
@ -338,32 +430,6 @@ class ChatListController extends State<ChatList> with TickerProviderStateMixin {
);
}
void onPopupMenuSelect(action) {
switch (action) {
case PopupMenuAction.setStatus:
setStatus();
break;
case PopupMenuAction.settings:
VRouter.of(context).to('/settings');
break;
case PopupMenuAction.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 PopupMenuAction.newGroup:
VRouter.of(context).to('/newgroup');
break;
case PopupMenuAction.newSpace:
VRouter.of(context).to('/newspace');
break;
case PopupMenuAction.archive:
VRouter.of(context).to('/archive');
break;
}
}
Future<void> _archiveSelectedRooms() async {
final client = Matrix.of(context).client;
while (selectedRoomIds.isNotEmpty) {
@ -593,15 +659,6 @@ class ChatListController extends State<ChatList> with TickerProviderStateMixin {
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),

View file

@ -8,9 +8,12 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pages/chat_list/spaces_bottom_bar.dart';
import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
import 'package:fluffychat/pages/chat_list/stories_header.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/connection_status_header.dart';
import 'package:fluffychat/widgets/profile_bottom_sheet.dart';
import 'package:fluffychat/widgets/public_room_bottom_sheet.dart';
import '../../utils/stream_extension.dart';
import '../../widgets/matrix.dart';
@ -46,6 +49,8 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
@override
Widget build(BuildContext context) {
final reversed = !_animationReversed();
final roomSearchResult = widget.controller.roomSearchResult;
final userSearchResult = widget.controller.userSearchResult;
Widget child;
if (widget.controller.waitForFirstSync &&
Matrix.of(context).client.prevBatch != null) {
@ -86,13 +91,113 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
itemBuilder: (BuildContext context, int i) {
if (displayStoriesHeader) {
if (i == 0) {
return const StoriesHeader();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const ConnectionStatusHeader(),
if (roomSearchResult != null) ...[
_SearchTitle(title: L10n.of(context)!.publicRooms),
AnimatedContainer(
height: roomSearchResult.chunk.isEmpty ? 0 : 106,
duration: const Duration(milliseconds: 250),
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: roomSearchResult.chunk.length,
itemBuilder: (context, i) => _SearchItem(
title: roomSearchResult.chunk[i].name ??
roomSearchResult
.chunk[i].canonicalAlias?.localpart ??
L10n.of(context)!.group,
avatar: roomSearchResult.chunk[i].avatarUrl,
onPressed: () => showModalBottomSheet(
context: context,
builder: (c) => PublicRoomBottomSheet(
roomAlias:
roomSearchResult.chunk[i].canonicalAlias ??
roomSearchResult.chunk[i].roomId,
outerContext: context,
chunk: roomSearchResult.chunk[i],
),
),
),
),
),
],
if (userSearchResult != null) ...[
_SearchTitle(title: L10n.of(context)!.users),
AnimatedContainer(
height: userSearchResult.results.isEmpty ? 0 : 106,
duration: const Duration(milliseconds: 250),
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: userSearchResult.results.length,
itemBuilder: (context, i) => _SearchItem(
title: userSearchResult.results[i].displayName ??
userSearchResult.results[i].userId.localpart ??
L10n.of(context)!.unknownDevice,
avatar: userSearchResult.results[i].avatarUrl,
onPressed: () => showModalBottomSheet(
context: context,
builder: (c) => ProfileBottomSheet(
userId: userSearchResult.results[i].userId,
outerContext: context,
),
),
),
),
),
],
if (widget.controller.isSearchMode)
_SearchTitle(title: L10n.of(context)!.stories),
StoriesHeader(
filter: widget.controller.searchController.text,
),
AnimatedContainer(
height: !widget.controller.isSearchMode &&
widget.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,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
trailing: const Icon(Icons.chevron_right_outlined),
onTap: widget.controller.firstRunBootstrapAction,
),
),
),
if (widget.controller.isSearchMode)
_SearchTitle(title: L10n.of(context)!.chats),
],
);
}
i--;
}
if (i >= rooms.length) {
return const ListTile();
}
if (!rooms[i].displayname.toLowerCase().contains(
widget.controller.searchController.text.toLowerCase())) {
return Container();
}
return ChatListItem(
rooms[i],
selected: widget.controller.selectedRoomIds.contains(rooms[i].id),
@ -176,13 +281,7 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: (widget.controller.snappingSheetController.isAttached
? widget
.controller.snappingSheetController.currentPosition
: 0) ==
kSpacesBottomBarHeight
? SharedAxisTransitionType.horizontal
: SharedAxisTransitionType.vertical,
transitionType: SharedAxisTransitionType.vertical,
fillColor: Theme.of(context).scaffoldBackgroundColor,
child: child,
);
@ -221,3 +320,77 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
return reversed;
}
}
class _SearchTitle extends StatelessWidget {
final String title;
const _SearchTitle({required this.title, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
)),
color: Theme.of(context).colorScheme.surface,
),
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(title,
textAlign: TextAlign.left,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 12,
fontWeight: FontWeight.bold,
)),
),
),
);
}
class _SearchItem extends StatelessWidget {
final String title;
final Uri? avatar;
final void Function() onPressed;
const _SearchItem({
required this.title,
this.avatar,
required this.onPressed,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) => InkWell(
onTap: onPressed,
child: SizedBox(
width: 84,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Avatar(
mxContent: avatar,
name: title,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
title,
maxLines: 2,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12,
),
),
),
],
),
),
);
}

View file

@ -0,0 +1,67 @@
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/matrix.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: [
Expanded(
child: SpacesDrawer(
controller: controller,
),
),
const Divider(),
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.adaptive.share_outlined,
color: Theme.of(context).colorScheme.onBackground,
),
title: Text(L10n.of(context)!.inviteContact),
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);
},
),
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,15 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:animations/animations.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:keyboard_shortcuts/keyboard_shortcuts.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/client_chooser_button.dart';
import '../../widgets/matrix.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
final ChatListController controller;
@ -21,23 +17,92 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
final selectMode = controller.selectMode;
return 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,
titleSpacing: 8,
automaticallyImplyLeading: false,
leading: 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,
),
title: selectMode == SelectMode.share
? Text(
L10n.of(context)!.share,
key: const ValueKey(SelectMode.share),
)
: selectMode == SelectMode.select
? Text(
controller.selectedRoomIds.length.toString(),
key: const ValueKey(SelectMode.select),
)
: TextField(
controller: controller.searchController,
textInputAction: TextInputAction.search,
onChanged: controller.onSearchEnter,
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(90),
borderSide: BorderSide.none,
),
hintText: L10n.of(context)!.search,
prefixIcon: controller.isSearchMode
? IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelSearch,
color: Theme.of(context).colorScheme.primary,
)
: IconButton(
onPressed: Scaffold.of(context).openDrawer,
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),
],
),
),
),
centerTitle: false,
actions: selectMode == SelectMode.share
? null
: selectMode == SelectMode.select
@ -75,138 +140,7 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
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: [
Icon(Icons.adaptive.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: PageTransitionSwitcher(
reverse: false,
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.scaled,
fillColor: Colors.transparent,
child: child,
);
},
layoutBuilder: (children) => Stack(
alignment: AlignmentDirectional.centerStart,
children: children,
),
child: selectMode == SelectMode.share
? Text(
L10n.of(context)!.share,
key: const ValueKey(SelectMode.share),
)
: selectMode == SelectMode.select
? Text(
controller.selectedRoomIds.length.toString(),
key: const ValueKey(SelectMode.select),
)
: (() {
final name = controller.activeSpaceId == null
? AppConfig.applicationName
: Matrix.of(context)
.client
.getRoomById(controller.activeSpaceId!)!
.displayname;
return Text(name, key: ValueKey(name));
})(),
),
: null,
);
}

View file

@ -4,12 +4,10 @@ import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:keyboard_shortcuts/keyboard_shortcuts.dart';
import 'package:snapping_sheet/snapping_sheet.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/spaces_bottom_bar.dart';
import 'package:fluffychat/widgets/connection_status_header.dart';
import 'package:fluffychat/pages/chat_list/chat_list_drawer.dart';
import '../../widgets/matrix.dart';
import 'chat_list_body.dart';
import 'chat_list_header.dart';
@ -25,8 +23,6 @@ class ChatListView extends StatelessWidget {
stream: Matrix.of(context).onShareContentChanged.stream,
builder: (_, __) {
final selectMode = controller.selectMode;
final showSpaces = controller.spacesEntries.length > 1 &&
controller.selectedRoomIds.isEmpty;
return VWidgetGuard(
onSystemPop: (redirector) async {
final selMode = controller.selectMode;
@ -35,87 +31,28 @@ class ChatListView extends StatelessWidget {
},
child: Scaffold(
appBar: ChatListHeader(controller: controller),
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,
),
),
),
Expanded(child: ChatListViewBody(controller)),
],
),
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,
);
},
),
body: ChatListViewBody(controller),
drawer: ChatListDrawer(controller),
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: () =>
VRouter.of(context).to('/newprivatechat'),
icon: const Icon(CupertinoIcons.chat_bubble),
label: Text(L10n.of(context)!.newChat),
),
keysToPress: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.keyN
},
onKeysPressed: () =>
? KeyBoardShortcuts(
child: FloatingActionButton.extended(
isExtended: controller.scrolledToTop,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
onPressed: () =>
VRouter.of(context).to('/newprivatechat'),
helpLabel: L10n.of(context)!.newChat,
icon: const Icon(CupertinoIcons.chat_bubble),
label: Text(L10n.of(context)!.newChat),
),
keysToPress: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.keyN
},
onKeysPressed: () =>
VRouter.of(context).to('/newprivatechat'),
helpLabel: L10n.of(context)!.newChat,
)
: null,
bottomNavigationBar: const SafeArea(
child: ConnectionStatusHeader(),
),
),
);
},

View file

@ -79,6 +79,16 @@ class ClientChooserButton extends StatelessWidget {
)
.toList(),
],
PopupMenuItem(
value: AddAccountAction.addAccount,
child: Row(
children: [
const Icon(Icons.person_add_outlined),
const SizedBox(width: 18),
Text(L10n.of(context)!.addAccount),
],
),
),
];
}
@ -124,7 +134,8 @@ class ClientChooserButton extends StatelessWidget {
),
PopupMenuButton<Object>(
child: Material(
borderRadius: BorderRadius.zero,
color: Colors.transparent,
borderRadius: BorderRadius.circular(99),
child: Avatar(
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ??
@ -158,6 +169,8 @@ class ClientChooserButton extends StatelessWidget {
controller.setActiveClient(object);
} else if (object is String) {
controller.setActiveBundle(object);
} else if (object == AddAccountAction.addAccount) {
controller.addAccountAction();
}
}
@ -222,3 +235,5 @@ class ClientChooserButton extends StatelessWidget {
_handleKeyboardShortcut(matrix, lastIndex! - 1);
}
}
enum AddAccountAction { addAccount }

View file

@ -1,164 +0,0 @@
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;
final GlobalKey _globalKey = GlobalKey();
class SpacesBottomBar extends StatelessWidget {
final ChatListController controller;
const SpacesBottomBar(this.controller, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Material(
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 SingleChildScrollView(
controller: controller.snappingSheetScrollContentController,
child: AnimatedBuilder(
child: _SpacesBottomNavigation(
key: _globalKey, controller: controller),
builder: (context, child) {
if (controller.snappingSheetContainerSize == null) {
return child!;
}
final rawPosition =
controller.snappingSheetController.isAttached
? controller.snappingSheetController.currentPosition
: 0;
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.spacesEntries.indexWhere((space) =>
controller.activeSpacesEntry.runtimeType == space.runtimeType &&
(controller.activeSpaceId == space.getSpace(context)?.id)) +
1;
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()
: controller.setActiveSpacesEntry(
context,
controller.spacesEntries[i - 1],
),
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(),
],
),
),
);
}
SalomonBottomBarItem _buildSpacesEntryUI(
BuildContext context, SpacesEntry entry) {
final space = entry.getSpace(context);
if (space != null) {
return SalomonBottomBarItem(
icon: InkWell(
borderRadius: BorderRadius.circular(28),
onTap: () => controller.setActiveSpacesEntry(
context,
entry,
),
onLongPress: () => controller.editSpace(context, space.id),
child: Avatar(
mxContent: space.avatar,
name: space.displayname,
size: 24,
fontSize: 12,
),
),
title: Text(entry.getName(context)),
);
}
return SalomonBottomBarItem(
icon: entry.getIcon(false),
activeIcon: entry.getIcon(true),
title: Text(entry.getName(context)),
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
import 'package:fluffychat/widgets/avatar.dart';
@ -22,53 +23,93 @@ class SpacesDrawer extends StatelessWidget {
// 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: Column(
children: List.generate(spaceHierarchy.length, (index) {
final space = spaceHierarchy.keys.toList()[index];
final room = space.getSpace(context);
final active = currentIndex == index;
return ListView.builder(
itemCount: spaceHierarchy.length + 2,
itemBuilder: (context, i) {
if (i == spaceHierarchy.length) {
return ListTile(
selected: active,
leading: index == 0
? const Icon(Icons.keyboard_arrow_down)
: room == null
? space.getIcon(active)
: Avatar(
mxContent: room.avatar,
name: space.getName(context),
size: 24,
fontSize: 12,
),
title: Text(space.getName(context)),
subtitle: room?.topic.isEmpty ?? true
? null
: Tooltip(
message: room!.topic,
child: Text(
room.topic.replaceAll('\n', ' '),
softWrap: false,
overflow: TextOverflow.fade,
),
),
onTap: () => controller.setActiveSpacesEntry(
context,
space,
leading: CircleAvatar(
radius: Avatar.defaultSize / 2,
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
foregroundColor:
Theme.of(context).colorScheme.onSecondaryContainer,
child: const Icon(
Icons.archive_outlined,
),
),
trailing: room != null
? IconButton(
icon: const Icon(Icons.edit),
title: Text(L10n.of(context)!.archive),
onTap: () {
Scaffold.of(context).closeDrawer();
VRouter.of(context).to('/archive');
},
);
}
if (i == spaceHierarchy.length + 1) {
return ListTile(
leading: CircleAvatar(
child: const Icon(Icons.group_work_outlined),
radius: Avatar.defaultSize / 2,
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
foregroundColor:
Theme.of(context).colorScheme.onSecondaryContainer,
),
title: Text(L10n.of(context)!.createNewSpace),
onTap: () {
Scaffold.of(context).closeDrawer();
VRouter.of(context).to('/newspace');
},
);
}
final space = spaceHierarchy.keys.toList()[i];
final room = space.getSpace(context);
final active = currentIndex == i;
return ListTile(
selected: active,
leading: room == null
? CircleAvatar(
child: space.getIcon(active),
radius: Avatar.defaultSize / 2,
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
foregroundColor:
Theme.of(context).colorScheme.onSecondaryContainer,
)
: Avatar(
mxContent: room.avatar,
name: space.getName(context),
),
title: Text(
space.getName(context),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: room?.topic.isEmpty ?? true
? null
: Tooltip(
message: room!.topic,
child: Text(
room.topic.replaceAll('\n', ' '),
softWrap: false,
overflow: TextOverflow.fade,
),
),
onTap: () => controller.setActiveSpacesEntry(
context,
space,
),
trailing: room != null
? SizedBox(
width: 32,
child: IconButton(
splashRadius: 24,
icon: const Icon(Icons.edit_outlined),
tooltip: L10n.of(context)!.edit,
onPressed: () => controller.editSpace(context, room.id),
)
: null,
);
}),
),
),
)
: null,
);
},
);
}
}

View file

@ -18,7 +18,8 @@ enum ContextualRoomAction {
}
class StoriesHeader extends StatelessWidget {
const StoriesHeader({Key? key}) : super(key: key);
final String filter;
const StoriesHeader({required this.filter, Key? key}) : super(key: key);
void _addToStoryAction(BuildContext context) =>
VRouter.of(context).to('/stories/create');
@ -105,7 +106,10 @@ class StoriesHeader extends StatelessWidget {
onTap: () => _addToStoryAction(context),
);
}
if (client.storiesRooms.isEmpty) {
if (client.storiesRooms.isEmpty ||
!client.storiesRooms.any((room) => room.displayname
.toLowerCase()
.contains(filter.toLowerCase()))) {
return Container();
}
final ownStoryRoom = client.storiesRooms
@ -130,6 +134,11 @@ class StoriesHeader extends StatelessWidget {
userId?.localpart ??
'Unknown';
final avatarUrl = snapshot.data?.avatarUrl;
if (!displayname
.toLowerCase()
.contains(filter.toLowerCase())) {
return Container();
}
return _StoryButton(
profile: Profile(
displayName: displayname,
@ -139,7 +148,7 @@ class StoriesHeader extends StatelessWidget {
hasPosts: room.hasPosts || room == ownStoryRoom,
showEditFab: userId == client.userID,
unread: room.membership == Membership.invite ||
room.hasNewMessages,
(room.hasNewMessages && room.hasPosts),
onPressed: () => _goToStoryAction(context, room.id),
onLongPressed: () =>
_contextualActions(context, room),