feat: гост мод и фиксы разные всякие я линус торвальдс

This commit is contained in:
Jganenokk
2026-06-13 22:19:45 +07:00
parent e84cdf15e3
commit 814e969c4a
17 changed files with 1341 additions and 412 deletions
+267 -285
View File
@@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/messages.dart';
import '../../core/config/app_bubble_behavior.dart';
import '../../core/config/app_bubble_shape.dart';
import '../../core/config/komet_settings.dart';
import '../../core/utils/bubble_radius.dart';
import '../../core/utils/format.dart';
import '../../core/utils/haptics.dart';
@@ -51,7 +52,7 @@ class _BubbleCtx {
}
final Expando<MessageType> _contentTypeCache = Expando<MessageType>();
final Expando<String> _clockTextCache = Expando<String>();
final Expando<({bool full, String text})> _clockTextCache = Expando();
class MessageBubble extends StatelessWidget {
static const double photoMaxSize = 280.0;
@@ -149,9 +150,17 @@ class MessageBubble extends StatelessWidget {
return _contentTypeCache[message] ??= _computeContentType();
}
String get _clockText => _clockTextCache[message] ??= formatClock(
DateTime.fromMillisecondsSinceEpoch(message.time),
);
String get _clockText {
final full = KometSettings.fullTimestamp.value;
final cached = _clockTextCache[message];
if (cached != null && cached.full == full) return cached.text;
final text = formatClock(
DateTime.fromMillisecondsSinceEpoch(message.time),
withSeconds: full,
);
_clockTextCache[message] = (full: full, text: text);
return text;
}
MessageType _computeContentType() {
if (message.isControl) return MessageType.control;
@@ -653,6 +662,10 @@ class MessageBubble extends StatelessWidget {
child: metaWidget,
),
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
if (message.deleted) ...[
const SizedBox(width: 4),
_buildDeletedIcon(ctx),
],
],
),
],
@@ -680,6 +693,10 @@ class MessageBubble extends StatelessWidget {
child: metaWidget,
),
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
if (message.deleted) ...[
const SizedBox(width: 4),
_buildDeletedIcon(ctx),
],
],
),
],
@@ -691,9 +708,13 @@ class MessageBubble extends StatelessWidget {
ForwardedMessageAttachment forwarded,
) {
final headerColor = ctx.dim;
final senderName = forwarded.originalSenderName;
final displaySender = senderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
final displaySender =
forwarded.originalSenderName ??
ContactCache.get(forwarded.originalSenderId) ??
forwarded.originalSenderId.toString();
final senderAvatar =
forwarded.originalSenderAvatar ??
ContactCache.getAvatar(forwarded.originalSenderId);
final origText = forwarded.originalText;
final hasOrigText = origText != null && origText.isNotEmpty;
@@ -760,6 +781,62 @@ class MessageBubble extends StatelessWidget {
);
}
Widget _buildForwardedHeader(
_BubbleCtx ctx,
ForwardedMessageAttachment forwarded,
) {
final headerColor = ctx.dim;
final displaySender =
forwarded.originalSenderName ??
ContactCache.get(forwarded.originalSenderId) ??
forwarded.originalSenderId.toString();
final senderAvatar =
forwarded.originalSenderAvatar ??
ContactCache.getAvatar(forwarded.originalSenderId);
return Padding(
padding: const EdgeInsets.only(left: 8, top: 8, right: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.forward, size: 14, color: headerColor),
const SizedBox(width: 4),
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: CachedNetworkImageProvider(
senderAvatar,
maxWidth: 96,
maxHeight: 96,
),
backgroundColor: ctx.cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: ctx.cs.primaryContainer,
child: Text(
displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?',
style: TextStyle(fontSize: 9, color: ctx.cs.onPrimaryContainer),
),
),
const SizedBox(width: 6),
Flexible(
child: Text(
displaySender,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
ForwardedMessageAttachment? _getForwardedAttachment() {
final attachments = message.attachments;
if (attachments == null || attachments.isEmpty) return null;
@@ -1053,59 +1130,13 @@ class MessageBubble extends StatelessWidget {
ForwardedMessageAttachment forwarded,
List<PhotoAttachment> photos,
) {
final headerColor = ctx.dim;
final displaySender =
forwarded.originalSenderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
final hasCaption = message.text != null && message.text!.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.forward, size: 14, color: headerColor),
const SizedBox(width: 4),
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: CachedNetworkImageProvider(
senderAvatar,
maxWidth: 96,
maxHeight: 96,
),
backgroundColor: ctx.cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: ctx.cs.primaryContainer,
child: Text(
displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 9,
color: ctx.cs.onPrimaryContainer,
),
),
),
const SizedBox(width: 6),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
_buildForwardedHeader(ctx, forwarded),
const SizedBox(height: 4),
if (hasCaption) ...[
Padding(
@@ -1127,66 +1158,21 @@ class MessageBubble extends StatelessWidget {
ForwardedMessageAttachment forwarded,
List<MessageAttachment> attachments,
) {
final headerColor = ctx.dim;
final displaySender =
forwarded.originalSenderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.forward, size: 14, color: headerColor),
const SizedBox(width: 4),
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: CachedNetworkImageProvider(
senderAvatar,
maxWidth: 96,
maxHeight: 96,
),
backgroundColor: ctx.cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: ctx.cs.primaryContainer,
child: Text(
displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 9,
color: ctx.cs.onPrimaryContainer,
),
),
),
const SizedBox(width: 6),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 4),
...attachments.map((a) {
if (a is FileAttachment) {
return _buildFileAttachment(ctx, a);
}
return const SizedBox.shrink();
}),
],
return IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_buildForwardedHeader(ctx, forwarded),
const SizedBox(height: 4),
...attachments.map((a) {
if (a is FileAttachment) {
return _buildFileAttachment(ctx, a, fill: true);
}
return const SizedBox.shrink();
}),
],
),
);
}
@@ -1774,7 +1760,11 @@ class MessageBubble extends StatelessWidget {
);
}
Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) {
Widget _buildFileAttachment(
_BubbleCtx ctx,
MessageAttachment file, {
bool fill = false,
}) {
final name = (file as dynamic).name as String? ?? 'File';
final size = (file as dynamic).size as int? ?? 0;
final sizeStr = formatBytes(size);
@@ -1784,132 +1774,129 @@ class MessageBubble extends StatelessWidget {
final preview = file is FileAttachment ? file.preview : null;
final previewUrl = preview?.baseUrl ?? preview?.previewData ?? '';
return IntrinsicWidth(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (previewUrl.isNotEmpty) ...[
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: CachedNetworkImage(
imageUrl: previewUrl,
width: 240,
height: 160,
fit: BoxFit.cover,
memCacheWidth: 480,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) => const SizedBox.shrink(),
final inner = Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (previewUrl.isNotEmpty) ...[
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: CachedNetworkImage(
imageUrl: previewUrl,
width: 240,
height: 160,
fit: BoxFit.cover,
memCacheWidth: 480,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
const SizedBox(height: 8),
],
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: isMe
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
: ctx.cs.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Symbols.description,
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
size: 20,
),
),
const SizedBox(height: 8),
],
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: isMe
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
: ctx.cs.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Symbols.description,
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
size: 20,
),
),
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
style: TextStyle(
color: ctx.text,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
ValueListenableBuilder<double?>(
valueListenable: MediaDownloadProgress.notifier(
cacheName,
),
builder: (context, progress, _) => Text(
progress != null
? '${(progress * 100).round()}% · $sizeStr'
: sizeStr,
style: TextStyle(
color: ctx.text,
fontSize: 14,
fontWeight: FontWeight.w500,
color: ctx.dim,
fontSize: 12,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
ValueListenableBuilder<double?>(
valueListenable: MediaDownloadProgress.notifier(
cacheName,
),
builder: (context, progress, _) => Text(
progress != null
? '${(progress * 100).round()}% · $sizeStr'
: sizeStr,
style: TextStyle(
color: ctx.dim,
fontSize: 12,
height: 1.2,
),
),
),
],
),
),
],
),
const SizedBox(width: 12),
ValueListenableBuilder<double?>(
valueListenable: MediaDownloadProgress.notifier(cacheName),
builder: (context, progress, _) {
final downloading = progress != null;
return GestureDetector(
onTap: downloading
? null
: () => _downloadFile(ctx.context, file, name),
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: isMe
? ctx.cs.onPrimaryContainer.withValues(
alpha: 0.12,
)
: ctx.cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: downloading
? Padding(
padding: const EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress > 0 ? progress : null,
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
),
)
: Icon(
Symbols.download,
),
const SizedBox(width: 12),
ValueListenableBuilder<double?>(
valueListenable: MediaDownloadProgress.notifier(cacheName),
builder: (context, progress, _) {
final downloading = progress != null;
return GestureDetector(
onTap: downloading
? null
: () => _downloadFile(ctx.context, file, name),
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: isMe
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
: ctx.cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: downloading
? Padding(
padding: const EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress > 0 ? progress : null,
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
size: 18,
),
),
);
},
),
],
),
_buildMeta(ctx),
],
),
)
: Icon(
Symbols.download,
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
size: 18,
),
),
);
},
),
],
),
_buildMeta(ctx),
],
),
);
return fill ? inner : IntrinsicWidth(child: inner);
}
Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) {
@@ -2047,59 +2034,12 @@ class MessageBubble extends StatelessWidget {
final bgColor = isMe
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
: ctx.cs.surfaceContainerHighest;
final headerColor = ctx.dim;
final displaySender =
forwarded.originalSenderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.forward, size: 14, color: headerColor),
const SizedBox(width: 4),
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: CachedNetworkImageProvider(
senderAvatar,
maxWidth: 96,
maxHeight: 96,
),
backgroundColor: ctx.cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: ctx.cs.primaryContainer,
child: Text(
displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 9,
color: ctx.cs.onPrimaryContainer,
),
),
),
const SizedBox(width: 6),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
_buildForwardedHeader(ctx, forwarded),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
@@ -2254,6 +2194,7 @@ class MessageBubble extends StatelessWidget {
url: url,
textColor: ctx.text,
isMe: isMe,
deleted: message.deleted,
status: overrideStatus ?? message.status,
time: message.time,
cs: ctx.cs,
@@ -2274,6 +2215,10 @@ class MessageBubble extends StatelessWidget {
children: [
Text(_clockText, style: TextStyle(color: ctx.dim, fontSize: 11)),
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
if (message.deleted) ...[
const SizedBox(width: 4),
_buildDeletedIcon(ctx),
],
],
),
);
@@ -2290,17 +2235,30 @@ class MessageBubble extends StatelessWidget {
color: bgColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
_clockText,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w500,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_clockText,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
if (message.deleted) ...[
const SizedBox(width: 3),
const Icon(Symbols.delete, size: 11, color: Colors.white),
],
],
),
);
}
Widget _buildDeletedIcon(_BubbleCtx ctx) {
return Icon(Symbols.delete, size: 13, color: ctx.dim);
}
Widget _buildStatusIcon(_BubbleCtx ctx) {
final status = overrideStatus ?? message.status;
IconData icon;
@@ -2338,6 +2296,7 @@ class _VoiceMessageBubble extends StatefulWidget {
final String url;
final Color textColor;
final bool isMe;
final bool deleted;
final String? status;
final int time;
final ColorScheme cs;
@@ -2352,6 +2311,7 @@ class _VoiceMessageBubble extends StatefulWidget {
required this.url,
required this.textColor,
required this.isMe,
this.deleted = false,
this.status,
required this.time,
required this.cs,
@@ -2565,7 +2525,10 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
),
if (!_transcriptionVisible) ...[
Text(
formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)),
formatClock(
DateTime.fromMillisecondsSinceEpoch(widget.time),
withSeconds: KometSettings.fullTimestamp.value,
),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10,
@@ -2575,6 +2538,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
const SizedBox(width: 2),
_buildStatusIcon(),
],
if (widget.deleted) ...[
const SizedBox(width: 2),
Icon(
Symbols.delete,
size: 13,
color: widget.textColor.withValues(alpha: 0.6),
),
],
],
],
),
@@ -2583,7 +2554,10 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)),
formatClock(
DateTime.fromMillisecondsSinceEpoch(widget.time),
withSeconds: KometSettings.fullTimestamp.value,
),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10,
@@ -2593,6 +2567,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
const SizedBox(width: 2),
_buildStatusIcon(),
],
if (widget.deleted) ...[
const SizedBox(width: 2),
Icon(
Symbols.delete,
size: 13,
color: widget.textColor.withValues(alpha: 0.6),
),
],
],
),
],