feat: New navigation design

This commit is contained in:
Christian Pauly 2022-08-30 20:24:36 +02:00
parent 560ee3d39f
commit 57649c70e5
23 changed files with 1024 additions and 1442 deletions

View File

@ -2104,7 +2104,7 @@
"type": "text",
"placeholders": {}
},
"separateChatTypes": "Separate Direct Chats, Groups, and Spaces",
"separateChatTypes": "Separate Direct Chats and Groups",
"@separateChatTypes": {
"type": "text",
"placeholders": {}
@ -2892,5 +2892,10 @@
"user": "User",
"custom": "Custom",
"whyIsThisMessageEncrypted": "Why is this message unreadable?",
"noKeyForThisMessage": "This can happen if the message was sent before you have signed in to your account at this device.\n\nIt is also possible that the sender has blocked your device or something went wrong with the internet connection.\n\nAre you able to read the message on another session? Then you can transfer the message from it! Go to Settings > Devices and make sure that your devices have verified each other. When you open the room the next time and both sessions are in the foreground, the keys will be transmitted automatically.\n\nDo you not want to loose the keys when logging out or switching devices? Make sure that you have enabled the chat backup in the settings."
"noKeyForThisMessage": "This can happen if the message was sent before you have signed in to your account at this device.\n\nIt is also possible that the sender has blocked your device or something went wrong with the internet connection.\n\nAre you able to read the message on another session? Then you can transfer the message from it! Go to Settings > Devices and make sure that your devices have verified each other. When you open the room the next time and both sessions are in the foreground, the keys will be transmitted automatically.\n\nDo you not want to loose the keys when logging out or switching devices? Make sure that you have enabled the chat backup in the settings.",
"newGroup": "New group",
"newSpace": "New space",
"enterSpace": "Enter space",
"enterRoom": "Enter room",
"allSpaces": "All spaces"
}

View File

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

View File

