477 lines
15 KiB
Dart
477 lines
15 KiB
Dart
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(
|
|
radius: 35,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|