Initial commit
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-11 15:59:56 -03:00
commit 0037b0b9cf
651 changed files with 199705 additions and 0 deletions

View 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),
),
],
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View 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),
),
),
],
),
),
);
}

View 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);
}

View 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,
),
),
),
),
),
);
}
}

View 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,
),
),
),
],
),
),
],
),
);
}
}

View 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,
}

View 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),
),
),
);
}
}

View 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,
),
),
),
),
),
),
],
),
);
},
);
}
}

View 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(),
),
],
),
);
},
),
),
);
}
}

View 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,
),
),
],
),
),
),
),
),
);
}
}

View 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)),
],
);
},
),
);
}
}

View 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),
);
}
}

View 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,
);
},
),
],
);
},
);
}
}

View 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),
),
],
);
}
}

View 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),
),
),
],
),
);
}
}

View 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;
}
}
}

View 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,
),
),
],
),
);
}
}

View 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);
},
);
},
),
],
);
},
);
}
}

View 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(),
);
}
}