@ -13,12 +13,12 @@ import 'package:uni_links/uni_links.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_view.dart';
import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
import 'package:fluffychat/utils/famedlysdk_store.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/space_navigator.dart';
import '../../../utils/account_bundles.dart';
import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart';
import '../../utils/url_launcher.dart';
@ -30,7 +30,11 @@ import '../settings_account/settings_account.dart';
import 'package:fluffychat/utils/tor_stub.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 {
settings,
@ -41,6 +45,13 @@ enum PopupMenuAction {
archive,
}
enum ActiveFilter {
allChats,
groups,
messages,
spaces,
}
class ChatList extends StatefulWidget {
const ChatList({Key? key}) : super(key: key);
@ -56,7 +67,95 @@ class ChatListController extends State<ChatList>
StreamSubscription? _intentUriStreamSubscription;
SpacesEntry? _activeSpacesEntry;
bool get displayNavigationBar =>
!FluffyThemes.isColumnMode(context) &&
(spaces.isNotEmpty || AppConfig.separateChatTypes);
String? activeSpaceId;
void resetActiveSpaceId() {
setState(() {
activeSpaceId = null;
});
}
void setActiveSpace(String? spaceId) {
setState(() {
activeSpaceId = spaceId;
activeFilter = ActiveFilter.spaces;
});
}
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.isStoryRoom)
.toList();
case ActiveFilter.groups:
return rooms
.where((room) =>
!room.isSpace && !room.isDirectChat && !room.isStoryRoom)
.toList();
case ActiveFilter.messages:
return rooms
.where((room) =>
!room.isSpace && room.isDirectChat && !room.isStoryRoom)
.toList();
case ActiveFilter.spaces:
return rooms.where((room) => room.isSpace).toList();
}
}
bool isSearchMode = false;
Future<QueryPublicRoomsResponse>? publicRoomsResponse;
@ -154,15 +253,8 @@ class ChatListController extends State<ChatList>
bool isTorBrowser = false;
SpacesEntry get activeSpacesEntry {
final id = _activeSpacesEntry;
return (id == null || !id.stillValid(context)) ? defaultSpacesEntry : id;
}
BoxConstraints? snappingSheetContainerSize;
String? get activeSpaceId => activeSpacesEntry.getSpace(context)?.id;
final ScrollController scrollController = ScrollController();
bool scrolledToTop = true;
@ -190,26 +282,6 @@ class ChatListController extends State<ChatList>
List<Room> get spaces =>
Matrix.of(context).client.rooms.where((r) => r.isSpace).toList();
// Note that this could change due to configuration, etc.
// Also be aware that _activeSpacesEntry = null is the expected reset method.
SpacesEntry get defaultSpacesEntry => AppConfig.separateChatTypes
? DirectChatsSpacesEntry()
: AllRoomsSpacesEntry();
List<SpacesEntry> get spacesEntries {
if (AppConfig.separateChatTypes) {
return [
defaultSpacesEntry,
GroupsSpacesEntry(),
...spaces.map((space) => SpaceSpacesEntry(space)).toList()
];
}
return [
defaultSpacesEntry,
...spaces.map((space) => SpaceSpacesEntry(space)).toList()
];
}
final selectedRoomIds = <String>{};
String? get activeChat => VRouter.of(context).pathParameters['roomid'];
@ -296,8 +368,6 @@ class ChatListController extends State<ChatList>
_checkTorBrowser();
_subscribeSpaceChanges();
super.initState();
}
@ -419,73 +489,43 @@ class ChatListController extends State<ChatList>
}
}
Future<void> addOrRemoveToSpace() async {
final id = activeSpaceId;
if (id != null) {
final consent = await showOkCancelAlertDialog(
Future<void> addToSpace() async {
final selectedSpace = await showConfirmationDialog<String>(
context: context,
title: L10n.of(context)!.removeFromSpace,
message: L10n.of(context)!.removeFromSpaceDescription,
okLabel: L10n.of(context)!.remove,
cancelLabel: L10n.of(context)!.cancel,
isDestructiveAction: true,
title: L10n.of(context)!.addToSpace,
message: L10n.of(context)!.addToSpaceDescription,
fullyCapitalizedForMaterial: false,
);
if (consent != OkCancelResult.ok) return;
final space = Matrix.of(context).client.getRoomById(id);
final result = await showFutureLoadingDialog(
context: context,
future: () async {
actions: Matrix.of(context)
.client
.rooms
.where((r) => r.isSpace)
.map(
(space) => AlertDialogAction(
key: space.id,
label: space.displayname,
),
)
.toList());
if (selectedSpace == null) return;
final result = await showFutureLoadingDialog(
context: context,
future: () async {
final space = Matrix.of(context).client.getRoomById(selectedSpace)!;
if (space.canSendDefaultStates) {
for (final roomId in selectedRoomIds) {
await space!.removeSpaceChild(roomId);
await space.setSpaceChild(roomId);
}
},
}
},
);
if (result.error == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.chatHasBeenAddedToThisSpace),
),
);
if (result.error == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.chatHasBeenRemovedFromThisSpace),
),
);
}
} else {
final selectedSpace = await showConfirmationDialog<String>(
context: context,
title: L10n.of(context)!.addToSpace,
message: L10n.of(context)!.addToSpaceDescription,
fullyCapitalizedForMaterial: false,
actions: Matrix.of(context)
.client
.rooms
.where((r) => r.isSpace)
.map(
(space) => AlertDialogAction(
key: space.id,
label: space.displayname,
),
)
.toList());
if (selectedSpace == null) return;
final result = await showFutureLoadingDialog(
context: context,
future: () async {
final space = Matrix.of(context).client.getRoomById(selectedSpace)!;
if (space.canSendDefaultStates) {
for (final roomId in selectedRoomIds) {
await space.setSpaceChild(roomId);
}
}
},
);
if (result.error == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.chatHasBeenAddedToThisSpace),
),
);
}
}
setState(() => selectedRoomIds.clear());
}
@ -517,18 +557,6 @@ class ChatListController extends State<ChatList>
}
}
}
// Load space members to display DM rooms
final spaceId = activeSpaceId;
if (spaceId != null) {
final space = client.getRoomById(spaceId)!;
final localMembers = space.getParticipants().length;
final actualMembersCount = (space.summary.mInvitedMemberCount ?? 0) +
(space.summary.mJoinedMemberCount ?? 0);
if (localMembers < actualMembersCount) {
await space.requestParticipants();
}
}
setState(() {
waitForFirstSync = true;
});
@ -546,7 +574,6 @@ class ChatListController extends State<ChatList>
void setActiveClient(Client client) {
VRouter.of(context).to('/rooms');
setState(() {
_activeSpacesEntry = null;
selectedRoomIds.clear();
Matrix.of(context).setActiveClient(client);
});
@ -556,7 +583,6 @@ class ChatListController extends State<ChatList>
void setActiveBundle(String bundle) {
VRouter.of(context).to('/rooms');
setState(() {
_activeSpacesEntry = null;
selectedRoomIds.clear();
Matrix.of(context).activeBundle = bundle;
if (!Matrix.of(context)
@ -651,27 +677,6 @@ class ChatListController extends State<ChatList>
Future<void> dehydrate() =>
SettingsAccountController.dehydrateDevice(context);
_adjustSpaceQuery(String? spaceId) {
cancelSearch();
setState(() {
if (spaceId != null) {
final matching =
spacesEntries.where((element) => element.routeHandle == spaceId);
if (matching.isNotEmpty) {
_activeSpacesEntry = matching.first;
} else {
_activeSpacesEntry = defaultSpacesEntry;
}
} else {
_activeSpacesEntry = defaultSpacesEntry;
}
});
}
void _subscribeSpaceChanges() {
_spacesSubscription = SpaceNavigator.stream.listen(_adjustSpaceQuery);
}
}
enum EditBundleAction { addToBundle, removeFromBundle }

View File

@ -5,20 +5,18 @@ import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix_link_text/link_text.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/search_title.dart';
import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
import 'package:fluffychat/pages/chat_list/space_view.dart';
import 'package:fluffychat/pages/chat_list/stories_header.dart';
import 'package:fluffychat/utils/url_launcher.dart';
import 'package:fluffychat/widgets/avatar.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/connection_status_header.dart';
import '../../widgets/matrix.dart';
import 'spaces_hierarchy_proposal.dart';
class ChatListViewBody extends StatefulWidget {
final ChatListController controller;
@ -33,10 +31,6 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
// the matrix sync stream
late StreamSubscription _subscription;
// used to check the animation direction
String? _lastUserId;
SpacesEntry? _lastSpace;
@override
void initState() {
_subscription = Matrix.of(context)
@ -51,160 +45,151 @@ 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) {
final rooms = widget.controller.activeSpacesEntry.getRooms(context);
Matrix.of(context).client.prevBatch != null &&
widget.controller.activeFilter != ActiveFilter.spaces) {
final rooms = widget.controller.filteredRooms;
final displayStoriesHeader = widget.controller.activeSpacesEntry
.shouldShowStoriesHeader(context) ||
rooms.isEmpty;
final displayStoriesHeader = {
ActiveFilter.allChats,
ActiveFilter.messages,
}.contains(widget.controller.activeFilter);
child = ListView.builder(
key: ValueKey(Matrix.of(context).client.userID.toString() +
widget.controller.activeSpaceId.toString() +
widget.controller.activeSpacesEntry.runtimeType.toString()),
widget.controller.activeFilter.toString()),
controller: widget.controller.scrollController,
// add +1 space below in order to properly scroll below the spaces bar
itemCount: rooms.length + (displayStoriesHeader ? 2 : 1),
itemCount: rooms.length + 1,
itemBuilder: (BuildContext context, int i) {
if (displayStoriesHeader) {
if (i == 0) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SpaceRoomListTopBar(widget.controller),
if (roomSearchResult != null) ...[
SearchTitle(
title: L10n.of(context)!.publicRooms,
icon: const Icon(Icons.explore_outlined),
),
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 (i == 0) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (roomSearchResult != null) ...[
SearchTitle(
title: L10n.of(context)!.publicRooms,
icon: const Icon(Icons.explore_outlined),
),
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,
icon: const Icon(Icons.group_outlined),
),
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 (userSearchResult != null) ...[
SearchTitle(
title: L10n.of(context)!.users,
icon: const Icon(Icons.group_outlined),
),
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,
icon: const Icon(Icons.camera_alt_outlined),
),
),
],
if (widget.controller.isSearchMode)
SearchTitle(
title: L10n.of(context)!.stories,
icon: const Icon(Icons.camera_alt_outlined),
),
if (displayStoriesHeader)
StoriesHeader(
filter: widget.controller.searchController.text,
),
AnimatedContainer(
height: widget.controller.isTorBrowser ? 64 : 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: const Icon(Icons.vpn_key),
title: Text(L10n.of(context)!.dehydrateTor),
subtitle: Text(L10n.of(context)!.dehydrateTorLong),
trailing: const Icon(Icons.chevron_right_outlined),
onTap: widget.controller.dehydrate,
),
const ConnectionStatusHeader(),
AnimatedContainer(
height: widget.controller.isTorBrowser ? 64 : 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: const Icon(Icons.vpn_key),
title: Text(L10n.of(context)!.dehydrateTor),
subtitle: Text(L10n.of(context)!.dehydrateTorLong),
trailing: const Icon(Icons.chevron_right_outlined),
onTap: widget.controller.dehydrate,
),
),
if (widget.controller.isSearchMode)
SearchTitle(
title: L10n.of(context)!.chats,
icon: const Icon(Icons.chat_outlined),
),
if (rooms.isEmpty && !widget.controller.isSearchMode)
Column(
key: const ValueKey(null),
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Image.asset(
'assets/private_chat_wallpaper.png',
width: 160,
height: 160,
),
Center(
child: Text(
L10n.of(context)!.startYourFirstChat,
textAlign: TextAlign.start,
style: const TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
if (widget.controller.isSearchMode)
SearchTitle(
title: L10n.of(context)!.chats,
icon: const Icon(Icons.chat_outlined),
),
if (rooms.isEmpty && !widget.controller.isSearchMode)
Column(
key: const ValueKey(null),
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Image.asset(
'assets/private_chat_wallpaper.png',
width: 160,
height: 160,
),
Center(
child: Text(
L10n.of(context)!.startYourFirstChat,
textAlign: TextAlign.start,
style: const TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
const SizedBox(height: 16),
],
),
],
);
}
i--;
}
if (i >= rooms.length) {
return SpacesHierarchyProposals(
space: widget.controller.activeSpacesEntry.getSpace(context)?.id,
query: widget.controller.isSearchMode
? widget.controller.searchController.text
: null,
),
const SizedBox(height: 16),
],
),
],
);
}
i--;
if (!rooms[i].displayname.toLowerCase().contains(
widget.controller.searchController.text.toLowerCase())) {
return Container();
@ -220,6 +205,12 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
);
},
);
} else if (widget.controller.activeFilter == ActiveFilter.spaces) {
child = SpaceView(
widget.controller,
scrollController: widget.controller.scrollController,
key: Key(widget.controller.activeSpaceId ?? 'Spaces'),
);
} else {
const dummyChatCount = 5;
final titleColor =
@ -227,6 +218,7 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
final subtitleColor =
Theme.of(context).textTheme.bodyText1!.color!.withAlpha(50);
child = ListView.builder(
key: const Key('dummychats'),
itemCount: dummyChatCount,
itemBuilder: (context, i) => Opacity(
opacity: (dummyChatCount - i) / dummyChatCount,
@ -282,7 +274,6 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
);
}
return PageTransitionSwitcher(
reverse: reversed,
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
@ -306,30 +297,6 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
super.dispose();
}
bool _animationReversed() {
bool reversed;
// in case the matrix id changes, check the indexOf the matrix id
final newClient = Matrix.of(context).client;
if (_lastUserId != newClient.userID) {
reversed = Matrix.of(context)
.currentBundle!
.indexWhere((element) => element!.userID == _lastUserId) <
Matrix.of(context)
.currentBundle!
.indexWhere((element) => element!.userID == newClient.userID);
}
// otherwise, the space changed...
else {
reversed = widget.controller.spacesEntries
.indexWhere((element) => element == _lastSpace) <
widget.controller.spacesEntries.indexWhere(
(element) => element == widget.controller.activeSpacesEntry);
}
_lastUserId = newClient.userID;
_lastSpace = widget.controller.activeSpacesEntry;
return reversed;
}
@override
void didUpdateWidget(covariant ChatListViewBody oldWidget) {
setState(() {});
@ -337,57 +304,6 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
}
}
class SpaceRoomListTopBar extends StatefulWidget {
final ChatListController controller;
const SpaceRoomListTopBar(this.controller, {Key? key}) : super(key: key);
@override
State<SpaceRoomListTopBar> createState() => _SpaceRoomListTopBarState();
}
class _SpaceRoomListTopBarState extends State<SpaceRoomListTopBar> {
bool _limitSize = true;
@override
Widget build(BuildContext context) {
if (widget.controller.activeSpacesEntry is SpaceSpacesEntry &&
!widget.controller.isSearchMode &&
(widget.controller.activeSpacesEntry as SpaceSpacesEntry)
.space
.topic
.isNotEmpty) {
return GestureDetector(
onTap: () => setState(() {
_limitSize = !_limitSize;
}),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: LinkText(
text: (widget.controller.activeSpacesEntry as SpaceSpacesEntry)
.space
.topic,
maxLines: _limitSize ? 3 : null,
linkStyle: const TextStyle(color: Colors.blueAccent),
textStyle: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.bodyText2!.color,
),
onLinkTap: (url) => UrlLauncher(context, url).launchUrl(),
),
),
const Divider(),
],
),
);
} else {
return Container();
}
}
}
class _SearchItem extends StatelessWidget {
final String title;
final Uri? avatar;

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,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.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 'package:fluffychat/widgets/matrix.dart';
import '../../widgets/matrix.dart';
class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
final ChatListController controller;
@ -53,39 +52,25 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
),
hintText: controller.activeSpacesEntry.getName(context),
prefixIcon: Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 4,
),
child: controller.isSearchMode
? IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelSearch,
color:
Theme.of(context).colorScheme.onBackground,
)
: 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(
hintText: L10n.of(context)!.search,
floatingLabelBehavior: FloatingLabelBehavior.never,
prefixIcon: controller.isSearchMode
? IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelSearch,
color: Theme.of(context).colorScheme.onBackground,
)
: Icon(
Icons.search_outlined,
color: Theme.of(context).colorScheme.onBackground,
),
suffixIcon: controller.isSearchMode
? controller.isSearching
? const CircularProgressIndicator.adaptive(
strokeWidth: 2,
)
: TextButton(
onPressed: controller.setServer,
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 12),
@ -98,24 +83,11 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
.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),
],
),
)
: SizedBox(
width: 0,
child: ClientChooserButton(controller),
),
),
),
),
@ -126,8 +98,8 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
if (controller.spaces.isNotEmpty)
IconButton(
tooltip: L10n.of(context)!.addToSpace,
icon: const Icon(Icons.group_work_outlined),
onPressed: controller.addOrRemoveToSpace,
icon: const Icon(Icons.workspaces_outlined),
onPressed: controller.addToSpace,
),
IconButton(
tooltip: L10n.of(context)!.toggleUnread,

View File

@ -5,9 +5,10 @@ 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/config/themes.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 'package:fluffychat/widgets/avatar.dart';
import '../../widgets/matrix.dart';
import 'chat_list_body.dart';
import 'chat_list_header.dart';
@ -18,6 +19,33 @@ class ChatListView extends StatelessWidget {
const ChatListView(this.controller, {Key? key}) : super(key: key);
List<NavigationDestination> getNavigationDestinations(BuildContext context) =>
[
if (AppConfig.separateChatTypes) ...[
NavigationDestination(
icon: const Icon(Icons.groups_outlined),
selectedIcon: const Icon(Icons.groups),
label: L10n.of(context)!.groups,
),
NavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: L10n.of(context)!.messages,
),
] else
NavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: L10n.of(context)!.chats,
),
if (controller.spaces.isNotEmpty)
const NavigationDestination(
icon: Icon(Icons.workspaces_outlined),
selectedIcon: Icon(Icons.workspaces),
label: 'Spaces',
),
];
@override
Widget build(BuildContext context) {
return StreamBuilder<Object?>(
@ -30,24 +58,154 @@ class ChatListView extends StatelessWidget {
if (selMode != SelectMode.normal) controller.cancelAction();
if (selMode == SelectMode.select) redirector.stopRedirection();
},
child: Scaffold(
appBar: ChatListHeader(controller: controller),
body: ChatListViewBody(controller),
drawer: ChatListDrawer(controller),
bottomNavigationBar: const ConnectionStatusHeader(),
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,
child: Row(
children: [
if (FluffyThemes.isColumnMode(context) &&
FluffyThemes.getDisplayNavigationRail(context)) ...[
Builder(builder: (context) {
final client = Matrix.of(context).client;
final allSpaces = client.rooms.where((room) => room.isSpace);
final rootSpaces = allSpaces
.where(
(space) => !allSpaces.any(
(parentSpace) => parentSpace.spaceChildren
.any((child) => child.roomId == space.id),
),
)
.toList();
final destinations = getNavigationDestinations(context)
..removeLast();
return SizedBox(
width: 64,
child: ListView.builder(
scrollDirection: Axis.vertical,
itemCount: rootSpaces.length +
1 +
(AppConfig.separateChatTypes ? 1 : 0),
itemBuilder: (context, i) {
if (i < destinations.length) {
final isSelected = i == controller.selectedIndex;
return Container(
height: 64,
width: 64,
decoration: BoxDecoration(
color: isSelected
? Theme.of(context)
.colorScheme
.secondaryContainer
: Theme.of(context).colorScheme.background,
border: Border(
left: BorderSide(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 4,
),
right: const BorderSide(
color: Colors.transparent,
width: 4,
),
),
),
alignment: Alignment.center,
child: IconButton(
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
icon: CircleAvatar(
backgroundColor: Theme.of(context)
.colorScheme
.secondaryContainer,
foregroundColor: Theme.of(context)
.colorScheme
.onSecondaryContainer,
child: i == controller.selectedIndex
? destinations[i].selectedIcon ??
destinations[i].icon
: destinations[i].icon),
tooltip: destinations[i].label,
onPressed: () =>
controller.onDestinationSelected(i),
),
);
}
i -= destinations.length;
final isSelected =
controller.activeFilter == ActiveFilter.spaces &&
rootSpaces[i].id == controller.activeSpaceId;
return Container(
height: 64,
width: 64,
decoration: BoxDecoration(
color: isSelected
? Theme.of(context)
.colorScheme
.secondaryContainer
: Theme.of(context).colorScheme.background,
border: Border(
left: BorderSide(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 4,
),
right: const BorderSide(
color: Colors.transparent,
width: 4,
),
),
),
alignment: Alignment.center,
child: IconButton(
tooltip: rootSpaces[i].displayname,
icon: Avatar(
mxContent: rootSpaces[i].avatar,
name: rootSpaces[i].displayname,
size: 32,
fontSize: 12,
),
onPressed: () =>
controller.setActiveSpace(rootSpaces[i].id),
),
);
},
),
);
}),
Container(
color: Theme.of(context).dividerColor,
width: 1,
),
],
Expanded(
child: Scaffold(
appBar: ChatListHeader(controller: controller),
body: ChatListViewBody(controller),
bottomNavigationBar: controller.displayNavigationBar
? NavigationBar(
height: 64,
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:keyboard_shortcuts/keyboard_shortcuts.dart';
import 'package:matrix/matrix.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../utils/fluffy_share.dart';
import 'chat_list.dart';
class ClientChooserButton extends StatelessWidget {
@ -23,6 +25,60 @@ class ClientChooserButton extends StatelessWidget {
? -1
: 1);
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.workspaces_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) ...[
if (matrix.accountBundles[bundle]!.length != 1 ||
matrix.accountBundles[bundle]!.single!.userID != bundle)
@ -80,7 +136,7 @@ class ClientChooserButton extends StatelessWidget {
.toList(),
],
PopupMenuItem(
value: AddAccountAction.addAccount,
value: SettingsAction.addAccount,
child: Row(
children: [
const Icon(Icons.person_add_outlined),
@ -98,42 +154,50 @@ class ClientChooserButton extends StatelessWidget {
int clientCount = 0;
matrix.accountBundles.forEach((key, value) => clientCount += value.length);
return Center(
child: FutureBuilder<Profile>(
future: matrix.client.fetchOwnProfile(),
builder: (context, snapshot) => Stack(
alignment: Alignment.center,
children: [
...List.generate(
clientCount,
(index) => KeyBoardShortcuts(
keysToPress: _buildKeyboardShortcut(index + 1),
helpLabel: L10n.of(context)!.switchToAccount(index + 1),
onKeysPressed: () => _handleKeyboardShortcut(matrix, index),
child: Container(),
return FutureBuilder<Profile>(
future: matrix.client.fetchOwnProfile(),
builder: (context, snapshot) => Stack(
alignment: Alignment.center,
children: [
...List.generate(
clientCount,
(index) => KeyBoardShortcuts(
keysToPress: _buildKeyboardShortcut(index + 1),
helpLabel: L10n.of(context)!.switchToAccount(index + 1),
onKeysPressed: () => _handleKeyboardShortcut(
matrix,
index,
context,
),
),
KeyBoardShortcuts(
keysToPress: {
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,
),
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),
child: PopupMenuButton<Object>(
shape: Border.all(
color: Theme.of(context).dividerColor,
),
onSelected: (o) => _clientSelected(o, context),
itemBuilder: _bundleMenuItems,
child: Material(
color: Colors.transparent,
@ -147,8 +211,8 @@ class ClientChooserButton extends StatelessWidget {
),
),
),
],
),
),
],
),
);
}
@ -164,17 +228,46 @@ class ClientChooserButton extends StatelessWidget {
}
}
void _clientSelected(Object object) {
void _clientSelected(
Object object,
BuildContext context,
) {
if (object is Client) {
controller.setActiveClient(object);
} else if (object is String) {
controller.setActiveBundle(object);
} else if (object == AddAccountAction.addAccount) {
controller.addAccountAction();
} else if (object is SettingsAction) {
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()
..sort((a, b) => a!.isValidMatrixId == b!.isValidMatrixId
? 0
@ -186,20 +279,20 @@ class ClientChooserButton extends StatelessWidget {
int clientCount = 0;
matrix.accountBundles
.forEach((key, value) => clientCount += value.length);
_handleKeyboardShortcut(matrix, clientCount);
_handleKeyboardShortcut(matrix, clientCount, context);
}
for (final bundleName in bundles) {
final bundle = matrix.accountBundles[bundleName];
if (bundle != null) {
if (index < bundle.length) {
return _clientSelected(bundle[index]!);
return _clientSelected(bundle[index]!, context);
} else {
index -= bundle.length;
}
}
}
// if index too high, restarting from 0
_handleKeyboardShortcut(matrix, 0);
_handleKeyboardShortcut(matrix, 0, context);
}
int? _shortcutIndexOfClient(MatrixState matrix, Client client) {
@ -223,17 +316,24 @@ class ClientChooserButton extends StatelessWidget {
return null;
}
void _nextAccount(MatrixState matrix) {
void _nextAccount(MatrixState matrix, BuildContext context) {
final client = 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 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

@ -1,119 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat_list/spaces_hierarchy_proposal.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/public_room_bottom_sheet.dart';
class RecommendedRoomListItem extends StatelessWidget {
final SpaceRoomsChunk room;
final VoidCallback onRoomJoined;
const RecommendedRoomListItem({
Key? key,
required this.room,
required this.onRoomJoined,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final leading = Avatar(
mxContent: room.avatarUrl,
name: room.name,
);
final title = Row(
children: <Widget>[
Expanded(
child: Text(
room.name ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.bodyText1!.color,
),
),
),
// number of joined users
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text.rich(
TextSpan(children: [
WidgetSpan(
child: Tooltip(
message: L10n.of(context)!
.numberRoomMembers(room.numJoinedMembers),
child: const Icon(
Icons.people_outlined,
size: 20,
),
),
alignment: PlaceholderAlignment.middle,
baseline: TextBaseline.alphabetic),
TextSpan(text: ' ${room.numJoinedMembers}')
]),
style: TextStyle(
fontSize: 13,
color: Theme.of(context).textTheme.bodyText2!.color,
),
),
),
],
);
final subtitle = room.topic != null
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Expanded(
child: Text(
room.topic!,
softWrap: false,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).textTheme.bodyText2!.color,
),
),
),
],
)
: null;
void handler() => showModalBottomSheet(
context: context,
builder: (c) => PublicRoomBottomSheet(
outerContext: context,
chunk: room,
onRoomJoined: onRoomJoined,
),
);
if (room.roomType == 'm.space') {
return Material(
color: Colors.transparent,
child: ExpansionTile(
leading: leading,
title: title,
subtitle: subtitle,
onExpansionChanged: (open) {
if (!open) handler();
},
children: [
SpacesHierarchyProposals(space: room.roomId),
],
),
);
} else {
return Material(
color: Colors.transparent,
child: ListTile(
leading: leading,
title: title,
subtitle: subtitle,
onTap: handler,
),
);
}
}
}

View File

@ -5,12 +5,14 @@ class SearchTitle extends StatelessWidget {
final Widget icon;
final Widget? trailing;
final void Function()? onTap;
final Color? color;
const SearchTitle({
required this.title,
required this.icon,
this.trailing,
this.onTap,
this.color,
Key? key,
}) : super(key: key);
@ -26,7 +28,7 @@ class SearchTitle extends StatelessWidget {
width: 1,
),
),
color: Theme.of(context).colorScheme.surface,
color: color ?? Theme.of(context).colorScheme.surface,
child: InkWell(
onTap: onTap,
splashColor: Theme.of(context).colorScheme.surface,

View File

@ -0,0 +1,309 @@
import 'package:flutter/material.dart';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:collection/collection.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/config/themes.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/search_title.dart';
import 'package:fluffychat/widgets/avatar.dart';
import '../../utils/localized_exception_extension.dart';
import '../../widgets/matrix.dart';
class SpaceView extends StatefulWidget {
final ChatListController controller;
final ScrollController scrollController;
const SpaceView(
this.controller, {
Key? key,
required this.scrollController,
}) : super(key: key);
@override
State<SpaceView> createState() => _SpaceViewState();
}
class _SpaceViewState extends State<SpaceView> {
static final Map<String, Future<GetSpaceHierarchyResponse>> _requests = {};
void _refresh() {
setState(() {
_requests.remove(widget.controller.activeSpaceId);
});
}
Future<GetSpaceHierarchyResponse> getFuture(String activeSpaceId) =>
_requests[activeSpaceId] ??=
Matrix.of(context).client.getSpaceHierarchy(activeSpaceId);
void _onJoinSpaceChild(SpaceRoomsChunk spaceChild) async {
final client = Matrix.of(context).client;
final space = client.getRoomById(widget.controller.activeSpaceId!);
if (client.getRoomById(spaceChild.roomId) == null) {
final result = await showFutureLoadingDialog(
context: context,
future: () async {
await client.joinRoom(spaceChild.roomId,
serverName: space?.spaceChildren
.firstWhereOrNull(
(child) => child.roomId == spaceChild.roomId)
?.via);
if (client.getRoomById(spaceChild.roomId) == null) {
// Wait for room actually appears in sync
await client.waitForRoomInSync(spaceChild.roomId, join: true);
}
},
);
if (result.error != null) return;
_refresh();
}
if (spaceChild.roomType == 'm.space') {
if (spaceChild.roomId == widget.controller.activeSpaceId) {
VRouter.of(context).toSegments(['spaces', spaceChild.roomId]);
} else {
widget.controller.setActiveSpace(spaceChild.roomId);
}
return;
}
VRouter.of(context).toSegments(['rooms', spaceChild.roomId]);
}
void _onSpaceChildContextMenu(
[SpaceRoomsChunk? spaceChild, Room? room]) async {
final client = Matrix.of(context).client;
final activeSpaceId = widget.controller.activeSpaceId;
final activeSpace =
activeSpaceId == null ? null : client.getRoomById(activeSpaceId);
final action = await showModalActionSheet<SpaceChildContextAction>(
context: context,
title: spaceChild?.name ?? room?.displayname,
message: spaceChild?.topic ?? room?.topic,
actions: [
if (room == null)
SheetAction(
key: SpaceChildContextAction.join,
label: L10n.of(context)!.joinRoom,
icon: Icons.send_outlined,
),
if (spaceChild != null && (activeSpace?.canSendDefaultStates ?? false))
SheetAction(
key: SpaceChildContextAction.removeFromSpace,
label: L10n.of(context)!.removeFromSpace,
icon: Icons.delete_sweep_outlined,
),
if (room != null)
SheetAction(
key: SpaceChildContextAction.leave,
label: L10n.of(context)!.leave,
icon: Icons.delete_outlined,
isDestructiveAction: true,
),
],
);
if (action == null) return;
switch (action) {
case SpaceChildContextAction.join:
_onJoinSpaceChild(spaceChild!);
break;
case SpaceChildContextAction.leave:
await showFutureLoadingDialog(
context: context,
future: room!.leave,
);
break;
case SpaceChildContextAction.removeFromSpace:
await showFutureLoadingDialog(
context: context,
future: () => activeSpace!.removeSpaceChild(spaceChild!.roomId),
);
break;
}
}
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
final activeSpaceId = widget.controller.activeSpaceId;
final allSpaces = client.rooms.where((room) => room.isSpace);
if (activeSpaceId == null) {
final rootSpaces = allSpaces
.where(
(space) => !allSpaces.any(
(parentSpace) => parentSpace.spaceChildren
.any((child) => child.roomId == space.id),
),
)
.toList();
return ListView.builder(
itemCount: rootSpaces.length,
controller: widget.scrollController,
itemBuilder: (context, i) => ListTile(
leading: Avatar(
mxContent: rootSpaces[i].avatar,
name: rootSpaces[i].displayname,
),
title: Text(
rootSpaces[i].displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text('${rootSpaces[i].spaceChildren.length} Chats'),
onTap: () => widget.controller.setActiveSpace(rootSpaces[i].id),
onLongPress: () => _onSpaceChildContextMenu(null, rootSpaces[i]),
trailing: const Icon(Icons.chevron_right_outlined),
),
);
}
return FutureBuilder<GetSpaceHierarchyResponse>(
future: getFuture(activeSpaceId),
builder: (context, snapshot) {
final response = snapshot.data;
final error = snapshot.error;
if (error != null) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(error.toLocalizedString(context)),
),
IconButton(
onPressed: _refresh,
icon: const Icon(Icons.refresh_outlined),
)
],
);
}
if (response == null) {
return const Center(child: CircularProgressIndicator.adaptive());
}
final parentSpace = allSpaces.firstWhereOrNull((space) => space
.spaceChildren
.any((child) => child.roomId == activeSpaceId));
return ListView.builder(
itemCount: response.rooms.length + 1,
controller: widget.scrollController,
itemBuilder: (context, i) {
if (i == 0) {
return ListTile(
leading: FluffyThemes.isColumnMode(context) &&
parentSpace == null
? null
: BackButton(
onPressed: () => widget.controller
.setActiveSpace(parentSpace?.id),
),
title: Text(parentSpace == null
? FluffyThemes.isColumnMode(context)
? L10n.of(context)!.showSpaces
: L10n.of(context)!.allSpaces
: parentSpace.displayname),
trailing: IconButton(
icon: snapshot.connectionState != ConnectionState.done
? const CircularProgressIndicator.adaptive()
: const Icon(Icons.refresh_outlined),
onPressed:
snapshot.connectionState != ConnectionState.done
? null
: _refresh,
),
);
}
i--;
final spaceChild = response.rooms[i];
final room = client.getRoomById(spaceChild.roomId);
if (room != null && !room.isSpace) {
return ChatListItem(
room,
onLongPress: () =>
_onSpaceChildContextMenu(spaceChild, room),
activeChat: widget.controller.activeChat == room.id,
);
}
final isSpace = spaceChild.roomType == 'm.space';
final topic =
spaceChild.topic?.isEmpty ?? true ? null : spaceChild.topic;
if (spaceChild.roomId == activeSpaceId) {
return SearchTitle(
title:
spaceChild.name ?? spaceChild.canonicalAlias ?? 'Space',
icon: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: Avatar(
size: 24,
mxContent: spaceChild.avatarUrl,
name: spaceChild.name,
fontSize: 9,
),
),
color: Theme.of(context)
.colorScheme
.secondaryContainer
.withAlpha(128),
trailing: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Icon(Icons.edit_outlined),
),
onTap: () => _onJoinSpaceChild(spaceChild),
);
}
return ListTile(
leading: Avatar(
mxContent: spaceChild.avatarUrl,
name: spaceChild.name,
),
title: Row(
children: [
Expanded(
child: Text(
spaceChild.name ??
spaceChild.canonicalAlias ??
L10n.of(context)!.chat,
maxLines: 1,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
if (!isSpace) ...[
const Icon(
Icons.people_outline,
size: 16,
),
const SizedBox(width: 4),
Text(
spaceChild.numJoinedMembers.toString(),
style: const TextStyle(fontSize: 14),
),
],
],
),
onTap: () => _onJoinSpaceChild(spaceChild),
onLongPress: () => _onSpaceChildContextMenu(spaceChild, room),
subtitle: Text(
topic ??
(isSpace
? L10n.of(context)!.enterSpace
: L10n.of(context)!.enterRoom),
maxLines: 1,
style: TextStyle(
color: Theme.of(context).colorScheme.onBackground),
),
trailing:
isSpace ? const Icon(Icons.chevron_right_outlined) : null,
);
});
});
}
}
enum SpaceChildContextAction {
join,
leave,
removeFromSpace,
}

View File

@ -1,190 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'chat_list.dart';
import 'spaces_drawer_entry.dart';
class SpacesDrawer extends StatelessWidget {
final ChatListController controller;
const SpacesDrawer({Key? key, required this.controller}) : super(key: key);
@override
Widget build(BuildContext context) {
final spaceEntries = controller.spacesEntries
.map((e) => SpacesEntryMaybeChildren.buildIfTopLevel(
e, controller.spacesEntries))
.whereNotNull()
.toList();
final childSpaceIds = <String>{};
final spacesHierarchy = <SpacesEntryMaybeChildren>[];
final matrix = Matrix.of(context);
for (final entry in spaceEntries) {
if (entry.spacesEntry is SpaceSpacesEntry) {
final space = entry.spacesEntry.getSpace(context);
if (space != null && space.spaceChildren.isNotEmpty) {
final children = space.spaceChildren;
// computing the children space entries
final childrenSpaceEntries = spaceEntries.where((element) {
// current ID
final id = element.spacesEntry.getSpace(context)?.id;
// comparing against the supposed IDs of the children and checking
// whether the room is already joined
return children.any(
(child) =>
child.roomId == id &&
matrix.client.rooms
.any((joinedRoom) => child.roomId == joinedRoom.id),
);
});
childSpaceIds.addAll(childrenSpaceEntries
.map((e) => e.spacesEntry.getSpace(context)?.id)
.whereNotNull());
entry.children.addAll(childrenSpaceEntries);
spacesHierarchy.add(entry);
} else {
// don't add rooms with parent space apart from those where the
// parent space is not joined
if (space?.hasNotJoinedParentSpace() ?? false) {
spacesHierarchy.add(entry);
}
}
} else {
spacesHierarchy.add(entry);
}
}
spacesHierarchy.removeWhere((element) =>
childSpaceIds.contains(element.spacesEntry.getSpace(context)?.id));
return ListView.builder(
itemCount: spacesHierarchy.length + 1,
itemBuilder: (context, i) {
if (i == spacesHierarchy.length) {
return ListTile(
leading: CircleAvatar(
radius: Avatar.defaultSize / 2,
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
child: const Icon(
Icons.archive_outlined,
),
),
title: Text(L10n.of(context)!.archive),
onTap: () {
Scaffold.of(context).closeDrawer();
VRouter.of(context).to('/archive');
},
);
}
final space = spacesHierarchy[i];
return SpacesDrawerEntry(
entry: space,
controller: controller,
);
},
);
}
}
class SpacesEntryMaybeChildren {
final SpacesEntry spacesEntry;
final Set<SpacesEntryMaybeChildren> children;
const SpacesEntryMaybeChildren(this.spacesEntry, [this.children = const {}]);
static SpacesEntryMaybeChildren? buildIfTopLevel(
SpacesEntry entry, List<SpacesEntry> allEntries,
[String? parent]) {
if (entry is SpaceSpacesEntry) {
final room = entry.space;
// don't add rooms with parent space apart from those where the
// parent space is not joined
if ((parent == null &&
room.spaceParents.isNotEmpty &&
room.hasNotJoinedParentSpace()) ||
(parent != null &&
!room.spaceParents.any((element) => element.roomId == parent))) {
return null;
} else {
final children = allEntries
.where((element) =>
element is SpaceSpacesEntry &&
element.space.spaceParents.any((parent) =>
parent.roomId == room.id /*&& (parent.canonical ?? true)*/))
.toList();
return SpacesEntryMaybeChildren(
entry,
children
.map((e) => buildIfTopLevel(e, allEntries, room.id))
.whereNotNull()
.toSet());
}
} else {
return SpacesEntryMaybeChildren(entry);
}
}
bool isActiveOfChild(ChatListController controller) =>
spacesEntry == controller.activeSpacesEntry ||
children.any(
(element) => element.isActiveOfChild(controller),
);
Map<String, dynamic> toJson() => {
'entry': spacesEntry is SpaceSpacesEntry
? (spacesEntry as SpaceSpacesEntry).space.id
: spacesEntry.runtimeType.toString(),
if (spacesEntry is SpaceSpacesEntry)
'rawSpaceParents': (spacesEntry as SpaceSpacesEntry)
.space
.spaceParents
.map((e) =>
{'roomId': e.roomId, 'canonical': e.canonical, 'via': e.via})
.toList(),
if (spacesEntry is SpaceSpacesEntry)
'rawSpaceChildren': (spacesEntry as SpaceSpacesEntry)
.space
.spaceChildren
.map(
(e) => {
'roomId': e.roomId,
'suggested': e.suggested,
'via': e.via,
'order': e.order
},
)
.toList(),
'children': children.map((e) => e.toJson()).toList(),
};
@override
String toString() {
return jsonEncode(toJson());
}
}
extension on Room {
bool hasNotJoinedParentSpace() {
return (spaceParents.isEmpty ||
spaceParents.none(
(p0) =>
(p0.canonical ?? true) &&
client.rooms.map((e) => e.id).contains(p0.roomId),
));
}
}

View File

@ -1,98 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/spaces_drawer.dart';
import 'package:fluffychat/utils/space_navigator.dart';
import 'package:fluffychat/widgets/avatar.dart';
class SpacesDrawerEntry extends StatelessWidget {
final SpacesEntryMaybeChildren entry;
final ChatListController controller;
const SpacesDrawerEntry(
{Key? key, required this.entry, required this.controller})
: super(key: key);
@override
Widget build(BuildContext context) {
final space = entry.spacesEntry;
final room = space.getSpace(context);
final active = controller.activeSpacesEntry == entry.spacesEntry;
final leading = room == null
? CircleAvatar(
radius: Avatar.defaultSize / 2,
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
child: space.getIcon(active),
)
: Avatar(
mxContent: room.avatar,
name: space.getName(context),
);
final title = Text(
space.getName(context),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
final subtitle = room?.topic.isEmpty ?? true
? null
: Tooltip(
message: room!.topic,
child: Text(
room.topic.replaceAll('\n', ' '),
softWrap: false,
overflow: TextOverflow.fade,
),
);
void onTap() {
SpaceNavigator.navigateToSpace(space.routeHandle);
Scaffold.of(context).closeDrawer();
}
final 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),
),
)
: const Icon(Icons.arrow_forward_ios_outlined);
if (entry.children.isEmpty) {
return ListTile(
selected: active,
leading: leading,
title: title,
subtitle: subtitle,
onTap: onTap,
trailing: trailing,
);
} else {
return ExpansionTile(
leading: leading,
initiallyExpanded:
entry.children.any((element) => entry.isActiveOfChild(controller)),
title: GestureDetector(
onTap: onTap,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Expanded(child: title),
const SizedBox(width: 8),
trailing
]),
),
children: entry.children
.map((e) => SpacesDrawerEntry(entry: e, controller: controller))
.toList(),
);
}
}
}

View File

@ -1,240 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart';
import '../../widgets/matrix.dart';
// This is not necessarily a Space, but an abstract categorization of a room.
// More to the point, it's a selectable entry that *could* be a Space.
// Note that view code is in spaces_bottom_bar.dart because of type-specific UI.
// So only really generic functions (so far, anything ChatList cares about) go here.
// If getRoom returns something non-null, then it gets the avatar and such of a Space.
// Otherwise it gets to look like All Rooms. Future work impending.
abstract class SpacesEntry {
const SpacesEntry();
// Gets the (translated) name of this entry.
String getName(BuildContext context);
// Gets an icon for this entry (avoided if a space is given)
Icon getIcon(bool active) => active
? const Icon(CupertinoIcons.chat_bubble_2_fill)
: const Icon(CupertinoIcons.chat_bubble_2);
// If this is a specific Room, returns the space Room for various purposes.
Room? getSpace(BuildContext context) => null;
// Gets a list of rooms - this is done as part of _ChatListViewBodyState to get the full list of rooms visible from this SpacesEntry.
List<Room> getRooms(BuildContext context);
// Checks that this entry is still valid.
bool stillValid(BuildContext context) => true;
// Returns true if the Stories header should be shown.
bool shouldShowStoriesHeader(BuildContext context) => false;
String? get routeHandle;
}
// Common room validity checks
bool _roomCheckCommon(Room room, BuildContext context) {
if (room.isSpace && room.membership == Membership.join && !room.isUnread) {
return false;
}
if (room.getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') ==
ClientStoriesExtension.storiesRoomType) {
return false;
}
return true;
}
bool _roomInsideSpace(Room room, Room space) {
if (space.spaceChildren.any((child) => child.roomId == room.id)) {
return true;
}
if (room.spaceParents.any((parent) => parent.roomId == space.id)) {
return true;
}
return false;
}
// "All rooms" entry.
class AllRoomsSpacesEntry extends SpacesEntry {
static final AllRoomsSpacesEntry _value = AllRoomsSpacesEntry._();
AllRoomsSpacesEntry._();
factory AllRoomsSpacesEntry() {
return _value;
}
@override
String getName(BuildContext context) => L10n.of(context)!.allChats;
@override
List<Room> getRooms(BuildContext context) {
return Matrix.of(context)
.client
.rooms
.where((room) => _roomCheckCommon(room, context))
.toList();
}
@override
final String? routeHandle = null;
@override
bool shouldShowStoriesHeader(BuildContext context) => true;
@override
bool operator ==(Object other) {
return runtimeType == other.runtimeType;
}
@override
int get hashCode => runtimeType.hashCode;
}
// "Direct Chats" entry.
class DirectChatsSpacesEntry extends SpacesEntry {
static final DirectChatsSpacesEntry _value = DirectChatsSpacesEntry._();
DirectChatsSpacesEntry._();
factory DirectChatsSpacesEntry() {
return _value;
}
@override
String getName(BuildContext context) => L10n.of(context)!.directChats;
@override
List<Room> getRooms(BuildContext context) {
return Matrix.of(context)
.client
.rooms
.where((room) => room.isDirectChat && _roomCheckCommon(room, context))
.toList();
}
@override
final String? routeHandle = null;
@override
bool shouldShowStoriesHeader(BuildContext context) => true;
@override
bool operator ==(Object other) {
return runtimeType == other.runtimeType;
}
@override
int get hashCode => runtimeType.hashCode;
}
// "Groups" entry.
class GroupsSpacesEntry extends SpacesEntry {
static final GroupsSpacesEntry _value = GroupsSpacesEntry._();
GroupsSpacesEntry._();
factory GroupsSpacesEntry() {
return _value;
}
@override
String getName(BuildContext context) => L10n.of(context)!.groups;
@override
Icon getIcon(bool active) =>
active ? const Icon(Icons.group) : const Icon(Icons.group_outlined);
@override
List<Room> getRooms(BuildContext context) {
final rooms = Matrix.of(context).client.rooms;
// Needs to match ChatList's definition of a space.
final spaces = rooms.where((room) => room.isSpace).toList();
return rooms
.where((room) =>
(!room.isDirectChat) &&
_roomCheckCommon(room, context) &&
separatedGroup(room, spaces))
.toList();
}
@override
final String? routeHandle = 'groups';
bool separatedGroup(Room room, List<Room> spaces) {
return !spaces.any((space) => _roomInsideSpace(room, space));
}
@override
bool operator ==(Object other) {
return runtimeType == other.runtimeType;
}
@override
int get hashCode => runtimeType.hashCode;
}
// All rooms associated with a specific space.
class SpaceSpacesEntry extends SpacesEntry {
final Room space;
const SpaceSpacesEntry(this.space);
@override
String getName(BuildContext context) => space.displayname;
@override
Room? getSpace(BuildContext context) => space;
@override
List<Room> getRooms(BuildContext context) {
return Matrix.of(context)
.client
.rooms
.where((room) => roomCheck(room, context))
.toList();
}
bool roomCheck(Room room, BuildContext context) {
if (!_roomCheckCommon(room, context)) {
return false;
}
if (_roomInsideSpace(room, space)) {
return true;
}
if (AppConfig.showDirectChatsInSpaces) {
if (room.isDirectChat &&
room.summary.mHeroes != null &&
room.summary.mHeroes!.any((userId) {
final user = space.getState(EventTypes.RoomMember, userId)?.asUser;
return user != null && user.membership == Membership.join;
})) {
return true;
}
}
return false;
}
@override
bool stillValid(BuildContext context) =>
Matrix.of(context).client.getRoomById(space.id) != null;
@override
String? get routeHandle => space.id;
@override
bool operator ==(Object other) {
return hashCode == other.hashCode;
}
@override
int get hashCode => space.id.hashCode;
}

View File

@ -1,156 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
import 'package:async/async.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat_list/search_title.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'recommended_room_list_item.dart';
class SpacesHierarchyProposals extends StatefulWidget {
static final Map<String, AsyncCache<GetSpaceHierarchyResponse?>> _cache = {};
final String? space;
final String? query;
const SpacesHierarchyProposals({
Key? key,
required this.space,
this.query,
}) : super(key: key);
@override
State<SpacesHierarchyProposals> createState() =>
_SpacesHierarchyProposalsState();
}
class _SpacesHierarchyProposalsState extends State<SpacesHierarchyProposals> {
@override
void didUpdateWidget(covariant SpacesHierarchyProposals oldWidget) {
if (oldWidget.space != widget.space || oldWidget.query != widget.query) {
setState(() {});
}
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
// check for recommended rooms in case the active space is a [SpaceSpacesEntry]
if (widget.space != null) {
final client = Matrix.of(context).client;
final cache = SpacesHierarchyProposals._cache[widget.space!] ??=
AsyncCache<GetSpaceHierarchyResponse?>(const Duration(minutes: 15));
/// additionally saving the future's state in the completer in order to
/// display the loading indicator when refreshing as a [FutureBuilder] is
/// a [StatefulWidget].
final completer = Completer();
final future = cache.fetch(() => client.getSpaceHierarchy(
widget.space!,
suggestedOnly: true,
maxDepth: 1,
));
future.then(completer.complete);
return FutureBuilder<GetSpaceHierarchyResponse?>(
future: future,
builder: (context, snapshot) {
Widget child;
if (snapshot.hasData) {
final thereWereRooms = snapshot.data!.rooms.isNotEmpty;
final rooms = snapshot.data!.rooms.where(
(element) =>
element.roomId != widget.space &&
// filtering in case a query is given
(widget.query != null
? (element.name?.contains(widget.query!) ?? false) ||
(element.topic?.contains(widget.query!) ?? false)
// in case not, just leave it...
: true) &&
client.rooms
.every((knownRoom) => element.roomId != knownRoom.id),
);
if (rooms.isEmpty && !thereWereRooms) {
child = const ListTile(key: ValueKey(false));
}
child = Column(
key: ValueKey(widget.space),
mainAxisSize: MainAxisSize.min,
children: [
SearchTitle(
title: L10n.of(context)!.suggestedRooms,
icon: const Icon(Icons.auto_awesome_outlined),
trailing: completer.isCompleted
? const Icon(
Icons.refresh_outlined,
size: 16,
)
: const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
),
),
onTap: _refreshRooms,
),
if (rooms.isEmpty && thereWereRooms)
ListTile(
leading: const Icon(Icons.info),
title: Text(L10n.of(context)!.allSuggestedRoomsJoined),
),
...rooms.map(
(e) => RecommendedRoomListItem(
room: e,
onRoomJoined: _refreshRooms,
),
),
],
);
} else {
child = Column(
key: const ValueKey(null),
children: [
if (!snapshot.hasError) const LinearProgressIndicator(),
const ListTile(),
],
);
}
return PageTransitionSwitcher(
// prevent the animation from re-building on dependency change
key: ValueKey(widget.space),
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: Alignment.topCenter,
children: children,
),
child: child,
);
},
);
} else {
return Container();
}
}
void _refreshRooms() => setState(
() => SpacesHierarchyProposals._cache[widget.space!]!.invalidate(),
);
}

View File

@ -1,7 +1,5 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:vrouter/vrouter.dart';
@ -13,39 +11,68 @@ class StartChatFloatingActionButton extends StatelessWidget {
const StartChatFloatingActionButton({Key? key, required this.controller})
: super(key: key);
void _onPressed(BuildContext context) {
switch (controller.activeFilter) {
case ActiveFilter.allChats:
case ActiveFilter.messages:
VRouter.of(context).to('/newprivatechat');
break;
case ActiveFilter.groups:
VRouter.of(context).to('/newgroup');
break;
case ActiveFilter.spaces:
VRouter.of(context).to('/newspace');
break;
}
}
IconData get icon {
switch (controller.activeFilter) {
case ActiveFilter.allChats:
case ActiveFilter.messages:
return Icons.edit_outlined;
case ActiveFilter.groups:
return Icons.group_add_outlined;
case ActiveFilter.spaces:
return Icons.workspaces_outlined;
}
}
String getLabel(BuildContext context) {
switch (controller.activeFilter) {
case ActiveFilter.allChats:
case ActiveFilter.messages:
return L10n.of(context)!.newChat;
case ActiveFilter.groups:
return L10n.of(context)!.newGroup;
case ActiveFilter.spaces:
return L10n.of(context)!.newSpace;
}
}
@override
Widget build(BuildContext context) {
return PageTransitionSwitcher(
reverse: !controller.scrolledToTop,
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
fillColor: Colors.transparent,
child: child,
);
},
layoutBuilder: (children) => Stack(
alignment: Alignment.centerRight,
children: children,
),
child: FloatingActionButton.extended(
key: ValueKey(controller.scrolledToTop),
isExtended: controller.scrolledToTop,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
onPressed: () => VRouter.of(context).to('/newprivatechat'),
icon: const Icon(CupertinoIcons.chat_bubble),
label: Text(
L10n.of(context)!.newChat,
overflow: TextOverflow.fade,
),
),
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: controller.scrolledToTop ? 144 : 64,
child: controller.scrolledToTop
? FloatingActionButton.extended(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
onPressed: () => _onPressed(context),
icon: Icon(icon),
label: Text(
getLabel(context),
overflow: TextOverflow.fade,
),
)
: FloatingActionButton(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
onPressed: () => _onPressed(context),
child: Icon(icon),
),
);
}
}

View File

@ -77,12 +77,6 @@ class SettingsChatView extends StatelessWidget {
),
),
const Divider(height: 1),
SettingsSwitchListTile.adaptive(
title: L10n.of(context)!.showDirectChatsInSpaces,
onChanged: (b) => AppConfig.showDirectChatsInSpaces = b,
storeKey: SettingKeys.showDirectChatsInSpaces,
defaultValue: AppConfig.showDirectChatsInSpaces,
),
SettingsSwitchListTile.adaptive(
title: L10n.of(context)!.separateChatTypes,
onChanged: (b) => AppConfig.separateChatTypes = b,

View File

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

View File

@ -1,23 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// this is a workaround to allow navigation of spaces out from any widget.
/// Reason is that we have no reliable way to listen on *query* changes of
/// VRouter.
///
/// Time wasted: 3h
abstract class SpaceNavigator {
const SpaceNavigator._();
// TODO(TheOneWithTheBraid): adjust routing table in order to represent spaces
// ... in any present path
static final routeObserver = RouteObserver();
static final StreamController<String?> _controller =
StreamController.broadcast();
static Stream<String?> get stream => _controller.stream;
static void navigateToSpace(String? spaceId) => _controller.add(spaceId);
}

View File

@ -10,7 +10,6 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/space_navigator.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/profile_bottom_sheet.dart';
import 'package:fluffychat/widgets/public_room_bottom_sheet.dart';
@ -133,7 +132,7 @@ class UrlLauncher {
servers.addAll(identityParts.via);
if (room != null) {
if (room.isSpace) {
SpaceNavigator.navigateToSpace(room.id);
// TODO: Implement navigate to space
VRouter.of(context).toSegments(['rooms']);
return;
}

View File

@ -11,7 +11,6 @@ import 'package:fluffychat/config/routes.dart';
import 'package:fluffychat/config/themes.dart';
import '../config/app_config.dart';
import '../utils/custom_scroll_behaviour.dart';
import '../utils/space_navigator.dart';
import 'matrix.dart';
class FluffyChatApp extends StatefulWidget {
@ -62,18 +61,14 @@ class FluffyChatAppState extends State<FluffyChatApp> {
initial: AdaptiveThemeMode.system,
builder: (theme, darkTheme) => LayoutBuilder(
builder: (context, constraints) {
const maxColumns = 3;
var newColumns =
(constraints.maxWidth / FluffyThemes.columnWidth).floor();
if (newColumns > maxColumns) newColumns = maxColumns;
columnMode ??= newColumns > 1;
_router ??= GlobalKey<VRouterState>();
if (columnMode != newColumns > 1) {
Logs().v('Set Column Mode = $columnMode');
final isColumnMode =
FluffyThemes.isColumnModeByWidth(constraints.maxWidth);
if (isColumnMode != columnMode) {
Logs().v('Set Column Mode = $isColumnMode');
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_initialUrl = _router?.currentState?.url;
columnMode = newColumns > 1;
columnMode = isColumnMode;
_router = GlobalKey<VRouterState>();
});
});
@ -86,9 +81,6 @@ class FluffyChatAppState extends State<FluffyChatApp> {
logs: kReleaseMode ? VLogs.none : VLogs.info,
darkTheme: darkTheme,
localizationsDelegates: L10n.localizationsDelegates,
navigatorObservers: [
SpaceNavigator.routeObserver,
],
supportedLocales: L10n.supportedLocales,
initialUrl: _initialUrl ?? '/',
routes: AppRoutes(columnMode ?? false).routes,

View File

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

View File

@ -138,7 +138,7 @@ SPEC CHECKSUMS:
flutter_secure_storage_macos: 6ceee8fbc7f484553ad17f79361b556259df89aa
flutter_web_auth: ae2c29ca9b98c00b4e0e8c0919bb4a05d44b76df
flutter_webrtc: 39478671aae60497438bceafc011357911e00056
FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424
FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
geolocator_apple: 821be05bbdb1b49500e029ebcbf2d6acf2dfb966
just_audio: 9b67ca7b97c61cfc9784ea23cd8cc55eb226d489