feat: гост мод и фиксы разные всякие я линус торвальдс
This commit is contained in:
@@ -9,6 +9,7 @@ import 'package:flutter/gestures.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'create_group_flow.dart';
|
||||
import '../../widgets/adaptive_shell.dart';
|
||||
import '../../widgets/online_dot.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
@@ -1472,7 +1473,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
previewText,
|
||||
_formatTime(chat.lastMsgTime),
|
||||
avatar ?? "",
|
||||
isOnline: chat.isOnline,
|
||||
presenceUserId: secondId,
|
||||
unreadCount: chat.unreadCount,
|
||||
isMuted: chat.isMuted,
|
||||
isVerified: isVerified,
|
||||
@@ -1510,7 +1511,6 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
(chat.iconUrl != null && chat.iconUrl!.isNotEmpty)
|
||||
? chat.iconUrl!
|
||||
: '',
|
||||
isOnline: chat.isOnline,
|
||||
unreadCount: chat.unreadCount,
|
||||
isMuted: chat.isMuted,
|
||||
isVerified: chat.isOfficial,
|
||||
@@ -2127,7 +2127,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
String message,
|
||||
String time,
|
||||
String imageUrl, {
|
||||
bool isOnline = false,
|
||||
int presenceUserId = 0,
|
||||
bool isTyping = false,
|
||||
bool isRead = false,
|
||||
int unreadCount = 0,
|
||||
@@ -2238,18 +2238,13 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (isOnline)
|
||||
else if (presenceUserId != 0)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: cs.surface, width: 2),
|
||||
),
|
||||
child: OnlineDot(
|
||||
userId: presenceUserId,
|
||||
borderColor: cs.surface,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -35,8 +35,10 @@ import '../../../core/config/app_message_actions_style.dart';
|
||||
import '../../../core/config/app_swipe_back_desktop.dart';
|
||||
import '../../../core/config/app_pranks.dart';
|
||||
import '../../../core/config/app_visual_style.dart';
|
||||
import '../../../core/config/komet_settings.dart';
|
||||
import '../../../models/attachment.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/online_dot.dart';
|
||||
import '../../widgets/message_bubble.dart';
|
||||
import '../../widgets/theme_reveal.dart';
|
||||
import '../../widgets/message_actions_overlay.dart';
|
||||
@@ -176,11 +178,13 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final Map<int, GlobalKey> _separatorKeys = {};
|
||||
String? _lastSentId;
|
||||
String? _lastMarkedId;
|
||||
final ValueNotifier<int> _otherUnread = ValueNotifier(0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
ChatsModule.chatsChanged.addListener(_onChatsBump);
|
||||
_messageController.addListener(_onTextChanged);
|
||||
_scrollController.addListener(_onScrollForDate);
|
||||
AppVisualStyle.current.addListener(_onVisualStyleChanged);
|
||||
@@ -205,6 +209,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_messageEventSub = ChatsModule.messageEvents
|
||||
.where((e) => e.chatId == widget.chatId)
|
||||
.listen(_onMessageEvent);
|
||||
PresenceFetch.revision.addListener(_onPresenceChanged);
|
||||
_floatingDateAnimController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
@@ -237,6 +242,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (!mounted) return;
|
||||
_myId = p?.id ?? 0;
|
||||
_restoreDraft();
|
||||
unawaited(_refreshBadge());
|
||||
|
||||
ChatsModule.getChat(_myId, widget.chatId)
|
||||
.then((value) {
|
||||
@@ -255,6 +261,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_myId,
|
||||
widget.chatId,
|
||||
limit: 20,
|
||||
onlyVisible: !KometSettings.viewDeleted.value,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (firstRows.isNotEmpty) {
|
||||
@@ -326,6 +333,37 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
);
|
||||
}
|
||||
|
||||
bool _badgeRefreshing = false;
|
||||
bool _badgeRefreshQueued = false;
|
||||
|
||||
void _onChatsBump() {
|
||||
if (_badgeRefreshing) {
|
||||
_badgeRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
unawaited(_runBadgeRefresh());
|
||||
}
|
||||
|
||||
Future<void> _runBadgeRefresh() async {
|
||||
_badgeRefreshing = true;
|
||||
try {
|
||||
await _refreshBadge();
|
||||
} finally {
|
||||
_badgeRefreshing = false;
|
||||
if (_badgeRefreshQueued && mounted) {
|
||||
_badgeRefreshQueued = false;
|
||||
unawaited(_runBadgeRefresh());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshBadge() async {
|
||||
if (_myId == 0) return;
|
||||
final total =
|
||||
await AppDatabase.sumUnread(_myId, excludeChatId: widget.chatId);
|
||||
if (mounted) _otherUnread.value = total;
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
if (_myId == 0) {
|
||||
final activeProfile = await AppDatabase.loadActiveProfile();
|
||||
@@ -340,10 +378,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
Future<void> _loadRemainingHistory() async {
|
||||
final onlyVisible = !KometSettings.viewDeleted.value;
|
||||
final fullRows = await AppDatabase.loadMessages(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
limit: 100,
|
||||
onlyVisible: onlyVisible,
|
||||
);
|
||||
final fullDecoded = await CachedMessage.fromDbRowsAsync(fullRows);
|
||||
if (mounted && fullDecoded.length > _messages.length) {
|
||||
@@ -363,12 +403,23 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
try {
|
||||
await messagesModule.fetchHistory(_myId, widget.chatId);
|
||||
final serverMessages = await messagesModule.fetchHistory(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
);
|
||||
ChatsModule.markHistoryFetched(widget.chatId);
|
||||
if (KometSettings.viewDeleted.value) {
|
||||
await ChatsModule.reconcileDeletedFromFetch(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
serverMessages,
|
||||
);
|
||||
}
|
||||
final updatedRows = await AppDatabase.loadMessages(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
limit: 100,
|
||||
onlyVisible: onlyVisible,
|
||||
);
|
||||
final updatedDecoded = await CachedMessage.fromDbRowsAsync(updatedRows);
|
||||
if (mounted) {
|
||||
@@ -446,7 +497,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
a.time == b.time &&
|
||||
a.status == b.status &&
|
||||
a.text == b.text &&
|
||||
a.senderId == b.senderId;
|
||||
a.senderId == b.senderId &&
|
||||
a.deleted == b.deleted;
|
||||
}
|
||||
|
||||
bool _listsEquivalent(List<CachedMessage> a, List<CachedMessage> b) {
|
||||
@@ -475,6 +527,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
ChatsModule.chatsChanged.removeListener(_onChatsBump);
|
||||
_otherUnread.dispose();
|
||||
_saveDraft();
|
||||
_messageController.removeListener(_onTextChanged);
|
||||
_scrollController.removeListener(_onScrollForDate);
|
||||
@@ -502,6 +556,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
t.cancel();
|
||||
}
|
||||
_typingTimers.clear();
|
||||
PresenceFetch.revision.removeListener(_onPresenceChanged);
|
||||
_headerStatusNotifier.dispose();
|
||||
_otherReadTime.dispose();
|
||||
_messagesRev.dispose();
|
||||
@@ -972,6 +1027,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_messages.removeAt(idx);
|
||||
_bumpMessages();
|
||||
_reactionNotifiers.remove(messageId)?.dispose();
|
||||
case MessageMarkedDeletedEvent(:final messageId):
|
||||
final idx = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (idx == -1) return;
|
||||
if (_messages[idx].deleted) return;
|
||||
_messages[idx] = _messages[idx].copyWith(deleted: true);
|
||||
_bumpMessages();
|
||||
case MessageReactionsChangedEvent(:final messageId, :final reactionInfo):
|
||||
_reactionNotifiers[messageId]?.value = reactionInfo;
|
||||
}
|
||||
@@ -981,19 +1042,97 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (_myId == 0) return;
|
||||
final otherId = widget.chatId ^ _myId;
|
||||
if (otherId <= 0) return;
|
||||
if (PresenceFetch.live(otherId) != null) return;
|
||||
try {
|
||||
final entry = await PresenceFetch.get(otherId);
|
||||
if (!mounted || entry == null) return;
|
||||
_otherStatus = (entry['status'] as int?) ?? 0;
|
||||
_otherSeenTime = entry['seen'] as int?;
|
||||
_recomputeHeaderStatus();
|
||||
PresenceFetch.apply(otherId, entry);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _onPresenceChanged() {
|
||||
if (!mounted || widget.chatType != 'DIALOG' || _myId == 0) return;
|
||||
final otherId = widget.chatId ^ _myId;
|
||||
if (otherId <= 0) return;
|
||||
final p = PresenceFetch.live(otherId);
|
||||
if (p == null) return;
|
||||
_otherStatus = (p['status'] as int?) ?? 0;
|
||||
_otherSeenTime = p['seen'] as int?;
|
||||
_recomputeHeaderStatus();
|
||||
}
|
||||
|
||||
Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) {
|
||||
if (widget.chatType != 'DIALOG' || _myId == 0) return avatar;
|
||||
final otherId = widget.chatId ^ _myId;
|
||||
if (otherId <= 0) return avatar;
|
||||
return Stack(
|
||||
children: [
|
||||
avatar,
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: OnlineDot(
|
||||
userId: otherId,
|
||||
borderColor: cs.surface,
|
||||
size: dotSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onVisualStyleChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Widget _backWithBadge(ColorScheme cs, Widget button) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
button,
|
||||
Positioned(
|
||||
right: -2,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(child: _backUnreadBadge(cs)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _backUnreadBadge(ColorScheme cs) {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _otherUnread,
|
||||
builder: (context, count, _) {
|
||||
return AnimatedScale(
|
||||
scale: count > 0 ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutBack,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minWidth: 18),
|
||||
height: 18,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(color: cs.surface, width: 1.5),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: _RollingCount(
|
||||
count: count > 99 ? 99 : count,
|
||||
style: TextStyle(
|
||||
color: cs.onPrimary,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _materialAppBar(ColorScheme cs) {
|
||||
return PreferredSize(
|
||||
preferredSize: Size.fromHeight(kToolbarHeight),
|
||||
@@ -1015,43 +1154,51 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
iconTheme: IconThemeData(color: cs.onSurface),
|
||||
leading: IconButton(
|
||||
icon: Icon(
|
||||
widget.embedded ? Symbols.close : Symbols.arrow_back,
|
||||
weight: 400,
|
||||
leading: _backWithBadge(
|
||||
cs,
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
widget.embedded ? Symbols.close : Symbols.arrow_back,
|
||||
weight: 400,
|
||||
),
|
||||
onPressed: () {
|
||||
if (widget.embedded) {
|
||||
widget.onClose?.call();
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
onPressed: () {
|
||||
if (widget.embedded) {
|
||||
widget.onClose?.call();
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
titleSpacing: 0,
|
||||
title: Row(
|
||||
children: [
|
||||
if (widget.imageUrl.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
widget.imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Text(
|
||||
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
_withOnlineDot(
|
||||
cs,
|
||||
widget.imageUrl.isNotEmpty
|
||||
? CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
widget.imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Text(
|
||||
widget.name.isNotEmpty
|
||||
? widget.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
dotSize: 11,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -1131,9 +1278,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return;
|
||||
}
|
||||
// Звонок уже идёт (возможно, свёрнут) — просто открываем его экран снова.
|
||||
final navigator = Navigator.of(context);
|
||||
final active = CallController.instance.activeSession;
|
||||
if (active != null) {
|
||||
Navigator.of(context).push(
|
||||
await navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
name: widget.name,
|
||||
@@ -1142,15 +1290,15 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
),
|
||||
),
|
||||
);
|
||||
_onCallScreenClosed();
|
||||
return;
|
||||
}
|
||||
final peerId = widget.chatId ^ _myId;
|
||||
if (peerId <= 0) return;
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
final session = await CallController.instance.startOutgoing(peerId);
|
||||
if (!mounted) return;
|
||||
navigator.push(
|
||||
await navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
name: widget.name,
|
||||
@@ -1159,18 +1307,49 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
),
|
||||
),
|
||||
);
|
||||
_onCallScreenClosed();
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Не удалось начать звонок');
|
||||
}
|
||||
}
|
||||
|
||||
void _onCallScreenClosed() {
|
||||
if (!mounted) return;
|
||||
if (CallController.instance.activeSession != null) return;
|
||||
unawaited(_refreshAfterCall());
|
||||
}
|
||||
|
||||
Future<void> _refreshAfterCall() async {
|
||||
await Future.delayed(const Duration(milliseconds: 700));
|
||||
if (!mounted || _myId == 0) return;
|
||||
try {
|
||||
final serverMessages =
|
||||
await messagesModule.fetchHistory(_myId, widget.chatId);
|
||||
if (KometSettings.viewDeleted.value) {
|
||||
await ChatsModule.reconcileDeletedFromFetch(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
serverMessages,
|
||||
);
|
||||
}
|
||||
final rows = await AppDatabase.loadMessages(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
limit: 100,
|
||||
onlyVisible: !KometSettings.viewDeleted.value,
|
||||
);
|
||||
final decoded = await CachedMessage.fromDbRowsAsync(rows);
|
||||
if (mounted) _applyMergedMessages(decoded);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _seedPresenceFromChat() {
|
||||
if (widget.chatType != 'DIALOG' || _myId == 0) return;
|
||||
if (_otherStatus != 0 || _otherSeenTime != null) return;
|
||||
final otherId = widget.chatId ^ _myId;
|
||||
if (otherId <= 0) return;
|
||||
final p = PresenceFetch.peek(otherId);
|
||||
final p = PresenceFetch.live(otherId);
|
||||
if (p == null) return;
|
||||
_otherStatus = (p['status'] as int?) ?? 0;
|
||||
_otherSeenTime = p['seen'] as int?;
|
||||
@@ -1414,7 +1593,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (msg.attachments != null) {
|
||||
for (final a in msg.attachments!) {
|
||||
if (a is ForwardedMessageAttachment) {
|
||||
if (a.originalSenderName == null) {
|
||||
if (a.originalSenderName == null &&
|
||||
ContactCache.get(a.originalSenderId) == null) {
|
||||
forwardIds.add(a.originalSenderId);
|
||||
}
|
||||
}
|
||||
@@ -1692,25 +1872,28 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: GlossyPill(
|
||||
onTap: () {
|
||||
if (widget.embedded) {
|
||||
widget.onClose?.call();
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Center(
|
||||
child: Icon(
|
||||
widget.embedded
|
||||
? Symbols.close
|
||||
: Symbols.arrow_back,
|
||||
color: cs.onSurface,
|
||||
weight: 500,
|
||||
size: 24,
|
||||
_backWithBadge(
|
||||
cs,
|
||||
SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: GlossyPill(
|
||||
onTap: () {
|
||||
if (widget.embedded) {
|
||||
widget.onClose?.call();
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Center(
|
||||
child: Icon(
|
||||
widget.embedded
|
||||
? Symbols.close
|
||||
: Symbols.arrow_back,
|
||||
color: cs.onSurface,
|
||||
weight: 500,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1732,31 +1915,34 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
padding: const EdgeInsets.fromLTRB(6, 6, 16, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.imageUrl.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
widget.imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Text(
|
||||
widget.name.isNotEmpty
|
||||
? widget.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
),
|
||||
_withOnlineDot(
|
||||
cs,
|
||||
widget.imageUrl.isNotEmpty
|
||||
? CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundImage:
|
||||
CachedNetworkImageProvider(
|
||||
widget.imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Text(
|
||||
widget.name.isNotEmpty
|
||||
? widget.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -3613,3 +3799,59 @@ class _SentMessageAnimationState extends State<_SentMessageAnimation>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RollingCount extends StatefulWidget {
|
||||
final int count;
|
||||
final TextStyle style;
|
||||
|
||||
const _RollingCount({required this.count, required this.style});
|
||||
|
||||
@override
|
||||
State<_RollingCount> createState() => _RollingCountState();
|
||||
}
|
||||
|
||||
class _RollingCountState extends State<_RollingCount> {
|
||||
late int _count = widget.count;
|
||||
bool _increasing = true;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_RollingCount old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.count != _count) {
|
||||
_increasing = widget.count > _count;
|
||||
_count = widget.count;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
transitionBuilder: (child, anim) {
|
||||
final incoming = (child.key as ValueKey<int>).value == _count;
|
||||
final Offset begin;
|
||||
if (incoming) {
|
||||
begin = _increasing ? const Offset(0, -1) : const Offset(0, 1);
|
||||
} else {
|
||||
begin = _increasing ? const Offset(0, 1) : const Offset(0, -1);
|
||||
}
|
||||
return ClipRect(
|
||||
child: FadeTransition(
|
||||
opacity: anim,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: begin, end: Offset.zero).animate(anim),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'${widget.count}',
|
||||
key: ValueKey<int>(widget.count),
|
||||
style: widget.style,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:m3e_collection/m3e_collection.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/config/komet_settings.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/section_header.dart';
|
||||
|
||||
class KometSettingsScreen extends StatelessWidget {
|
||||
const KometSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBarM3E(titleText: 'Komet', backgroundColor: cs.surface),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
|
||||
children: [
|
||||
const SectionHeader(
|
||||
'Сообщения',
|
||||
padding: EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
fontSize: 14,
|
||||
),
|
||||
_card(cs, [
|
||||
_toggle(
|
||||
cs,
|
||||
icon: Symbols.delete_history,
|
||||
label: 'View deleted message',
|
||||
subtitle: 'Показывать удалённые сообщения',
|
||||
notifier: KometSettings.viewDeleted,
|
||||
onChanged: KometSettings.setViewDeleted,
|
||||
),
|
||||
_divider(cs),
|
||||
_toggle(
|
||||
cs,
|
||||
icon: Symbols.history_edu,
|
||||
label: 'View redacted message history',
|
||||
subtitle: 'Показывать историю у редактированных сообщений',
|
||||
notifier: KometSettings.viewRedacted,
|
||||
onChanged: KometSettings.setViewRedacted,
|
||||
),
|
||||
_divider(cs),
|
||||
_toggle(
|
||||
cs,
|
||||
icon: Symbols.schedule,
|
||||
label: 'View full timestamp',
|
||||
subtitle: 'Показывать время в секундах у сообщений',
|
||||
notifier: KometSettings.fullTimestamp,
|
||||
onChanged: KometSettings.setFullTimestamp,
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
const SectionHeader(
|
||||
'Ghost Mode',
|
||||
padding: EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
fontSize: 14,
|
||||
),
|
||||
_card(cs, [
|
||||
_toggle(
|
||||
cs,
|
||||
icon: Symbols.visibility_off,
|
||||
label: 'Ghost Mode',
|
||||
subtitle: 'Не отмечать вас в сети: пинги идут скрытно',
|
||||
notifier: KometSettings.ghostMode,
|
||||
onChanged: _setGhostMode,
|
||||
),
|
||||
_divider(cs),
|
||||
_toggle(
|
||||
cs,
|
||||
icon: Symbols.radar,
|
||||
label: 'Self Online Check',
|
||||
subtitle:
|
||||
'Каждые ~10 секунд сверяет, когда вы были онлайн. '
|
||||
'Полезно для проверки ghost mode',
|
||||
notifier: KometSettings.selfOnlineCheck,
|
||||
onChanged: KometSettings.setSelfOnlineCheck,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setGhostMode(bool value) async {
|
||||
await KometSettings.setGhostMode(value);
|
||||
api.sendPing(interactive: !value);
|
||||
}
|
||||
|
||||
Widget _card(ColorScheme cs, List<Widget> children) {
|
||||
return GlossyPill(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
depth: 6,
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _divider(ColorScheme cs) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 58),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toggle(
|
||||
ColorScheme cs, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String subtitle,
|
||||
required ValueNotifier<bool> notifier,
|
||||
required Future<void> Function(bool) onChanged,
|
||||
}) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: notifier,
|
||||
builder: (context, value, _) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => onChanged(!value),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Switch(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../../../backend/modules/messages.dart';
|
||||
import '../../../core/cache/self_presence.dart';
|
||||
import '../../../core/config/komet_settings.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart';
|
||||
@@ -28,6 +31,7 @@ import 'debug_menu_screen.dart';
|
||||
import 'devices_screen.dart';
|
||||
import 'edit_profile_screen.dart';
|
||||
import 'info_screen.dart';
|
||||
import 'komet_settings_screen.dart';
|
||||
import 'notifications_screen.dart';
|
||||
import 'security_screen.dart';
|
||||
import 'spoof_screen.dart';
|
||||
@@ -286,6 +290,19 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
);
|
||||
},
|
||||
),
|
||||
_SettingsItem(
|
||||
// Иконку кометы блять дайте!!!!!!!1
|
||||
icon: Symbols.auto_awesome,
|
||||
label: 'Komet',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const KometSettingsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Symbols.info,
|
||||
label: 'Info',
|
||||
@@ -584,7 +601,7 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
fontSize: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
@@ -594,7 +611,8 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildOnlineStatus(cs),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -627,6 +645,64 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatSelfSeen(int seconds) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
|
||||
final now = DateTime.now();
|
||||
final time = formatClock(dt);
|
||||
final isToday =
|
||||
dt.year == now.year && dt.month == now.month && dt.day == now.day;
|
||||
if (isToday) return time;
|
||||
final datePart = dt.year == now.year
|
||||
? '${dt.day} ${kRuMonthsShort[dt.month - 1]}'
|
||||
: '${dt.day} ${kRuMonthsShort[dt.month - 1]} ${dt.year}';
|
||||
return '$datePart, $time';
|
||||
}
|
||||
|
||||
Widget _buildOnlineStatus(ColorScheme cs) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: KometSettings.selfOnlineCheck,
|
||||
builder: (context, enabled, _) {
|
||||
if (!enabled) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: SelfPresence.isOnline,
|
||||
builder: (context, online, _) => ValueListenableBuilder<int?>(
|
||||
valueListenable: SelfPresence.lastSeenSeconds,
|
||||
builder: (context, seen, _) {
|
||||
final label = online
|
||||
? 'онлайн'
|
||||
: (seen != null ? 'Был(-а) ${_formatSelfSeen(seen)}' : 'офлайн');
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.check_circle,
|
||||
fill: 1,
|
||||
size: 15,
|
||||
color: online
|
||||
? const Color(0xFF34C759)
|
||||
: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(
|
||||
BuildContext context,
|
||||
ColorScheme cs, {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/cache/info_cache.dart';
|
||||
|
||||
class OnlineDot extends StatelessWidget {
|
||||
final int userId;
|
||||
final double size;
|
||||
final Color color;
|
||||
final Color borderColor;
|
||||
final double borderWidth;
|
||||
|
||||
const OnlineDot({
|
||||
super.key,
|
||||
required this.userId,
|
||||
required this.borderColor,
|
||||
this.size = 12,
|
||||
this.color = const Color(0xFF2EC36B),
|
||||
this.borderWidth = 2,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: PresenceFetch.revision,
|
||||
builder: (context, _, _) {
|
||||
final online = PresenceFetch.isOnline(userId);
|
||||
return AnimatedScale(
|
||||
scale: online ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: online ? Curves.easeOutBack : Curves.easeIn,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: borderColor, width: borderWidth),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user