Initial commit
This commit is contained in:
49
lib/pages/chat_list/active_call_indicator.dart
Normal file
49
lib/pages/chat_list/active_call_indicator.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
|
||||
import 'package:tatchat/widgets/pulsating_widget.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class ActiveCallIndicator extends StatelessWidget {
|
||||
final Room room;
|
||||
const ActiveCallIndicator({required this.room, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return PulsatingWidget(
|
||||
spreadRadius: 3.0,
|
||||
color: theme.colorScheme.error,
|
||||
child: Material(
|
||||
color: theme.colorScheme.error,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Row(
|
||||
spacing: 4.0,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
switch (room.activeMatrixRtcCallIntent) {
|
||||
.voice => Icons.video_call_outlined,
|
||||
_ => Icons.mic_outlined,
|
||||
},
|
||||
size: 16,
|
||||
color: theme.colorScheme.onError,
|
||||
),
|
||||
Text(
|
||||
room.getActiveMatrixRtcMembers().length.toString(),
|
||||
style: TextStyle(color: theme.colorScheme.onError),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1098
lib/pages/chat_list/chat_list.dart
Normal file
1098
lib/pages/chat_list/chat_list.dart
Normal file
File diff suppressed because it is too large
Load Diff
338
lib/pages/chat_list/chat_list_body.dart
Normal file
338
lib/pages/chat_list/chat_list_body.dart
Normal file
@@ -0,0 +1,338 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
||||
import 'package:tatchat/pages/chat_list/chat_list_item.dart';
|
||||
import 'package:tatchat/pages/chat_list/dummy_chat_list_item.dart';
|
||||
import 'package:tatchat/pages/chat_list/search_title.dart';
|
||||
import 'package:tatchat/pages/chat_list/space_view.dart';
|
||||
import 'package:tatchat/utils/stream_extension.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/public_room_dialog.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../config/themes.dart';
|
||||
import '../../widgets/adaptive_dialogs/user_dialog.dart';
|
||||
import '../../widgets/matrix.dart';
|
||||
import 'chat_list_header.dart';
|
||||
|
||||
class ChatListViewBody extends StatelessWidget {
|
||||
final ChatListController controller;
|
||||
|
||||
const ChatListViewBody(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final client = Matrix.of(context).client;
|
||||
final activeSpace = controller.activeSpaceId;
|
||||
if (activeSpace != null) {
|
||||
return SpaceView(
|
||||
key: ValueKey(activeSpace),
|
||||
spaceId: activeSpace,
|
||||
onBack: controller.clearActiveSpace,
|
||||
onChatTab: controller.onChatTap,
|
||||
activeChat: controller.activeChat,
|
||||
);
|
||||
}
|
||||
final spaces = client.rooms.where((r) => r.isSpace);
|
||||
final spaceDelegateCandidates = <String, Room>{};
|
||||
for (final space in spaces) {
|
||||
for (final spaceChild in space.spaceChildren) {
|
||||
final roomId = spaceChild.roomId;
|
||||
if (roomId == null) continue;
|
||||
spaceDelegateCandidates[roomId] = space;
|
||||
}
|
||||
}
|
||||
|
||||
final publicRooms = controller.roomSearchResult?.chunk
|
||||
.where((room) => room.roomType != 'm.space')
|
||||
.toList();
|
||||
final publicSpaces = controller.roomSearchResult?.chunk
|
||||
.where((room) => room.roomType == 'm.space')
|
||||
.toList();
|
||||
final userSearchResult = controller.userSearchResult;
|
||||
const dummyChatCount = 4;
|
||||
final filter = controller.searchController.text.toLowerCase();
|
||||
return StreamBuilder(
|
||||
key: ValueKey(client.userID.toString()),
|
||||
stream: client.onSync.stream
|
||||
.where((s) => s.hasRoomUpdate)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, _) {
|
||||
final rooms = controller.filteredRooms
|
||||
.where(
|
||||
(room) =>
|
||||
!AppSettings.hideRoomsInSpaces.value ||
|
||||
spaceDelegateCandidates[room.id] == null,
|
||||
)
|
||||
.toList();
|
||||
|
||||
return CustomScrollView(
|
||||
controller: controller.scrollController,
|
||||
slivers: [
|
||||
ChatListHeader(controller: controller),
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
if (controller.isSearchMode) ...[
|
||||
SearchTitle(
|
||||
title: L10n.of(context).publicRooms,
|
||||
icon: const Icon(Icons.explore_outlined),
|
||||
),
|
||||
PublicRoomsHorizontalList(publicRooms: publicRooms),
|
||||
SearchTitle(
|
||||
title: L10n.of(context).publicSpaces,
|
||||
icon: const Icon(Icons.workspaces_outlined),
|
||||
),
|
||||
PublicRoomsHorizontalList(publicRooms: publicSpaces),
|
||||
SearchTitle(
|
||||
title: L10n.of(context).users,
|
||||
icon: const Icon(Icons.group_outlined),
|
||||
),
|
||||
AnimatedContainer(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
height:
|
||||
userSearchResult == null ||
|
||||
userSearchResult.results.isEmpty
|
||||
? 0
|
||||
: 106,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: userSearchResult == null
|
||||
? null
|
||||
: 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: () => UserDialog.show(
|
||||
context: context,
|
||||
profile: userSearchResult.results[i],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (client.rooms.isNotEmpty && !controller.isSearchMode)
|
||||
Container(
|
||||
height: 36 + 8 + 8,
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
...ActiveFilter.values
|
||||
.where((filter) => filter != ActiveFilter.tag)
|
||||
.map(
|
||||
(filter) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4.0,
|
||||
),
|
||||
child: Center(
|
||||
child: FilterChip(
|
||||
selected: filter == controller.activeFilter,
|
||||
onSelected: (_) => controller
|
||||
.setActiveFilter(filter, null),
|
||||
label: Text(
|
||||
filter.toLocalizedString(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
...controller.roomTags.entries.map(
|
||||
(entry) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4.0,
|
||||
),
|
||||
child: Center(
|
||||
child: FilterChip(
|
||||
selected: entry.key == controller.activeTag,
|
||||
onSelected: (_) => controller.setActiveFilter(
|
||||
ActiveFilter.tag,
|
||||
entry.key,
|
||||
),
|
||||
label: Text(entry.key.replaceFirst('u.', '')),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (controller.isSearchMode)
|
||||
SearchTitle(
|
||||
title: L10n.of(context).chats,
|
||||
icon: const Icon(Icons.forum_outlined),
|
||||
),
|
||||
if (client.prevBatch != null &&
|
||||
rooms.isEmpty &&
|
||||
!controller.isSearchMode) ...[
|
||||
Column(
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
const Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
DummyChatListItem(opacity: 0.5, animate: false),
|
||||
DummyChatListItem(opacity: 0.3, animate: false),
|
||||
],
|
||||
),
|
||||
Icon(
|
||||
CupertinoIcons.chat_bubble_text_fill,
|
||||
size: 128,
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
client.rooms.isEmpty
|
||||
? L10n.of(context).noChatsFoundHere
|
||||
: L10n.of(context).noMoreChatsFound,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
if (client.prevBatch == null)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) => DummyChatListItem(
|
||||
opacity: (dummyChatCount - i) / dummyChatCount,
|
||||
animate: true,
|
||||
),
|
||||
childCount: dummyChatCount,
|
||||
),
|
||||
),
|
||||
if (client.prevBatch != null)
|
||||
SliverSafeArea(
|
||||
top: false,
|
||||
sliver: SliverList.builder(
|
||||
itemCount: rooms.length,
|
||||
itemBuilder: (BuildContext context, int i) {
|
||||
final room = rooms[i];
|
||||
final space = spaceDelegateCandidates[room.id];
|
||||
return ChatListItem(
|
||||
room,
|
||||
space: space,
|
||||
key: Key('chat_list_item_${room.id}'),
|
||||
filter: filter,
|
||||
onTap: () => controller.onChatTap(room),
|
||||
onLongPress: (context) =>
|
||||
controller.chatContextAction(room, context, space),
|
||||
activeChat: controller.activeChat == room.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PublicRoomsHorizontalList extends StatelessWidget {
|
||||
const PublicRoomsHorizontalList({super.key, required this.publicRooms});
|
||||
|
||||
final List<PublishedRoomsChunk>? publicRooms;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final publicRooms = this.publicRooms;
|
||||
return AnimatedContainer(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
height: publicRooms == null || publicRooms.isEmpty ? 0 : 106,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: publicRooms == null
|
||||
? null
|
||||
: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: publicRooms.length,
|
||||
itemBuilder: (context, i) => _SearchItem(
|
||||
title:
|
||||
publicRooms[i].name ??
|
||||
publicRooms[i].canonicalAlias?.localpart ??
|
||||
L10n.of(context).group,
|
||||
avatar: publicRooms[i].avatarUrl,
|
||||
onPressed: () => showAdaptiveDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (c) => PublicRoomDialog(
|
||||
roomAlias:
|
||||
publicRooms[i].canonicalAlias ?? publicRooms[i].roomId,
|
||||
chunk: publicRooms[i],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchItem extends StatelessWidget {
|
||||
final String title;
|
||||
final Uri? avatar;
|
||||
final void Function() onPressed;
|
||||
|
||||
const _SearchItem({
|
||||
required this.title,
|
||||
this.avatar,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => InkWell(
|
||||
onTap: onPressed,
|
||||
child: SizedBox(
|
||||
width: 84,
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Avatar(mxContent: avatar, name: title),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
152
lib/pages/chat_list/chat_list_header.dart
Normal file
152
lib/pages/chat_list/chat_list_header.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
||||
import 'package:tatchat/pages/chat_list/client_chooser_button.dart';
|
||||
import 'package:tatchat/utils/sync_status_localization.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../widgets/matrix.dart';
|
||||
|
||||
class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
|
||||
final ChatListController controller;
|
||||
final bool globalSearch;
|
||||
|
||||
const ChatListHeader({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.globalSearch = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final client = Matrix.of(context).client;
|
||||
final isColumnMode = FluffyThemes.isColumnMode(context);
|
||||
|
||||
return SliverAppBar(
|
||||
floating: true,
|
||||
toolbarHeight: 72,
|
||||
pinned: isColumnMode,
|
||||
scrolledUnderElevation: 0,
|
||||
shape: isColumnMode
|
||||
? Border(bottom: BorderSide(color: theme.dividerColor, width: 1))
|
||||
: null,
|
||||
backgroundColor: isColumnMode
|
||||
? theme.colorScheme.surface.withAlpha(240)
|
||||
: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
title: StreamBuilder(
|
||||
stream: client.onSyncStatus.stream,
|
||||
builder: (context, snapshot) {
|
||||
final status =
|
||||
client.onSyncStatus.value ??
|
||||
const SyncStatusUpdate(SyncStatus.waitingForResponse);
|
||||
final hide =
|
||||
client.onSync.value != null &&
|
||||
status.status != SyncStatus.error &&
|
||||
client.prevBatch != null;
|
||||
return TextField(
|
||||
controller: controller.searchController,
|
||||
focusNode: controller.searchFocusNode,
|
||||
textInputAction: TextInputAction.search,
|
||||
onChanged: (text) =>
|
||||
controller.onSearchEnter(text, globalSearch: globalSearch),
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.secondaryContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
hintText: hide
|
||||
? L10n.of(context).searchChatsRooms
|
||||
: status.calcLocalizedString(context),
|
||||
hintStyle: TextStyle(
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
|
||||
prefixIcon: hide
|
||||
? controller.isSearchMode
|
||||
? IconButton(
|
||||
tooltip: L10n.of(context).cancel,
|
||||
icon: const Icon(Icons.close_outlined),
|
||||
onPressed: controller.cancelSearch,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
: isColumnMode ||
|
||||
(controller.spaces.isEmpty &&
|
||||
!AppSettings.displayNavigationRail.value)
|
||||
? IconButton(
|
||||
tooltip: L10n.of(context).search,
|
||||
onPressed: controller.startSearch,
|
||||
icon: Icon(
|
||||
Icons.search_outlined,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
tooltip: L10n.of(context).displayNavigationRail,
|
||||
onPressed: controller.openNavrail,
|
||||
icon: Icon(
|
||||
Icons.menu,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2,
|
||||
value: status.progress,
|
||||
),
|
||||
),
|
||||
),
|
||||
suffixIcon: controller.isSearchMode && globalSearch
|
||||
? controller.isSearching
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 10.0,
|
||||
horizontal: 12,
|
||||
),
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
)
|
||||
: TextButton.icon(
|
||||
onPressed: controller.setServer,
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
label: Text(
|
||||
controller.searchServer ??
|
||||
Matrix.of(context).client.homeserver!.host,
|
||||
maxLines: 2,
|
||||
),
|
||||
)
|
||||
: SizedBox(width: 0, child: ClientChooserButton(controller)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(56);
|
||||
}
|
||||
412
lib/pages/chat_list/chat_list_item.dart
Normal file
412
lib/pages/chat_list/chat_list_item.dart
Normal file
@@ -0,0 +1,412 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat_list/active_call_indicator.dart';
|
||||
import 'package:tatchat/pages/chat_list/unread_bubble.dart';
|
||||
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:tatchat/utils/room_status_extension.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/hover_builder.dart';
|
||||
import 'package:tatchat/widgets/typing_animation.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../config/themes.dart';
|
||||
import '../../utils/date_time_extension.dart';
|
||||
import '../../widgets/avatar.dart';
|
||||
|
||||
class ChatListItem extends StatelessWidget {
|
||||
final Room room;
|
||||
final Room? space;
|
||||
final bool activeChat;
|
||||
final void Function(BuildContext context)? onLongPress;
|
||||
final void Function()? onForget;
|
||||
final void Function() onTap;
|
||||
final String? filter;
|
||||
|
||||
const ChatListItem(
|
||||
this.room, {
|
||||
this.activeChat = false,
|
||||
required this.onTap,
|
||||
this.onLongPress,
|
||||
this.onForget,
|
||||
this.filter,
|
||||
this.space,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final isMuted = room.pushRuleState != PushRuleState.notify;
|
||||
final typingText = room.getLocalizedTypingText(context);
|
||||
final lastEvent = room.lastEvent;
|
||||
final ownMessage = lastEvent?.senderId == room.client.userID;
|
||||
final directChatMatrixId = room.directChatMatrixID;
|
||||
final isDirectChat = directChatMatrixId != null;
|
||||
final hasNotifications = room.notificationCount > 0;
|
||||
final backgroundColor = activeChat
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: null;
|
||||
final displayname = room.getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
);
|
||||
final filter = this.filter;
|
||||
if (filter != null && !displayname.toLowerCase().contains(filter)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final needLastEventSender =
|
||||
lastEvent != null &&
|
||||
room.getState(EventTypes.RoomMember, lastEvent.senderId) == null;
|
||||
final space = this.space;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
color: backgroundColor,
|
||||
child: FutureBuilder(
|
||||
future: room.name.isEmpty ? room.loadHeroUsers() : null,
|
||||
builder: (context, _) => HoverBuilder(
|
||||
builder: (context, listTileHovered) => ListTile(
|
||||
minVerticalPadding: 16,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
onLongPress: () => onLongPress?.call(context),
|
||||
leading: HoverBuilder(
|
||||
builder: (context, hovered) => AnimatedScale(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
scale: hovered ? 1.1 : 1.0,
|
||||
child: SizedBox(
|
||||
width: Avatar.defaultSize,
|
||||
height: Avatar.defaultSize,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (space != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: Avatar(
|
||||
shapeBorder: RoundedSuperellipseBorder(
|
||||
side: BorderSide(
|
||||
width: 2,
|
||||
color:
|
||||
backgroundColor ??
|
||||
theme.colorScheme.surface,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.spaceBorderRadius * 0.75,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.spaceBorderRadius * 0.75,
|
||||
),
|
||||
mxContent: space.avatar,
|
||||
size: Avatar.defaultSize * 0.75,
|
||||
name: space.getLocalizedDisplayname(),
|
||||
onTap: () => onLongPress?.call(context),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Avatar(
|
||||
shapeBorder: space == null
|
||||
? room.isSpace
|
||||
? RoundedSuperellipseBorder(
|
||||
side: BorderSide(
|
||||
width: 1,
|
||||
color: theme.dividerColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.spaceBorderRadius,
|
||||
),
|
||||
)
|
||||
: null
|
||||
: RoundedRectangleBorder(
|
||||
side: BorderSide(
|
||||
width: 2,
|
||||
color:
|
||||
backgroundColor ??
|
||||
theme.colorScheme.surface,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
Avatar.defaultSize,
|
||||
),
|
||||
),
|
||||
borderRadius: room.isSpace
|
||||
? BorderRadius.circular(
|
||||
AppConfig.spaceBorderRadius,
|
||||
)
|
||||
: null,
|
||||
mxContent: room.avatar,
|
||||
size: space != null
|
||||
? Avatar.defaultSize * 0.75
|
||||
: Avatar.defaultSize,
|
||||
name: displayname,
|
||||
presenceUserId: directChatMatrixId,
|
||||
presenceBackgroundColor: backgroundColor,
|
||||
onTap: () => onLongPress?.call(context),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () => onLongPress?.call(context),
|
||||
child: AnimatedScale(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
scale: listTileHovered ? 1.0 : 0.0,
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: const Icon(
|
||||
Icons.arrow_drop_down_circle_outlined,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
if (isMuted)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 4.0),
|
||||
child: Icon(Icons.notifications_off_outlined, size: 16),
|
||||
),
|
||||
if (room.isLowPriority)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: hasNotifications ? 4.0 : 0.0,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.low_priority,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
if (room.isFavourite)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: hasNotifications ? 4.0 : 0.0,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.push_pin,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
if (!room.isSpace && room.membership != Membership.invite)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0),
|
||||
child: Text(
|
||||
room.latestEventReceivedTime.localizedTimeShort(
|
||||
context,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: room.hasNewMessages
|
||||
? FontWeight.bold
|
||||
: null,
|
||||
color: room.highlightCount >= 1
|
||||
? theme.colorScheme.error
|
||||
: room.hasNewMessages
|
||||
? theme.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
if (room.hasActiveMatrixRtcCall)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: ActiveCallIndicator(room: room),
|
||||
),
|
||||
if (typingText.isEmpty &&
|
||||
ownMessage &&
|
||||
room.lastEvent?.status.isSending == true) ...[
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
AnimatedSize(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: typingText.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: TypingAnimation(size: 4.0),
|
||||
)
|
||||
: room.lastEvent?.relationshipType ==
|
||||
RelationshipTypes.thread
|
||||
? Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
),
|
||||
margin: const EdgeInsets.only(right: 4.0),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.message_outlined,
|
||||
size: 12,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
L10n.of(context).thread,
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
Expanded(
|
||||
child: room.isSpace && room.membership == Membership.join
|
||||
? Text(
|
||||
L10n.of(
|
||||
context,
|
||||
).countChats(room.spaceChildren.length),
|
||||
)
|
||||
: typingText.isNotEmpty
|
||||
? Text(
|
||||
typingText,
|
||||
style: TextStyle(color: theme.colorScheme.primary),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
)
|
||||
: FutureBuilder(
|
||||
key: ValueKey(
|
||||
'${lastEvent?.eventId}_${lastEvent?.type}_${lastEvent?.redacted}',
|
||||
),
|
||||
future: needLastEventSender
|
||||
? lastEvent.calcLocalizedBody(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
hideReply: true,
|
||||
hideEdit: true,
|
||||
plaintextBody: true,
|
||||
removeMarkdown: true,
|
||||
withSenderNamePrefix:
|
||||
(!isDirectChat ||
|
||||
directChatMatrixId !=
|
||||
room.lastEvent?.senderId),
|
||||
)
|
||||
: null,
|
||||
initialData: lastEvent?.calcLocalizedBodyFallback(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
hideReply: true,
|
||||
hideEdit: true,
|
||||
plaintextBody: true,
|
||||
removeMarkdown: true,
|
||||
withSenderNamePrefix:
|
||||
(!isDirectChat ||
|
||||
directChatMatrixId !=
|
||||
room.lastEvent?.senderId),
|
||||
),
|
||||
builder: (context, snapshot) => Text(
|
||||
room.membership == Membership.invite
|
||||
? room
|
||||
.getState(
|
||||
EventTypes.RoomMember,
|
||||
room.client.userID!,
|
||||
)
|
||||
?.content
|
||||
.tryGet<String>('reason') ??
|
||||
(isDirectChat
|
||||
? L10n.of(context).newChatRequest
|
||||
: L10n.of(context).inviteGroupChat)
|
||||
: snapshot.data?.trim().replaceAll(
|
||||
'\n',
|
||||
' ',
|
||||
) ??
|
||||
L10n.of(context).noMessagesYet,
|
||||
softWrap: false,
|
||||
maxLines: room.notificationCount >= 1 ? 2 : 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
decoration: room.lastEvent?.redacted == true
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
UnreadBubble(room: room),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
trailing: onForget == null
|
||||
? room.membership == Membership.invite
|
||||
? IconButton(
|
||||
tooltip: L10n.of(context).declineInvitation,
|
||||
icon: const Icon(Icons.delete_forever_outlined),
|
||||
color: theme.colorScheme.error,
|
||||
onPressed: () async {
|
||||
final consent = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).declineInvitation,
|
||||
message: L10n.of(context).areYouSure,
|
||||
okLabel: L10n.of(context).yes,
|
||||
isDestructive: true,
|
||||
);
|
||||
if (consent != OkCancelResult.ok) return;
|
||||
if (!context.mounted) return;
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: room.leave,
|
||||
);
|
||||
},
|
||||
)
|
||||
: null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
onPressed: onForget,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/pages/chat_list/chat_list_view.dart
Normal file
146
lib/pages/chat_list/chat_list_view.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
||||
import 'package:tatchat/pages/chat_list/navigation_rail.dart';
|
||||
import 'package:tatchat/pages/chat_list/start_chat_fab.dart';
|
||||
import 'package:tatchat/pages/chat_list/tabs/channels_tab.dart';
|
||||
import 'package:tatchat/pages/chat_list/tabs/chat_tab_bar.dart';
|
||||
import 'package:tatchat/pages/chat_list/tabs/home_tab.dart';
|
||||
import 'package:tatchat/pages/chat_list/tabs/murals_tab.dart';
|
||||
import 'package:tatchat/pages/chat_list/tabs/spaces_tab.dart';
|
||||
|
||||
import 'chat_list_body.dart';
|
||||
|
||||
class ChatListView extends StatelessWidget {
|
||||
final ChatListController controller;
|
||||
|
||||
const ChatListView(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final oneColumnSpacesMode =
|
||||
!FluffyThemes.isColumnMode(context) &&
|
||||
AppSettings.displayNavigationRail.value;
|
||||
final isColumnMode = FluffyThemes.isColumnMode(context);
|
||||
final homeTab = controller.homeTab;
|
||||
final showSpacesMode = homeTab == HomeTab.chats;
|
||||
final showNavigationRail =
|
||||
showSpacesMode &&
|
||||
(isColumnMode || AppSettings.displayNavigationRail.value);
|
||||
return PopScope(
|
||||
canPop: !controller.isSearchMode && controller.activeSpaceId == null,
|
||||
onPopInvokedWithResult: (pop, _) {
|
||||
if (pop) return;
|
||||
if (controller.activeSpaceId != null) {
|
||||
controller.clearActiveSpace();
|
||||
return;
|
||||
}
|
||||
if (controller.isSearchMode) {
|
||||
controller.cancelSearch();
|
||||
return;
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (isColumnMode)
|
||||
ChatTopTabBar(
|
||||
currentTab: homeTab,
|
||||
onTabSelected: controller.setHomeTab,
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: showNavigationRail
|
||||
? SpacesNavigationRail(
|
||||
activeSpaceId: controller.activeSpaceId,
|
||||
onGoToChats: controller.clearActiveSpace,
|
||||
onGoToSpaceId: controller.setActiveSpace,
|
||||
)
|
||||
: SizedBox(
|
||||
width: 0,
|
||||
height: MediaQuery.sizeOf(context).height,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showNavigationRail && isColumnMode)
|
||||
Container(width: 1, color: Theme.of(context).dividerColor),
|
||||
|
||||
Expanded(
|
||||
child: Scaffold(
|
||||
backgroundColor: showSpacesMode && oneColumnSpacesMode
|
||||
? Theme.of(context).colorScheme.surfaceContainer
|
||||
: null,
|
||||
body: SafeArea(
|
||||
top: showSpacesMode && oneColumnSpacesMode,
|
||||
bottom: false,
|
||||
left: false,
|
||||
right: false,
|
||||
child: IndexedStack(
|
||||
index: homeTab.index,
|
||||
children: [
|
||||
Material(
|
||||
clipBehavior: showSpacesMode && oneColumnSpacesMode
|
||||
? Clip.hardEdge
|
||||
: Clip.none,
|
||||
borderRadius:
|
||||
showSpacesMode && oneColumnSpacesMode
|
||||
? BorderRadius.only(
|
||||
topLeft: Radius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
color: showSpacesMode && oneColumnSpacesMode
|
||||
? Theme.of(context).colorScheme.surface
|
||||
: null,
|
||||
child: ChatListViewBody(controller),
|
||||
),
|
||||
ChannelsTab(controller),
|
||||
SpacesTab(controller),
|
||||
MuralsTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton:
|
||||
showSpacesMode &&
|
||||
!controller.isSearchMode &&
|
||||
controller.activeSpaceId == null &&
|
||||
!isColumnMode
|
||||
? ValueListenableBuilder(
|
||||
valueListenable: controller.scrolledToTop,
|
||||
builder: (context, scrolledToTop, _) => StartChatFab(
|
||||
extended:
|
||||
scrolledToTop &&
|
||||
!AppSettings.displayNavigationRail.value,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
bottomNavigationBar: isColumnMode
|
||||
? null
|
||||
: ChatBottomTabBar(
|
||||
currentTab: homeTab,
|
||||
onTabSelected: controller.setHomeTab,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
241
lib/pages/chat_list/client_chooser_button.dart
Normal file
241
lib/pages/chat_list/client_chooser_button.dart
Normal file
@@ -0,0 +1,241 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart' hide Result;
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import 'package:tatchat/utils/tatu_share.dart';
|
||||
import 'chat_list.dart';
|
||||
|
||||
class ClientChooserButton extends StatelessWidget {
|
||||
final ChatListController controller;
|
||||
|
||||
const ClientChooserButton(this.controller, {super.key});
|
||||
|
||||
List<PopupMenuEntry<Object>> _bundleMenuItems(BuildContext context) {
|
||||
final matrix = Matrix.of(context);
|
||||
final bundles = matrix.accountBundles.keys.toList()
|
||||
..sort(
|
||||
(a, b) => a!.isValidMatrixIdStrict() == b!.isValidMatrixIdStrict()
|
||||
? 0
|
||||
: a.isValidMatrixIdStrict() && !b.isValidMatrixIdStrict()
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
return <PopupMenuEntry<Object>>[
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.newGroup,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.group_add_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).createGroup),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.setStatus,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).setStatus),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.invite,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.adaptive.share_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).inviteContact),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.archive,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.archive_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).archive),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.settings,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.settings_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).settings),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.support,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.favorite, color: Colors.red),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).supportFluffyChat),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
for (final bundle in bundles) ...[
|
||||
if (matrix.accountBundles[bundle]!.length != 1 ||
|
||||
matrix.accountBundles[bundle]!.single!.userID != bundle)
|
||||
PopupMenuItem(
|
||||
value: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(
|
||||
bundle!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).textTheme.titleMedium!.color,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
...matrix.accountBundles[bundle]!
|
||||
.whereType<Client>()
|
||||
.where((client) => client.isLogged())
|
||||
.map(
|
||||
(client) => PopupMenuItem(
|
||||
value: client,
|
||||
child: FutureBuilder<Profile?>(
|
||||
future: client.fetchOwnProfile(),
|
||||
builder: (context, snapshot) {
|
||||
final displayname =
|
||||
snapshot.data?.displayName ?? client.userID!.localpart!;
|
||||
return Row(
|
||||
key: ValueKey('switch_account_$displayname'),
|
||||
children: [
|
||||
Avatar(
|
||||
mxContent: snapshot.data?.avatarUrl,
|
||||
name: displayname,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayname,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => controller.editBundlesForAccount(
|
||||
client.userID,
|
||||
bundle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.addAccount,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person_add_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).addAccount),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final matrix = Matrix.of(context);
|
||||
final client = Result(() => matrix.client).asValue?.value;
|
||||
|
||||
var clientCount = 0;
|
||||
matrix.accountBundles.forEach((key, value) => clientCount += value.length);
|
||||
return FutureBuilder<Profile>(
|
||||
future: client?.isLogged() == true ? client?.fetchOwnProfile() : null,
|
||||
builder: (context, snapshot) => Material(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
color: Colors.transparent,
|
||||
child: PopupMenuButton<Object>(
|
||||
key: Key('accounts_and_settings_buttons'),
|
||||
tooltip: L10n.of(context).accountsAndSettings,
|
||||
onSelected: (o) => _clientSelected(o, context),
|
||||
itemBuilder: _bundleMenuItems,
|
||||
icon: Avatar(
|
||||
mxContent: snapshot.data?.avatarUrl,
|
||||
name: snapshot.data?.displayName ?? client?.userID?.localpart,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clientSelected(Object object, BuildContext context) async {
|
||||
if (object is Client) {
|
||||
controller.setActiveClient(object);
|
||||
} else if (object is String) {
|
||||
controller.setActiveBundle(object);
|
||||
} else if (object is SettingsAction) {
|
||||
switch (object) {
|
||||
case SettingsAction.addAccount:
|
||||
if (!context.mounted) return;
|
||||
context.go('/rooms/settings/addaccount');
|
||||
break;
|
||||
case SettingsAction.newGroup:
|
||||
context.go('/rooms/newgroup');
|
||||
break;
|
||||
case SettingsAction.invite:
|
||||
TatuShare.shareInviteLink(context);
|
||||
break;
|
||||
case SettingsAction.support:
|
||||
launchUrlString(
|
||||
'https://ko-fi.com/post/How-can-I-support-FluffyChat-J2G325WE6I',
|
||||
);
|
||||
break;
|
||||
case SettingsAction.settings:
|
||||
context.go('/rooms/settings');
|
||||
break;
|
||||
case SettingsAction.archive:
|
||||
context.go('/rooms/archive');
|
||||
break;
|
||||
case SettingsAction.setStatus:
|
||||
controller.setStatus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsAction {
|
||||
addAccount,
|
||||
newGroup,
|
||||
setStatus,
|
||||
invite,
|
||||
support,
|
||||
settings,
|
||||
archive,
|
||||
}
|
||||
77
lib/pages/chat_list/dummy_chat_list_item.dart
Normal file
77
lib/pages/chat_list/dummy_chat_list_item.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class DummyChatListItem extends StatelessWidget {
|
||||
final double opacity;
|
||||
final bool animate;
|
||||
|
||||
const DummyChatListItem({
|
||||
required this.opacity,
|
||||
required this.animate,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final titleColor = theme.textTheme.bodyLarge!.color!.withAlpha(100);
|
||||
final subtitleColor = theme.textTheme.bodyLarge!.color!.withAlpha(50);
|
||||
return Opacity(
|
||||
opacity: opacity,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: titleColor,
|
||||
child: animate
|
||||
? CircularProgressIndicator(
|
||||
strokeWidth: 1,
|
||||
color: theme.textTheme.bodyLarge!.color,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: titleColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 36),
|
||||
Container(
|
||||
height: 14,
|
||||
width: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: subtitleColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
height: 14,
|
||||
width: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: subtitleColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: subtitleColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
height: 12,
|
||||
margin: const EdgeInsets.only(right: 22),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/pages/chat_list/navi_rail_item.dart
Normal file
103
lib/pages/chat_list/navi_rail_item.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:badges/badges.dart';
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/widgets/hover_builder.dart';
|
||||
import 'package:tatchat/widgets/unread_rooms_badge.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../config/themes.dart';
|
||||
|
||||
class NaviRailItem extends StatelessWidget {
|
||||
final String toolTip;
|
||||
final bool isSelected;
|
||||
final void Function() onTap;
|
||||
final Widget icon;
|
||||
final Widget? selectedIcon;
|
||||
final bool Function(Room)? unreadBadgeFilter;
|
||||
|
||||
const NaviRailItem({
|
||||
required this.toolTip,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.icon,
|
||||
this.selectedIcon,
|
||||
this.unreadBadgeFilter,
|
||||
super.key,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final borderRadius = BorderRadius.circular(AppConfig.borderRadius);
|
||||
final icon = isSelected ? selectedIcon ?? this.icon : this.icon;
|
||||
final unreadBadgeFilter = this.unreadBadgeFilter;
|
||||
return HoverBuilder(
|
||||
builder: (context, hovered) {
|
||||
return SizedBox(
|
||||
height: 64,
|
||||
width: FluffyThemes.navRailWidth,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
left: 0,
|
||||
child: AnimatedContainer(
|
||||
width: isSelected
|
||||
? FluffyThemes.isColumnMode(context)
|
||||
? 8
|
||||
: 4
|
||||
: 0,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(90),
|
||||
bottomRight: Radius.circular(90),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: AnimatedScale(
|
||||
scale: hovered ? 1.1 : 1.0,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: Material(
|
||||
borderRadius: borderRadius,
|
||||
color: isSelected
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
child: Tooltip(
|
||||
message: toolTip,
|
||||
child: InkWell(
|
||||
borderRadius: borderRadius,
|
||||
onTap: onTap,
|
||||
child: unreadBadgeFilter == null
|
||||
? icon
|
||||
: UnreadRoomsBadge(
|
||||
filter: unreadBadgeFilter,
|
||||
badgePosition: BadgePosition.topEnd(
|
||||
top: -12,
|
||||
end: -8,
|
||||
),
|
||||
child: icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
137
lib/pages/chat_list/navigation_rail.dart
Normal file
137
lib/pages/chat_list/navigation_rail.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat_list/navi_rail_item.dart';
|
||||
import 'package:tatchat/pages/chat_list/start_chat_fab.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:tatchat/utils/stream_extension.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class SpacesNavigationRail extends StatelessWidget {
|
||||
final String? activeSpaceId;
|
||||
final void Function() onGoToChats;
|
||||
final void Function(String) onGoToSpaceId;
|
||||
|
||||
const SpacesNavigationRail({
|
||||
required this.activeSpaceId,
|
||||
required this.onGoToChats,
|
||||
required this.onGoToSpaceId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = Matrix.of(context).client;
|
||||
final coloredMode = !FluffyThemes.isColumnMode(context);
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: coloredMode ? theme.colorScheme.surfaceContainer : null,
|
||||
child: SafeArea(
|
||||
child: StreamBuilder(
|
||||
key: ValueKey(client.userID.toString()),
|
||||
stream: client.onSync.stream
|
||||
.where((s) => s.hasRoomUpdate)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, _) {
|
||||
final allSpaces = client.rooms
|
||||
.where((room) => room.isSpace)
|
||||
.toList();
|
||||
|
||||
return SizedBox(
|
||||
width: FluffyThemes.isColumnMode(context)
|
||||
? FluffyThemes.navRailWidth
|
||||
: FluffyThemes.navRailWidth - 8,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.symmetric(vertical: 4),
|
||||
scrollDirection: Axis.vertical,
|
||||
itemCount: allSpaces.length + 2,
|
||||
itemBuilder: (context, i) {
|
||||
if (i == 0) {
|
||||
return NaviRailItem(
|
||||
isSelected: activeSpaceId == null,
|
||||
onTap: onGoToChats,
|
||||
icon: const Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: Icon(Icons.forum_outlined),
|
||||
),
|
||||
selectedIcon: const Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: Icon(Icons.forum),
|
||||
),
|
||||
toolTip: L10n.of(context).chats,
|
||||
unreadBadgeFilter: (room) => true,
|
||||
);
|
||||
}
|
||||
i--;
|
||||
if (i == allSpaces.length) {
|
||||
return NaviRailItem(
|
||||
isSelected: false,
|
||||
onTap: () => context.go('/rooms/newspace'),
|
||||
icon: const Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: Icon(Icons.add),
|
||||
),
|
||||
toolTip: L10n.of(context).createNewSpace,
|
||||
);
|
||||
}
|
||||
final space = allSpaces[i];
|
||||
final displayname = allSpaces[i]
|
||||
.getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
);
|
||||
final spaceChildrenIds = space.spaceChildren
|
||||
.map((c) => c.roomId)
|
||||
.toSet();
|
||||
return NaviRailItem(
|
||||
toolTip: displayname,
|
||||
isSelected: activeSpaceId == space.id,
|
||||
onTap: () => onGoToSpaceId(allSpaces[i].id),
|
||||
unreadBadgeFilter: (room) =>
|
||||
spaceChildrenIds.contains(room.id),
|
||||
icon: Avatar(
|
||||
mxContent: allSpaces[i].avatar,
|
||||
name: displayname,
|
||||
//size: 36,
|
||||
shapeBorder: RoundedSuperellipseBorder(
|
||||
side: BorderSide(
|
||||
width: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.spaceBorderRadius,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.spaceBorderRadius,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (FluffyThemes.isColumnMode(context))
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: StartChatFab(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/pages/chat_list/search_title.dart
Normal file
71
lib/pages/chat_list/search_title.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class SearchTitle extends StatelessWidget {
|
||||
final String title;
|
||||
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,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Material(
|
||||
shape: Border(
|
||||
top: BorderSide(color: theme.dividerColor, width: 1),
|
||||
bottom: BorderSide(color: theme.dividerColor, width: 1),
|
||||
),
|
||||
color: color ?? theme.colorScheme.surface,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: theme.colorScheme.surface,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: IconTheme(
|
||||
data: theme.iconTheme.copyWith(size: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (trailing != null)
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: trailing,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
644
lib/pages/chat_list/space_view.dart
Normal file
644
lib/pages/chat_list/space_view.dart
Normal file
@@ -0,0 +1,644 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat_list/active_call_indicator.dart';
|
||||
import 'package:tatchat/pages/chat_list/unread_bubble.dart';
|
||||
import 'package:tatchat/utils/localized_exception_extension.dart';
|
||||
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
|
||||
import 'package:tatchat/utils/stream_extension.dart';
|
||||
import 'package:tatchat/utils/string_color.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/hover_builder.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart' as sdk;
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
enum SpaceChildAction {
|
||||
mute,
|
||||
unmute,
|
||||
markAsUnread,
|
||||
markAsRead,
|
||||
removeFromSpace,
|
||||
leave,
|
||||
}
|
||||
|
||||
enum SpaceActions { addChild, settings, leave }
|
||||
|
||||
class SpaceView extends StatefulWidget {
|
||||
final String spaceId;
|
||||
final void Function() onBack;
|
||||
final void Function(Room room) onChatTab;
|
||||
final String? activeChat;
|
||||
|
||||
const SpaceView({
|
||||
required this.spaceId,
|
||||
required this.onBack,
|
||||
required this.onChatTab,
|
||||
required this.activeChat,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SpaceView> createState() => _SpaceViewState();
|
||||
}
|
||||
|
||||
class _SpaceViewState extends State<SpaceView> {
|
||||
final List<SpaceRoomsChunk$2> _discoveredChildren = [];
|
||||
final TextEditingController _filterController = TextEditingController();
|
||||
String? _nextBatch;
|
||||
bool _noMoreRooms = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
StreamSubscription? _childStateSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_loadHierarchy();
|
||||
_childStateSub = Matrix.of(context).client.onSync.stream
|
||||
.where(
|
||||
(syncUpdate) =>
|
||||
syncUpdate.rooms?.join?[widget.spaceId]?.timeline?.events?.any(
|
||||
(event) => event.type == EventTypes.SpaceChild,
|
||||
) ??
|
||||
false,
|
||||
)
|
||||
.listen(_loadHierarchy);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_childStateSub?.cancel();
|
||||
_filterController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadHierarchy([_]) async {
|
||||
final matrix = Matrix.of(context);
|
||||
final room = matrix.client.getRoomById(widget.spaceId);
|
||||
if (room == null) return;
|
||||
|
||||
final cacheKey = 'spaces_history_cache${room.id}';
|
||||
if (_discoveredChildren.isEmpty) {
|
||||
final cachedChildren = matrix.store.getStringList(cacheKey);
|
||||
if (cachedChildren != null) {
|
||||
try {
|
||||
_discoveredChildren.addAll(
|
||||
cachedChildren.map(
|
||||
(jsonString) =>
|
||||
SpaceRoomsChunk$2.fromJson(jsonDecode(jsonString)),
|
||||
),
|
||||
);
|
||||
} catch (e, s) {
|
||||
Logs().e('Unable to json decode spaces hierarchy cache!', e, s);
|
||||
matrix.store.remove(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final hierarchy = await room.client.getSpaceHierarchy(
|
||||
widget.spaceId,
|
||||
suggestedOnly: false,
|
||||
maxDepth: 2,
|
||||
from: _nextBatch,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (_nextBatch == null) _discoveredChildren.clear();
|
||||
_nextBatch = hierarchy.nextBatch;
|
||||
if (hierarchy.nextBatch == null) {
|
||||
_noMoreRooms = true;
|
||||
}
|
||||
_discoveredChildren.addAll(
|
||||
hierarchy.rooms.where((room) => room.roomId != widget.spaceId),
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
if (_nextBatch == null) {
|
||||
matrix.store.setStringList(
|
||||
cacheKey,
|
||||
_discoveredChildren
|
||||
.map((child) => jsonEncode(child.toJson()))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
} catch (e, s) {
|
||||
Logs().w('Unable to load hierarchy', e, s);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _joinChildRoom(SpaceRoomsChunk$2 item) async {
|
||||
final client = Matrix.of(context).client;
|
||||
final space = client.getRoomById(widget.spaceId);
|
||||
final via = space?.spaceChildren
|
||||
.firstWhereOrNull((child) => child.roomId == item.roomId)
|
||||
?.via;
|
||||
final roomResult = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
final waitForRoom = client.waitForRoomInSync(item.roomId, join: true);
|
||||
await client.joinRoom(item.roomId, via: via);
|
||||
await waitForRoom;
|
||||
return client.getRoomById(item.roomId)!;
|
||||
},
|
||||
);
|
||||
final room = roomResult.result;
|
||||
if (room != null) widget.onChatTab(room);
|
||||
}
|
||||
|
||||
Future<void> _onSpaceAction(SpaceActions action) async {
|
||||
final space = Matrix.of(context).client.getRoomById(widget.spaceId);
|
||||
|
||||
switch (action) {
|
||||
case SpaceActions.settings:
|
||||
await space?.postLoad();
|
||||
if (!mounted) return;
|
||||
context.push('/rooms/${widget.spaceId}/details');
|
||||
break;
|
||||
case SpaceActions.addChild:
|
||||
context.go('/rooms/newgroup?space_id=${widget.spaceId}');
|
||||
break;
|
||||
case SpaceActions.leave:
|
||||
final confirmed = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).areYouSure,
|
||||
message: L10n.of(context).archiveRoomDescription,
|
||||
okLabel: L10n.of(context).leave,
|
||||
cancelLabel: L10n.of(context).cancel,
|
||||
isDestructive: true,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (confirmed != OkCancelResult.ok) return;
|
||||
|
||||
final success = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async => await space?.leave(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (success.error != null) return;
|
||||
widget.onBack();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showSpaceChildEditMenu(
|
||||
BuildContext posContext,
|
||||
String roomId,
|
||||
) async {
|
||||
final client = Matrix.of(context).client;
|
||||
final space = client.getRoomById(widget.spaceId);
|
||||
final room = client.getRoomById(roomId);
|
||||
if (space == null) return;
|
||||
final overlay =
|
||||
Overlay.of(posContext).context.findRenderObject() as RenderBox;
|
||||
|
||||
final button = posContext.findRenderObject() as RenderBox;
|
||||
|
||||
final position = RelativeRect.fromRect(
|
||||
Rect.fromPoints(
|
||||
button.localToGlobal(const Offset(0, -65), ancestor: overlay),
|
||||
button.localToGlobal(
|
||||
button.size.bottomRight(Offset.zero) + const Offset(-50, 0),
|
||||
ancestor: overlay,
|
||||
),
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
|
||||
final action = await showMenu<SpaceChildAction>(
|
||||
context: posContext,
|
||||
position: position,
|
||||
items: [
|
||||
if (room != null && room.membership == Membership.join) ...[
|
||||
PopupMenuItem(
|
||||
value: room.pushRuleState == PushRuleState.notify
|
||||
? SpaceChildAction.mute
|
||||
: SpaceChildAction.unmute,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
room.pushRuleState == PushRuleState.notify
|
||||
? Icons.notifications_off_outlined
|
||||
: Icons.notifications_on_outlined,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.pushRuleState == PushRuleState.notify
|
||||
? L10n.of(context).muteChat
|
||||
: L10n.of(context).unmuteChat,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: room.markedUnread
|
||||
? SpaceChildAction.markAsRead
|
||||
: SpaceChildAction.markAsUnread,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
room.markedUnread
|
||||
? Icons.mark_as_unread
|
||||
: Icons.mark_as_unread_outlined,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.isUnread
|
||||
? L10n.of(context).markAsRead
|
||||
: L10n.of(context).markAsUnread,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SpaceChildAction.leave,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_outlined,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
L10n.of(context).leave,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (space.canChangeStateEvent(EventTypes.SpaceChild) == true)
|
||||
PopupMenuItem(
|
||||
value: SpaceChildAction.removeFromSpace,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.remove,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
L10n.of(context).removeFromSpace,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (action == null) return;
|
||||
if (!mounted) return;
|
||||
switch (action) {
|
||||
case SpaceChildAction.removeFromSpace:
|
||||
final consent = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).removeFromSpace,
|
||||
message: L10n.of(context).removeFromSpaceDescription,
|
||||
);
|
||||
if (consent != OkCancelResult.ok) return;
|
||||
if (!mounted) return;
|
||||
final result = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => space.removeSpaceChild(roomId),
|
||||
);
|
||||
if (result.isError) return;
|
||||
if (!mounted) return;
|
||||
_nextBatch = null;
|
||||
return;
|
||||
case SpaceChildAction.mute:
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room!.setPushRuleState(PushRuleState.mentionsOnly),
|
||||
);
|
||||
case SpaceChildAction.unmute:
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room!.setPushRuleState(PushRuleState.notify),
|
||||
);
|
||||
case SpaceChildAction.markAsUnread:
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room!.markUnread(true),
|
||||
);
|
||||
case SpaceChildAction.markAsRead:
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room!.markUnread(false),
|
||||
);
|
||||
case SpaceChildAction.leave:
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room!.leave(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final room = Matrix.of(context).client.getRoomById(widget.spaceId);
|
||||
final displayname =
|
||||
room?.getLocalizedDisplayname() ?? L10n.of(context).nothingFound;
|
||||
const avatarSize = Avatar.defaultSize / 1.5;
|
||||
final isAdmin = room?.canChangeStateEvent(EventTypes.SpaceChild) == true;
|
||||
return Scaffold(
|
||||
body: room == null
|
||||
? const Center(child: Icon(Icons.search_outlined, size: 80))
|
||||
: StreamBuilder(
|
||||
stream: room.client.onSync.stream
|
||||
.where((s) => s.hasRoomUpdate)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, snapshot) {
|
||||
final filter = _filterController.text.trim().toLowerCase();
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
toolbarHeight: 72,
|
||||
floating: true,
|
||||
scrolledUnderElevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
title: TextField(
|
||||
controller: _filterController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.secondaryContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: L10n.of(context).search,
|
||||
hintStyle: TextStyle(
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
prefixIcon: IconButton(
|
||||
onPressed: () {},
|
||||
icon: Icon(
|
||||
Icons.search_outlined,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
suffixIcon: PopupMenuButton<SpaceActions>(
|
||||
icon: Avatar(
|
||||
size: avatarSize,
|
||||
mxContent: room.avatar,
|
||||
name: displayname,
|
||||
shapeBorder: RoundedSuperellipseBorder(
|
||||
side: BorderSide(
|
||||
width: 1,
|
||||
color: theme.dividerColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.spaceBorderRadius,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.spaceBorderRadius,
|
||||
),
|
||||
),
|
||||
useRootNavigator: true,
|
||||
onSelected: _onSpaceAction,
|
||||
itemBuilder: (context) => [
|
||||
if (isAdmin)
|
||||
PopupMenuItem(
|
||||
value: SpaceActions.addChild,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.edit_square),
|
||||
title: Text(
|
||||
L10n.of(context).addChatOrSubSpace,
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SpaceActions.settings,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.settings_outlined),
|
||||
title: Text(L10n.of(context).settings),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SpaceActions.leave,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete_outlined),
|
||||
title: Text(L10n.of(context).leave),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: _discoveredChildren.length + 1,
|
||||
itemBuilder: (context, i) {
|
||||
if (i == _discoveredChildren.length) {
|
||||
if (_noMoreRooms) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12.0,
|
||||
vertical: 2.0,
|
||||
),
|
||||
child: TextButton(
|
||||
onPressed: _isLoading ? null : _loadHierarchy,
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator.adaptive()
|
||||
: Text(L10n.of(context).loadMore),
|
||||
),
|
||||
);
|
||||
}
|
||||
final item = _discoveredChildren[i];
|
||||
var joinedRoom = room.client.getRoomById(item.roomId);
|
||||
final displayname =
|
||||
item.name ??
|
||||
item.canonicalAlias ??
|
||||
joinedRoom?.getLocalizedDisplayname() ??
|
||||
L10n.of(context).emptyChat;
|
||||
final avatarUrl = item.avatarUrl ?? joinedRoom?.avatar;
|
||||
if (!displayname.toLowerCase().contains(filter)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (joinedRoom?.membership == Membership.leave) {
|
||||
joinedRoom = null;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 1,
|
||||
),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
color:
|
||||
joinedRoom != null &&
|
||||
widget.activeChat == joinedRoom.id
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: Colors.transparent,
|
||||
child: HoverBuilder(
|
||||
builder: (context, hovered) => ListTile(
|
||||
visualDensity: const VisualDensity(
|
||||
vertical: -0.5,
|
||||
),
|
||||
contentPadding: EdgeInsets.only(
|
||||
left: 8,
|
||||
right: joinedRoom == null ? 0 : 8,
|
||||
),
|
||||
onTap: joinedRoom != null
|
||||
? () => widget.onChatTab(joinedRoom!)
|
||||
: null,
|
||||
onLongPress: joinedRoom != null
|
||||
? () => _showSpaceChildEditMenu(
|
||||
context,
|
||||
item.roomId,
|
||||
)
|
||||
: null,
|
||||
leading:
|
||||
hovered && (isAdmin || joinedRoom != null)
|
||||
? SizedBox.square(
|
||||
dimension: avatarSize,
|
||||
child: IconButton(
|
||||
splashRadius: avatarSize,
|
||||
iconSize: 14,
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: theme
|
||||
.colorScheme
|
||||
.onTertiaryContainer,
|
||||
backgroundColor: theme
|
||||
.colorScheme
|
||||
.tertiaryContainer,
|
||||
),
|
||||
onPressed: () =>
|
||||
_showSpaceChildEditMenu(
|
||||
context,
|
||||
item.roomId,
|
||||
),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
)
|
||||
: Avatar(
|
||||
size: avatarSize,
|
||||
mxContent: avatarUrl,
|
||||
name: '#',
|
||||
backgroundColor:
|
||||
theme.colorScheme.surfaceContainer,
|
||||
textColor:
|
||||
item.name?.colorScheme.primary ??
|
||||
theme.colorScheme.onSurface,
|
||||
shapeBorder: item.roomType == 'm.space'
|
||||
? RoundedSuperellipseBorder(
|
||||
side: BorderSide(
|
||||
color: theme
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(
|
||||
AppConfig.borderRadius /
|
||||
4,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
borderRadius: item.roomType == 'm.space'
|
||||
? BorderRadius.circular(
|
||||
AppConfig.borderRadius / 4,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
if (joinedRoom != null &&
|
||||
joinedRoom.hasActiveMatrixRtcCall ==
|
||||
true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 4.0,
|
||||
),
|
||||
child: ActiveCallIndicator(
|
||||
room: joinedRoom,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Opacity(
|
||||
opacity: joinedRoom == null ? 0.5 : 1,
|
||||
child: Text(
|
||||
displayname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (joinedRoom != null &&
|
||||
joinedRoom.pushRuleState !=
|
||||
PushRuleState.notify)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 4.0),
|
||||
child: Icon(
|
||||
Icons.notifications_off_outlined,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
if (joinedRoom != null)
|
||||
UnreadBubble(room: joinedRoom)
|
||||
else
|
||||
TextButton(
|
||||
onPressed: () => _joinChildRoom(item),
|
||||
child: Text(L10n.of(context).join),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SliverPadding(padding: EdgeInsets.only(top: 32)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/pages/chat_list/start_chat_fab.dart
Normal file
35
lib/pages/chat_list/start_chat_fab.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class StartChatFab extends StatelessWidget {
|
||||
final bool extended;
|
||||
const StartChatFab({this.extended = false, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FloatingActionButton.extended(
|
||||
heroTag: 'start_chat_fab',
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
onPressed: () => context.go('/rooms/newprivatechat'),
|
||||
extendedIconLabelSpacing: extended ? 10 : 0,
|
||||
extendedPadding: extended
|
||||
? null
|
||||
: const EdgeInsets.symmetric(horizontal: 16),
|
||||
label: AnimatedSize(
|
||||
alignment: Alignment.centerLeft,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: extended ? Text(L10n.of(context).newChat) : SizedBox.shrink(),
|
||||
),
|
||||
icon: const Icon(Icons.edit_square),
|
||||
);
|
||||
}
|
||||
}
|
||||
79
lib/pages/chat_list/tabs/channels_tab.dart
Normal file
79
lib/pages/chat_list/tabs/channels_tab.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
||||
import 'package:tatchat/pages/chat_list/chat_list_item.dart';
|
||||
import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart';
|
||||
import 'package:tatchat/utils/stream_extension.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
|
||||
class ChannelsTab extends StatelessWidget {
|
||||
final ChatListController controller;
|
||||
|
||||
const ChannelsTab(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = Matrix.of(context).client;
|
||||
final theme = Theme.of(context);
|
||||
return StreamBuilder(
|
||||
key: ValueKey(client.userID.toString()),
|
||||
stream: client.onSync.stream
|
||||
.where((s) => s.hasRoomUpdate)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, _) {
|
||||
final channels = client.rooms
|
||||
.where((room) => !room.isSpace && !room.isDirectChat)
|
||||
.toList();
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.tag_outlined,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
L10n.of(context).channels,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (channels.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyTabView(
|
||||
icon: Icons.tag_outlined,
|
||||
title: L10n.of(context).noChannelsFound,
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList.builder(
|
||||
itemCount: channels.length,
|
||||
itemBuilder: (context, i) {
|
||||
final room = channels[i];
|
||||
return ChatListItem(
|
||||
room,
|
||||
key: Key('channel_list_item_${room.id}'),
|
||||
onTap: () => controller.onChatTap(room),
|
||||
activeChat: controller.activeChat == room.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/pages/chat_list/tabs/chat_tab_bar.dart
Normal file
115
lib/pages/chat_list/tabs/chat_tab_bar.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/pages/chat_list/tabs/home_tab.dart';
|
||||
|
||||
class ChatTopTabBar extends StatelessWidget {
|
||||
final HomeTab currentTab;
|
||||
final ValueChanged<HomeTab> onTabSelected;
|
||||
|
||||
const ChatTopTabBar({
|
||||
super.key,
|
||||
required this.currentTab,
|
||||
required this.onTabSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: theme.dividerColor)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
for (final tab in HomeTab.values)
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => onTabSelected(tab),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
tab == currentTab
|
||||
? tab.selectedIcon
|
||||
: tab.icon,
|
||||
size: 20,
|
||||
color: tab == currentTab
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
tab.label(context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: tab == currentTab
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: tab == currentTab
|
||||
? FontWeight.w600
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
height: 3,
|
||||
color: tab == currentTab
|
||||
? theme.colorScheme.primary
|
||||
: Colors.transparent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatBottomTabBar extends StatelessWidget {
|
||||
final HomeTab currentTab;
|
||||
final ValueChanged<HomeTab> onTabSelected;
|
||||
|
||||
const ChatBottomTabBar({
|
||||
super.key,
|
||||
required this.currentTab,
|
||||
required this.onTabSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NavigationBar(
|
||||
selectedIndex: currentTab.index,
|
||||
onDestinationSelected: (index) => onTabSelected(HomeTab.values[index]),
|
||||
destinations: [
|
||||
for (final tab in HomeTab.values)
|
||||
NavigationDestination(
|
||||
icon: Icon(tab.icon),
|
||||
selectedIcon: Icon(tab.selectedIcon),
|
||||
label: tab.label(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/pages/chat_list/tabs/empty_tab_view.dart
Normal file
56
lib/pages/chat_list/tabs/empty_tab_view.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class EmptyTabView extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? description;
|
||||
|
||||
const EmptyTabView({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.description,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 96,
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||
child: Text(
|
||||
description!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: theme.colorScheme.secondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
51
lib/pages/chat_list/tabs/home_tab.dart
Normal file
51
lib/pages/chat_list/tabs/home_tab.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
|
||||
enum HomeTab { chats, channels, spaces, murals }
|
||||
|
||||
extension HomeTabExtension on HomeTab {
|
||||
String label(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
switch (this) {
|
||||
case HomeTab.chats:
|
||||
return l10n.chats;
|
||||
case HomeTab.channels:
|
||||
return l10n.channels;
|
||||
case HomeTab.spaces:
|
||||
return l10n.spaces;
|
||||
case HomeTab.murals:
|
||||
return l10n.murals;
|
||||
}
|
||||
}
|
||||
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case HomeTab.chats:
|
||||
return Icons.forum_outlined;
|
||||
case HomeTab.channels:
|
||||
return Icons.tag_outlined;
|
||||
case HomeTab.spaces:
|
||||
return Icons.workspaces_outlined;
|
||||
case HomeTab.murals:
|
||||
return Icons.dashboard_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
IconData get selectedIcon {
|
||||
switch (this) {
|
||||
case HomeTab.chats:
|
||||
return Icons.forum;
|
||||
case HomeTab.channels:
|
||||
return Icons.tag;
|
||||
case HomeTab.spaces:
|
||||
return Icons.workspaces;
|
||||
case HomeTab.murals:
|
||||
return Icons.dashboard;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
lib/pages/chat_list/tabs/murals_tab.dart
Normal file
46
lib/pages/chat_list/tabs/murals_tab.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart';
|
||||
|
||||
class MuralsTab extends StatelessWidget {
|
||||
const MuralsTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.dashboard_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(l10n.murals, style: Theme.of(context).textTheme.titleLarge),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyTabView(
|
||||
icon: Icons.dashboard_outlined,
|
||||
title: l10n.muralsComingSoon,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/pages/chat_list/tabs/spaces_tab.dart
Normal file
89
lib/pages/chat_list/tabs/spaces_tab.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
||||
import 'package:tatchat/pages/chat_list/chat_list_item.dart';
|
||||
import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart';
|
||||
import 'package:tatchat/pages/chat_list/tabs/home_tab.dart';
|
||||
import 'package:tatchat/utils/stream_extension.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
|
||||
class SpacesTab extends StatelessWidget {
|
||||
final ChatListController controller;
|
||||
|
||||
const SpacesTab(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = Matrix.of(context).client;
|
||||
final theme = Theme.of(context);
|
||||
return StreamBuilder(
|
||||
key: ValueKey(client.userID.toString()),
|
||||
stream: client.onSync.stream
|
||||
.where((s) => s.hasRoomUpdate)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, _) {
|
||||
final spaces = client.rooms.where((room) => room.isSpace).toList();
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.workspaces_outlined,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
L10n.of(context).spaces,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (spaces.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyTabView(
|
||||
icon: Icons.workspaces_outlined,
|
||||
title: L10n.of(context).noSpacesFound,
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList.builder(
|
||||
itemCount: spaces.length + 1,
|
||||
itemBuilder: (context, i) {
|
||||
if (i == spaces.length) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: Text(L10n.of(context).createNewSpace),
|
||||
onTap: () => context.go('/rooms/newspace'),
|
||||
);
|
||||
}
|
||||
final space = spaces[i];
|
||||
return ChatListItem(
|
||||
space,
|
||||
key: Key('spaces_tab_item_${space.id}'),
|
||||
activeChat: controller.activeSpaceId == space.id,
|
||||
onTap: () {
|
||||
controller.setActiveSpace(space.id);
|
||||
controller.setHomeTab(HomeTab.chats);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
55
lib/pages/chat_list/unread_bubble.dart
Normal file
55
lib/pages/chat_list/unread_bubble.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class UnreadBubble extends StatelessWidget {
|
||||
final Room room;
|
||||
const UnreadBubble({required this.room, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final unread = room.isUnread;
|
||||
final hasNotifications = room.notificationCount > 0;
|
||||
final unreadBubbleSize = unread || room.hasNewMessages
|
||||
? room.notificationCount > 0
|
||||
? 20.0
|
||||
: 14.0
|
||||
: 0.0;
|
||||
return AnimatedContainer(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
height: unreadBubbleSize,
|
||||
width: !hasNotifications && !unread && !room.hasNewMessages
|
||||
? 0
|
||||
: (unreadBubbleSize - 9) * room.notificationCount.toString().length +
|
||||
9,
|
||||
decoration: BoxDecoration(
|
||||
color: room.highlightCount > 0 || hasNotifications
|
||||
? theme.colorScheme.tertiary
|
||||
: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(unreadBubbleSize),
|
||||
),
|
||||
child: hasNotifications || room.markedUnread
|
||||
? Text(
|
||||
room.notificationCount.toString(),
|
||||
style: TextStyle(
|
||||
color: room.highlightCount > 0 || hasNotifications
|
||||
? theme.colorScheme.onTertiary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: .center,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user