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
@@ -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,
),
),
],
+328 -86
View File
@@ -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),
],
),
),
),
);
},
);
}
}
+78 -2
View File
@@ -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, {
+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),
),
],
],
),
],
+44
View File
@@ -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),
),
),
);
},
);
}
}