refactor: Replace stories feature with presence status msg

This commit is contained in:
Krille 2023-12-22 17:15:14 +01:00
commit 895de76e70
No known key found for this signature in database
GPG key ID: E067ECD60F1A0652
21 changed files with 364 additions and 2386 deletions

View file

@ -18,7 +18,6 @@ import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat_list/chat_list_view.dart';
import 'package:fluffychat/pages/settings_security/settings_security.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/client_stories_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import '../../../utils/account_bundles.dart';
@ -139,13 +138,11 @@ class ChatListController extends State<ChatList>
bool Function(Room) getRoomFilterByActiveFilter(ActiveFilter activeFilter) {
switch (activeFilter) {
case ActiveFilter.allChats:
return (room) => !room.isSpace && !room.isStoryRoom;
return (room) => !room.isSpace;
case ActiveFilter.groups:
return (room) =>
!room.isSpace && !room.isDirectChat && !room.isStoryRoom;
return (room) => !room.isSpace && !room.isDirectChat;
case ActiveFilter.messages:
return (room) =>
!room.isSpace && room.isDirectChat && !room.isStoryRoom;
return (room) => !room.isSpace && room.isDirectChat;
case ActiveFilter.spaces:
return (r) => r.isSpace;
}
@ -487,11 +484,15 @@ class ChatListController extends State<ChatList>
useRootNavigator: false,
context: context,
title: L10n.of(context)!.setStatus,
message: L10n.of(context)!.leaveEmptyToClearStatus,
okLabel: L10n.of(context)!.ok,
cancelLabel: L10n.of(context)!.cancel,
textFields: [
DialogTextField(
hintText: L10n.of(context)!.statusExampleMessage,
maxLines: 6,
minLines: 1,
maxLength: 255,
),
],
);

View file

