mirror of
https://gitlab.com/famedly/fluffychat.git
synced 2024-12-23 22:12:34 +01:00
feat: New navigation design
This commit is contained in:
parent
560ee3d39f
commit
57649c70e5
@ -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"
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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 }
|
||||
|
@ -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;
|
||||
|
@ -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');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
309
lib/pages/chat_list/space_view.dart
Normal file
309
lib/pages/chat_list/space_view.dart
Normal 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,
|
||||
}
|
@ -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),
|
||||
));
|
||||
}
|
||||
}
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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(),
|
||||
);
|
||||
}
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user