feat(ui): Remove ConversationsListItem completely

This commit is contained in:
2024-04-07 19:35:07 +02:00
parent 3c937194b4
commit 2305976f3c
3 changed files with 108 additions and 489 deletions

View File

@@ -5,11 +5,12 @@ import 'package:move_to_background/move_to_background.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/conversation.dart'; import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart'; import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/state/navigation.dart' as navigation; import 'package:moxxyv2/ui/state/navigation.dart' as navigation;
import 'package:moxxyv2/ui/state/share_selection.dart'; import 'package:moxxyv2/ui/state/share_selection.dart';
import 'package:moxxyv2/ui/widgets/conversation.dart'; import 'package:moxxyv2/ui/widgets/conversation_card.dart';
class ShareSelectionPage extends StatelessWidget { class ShareSelectionPage extends StatelessWidget {
const ShareSelectionPage({super.key}); const ShareSelectionPage({super.key});
@@ -31,13 +32,13 @@ class ShareSelectionPage extends StatelessWidget {
prev.type != next.type; prev.type != next.type;
} }
IconData? _getSuffixIcon(ShareListItem item) { Widget? _getSuffixIcon(ShareListItem item) {
if (item.pseudoRosterItem) { if (item.pseudoRosterItem) {
return Icons.smartphone; return const Icon(Icons.smartphone);
} }
if (item.isEncrypted) { if (item.isEncrypted) {
return Icons.lock; return const Icon(Icons.lock);
} }
return null; return null;
@@ -68,11 +69,22 @@ class ShareSelectionPage extends StatelessWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = state.items[index]; final item = state.items[index];
return ConversationsListRow( return ConversationCard(
Conversation( conversation: Conversation(
'', '',
item.titleWithOptionalContact, item.titleWithOptionalContact,
null, Message(
'',
'',
'',
item.jid,
0,
'',
'',
false,
false,
false,
),
item.avatarPath, item.avatarPath,
item.avatarHash, item.avatarHash,
item.jid, item.jid,
@@ -89,11 +101,10 @@ class ShareSelectionPage extends StatelessWidget {
contactAvatarPath: item.contactAvatarPath, contactAvatarPath: item.contactAvatarPath,
contactDisplayName: item.contactDisplayName, contactDisplayName: item.contactDisplayName,
), ),
false,
titleSuffixIcon: _getSuffixIcon(item), titleSuffixIcon: _getSuffixIcon(item),
showTimestamp: false, showTimestamp: false,
isSelected: state.selection.contains(index), selected: state.selection.contains(index),
onPressed: () { onTap: () {
context.read<ShareSelectionCubit>().selectionToggled(index); context.read<ShareSelectionCubit>().selectionToggled(index);
}, },
); );

View File

@@ -1,477 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/state/account.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/image.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/video.dart';
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
import 'package:moxxyv2/ui/widgets/contact_helper.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
class AnimatedMaterialColor extends StatefulWidget {
const AnimatedMaterialColor({
required this.color,
required this.child,
super.key,
});
/// The color attribute of the [Material] widget.
final Color color;
/// The child widget of the [Material] widget.
final Widget child;
@override
AnimatedMaterialColorState createState() => AnimatedMaterialColorState();
}
class AnimatedMaterialColorState extends State<AnimatedMaterialColor>
with TickerProviderStateMixin {
late final AnimationController _controller;
late final ColorTween _tween;
late final Animation<Color?> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_tween = ColorTween(
begin: widget.color,
end: widget.color,
);
_animation = _tween.animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutCubic,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(AnimatedMaterialColor oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.color != widget.color) {
_tween
..begin = oldWidget.color
..end = widget.color;
_controller
..reset()
..forward();
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (_, child) => Material(
color: _animation.value,
child: child,
),
child: widget.child,
);
}
}
// TODO(Unknown): Make this widget less reliant on a [Conversation], so that we can use
// it to build the icon entry in other pages, like the newconversation page.
class ConversationsListRow extends StatefulWidget {
const ConversationsListRow(
this.conversation,
this.update, {
required this.isSelected,
this.showTimestamp = true,
this.titleSuffixIcon,
this.enableAvatarOnTap = false,
this.avatarWidget,
this.onPressed,
super.key,
});
final Conversation conversation;
final bool update; // Should a timer run to update the timestamp
final IconData? titleSuffixIcon;
final bool showTimestamp;
final bool enableAvatarOnTap;
final Widget? avatarWidget;
/// Flag indicating whether the conversation row is selected (true) or not (false).
final bool isSelected;
/// Callback for when the row has been tapped.
final VoidCallback? onPressed;
@override
ConversationsListRowState createState() => ConversationsListRowState();
}
class ConversationsListRowState extends State<ConversationsListRow> {
late String _timestampString;
late Timer? _updateTimer;
@override
void initState() {
super.initState();
final initNow = DateTime.now().millisecondsSinceEpoch;
_timestampString = formatConversationTimestamp(
widget.conversation.lastChangeTimestamp,
initNow,
);
// NOTE: We could also check and run the timer hourly, but who has a messenger on the
// conversation screen open for hours on end?
if (widget.update &&
widget.conversation.lastChangeTimestamp > -1 &&
initNow - widget.conversation.lastChangeTimestamp >=
60 * Duration.millisecondsPerMinute) {
_updateTimer = Timer.periodic(const Duration(minutes: 1), (timer) {
final now = DateTime.now().millisecondsSinceEpoch;
setState(() {
_timestampString = formatConversationTimestamp(
widget.conversation.lastChangeTimestamp,
now,
);
});
if (now - widget.conversation.lastChangeTimestamp >=
60 * Duration.millisecondsPerMinute) {
_updateTimer!.cancel();
_updateTimer = null;
}
});
} else {
_updateTimer = null;
}
}
@override
void dispose() {
if (_updateTimer != null) {
_updateTimer!.cancel();
}
super.dispose();
}
Widget _buildAvatar() {
return RebuildOnContactIntegrationChange(
builder: () {
final avatar = CachingXMPPAvatar(
borderRadius: 35,
size: 70,
jid: widget.conversation.jid,
hash: widget.conversation.avatarHash,
path: widget.conversation.avatarPathWithOptionalContact,
hasContactId: widget.conversation.contactId != null,
isGroupchat: widget.conversation.isGroupchat,
altIcon: widget.conversation.type == ConversationType.note
? Icons.notes
: null,
shouldRequest: widget.conversation.type != ConversationType.note,
);
if (widget.enableAvatarOnTap &&
widget.conversation.avatarPathWithOptionalContact != null &&
widget.conversation.avatarPathWithOptionalContact!.isNotEmpty) {
return InkWell(
onTap: () => showDialog<void>(
context: context,
builder: (context) {
return IgnorePointer(
child: Image.file(
File(widget.conversation.avatarPathWithOptionalContact!),
),
);
},
),
child: avatar,
);
}
return avatar;
},
);
}
Widget _buildLastMessagePreview() {
Widget? preview;
if (widget.conversation.lastMessage!.stickerPackId != null) {
if (widget.conversation.lastMessage!.fileMetadata!.path != null) {
preview = SharedImageWidget(
widget.conversation.lastMessage!.fileMetadata!.path!,
borderRadius: 5,
size: 30,
);
} else {
preview = Icon(
PhosphorIcons.regular.sticker,
size: 30,
);
}
} else if (widget.conversation.lastMessage!.fileMetadata!.mimeType!
.startsWith('image/')) {
if (widget.conversation.lastMessage!.fileMetadata!.path == null) {
preview = const SizedBox();
} else {
preview = SharedImageWidget(
widget.conversation.lastMessage!.fileMetadata!.path!,
borderRadius: 5,
size: 30,
);
}
} else if (widget.conversation.lastMessage!.fileMetadata!.mimeType!
.startsWith('video/')) {
if (widget.conversation.lastMessage!.fileMetadata!.path == null) {
preview = const SizedBox();
} else {
preview = SharedVideoWidget(
widget.conversation.lastMessage!.fileMetadata!.path!,
widget.conversation.jid,
widget.conversation.lastMessage!.fileMetadata!.mimeType!,
borderRadius: 5,
size: 30,
);
}
}
return Padding(
padding: const EdgeInsets.only(right: 5),
child: preview,
);
}
Widget _buildLastMessageBody() {
if (widget.conversation.isTyping) {
return const TypingIndicatorWidget(Colors.black, Colors.white);
}
final lastMessage = widget.conversation.lastMessage;
String body;
if (lastMessage == null) {
body = '';
} else {
if (lastMessage.isRetracted) {
body = t.messages.retracted;
} else if (lastMessage.isMedia) {
// If the file is thumbnailable, we display a small preview on the left of the
// body, so we don't need the emoji then.
if (lastMessage.stickerPackId != null) {
body = t.messages.sticker;
} else if (lastMessage.isThumbnailable) {
body = mimeTypeToName(lastMessage.fileMetadata!.mimeType);
} else {
body = mimeTypeToEmoji(lastMessage.fileMetadata!.mimeType);
}
} else {
body = widget.conversation.lastMessage!.body;
}
}
return Text(
body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
Widget _getLastMessageIcon(bool sentBySelf) {
final lastMessage = widget.conversation.lastMessage;
if (lastMessage == null) return const SizedBox();
Widget? icon;
if (sentBySelf) {
if (lastMessage.displayed) {
icon = Icon(
Icons.done_all,
color: Colors.blue.shade700,
);
} else if (lastMessage.received) {
icon = const Icon(Icons.done_all);
} else if (lastMessage.acked) {
icon = const Icon(Icons.done);
} else if (lastMessage.hasError) {
icon = const Icon(
Icons.info_outline,
color: Colors.red,
);
}
} else {
if (lastMessage.isEdited) {
icon = const Icon(Icons.edit);
}
}
if (icon != null) {
if (widget.conversation.unreadCounter > 0) {
return Padding(
padding: const EdgeInsets.only(right: 5),
child: icon,
);
} else {
return icon;
}
}
return const SizedBox();
}
/// Figure out the display name of the last message's sender.
/// [sentBySelf] indicates that the last message was sent by us.
/// Requires that [widget.conversation.lastMessage] is non-null.
String _getSenderName(bool sentBySelf) {
if (sentBySelf) {
return t.messages.you;
}
assert(
widget.conversation.lastMessage != null,
'The conversation must have a last message',
);
assert(
widget.conversation.isGroupchat,
'The sender name should not be displayed for non-groupchat conversations',
);
return widget.conversation.lastMessage!.senderJid.resource;
}
@override
Widget build(BuildContext context) {
final badgeText = widget.conversation.unreadCounter > 99
? '99+'
: widget.conversation.unreadCounter.toString();
final showTimestamp =
widget.conversation.lastChangeTimestamp != timestampNever &&
widget.showTimestamp;
final sentBySelf = widget.conversation.lastMessage?.sender ==
GetIt.I.get<AccountCubit>().state.account.jid;
final showBadge = widget.conversation.unreadCounter > 0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(radiusLargeSize),
child: AnimatedMaterialColor(
color: widget.isSelected ? Colors.blue : Colors.transparent,
child: InkWell(
onTap: widget.onPressed ?? () {},
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
_buildAvatar(),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
RebuildOnContactIntegrationChange(
builder: () => Text(
widget.conversation.titleWithOptionalContact,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 17,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (widget.titleSuffixIcon != null)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 6),
child: Icon(
widget.titleSuffixIcon,
size: 17,
),
),
if (showTimestamp) const Spacer(),
if (showTimestamp) Text(_timestampString),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!widget.conversation.isTyping &&
widget.conversation.lastMessage != null &&
(sentBySelf ||
widget.conversation.isGroupchat))
Padding(
padding: const EdgeInsets.only(
right: 8,
),
child: Text(
'${_getSenderName(sentBySelf)}:',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
if ((widget.conversation.lastMessage
?.isThumbnailable ??
false) &&
!widget.conversation.isTyping)
Padding(
padding: const EdgeInsets.only(right: 5),
child: _buildLastMessagePreview(),
)
else
const SizedBox(height: 30),
Expanded(
child: _buildLastMessageBody(),
),
_getLastMessageIcon(sentBySelf),
// Off-stage the badge if not visible to prevent the invisible
// badge taking up space.
Offstage(
offstage: !showBadge,
child: badges.Badge(
badgeContent: Text(badgeText),
showBadge: showBadge,
badgeStyle: const badges.BadgeStyle(
badgeColor: bubbleColorSent,
),
),
),
],
),
],
),
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -28,6 +28,84 @@ IconData? _messageStateToIcon(Message msg) {
return null; return null;
} }
class AnimatedMaterialColor extends StatefulWidget {
const AnimatedMaterialColor({
required this.color,
required this.child,
super.key,
});
/// The color attribute of the [Material] widget.
final Color color;
/// The child widget of the [Material] widget.
final Widget child;
@override
AnimatedMaterialColorState createState() => AnimatedMaterialColorState();
}
class AnimatedMaterialColorState extends State<AnimatedMaterialColor>
with TickerProviderStateMixin {
late final AnimationController _controller;
late final ColorTween _tween;
late final Animation<Color?> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_tween = ColorTween(
begin: widget.color,
end: widget.color,
);
_animation = _tween.animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutCubic,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(AnimatedMaterialColor oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.color != widget.color) {
_tween
..begin = oldWidget.color
..end = widget.color;
_controller
..reset()
..forward();
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (_, child) => Material(
color: _animation.value,
child: child,
),
child: widget.child,
);
}
}
class _RowIcon extends StatelessWidget { class _RowIcon extends StatelessWidget {
const _RowIcon( const _RowIcon(
this.icon, { this.icon, {
@@ -139,6 +217,7 @@ class ConversationCard extends StatelessWidget {
required this.onTap, required this.onTap,
this.highlightWord, this.highlightWord,
this.showTimestamp = true, this.showTimestamp = true,
this.selected = false,
this.titleSuffixIcon, this.titleSuffixIcon,
super.key, super.key,
}); });
@@ -158,6 +237,10 @@ class ConversationCard extends StatelessWidget {
/// Icon to show after the conversation title. /// Icon to show after the conversation title.
final Widget? titleSuffixIcon; final Widget? titleSuffixIcon;
/// Flag indicating whether we should show a selection indicator (true) or
/// not (false).
final bool selected;
Widget _buildLastMessagePreview() { Widget _buildLastMessagePreview() {
Widget? preview; Widget? preview;
if (conversation.lastMessage!.stickerPackId != null) { if (conversation.lastMessage!.stickerPackId != null) {
@@ -283,8 +366,10 @@ class ConversationCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sentBySelf = conversation.lastMessage?.sender == final sentBySelf = conversation.lastMessage?.sender ==
GetIt.I.get<AccountCubit>().state.account.jid; GetIt.I.get<AccountCubit>().state.account.jid;
return Material( return AnimatedMaterialColor(
color: Colors.transparent, color: selected
? Theme.of(context).colorScheme.secondaryContainer
: Colors.transparent,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
child: SizedBox( child: SizedBox(