@ -9,10 +9,9 @@ import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pages/chat_list/search_title.dart';
import 'package:fluffychat/pages/chat_list/space_view.dart';
import 'package:fluffychat/pages/chat_list/stories_header.dart';
import 'package:fluffychat/pages/chat_list/status_msg_list.dart';
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/client_stories_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/avatar.dart';
@ -71,11 +70,6 @@ class ChatListViewBody extends StatelessWidget {
);
}
final rooms = controller.filteredRooms;
final displayStoriesHeader = {
ActiveFilter.allChats,
ActiveFilter.messages,
}.contains(controller.activeFilter) &&
client.storiesRooms.isNotEmpty;
return SafeArea(
child: CustomScrollView(
controller: controller.scrollController,
@ -158,16 +152,10 @@ class ChatListViewBody extends StatelessWidget {
),
),
),
SearchTitle(
title: L10n.of(context)!.stories,
icon: const Icon(Icons.camera_alt_outlined),
),
],
if (displayStoriesHeader)
StoriesHeader(
key: const Key('stories_header'),
filter: controller.searchController.text,
),
StatusMessageList(
onStatusEdit: controller.setStatus,
),
const ConnectionStatusHeader(),
AnimatedContainer(
height: controller.isTorBrowser ? 64 : 0,

View file

@ -18,7 +18,7 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
return SliverAppBar(
floating: true,
toolbarHeight: Theme.of(context).appBarTheme.toolbarHeight ?? 56,
toolbarHeight: (Theme.of(context).appBarTheme.toolbarHeight ?? 56) + 16,
pinned:
FluffyThemes.isColumnMode(context) || selectMode != SelectMode.normal,
scrolledUnderElevation: selectMode == SelectMode.normal ? 0 : null,
@ -55,19 +55,27 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
borderRadius: BorderRadius.circular(99),
),
hintText: L10n.of(context)!.searchChatsRooms,
hintStyle: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
prefixIcon: controller.isSearchMode
? IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelSearch,
color: Theme.of(context).colorScheme.onBackground,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
)
: IconButton(
onPressed: controller.startSearch,
icon: Icon(
Icons.search_outlined,
color: Theme.of(context).colorScheme.onBackground,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
suffixIcon: controller.isSearchMode

View file

@ -28,16 +28,6 @@ class ClientChooserButton extends StatelessWidget {
: 1,
);
return <PopupMenuEntry<Object>>[
PopupMenuItem(
value: SettingsAction.newStory,
child: Row(
children: [
const Icon(Icons.camera_outlined),
const SizedBox(width: 18),
Text(L10n.of(context)!.yourStory),
],
),
),
PopupMenuItem(
value: SettingsAction.newGroup,
child: Row(
@ -58,6 +48,16 @@ class ClientChooserButton extends StatelessWidget {
],
),
),
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(
@ -260,9 +260,6 @@ class ClientChooserButton extends StatelessWidget {
if (consent != OkCancelResult.ok) return;
context.go('/rooms/settings/addaccount');
break;
case SettingsAction.newStory:
context.go('/rooms/stories/create');
break;
case SettingsAction.newGroup:
context.go('/rooms/newgroup');
break;
@ -278,6 +275,9 @@ class ClientChooserButton extends StatelessWidget {
case SettingsAction.archive:
context.go('/rooms/archive');
break;
case SettingsAction.setStatus:
controller.setStatus();
break;
}
}
}
@ -354,9 +354,9 @@ class ClientChooserButton extends StatelessWidget {
enum SettingsAction {
addAccount,
newStory,
newGroup,
newSpace,
setStatus,
invite,
settings,
archive,

View file

@ -0,0 +1,305 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
import 'package:fluffychat/widgets/matrix.dart';
class StatusMessageList extends StatelessWidget {
final void Function() onStatusEdit;
const StatusMessageList({
required this.onStatusEdit,
super.key,
});
static const double height = 108;
void _onStatusTab(BuildContext context, Profile profile) {
final client = Matrix.of(context).client;
if (profile.userId == client.userID) return onStatusEdit();
showAdaptiveBottomSheet(
context: context,
builder: (c) => UserBottomSheet(
profile: profile,
outerContext: context,
),
);
return;
}
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
return StreamBuilder(
stream: client.onSync.stream.rateLimit(const Duration(seconds: 3)),
builder: (context, snapshot) {
return AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: Curves.easeInOut,
child: FutureBuilder(
future: Future.wait(
client.interestingPresences
.map((userId) => client.fetchCurrentPresence(userId)),
),
builder: (context, snapshot) {
final presences =
snapshot.data?.where(isInterestingPresence).toList();
// If no other presences than the own entry is interesting, we
// hide the presence header.
if (presences == null || presences.length <= 1) {
return const SizedBox.shrink();
}
// Make sure own entry is at the first position. Sort by last
// active instead.
presences.sort((a, b) {
if (a.userid == client.userID) return -1;
if (b.userid == client.userID) return 1;
return b.sortOrderDateTime.compareTo(a.sortOrderDateTime);
});
return SizedBox(
height: StatusMessageList.height,
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8),
scrollDirection: Axis.horizontal,
itemCount: presences.length,
itemBuilder: (context, i) => PresenceAvatar(
presence: presences[i],
height: StatusMessageList.height,
onTap: (profile) => _onStatusTab(context, profile),
),
),
);
},
),
);
},
);
}
}
class PresenceAvatar extends StatelessWidget {
final CachedPresence presence;
final double height;
final void Function(Profile) onTap;
const PresenceAvatar({
required this.presence,
required this.height,
required this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
final avatarSize = height - 16 - 16;
final client = Matrix.of(context).client;
return FutureBuilder<Profile>(
future: client.getProfileFromUserId(presence.userid),
builder: (context, snapshot) {
final profile = snapshot.data;
final displayName = profile?.displayName ??
presence.userid.localpart ??
presence.userid;
final statusMsg = presence.statusMsg;
final statusMsgBubbleElevation =
Theme.of(context).appBarTheme.scrolledUnderElevation ?? 4;
final statusMsgBubbleShadowColor =
Theme.of(context).appBarTheme.shadowColor;
final statusMsgBubbleColor = Colors.white.withAlpha(245);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: SizedBox(
width: avatarSize,
child: Column(
children: [
HoverBuilder(
builder: (context, hovered) {
return AnimatedScale(
scale: hovered ? 1.15 : 1.0,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: InkWell(
borderRadius: BorderRadius.circular(avatarSize),
onTap: profile == null ? null : () => onTap(profile),
child: Material(
borderRadius: BorderRadius.circular(avatarSize),
child: Stack(
children: [
Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
gradient: presence.gradient,
borderRadius:
BorderRadius.circular(avatarSize),
),
child: Avatar(
name: displayName,
mxContent: profile?.avatarUrl,
size: avatarSize - 6,
),
),
if (presence.userid == client.userID)
Positioned(
right: 0,
bottom: 0,
child: SizedBox(
width: 24,
height: 24,
child: FloatingActionButton.small(
heroTag: null,
onPressed: () => onTap(
profile ??
Profile(userId: presence.userid),
),
child: const Icon(
Icons.add_outlined,
size: 16,
),
),
),
),
if (statusMsg != null) ...[
Positioned(
left: 0,
top: 0,
right: 8,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
Material(
elevation: statusMsgBubbleElevation,
shadowColor: statusMsgBubbleShadowColor,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
color: statusMsgBubbleColor,
child: Padding(
padding: const EdgeInsets.all(2.0),
child: Text(
statusMsg,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.black,
fontSize: 10.5,
),
),
),
),
Padding(
padding: const EdgeInsets.only(
left: 26.0,
top: 4.0,
),
child: Center(
child: SizedBox(
width: 12,
height: 12,
child: Material(
elevation:
statusMsgBubbleElevation,
shadowColor:
statusMsgBubbleShadowColor,
borderRadius:
BorderRadius.circular(99),
color: statusMsgBubbleColor,
),
),
),
),
],
),
),
],
],
),
),
),
);
},
),
const Spacer(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Text(
displayName,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
),
),
),
],
),
),
);
},
);
}
}
extension on Client {
Set<String> get interestingPresences {
final allHeroes = rooms.map((room) => room.summary.mHeroes).fold(
<String>{},
(previousValue, element) => previousValue..addAll(element ?? {}),
);
allHeroes.add(userID!);
return allHeroes;
}
}
bool isInterestingPresence(CachedPresence presence) =>
!presence.presence.isOffline || (presence.statusMsg?.isNotEmpty ?? false);
extension on CachedPresence {
DateTime get sortOrderDateTime =>
lastActiveTimestamp ??
(currentlyActive == true
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(0));
LinearGradient get gradient => presence.isOnline == true
? LinearGradient(
colors: [
Colors.green,
Colors.green.shade200,
Colors.green.shade900,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: presence.isUnavailable
? LinearGradient(
colors: [
Colors.yellow,
Colors.yellow.shade200,
Colors.yellow.shade900,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: LinearGradient(
colors: [
Colors.grey,
Colors.grey.shade200,
Colors.grey.shade900,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}

View file

@ -1,361 +0,0 @@
import 'package:flutter/material.dart';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:collection/collection.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/client_stories_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../config/themes.dart';
enum ContextualRoomAction {
mute,
unmute,
leave,
}
class StoriesHeader extends StatelessWidget {
final String filter;
const StoriesHeader({required this.filter, super.key});
void _addToStoryAction(BuildContext context) =>
context.go('/rooms/stories/create');
void _goToStoryAction(BuildContext context, String roomId) async {
final room = Matrix.of(context).client.getRoomById(roomId);
if (room == null) return;
if (room.membership != Membership.join) {
final result = await showFutureLoadingDialog(
context: context,
future: room.join,
);
if (result.error != null) return;
}
context.go('/rooms/stories/$roomId');
}
void _contextualActions(BuildContext context, Room room) async {
final action = await showModalActionSheet<ContextualRoomAction>(
cancelLabel: L10n.of(context)!.cancel,
context: context,
actions: [
if (room.pushRuleState != PushRuleState.notify)
SheetAction(
label: L10n.of(context)!.unmuteChat,
key: ContextualRoomAction.unmute,
icon: Icons.notifications_outlined,
)
else
SheetAction(
label: L10n.of(context)!.muteChat,
key: ContextualRoomAction.mute,
icon: Icons.notifications_off_outlined,
),
SheetAction(
label: L10n.of(context)!.unsubscribeStories,
key: ContextualRoomAction.leave,
icon: Icons.unsubscribe_outlined,
isDestructiveAction: true,
),
],
);
if (action == null) return;
switch (action) {
case ContextualRoomAction.mute:
await showFutureLoadingDialog(
context: context,
future: () => room.setPushRuleState(PushRuleState.dontNotify),
);
break;
case ContextualRoomAction.unmute:
await showFutureLoadingDialog(
context: context,
future: () => room.setPushRuleState(PushRuleState.notify),
);
break;
case ContextualRoomAction.leave:
await showFutureLoadingDialog(
context: context,
future: () => room.leave(),
);
break;
}
}
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
if (Matrix.of(context).shareContent != null) {
return ListTile(
leading: CircleAvatar(
radius: Avatar.defaultSize / 2,
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
child: const Icon(Icons.camera_alt_outlined),
),
title: Text(L10n.of(context)!.addToStory),
onTap: () => _addToStoryAction(context),
);
}
final ownStoryRoom = client.storiesRooms
.firstWhereOrNull((r) => r.creatorId == client.userID);
final stories = [
if (ownStoryRoom != null) ownStoryRoom,
...client.storiesRooms..remove(ownStoryRoom),
];
return SizedBox(
height: 104,
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
scrollDirection: Axis.horizontal,
itemCount: stories.length,
itemBuilder: (context, i) {
final room = stories[i];
final creator = room
.unsafeGetUserFromMemoryOrFallback(room.creatorId ?? 'Unknown');
final userId = room.creatorId;
final displayname = creator.calcDisplayname();
final avatarUrl = creator.avatarUrl;
if (!displayname.toLowerCase().contains(filter.toLowerCase())) {
return const SizedBox.shrink();
}
return _StoryButton(
profile: Profile(
displayName: displayname,
avatarUrl: avatarUrl,
userId: userId ?? 'Unknown',
),
lastMessage: room.hasPosts
? room.lastEvent?.calcLocalizedBodyFallback(
MatrixLocals(
L10n.of(context)!,
),
)
: null,
heroTag: 'stories_${room.id}',
hasPosts: room.hasPosts || room == ownStoryRoom,
showEditFab: userId == client.userID,
unread: room.membership == Membership.invite ||
(room.hasNewMessages && room.hasPosts),
onPressed: () => _goToStoryAction(context, room.id),
onLongPressed: () => _contextualActions(context, room),
);
},
),
);
}
}
extension on Room {
bool get hasPosts {
if (membership == Membership.invite) return true;
final lastEvent = this.lastEvent;
if (lastEvent == null) return false;
if (lastEvent.type != EventTypes.Message) return false;
if (DateTime.now().difference(lastEvent.originServerTs).inHours >
ClientStoriesExtension.lifeTimeInHours) {
return false;
}
return true;
}
}
class _StoryButton extends StatefulWidget {
final Profile profile;
final bool showEditFab;
final bool unread;
final bool hasPosts;
final void Function() onPressed;
final void Function()? onLongPressed;
final String heroTag;
final String? lastMessage;
const _StoryButton({
required this.profile,
required this.onPressed,
required this.heroTag,
required this.lastMessage,
this.showEditFab = false,
this.hasPosts = true,
this.unread = false,
this.onLongPressed,
});
@override
State<_StoryButton> createState() => _StoryButtonState();
}
class _StoryButtonState extends State<_StoryButton> {
bool _hovered = false;
void _onHover(bool hover) {
if (hover == _hovered) return;
setState(() {
_hovered = hover;
});
}
@override
Widget build(BuildContext context) {
final lastMessage = widget.lastMessage;
final lastMessageBubbleElevation =
Theme.of(context).appBarTheme.scrolledUnderElevation ?? 4;
final lastMessageBubbleShadowColor =
Theme.of(context).appBarTheme.shadowColor;
final lastMessageBubbleColor = Colors.white.withAlpha(245);
return SizedBox(
width: 82,
child: InkWell(
onHover: _onHover,
borderRadius: BorderRadius.circular(7),
onTap: widget.onPressed,
onLongPress: widget.onLongPressed,
child: Opacity(
opacity: widget.hasPosts ? 1 : 0.4,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
children: [
const SizedBox(height: 8),
AnimatedScale(
scale: _hovered ? 1.15 : 1.0,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: Material(
borderRadius: BorderRadius.circular(Avatar.defaultSize),
child: Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
gradient: widget.unread
? const LinearGradient(
colors: [
Colors.red,
Colors.purple,
Colors.orange,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: null,
color: widget.unread
? null
: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(Avatar.defaultSize),
),
child: Stack(
children: [
Hero(
tag: widget.heroTag,
child: Avatar(
mxContent: widget.profile.avatarUrl,
name: widget.profile.displayName,
size: 72,
fontSize: 26,
),
),
if (widget.showEditFab)
Positioned(
right: 0,
bottom: 0,
child: SizedBox(
width: 24,
height: 24,
child: FloatingActionButton.small(
heroTag: null,
onPressed: () =>
context.go('/rooms/stories/create'),
child: const Icon(
Icons.add_outlined,
size: 16,
),
),
),
),
if (lastMessage != null) ...[
Positioned(
left: 0,
top: 0,
right: 8,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Material(
elevation: lastMessageBubbleElevation,
shadowColor: lastMessageBubbleShadowColor,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
color: lastMessageBubbleColor,
child: Padding(
padding: const EdgeInsets.all(2.0),
child: Text(
lastMessage,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.black,
fontSize: 11,
),
),
),
),
Padding(
padding: const EdgeInsets.only(
left: 26.0,
top: 4.0,
),
child: Center(
child: SizedBox(
width: 12,
height: 12,
child: Material(
elevation: lastMessageBubbleElevation,
shadowColor:
lastMessageBubbleShadowColor,
borderRadius:
BorderRadius.circular(99),
color: lastMessageBubbleColor,
),
),
),
),
],
),
),
],
],
),
),
),
),
Center(
child: Text(
widget.profile.displayName ?? '',
maxLines: 1,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontWeight: widget.unread ? FontWeight.bold : null,
),
),
),
],
),
),
),
),
);
}
}
extension on Room {
String? get creatorId => getState(EventTypes.RoomCreate)?.senderId;
}