7569 lines
237 KiB
Dart
7569 lines
237 KiB
Dart
import 'dart:async';
|
||
import 'dart:io' show File;
|
||
import 'dart:math' as math;
|
||
import 'dart:ui' as ui;
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:file_picker/file_picker.dart';
|
||
import 'package:geolocator/geolocator.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:record/record.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/rendering.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:komet/backend/modules/chats.dart';
|
||
import 'package:komet/backend/modules/file_uploader.dart';
|
||
import 'package:komet/backend/modules/upload_notification_service.dart';
|
||
import 'package:komet/core/media/gallery_source.dart';
|
||
import 'package:komet/core/media/opus_ogg_encoder.dart';
|
||
import 'package:komet/core/media/native_video_note_recorder.dart';
|
||
import 'package:komet/core/utils/format.dart';
|
||
import 'package:komet/core/utils/logger.dart';
|
||
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
|
||
import 'package:komet/frontend/screens/contacts/contact_profile_screen.dart';
|
||
import 'package:komet/frontend/screens/chats/chat_list_screen.dart';
|
||
import 'package:komet/frontend/screens/chats/poll_create_screen.dart';
|
||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||
import 'package:komet/frontend/widgets/chat_menu_overlay.dart';
|
||
import 'package:komet/frontend/widgets/animated_lottie_icon.dart';
|
||
import 'package:material_symbols_icons/symbols.dart';
|
||
import '../../../main.dart';
|
||
import '../../../backend/api.dart';
|
||
import '../../../backend/modules/messages.dart';
|
||
import '../../../backend/modules/complaints.dart';
|
||
import '../../../core/calls/call_controller.dart';
|
||
import '../calls/call_screen.dart';
|
||
import '../../../core/protocol/opcode_map.dart';
|
||
import '../../../core/protocol/packet.dart';
|
||
import '../../../core/push/push_service.dart';
|
||
import '../../../core/storage/app_database.dart';
|
||
import '../../../core/storage/chat_activity_store.dart';
|
||
import '../../../core/storage/chat_wallpaper_store.dart';
|
||
import '../../../core/storage/draft_store.dart';
|
||
import '../../../core/cache/info_cache.dart';
|
||
import '../../../core/cache/message_session_cache.dart';
|
||
import '../../../core/utils/haptics.dart';
|
||
import '../../../core/config/app_animations.dart';
|
||
import '../../../core/config/app_cache_extent.dart';
|
||
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_commands.dart';
|
||
import '../../../core/config/app_visual_style.dart';
|
||
import '../../../core/config/app_chat_chrome.dart';
|
||
import '../../../core/config/komet_settings.dart';
|
||
import '../../../models/attachment.dart';
|
||
import '../../../models/sticker.dart';
|
||
import '../../commands/command_registry.dart';
|
||
import '../../commands/slash_command.dart';
|
||
import '../../widgets/avatar_hero.dart';
|
||
import '../../widgets/komet_avatar.dart';
|
||
import '../../widgets/glossy_pill.dart';
|
||
import '../../widgets/rich_message_controller.dart';
|
||
import '../../../core/utils/text_format.dart';
|
||
import '../../widgets/command_suggestions_panel.dart';
|
||
import '../../widgets/online_dot.dart';
|
||
import '../../widgets/connection_status.dart';
|
||
import '../../widgets/message_bubble.dart';
|
||
import '../../widgets/theme_reveal.dart';
|
||
import '../../widgets/message_actions_overlay.dart';
|
||
import '../../widgets/attachment_panel.dart';
|
||
import '../../widgets/attachment/attachment_sheet.dart';
|
||
import '../../widgets/sticker_panel.dart';
|
||
import '../../widgets/sticker_pack_sheet.dart';
|
||
import '../../widgets/swipe_to_pop.dart';
|
||
import '../../widgets/swipe_route.dart';
|
||
import '../../widgets/schedule_time_picker.dart';
|
||
import '../../widgets/chat_wallpaper_sheet.dart';
|
||
import '../../widgets/chat_wallpaper_view.dart';
|
||
import 'scheduled_messages_screen.dart';
|
||
import 'chat_wallpaper_preview_screen.dart';
|
||
|
||
class _UploadStatus {
|
||
final bool active;
|
||
final int sent;
|
||
final int total;
|
||
|
||
const _UploadStatus({this.active = false, this.sent = 0, this.total = 0});
|
||
|
||
bool get awaitingResponse => active && total > 0 && sent >= total;
|
||
double? get progressValue =>
|
||
(!active || total == 0 || awaitingResponse) ? null : sent / total;
|
||
}
|
||
|
||
class _DateSeparatorItem {
|
||
final DateTime date;
|
||
final GlobalKey key;
|
||
_DateSeparatorItem(this.date, this.key);
|
||
}
|
||
|
||
class _MessageItem {
|
||
final CachedMessage message;
|
||
final int index;
|
||
const _MessageItem(this.message, this.index);
|
||
}
|
||
|
||
class _MessageSearchResult {
|
||
final String id;
|
||
final int time;
|
||
final int senderId;
|
||
final String text;
|
||
final List<String> highlights;
|
||
|
||
const _MessageSearchResult({
|
||
required this.id,
|
||
required this.time,
|
||
required this.senderId,
|
||
required this.text,
|
||
required this.highlights,
|
||
});
|
||
|
||
static _MessageSearchResult? fromRaw(Map<dynamic, dynamic> raw) {
|
||
final message = raw['message'];
|
||
if (message is! Map) return null;
|
||
final id = message['id']?.toString();
|
||
if (id == null) return null;
|
||
final rawHighlights = raw['highlights'];
|
||
final highlights = rawHighlights is List
|
||
? rawHighlights.whereType<String>().toList()
|
||
: const <String>[];
|
||
final time = message['time'];
|
||
final sender = message['sender'];
|
||
return _MessageSearchResult(
|
||
id: id,
|
||
time: time is int ? time : int.tryParse('${time ?? 0}') ?? 0,
|
||
senderId: sender is int ? sender : int.tryParse('${sender ?? 0}') ?? 0,
|
||
text: message['text']?.toString() ?? '',
|
||
highlights: highlights,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _RecordingDot extends StatefulWidget {
|
||
final Color color;
|
||
const _RecordingDot({required this.color});
|
||
|
||
@override
|
||
State<_RecordingDot> createState() => _RecordingDotState();
|
||
}
|
||
|
||
class _RecordingDotState extends State<_RecordingDot>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _c = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 900),
|
||
)..repeat(reverse: true);
|
||
|
||
@override
|
||
void dispose() {
|
||
_c.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return FadeTransition(
|
||
opacity: Tween<double>(begin: 1.0, end: 0.25).animate(_c),
|
||
child: Container(
|
||
width: 12,
|
||
height: 12,
|
||
decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LiveWavePainter extends CustomPainter {
|
||
final List<double> amps;
|
||
final Color color;
|
||
|
||
const _LiveWavePainter({required this.amps, required this.color});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
const slot = 5.0;
|
||
const barW = 3.0;
|
||
final count = (size.width / slot).floor();
|
||
if (count <= 0 || amps.isEmpty) return;
|
||
|
||
final start = amps.length > count ? amps.length - count : 0;
|
||
final visible = amps.sublist(start);
|
||
final center = size.height / 2;
|
||
final paint = Paint()..color = color;
|
||
final offset = size.width - visible.length * slot;
|
||
|
||
for (var i = 0; i < visible.length; i++) {
|
||
final h = (visible[i] * size.height).clamp(2.0, size.height);
|
||
final x = offset + i * slot + (slot - barW) / 2;
|
||
canvas.drawRRect(
|
||
RRect.fromRectAndRadius(
|
||
Rect.fromLTWH(x, center - h / 2, barW, h),
|
||
const Radius.circular(barW / 2),
|
||
),
|
||
paint,
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_LiveWavePainter old) => true;
|
||
}
|
||
|
||
class _ButtonClipper extends CustomClipper<Rect> {
|
||
final double t;
|
||
const _ButtonClipper(this.t);
|
||
|
||
@override
|
||
Rect getClip(Size size) {
|
||
if (t <= 0.001) {
|
||
return Rect.fromLTRB(-120, -260, size.width + 120, size.height + 40);
|
||
}
|
||
return Rect.fromLTRB(0, 0, size.width, size.height);
|
||
}
|
||
|
||
@override
|
||
bool shouldReclip(_ButtonClipper old) => old.t != t;
|
||
}
|
||
|
||
class _FrostedPanel extends StatelessWidget {
|
||
final Color tint;
|
||
final Border? border;
|
||
final Widget child;
|
||
|
||
const _FrostedPanel({required this.tint, this.border, required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ClipRect(
|
||
child: BackdropFilter(
|
||
filter: ui.ImageFilter.blur(sigmaX: 24, sigmaY: 24),
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(color: tint, border: border),
|
||
child: child,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MeasureSize extends StatefulWidget {
|
||
final Widget child;
|
||
final ValueChanged<double> onHeight;
|
||
|
||
const _MeasureSize({required this.onHeight, required this.child});
|
||
|
||
@override
|
||
State<_MeasureSize> createState() => _MeasureSizeState();
|
||
}
|
||
|
||
class _MeasureSizeState extends State<_MeasureSize> {
|
||
final GlobalKey _key = GlobalKey();
|
||
double _last = -1;
|
||
|
||
void _report() {
|
||
if (!mounted) return;
|
||
final height = _key.currentContext?.size?.height;
|
||
if (height == null) return;
|
||
if ((height - _last).abs() > 0.5) {
|
||
_last = height;
|
||
widget.onHeight(height);
|
||
}
|
||
}
|
||
|
||
void _scheduleReport() {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _report());
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
_scheduleReport();
|
||
return NotificationListener<SizeChangedLayoutNotification>(
|
||
onNotification: (_) {
|
||
_scheduleReport();
|
||
return true;
|
||
},
|
||
child: SizeChangedLayoutNotifier(
|
||
child: SizedBox(key: _key, child: widget.child),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class ForwardRequest {
|
||
final int sourceChatId;
|
||
final List<CachedMessage> optimistic;
|
||
|
||
const ForwardRequest({
|
||
required this.sourceChatId,
|
||
required this.optimistic,
|
||
});
|
||
}
|
||
|
||
class ChatScreen extends StatefulWidget {
|
||
final int chatId;
|
||
final String name;
|
||
final String imageUrl;
|
||
final String chatType;
|
||
final bool embedded;
|
||
final VoidCallback? onClose;
|
||
final ForwardRequest? forwardRequest;
|
||
|
||
const ChatScreen({
|
||
super.key,
|
||
required this.chatId,
|
||
required this.name,
|
||
required this.imageUrl,
|
||
required this.chatType,
|
||
this.embedded = false,
|
||
this.onClose,
|
||
this.forwardRequest,
|
||
});
|
||
|
||
@override
|
||
State<ChatScreen> createState() => _ChatScreenState();
|
||
}
|
||
|
||
class _ChatScreenState extends State<ChatScreen>
|
||
with TickerProviderStateMixin, WidgetsBindingObserver {
|
||
final RichMessageController _messageController = RichMessageController();
|
||
final FocusNode _messageFocusNode = FocusNode();
|
||
double _keyboardReserve = 0;
|
||
bool _keyboardWasOpen = false;
|
||
final ScrollController _scrollController = ScrollController();
|
||
final GlobalKey _listKey = GlobalKey();
|
||
final ValueNotifier<bool> _hasText = ValueNotifier(false);
|
||
bool _isLoading = true;
|
||
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
|
||
final ValueNotifier<bool> _showStickerPanel = ValueNotifier(false);
|
||
double _stickerPanelHeight = 300;
|
||
Timer? _stickerTypingTimer;
|
||
late final AnimationController _stickerAnim;
|
||
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(
|
||
const _UploadStatus(),
|
||
);
|
||
StreamSubscription<UploadEvent>? _uploadSub;
|
||
StreamSubscription<Packet>? _pushSub;
|
||
StreamSubscription<MessageEvent>? _messageEventSub;
|
||
StreamSubscription<SessionState>? _connSub;
|
||
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers =
|
||
{};
|
||
final Map<String, ValueNotifier<List<double>>> _photoUploadProgress = {};
|
||
final ValueNotifier<int> _scheduledCount = ValueNotifier(0);
|
||
|
||
AudioRecorder? _voiceRecorder;
|
||
final ValueNotifier<bool> _isRecordingVoice = ValueNotifier(false);
|
||
final ValueNotifier<int> _voiceElapsedMs = ValueNotifier(0);
|
||
final ValueNotifier<double> _voiceCancelDrag = ValueNotifier(0);
|
||
final ValueNotifier<double> _voiceAmplitude = ValueNotifier(0);
|
||
final ValueNotifier<int> _voiceWaveRev = ValueNotifier(0);
|
||
final ValueNotifier<bool> _voiceLocked = ValueNotifier(false);
|
||
final ValueNotifier<double> _voiceLockDrag = ValueNotifier(0);
|
||
final Stopwatch _voiceStopwatch = Stopwatch();
|
||
final List<double> _voiceAmps = [];
|
||
Timer? _voiceTimer;
|
||
StreamSubscription<Amplitude>? _voiceAmpSub;
|
||
String? _voicePath;
|
||
bool _voiceCancelled = false;
|
||
bool _voiceStopRequested = false;
|
||
bool _voiceTranscode = false;
|
||
|
||
final ValueNotifier<bool> _videoNoteMode = ValueNotifier(false);
|
||
final NativeVideoNoteRecorder _noteRec = NativeVideoNoteRecorder();
|
||
final ValueNotifier<int?> _noteTextureId = ValueNotifier(null);
|
||
final ValueNotifier<bool> _noteCamReady = ValueNotifier(false);
|
||
final ValueNotifier<bool> _isRecordingNote = ValueNotifier(false);
|
||
final ValueNotifier<int> _noteElapsedMs = ValueNotifier(0);
|
||
final ValueNotifier<double> _noteCancelDrag = ValueNotifier(0);
|
||
final Stopwatch _noteStopwatch = Stopwatch();
|
||
Timer? _noteTimer;
|
||
bool _noteCancelled = false;
|
||
bool _noteStopRequested = false;
|
||
|
||
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
|
||
_photoUploadProgress[m.id];
|
||
|
||
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
|
||
final existing = _reactionNotifiers[m.id];
|
||
if (existing != null) return existing;
|
||
final info = m.payload?['reactionInfo'];
|
||
final notifier = ValueNotifier<Map<String, dynamic>?>(
|
||
info is Map ? Map<String, dynamic>.from(info) : null,
|
||
);
|
||
_reactionNotifiers[m.id] = notifier;
|
||
return notifier;
|
||
}
|
||
|
||
void _pruneReactionNotifiers() {
|
||
final liveIds = _messages.map((m) => m.id).toSet();
|
||
final dead = _reactionNotifiers.keys
|
||
.where((id) => !liveIds.contains(id))
|
||
.toList();
|
||
for (final id in dead) {
|
||
_reactionNotifiers.remove(id)?.dispose();
|
||
}
|
||
}
|
||
|
||
int _otherStatus = 0;
|
||
int? _otherSeenTime;
|
||
int? _participantsCount;
|
||
|
||
final ValueNotifier<CachedMessage?> _replyTo = ValueNotifier(null);
|
||
final ValueNotifier<String?> _highlightMessageId = ValueNotifier(null);
|
||
|
||
final ValueNotifier<bool> _searchMode = ValueNotifier(false);
|
||
late final AnimationController _searchAnim;
|
||
final TextEditingController _searchController = TextEditingController();
|
||
final FocusNode _searchFocusNode = FocusNode();
|
||
final ValueNotifier<List<_MessageSearchResult>> _searchResults =
|
||
ValueNotifier(const []);
|
||
final ValueNotifier<bool> _searchLoading = ValueNotifier(false);
|
||
final ValueNotifier<bool> _searchPerformed = ValueNotifier(false);
|
||
Timer? _searchDebounce;
|
||
int _searchSeq = 0;
|
||
|
||
bool _prankActive = false;
|
||
String? _prankBubbleId;
|
||
final GlobalKey _prankBubbleKey = GlobalKey();
|
||
final GlobalKey _prankCaptureKey = GlobalKey();
|
||
OverlayEntry? _prankRevealEntry;
|
||
AnimationController? _prankRevealController;
|
||
ui.Image? _prankRevealImage;
|
||
final ValueNotifier<String> _headerStatusNotifier = ValueNotifier('');
|
||
final ValueNotifier<int> _otherReadTime = ValueNotifier(0);
|
||
int _tempIdCounter = 0;
|
||
late final AnimationController _attachAnim;
|
||
late final AnimationController _commandAnim;
|
||
bool _commandPanelVisible = false;
|
||
final ValueNotifier<List<SlashCommand>> _commandMatches =
|
||
ValueNotifier(const []);
|
||
|
||
String _nextTempId() =>
|
||
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
|
||
late AnimationController _shimmerController;
|
||
Timer? _shimmerStartTimer;
|
||
bool _historyKickedOff = false;
|
||
bool _previewChat = false;
|
||
bool _forwardRequestDone = false;
|
||
List<CachedMessage> _messages = [];
|
||
final ValueNotifier<int> _messagesRev = ValueNotifier(0);
|
||
final Set<String> _deletingIds = {};
|
||
|
||
static const int _historyPageSize = 30;
|
||
static const int _historyInitialLimit = 50;
|
||
static const double _avgMessageHeight = 72.0;
|
||
static const double _historyPrefetchExtent = _avgMessageHeight * 8;
|
||
static const double _glossyHeaderHeight = 76.0;
|
||
static const double _glossySearchHeight = 58.0;
|
||
bool _isLoadingMore = false;
|
||
bool _hasMoreHistory = true;
|
||
List<Object>? _combinedItemsCache;
|
||
int? _combinedItemsKey;
|
||
bool _floatingDateScheduled = false;
|
||
int _myId = 0;
|
||
CachedChat? chat;
|
||
ChatWallpaper? _wallpaper;
|
||
final ValueNotifier<double> _composerHeight = ValueNotifier(96);
|
||
|
||
final ValueNotifier<DateTime?> _floatingDate = ValueNotifier(null);
|
||
Timer? _floatingDateTimer;
|
||
late final AnimationController _floatingDateAnimController;
|
||
late final CurvedAnimation _floatingDateCurved;
|
||
final Map<int, GlobalKey> _separatorKeys = {};
|
||
String? _lastSentId;
|
||
String? _lastMarkedId;
|
||
final ValueNotifier<int> _otherUnread = ValueNotifier(0);
|
||
|
||
final ValueNotifier<Set<String>> _selectedIds = ValueNotifier(const {});
|
||
late final AnimationController _selectionAnim;
|
||
|
||
bool get _selectionMode => _selectedIds.value.isNotEmpty;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
unawaited(PushService.clearChatNotification(widget.chatId));
|
||
WidgetsBinding.instance.addObserver(this);
|
||
ChatsModule.chatsChanged.addListener(_onChatsBump);
|
||
_messageController.addListener(_onTextChanged);
|
||
_messageFocusNode.addListener(_onComposerFocusChanged);
|
||
_showStickerPanel.addListener(_onStickerPanelToggle);
|
||
_scrollController.addListener(_onScrollForDate);
|
||
_scrollController.addListener(_maybeLoadMoreHistory);
|
||
AppVisualStyle.current.addListener(_onVisualStyleChanged);
|
||
AppChatChrome.current.addListener(_onVisualStyleChanged);
|
||
_shimmerController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 1500),
|
||
);
|
||
_attachAnim = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 320),
|
||
reverseDuration: const Duration(milliseconds: 240),
|
||
);
|
||
_stickerAnim = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 240),
|
||
reverseDuration: const Duration(milliseconds: 200),
|
||
);
|
||
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
||
_commandAnim = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 200),
|
||
);
|
||
_selectionAnim = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 260),
|
||
reverseDuration: const Duration(milliseconds: 200),
|
||
);
|
||
_searchAnim = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 280),
|
||
reverseDuration: const Duration(milliseconds: 220),
|
||
);
|
||
_searchController.addListener(_onSearchTextChanged);
|
||
AppCommands.current.addListener(_updateCommandPanel);
|
||
_pushSub = api.pushStream
|
||
.where(
|
||
(p) =>
|
||
p.opcode == Opcode.notifMark ||
|
||
p.opcode == Opcode.notifTyping ||
|
||
p.opcode == Opcode.notifMsgDelayed,
|
||
)
|
||
.listen(_onIncomingPush);
|
||
_messageEventSub = ChatsModule.messageEvents
|
||
.where((e) => e.chatId == widget.chatId)
|
||
.listen(_onMessageEvent);
|
||
ChatActivityStore.instance
|
||
.listenable(widget.chatId)
|
||
.addListener(_recomputeHeaderStatus);
|
||
_connSub = api.stateStream.listen((_) {
|
||
if (mounted) _recomputeHeaderStatus();
|
||
});
|
||
debugForceOffline.addListener(_recomputeHeaderStatus);
|
||
PresenceFetch.revision.addListener(_onPresenceChanged);
|
||
_floatingDateAnimController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 220),
|
||
reverseDuration: const Duration(milliseconds: 380),
|
||
);
|
||
_floatingDateCurved = CurvedAnimation(
|
||
parent: _floatingDateAnimController,
|
||
curve: Curves.easeOut,
|
||
reverseCurve: Curves.easeIn,
|
||
);
|
||
|
||
unawaited(
|
||
_fastPreloadCache().then((_) {
|
||
if (mounted) unawaited(_runForwardRequest());
|
||
}),
|
||
);
|
||
unawaited(_loadParticipantsCount());
|
||
WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered);
|
||
}
|
||
|
||
Future<void> _loadParticipantsCount() async {
|
||
if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return;
|
||
final info = await ChatsModule.getChatInfo(api, widget.chatId);
|
||
if (!mounted) return;
|
||
final count = info?['participantsCount'] as int?;
|
||
if (count != null && count != _participantsCount) {
|
||
_participantsCount = count;
|
||
_recomputeHeaderStatus();
|
||
}
|
||
}
|
||
|
||
Future<void> _fastPreloadCache() async {
|
||
final p = await AppDatabase.loadActiveProfile();
|
||
if (!mounted) return;
|
||
_myId = p?.id ?? 0;
|
||
_restoreDraft();
|
||
unawaited(_loadWallpaper());
|
||
unawaited(_refreshBadge());
|
||
|
||
ChatsModule.getChat(_myId, widget.chatId)
|
||
.then((value) {
|
||
if (mounted && value.isNotEmpty) {
|
||
setState(() {
|
||
chat = value.first;
|
||
});
|
||
_seedPresenceFromChat();
|
||
_recomputeHeaderStatus();
|
||
_syncOtherReadTime();
|
||
}
|
||
})
|
||
.catchError((_) {});
|
||
|
||
final cached = MessageSessionCache.get(_myId, widget.chatId);
|
||
if (cached != null && cached.messages.isNotEmpty) {
|
||
setState(() {
|
||
_messages = List<CachedMessage>.of(cached.messages);
|
||
_hasMoreHistory = !cached.reachedStart;
|
||
_messagesRev.value++;
|
||
_isLoading = false;
|
||
_onLoadingFinished();
|
||
});
|
||
_syncReactionNotifiersFromMessages();
|
||
return;
|
||
}
|
||
|
||
final firstRows = await AppDatabase.loadMessages(
|
||
_myId,
|
||
widget.chatId,
|
||
limit: 20,
|
||
onlyVisible: !KometSettings.viewDeleted.value,
|
||
);
|
||
if (!mounted) return;
|
||
if (firstRows.isNotEmpty) {
|
||
final first = firstRows.reversed
|
||
.map((r) => CachedMessage.fromDbRow(r))
|
||
.toList();
|
||
setState(() {
|
||
_messages = first;
|
||
_messagesRev.value++;
|
||
_isLoading = false;
|
||
_onLoadingFinished();
|
||
});
|
||
}
|
||
}
|
||
|
||
void _onFirstFrameRendered(Duration _) {
|
||
if (!mounted) return;
|
||
if (widget.embedded) {
|
||
_kickoffHistory();
|
||
return;
|
||
}
|
||
final anim = ModalRoute.of(context)?.animation;
|
||
if (anim == null || anim.status == AnimationStatus.completed) {
|
||
_kickoffHistory();
|
||
return;
|
||
}
|
||
Timer? safety;
|
||
void onStatus(AnimationStatus status) {
|
||
if (status != AnimationStatus.completed) return;
|
||
anim.removeStatusListener(onStatus);
|
||
safety?.cancel();
|
||
if (!mounted) return;
|
||
_kickoffHistory();
|
||
}
|
||
|
||
anim.addStatusListener(onStatus);
|
||
safety = Timer(const Duration(milliseconds: 400), () {
|
||
anim.removeStatusListener(onStatus);
|
||
if (!mounted) return;
|
||
_kickoffHistory();
|
||
});
|
||
}
|
||
|
||
void _kickoffHistory() {
|
||
if (_historyKickedOff) return;
|
||
_historyKickedOff = true;
|
||
_shimmerStartTimer = Timer(const Duration(milliseconds: 150), () {
|
||
if (!mounted || !_isLoading) return;
|
||
_shimmerController.repeat();
|
||
});
|
||
_loadHistory();
|
||
}
|
||
|
||
void _onLoadingFinished() {
|
||
_shimmerStartTimer?.cancel();
|
||
_shimmerStartTimer = null;
|
||
if (_shimmerController.isAnimating) _shimmerController.stop();
|
||
_markRead();
|
||
}
|
||
|
||
void _markRead() {
|
||
if (_myId == 0 || _messages.isEmpty) return;
|
||
final newest = _messages.last;
|
||
if (newest.id == _lastMarkedId) return;
|
||
_lastMarkedId = newest.id;
|
||
unawaited(
|
||
ChatsModule.markRead(api, _myId, widget.chatId, newest.id, newest.time),
|
||
);
|
||
}
|
||
|
||
Future<void> _markMessageUnread(CachedMessage message) async {
|
||
final unread = await ChatsModule.markUnread(
|
||
api,
|
||
_myId,
|
||
widget.chatId,
|
||
message.time,
|
||
);
|
||
if (!mounted) return;
|
||
if (unread == null) {
|
||
showCustomNotification(context, 'Не удалось пометить непрочитанным');
|
||
return;
|
||
}
|
||
Navigator.of(context).pop();
|
||
}
|
||
|
||
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();
|
||
if (!mounted) return;
|
||
_myId = activeProfile?.id ?? 0;
|
||
}
|
||
if (widget.chatType == 'DIALOG') {
|
||
unawaited(_loadOtherPresence());
|
||
}
|
||
unawaited(_refreshScheduledCount());
|
||
await _loadRemainingHistory();
|
||
}
|
||
|
||
Future<void> _loadRemainingHistory() async {
|
||
final onlyVisible = !KometSettings.viewDeleted.value;
|
||
final fullRows = await AppDatabase.loadMessages(
|
||
_myId,
|
||
widget.chatId,
|
||
limit: _historyInitialLimit,
|
||
onlyVisible: onlyVisible,
|
||
);
|
||
final fullDecoded = await CachedMessage.fromDbRowsAsync(fullRows);
|
||
if (mounted) {
|
||
_applyMergedMessages(fullDecoded);
|
||
}
|
||
|
||
if (fullRows.isNotEmpty &&
|
||
ChatsModule.wasHistoryFetched(widget.chatId)) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isLoading = false;
|
||
_onLoadingFinished();
|
||
});
|
||
}
|
||
_loadForwardedSenderNames();
|
||
_loadGroupSenderNames();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
final cachedRows = await AppDatabase.loadChat(_myId, widget.chatId);
|
||
if (cachedRows.isEmpty) {
|
||
_previewChat = true;
|
||
await ChatsModule.ensureChatCached(api, _myId, widget.chatId);
|
||
await ChatsModule.subscribeChat(api, 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: _historyInitialLimit,
|
||
onlyVisible: onlyVisible,
|
||
);
|
||
final updatedDecoded = await CachedMessage.fromDbRowsAsync(updatedRows);
|
||
if (mounted) {
|
||
_applyMergedMessages(updatedDecoded, markLoaded: true);
|
||
}
|
||
unawaited(
|
||
ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId),
|
||
);
|
||
_loadForwardedSenderNames();
|
||
_loadGroupSenderNames();
|
||
} catch (e) {
|
||
logger.e('Error fetching history: $e');
|
||
if (mounted) {
|
||
setState(() {
|
||
_isLoading = false;
|
||
_onLoadingFinished();
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
void _maybeLoadMoreHistory() {
|
||
if (!_scrollController.hasClients) return;
|
||
if (_isLoading || _isLoadingMore || !_hasMoreHistory) return;
|
||
if (_messages.isEmpty) return;
|
||
final pos = _scrollController.position;
|
||
if (pos.maxScrollExtent <= 0) return;
|
||
if (pos.maxScrollExtent - pos.pixels <= _historyPrefetchExtent) {
|
||
unawaited(_loadMoreHistory());
|
||
}
|
||
}
|
||
|
||
Future<void> _loadMoreHistory() async {
|
||
if (_isLoadingMore || !_hasMoreHistory || _messages.isEmpty) return;
|
||
_isLoadingMore = true;
|
||
setState(() {});
|
||
|
||
final oldest = _messages.first;
|
||
final onlyVisible = !KometSettings.viewDeleted.value;
|
||
|
||
try {
|
||
var older = await _loadOlderFromDb(oldest.time, onlyVisible);
|
||
|
||
if (older.length < _historyPageSize) {
|
||
final fetched = await messagesModule.fetchHistory(
|
||
_myId,
|
||
widget.chatId,
|
||
fromTime: oldest.time,
|
||
count: _historyPageSize,
|
||
);
|
||
if (fetched.isNotEmpty) {
|
||
if (KometSettings.viewDeleted.value) {
|
||
await ChatsModule.reconcileDeletedFromFetch(
|
||
_myId,
|
||
widget.chatId,
|
||
fetched,
|
||
);
|
||
}
|
||
older = await _loadOlderFromDb(oldest.time, onlyVisible);
|
||
}
|
||
}
|
||
|
||
if (!mounted) return;
|
||
final added = _prependOlder(older);
|
||
setState(() {
|
||
_isLoadingMore = false;
|
||
if (added == 0) _hasMoreHistory = false;
|
||
});
|
||
_persistSessionCache();
|
||
_loadForwardedSenderNames();
|
||
_loadGroupSenderNames();
|
||
} catch (e) {
|
||
logger.e('Error loading more history: $e');
|
||
if (mounted) setState(() => _isLoadingMore = false);
|
||
}
|
||
}
|
||
|
||
Future<List<CachedMessage>> _loadOlderFromDb(
|
||
int beforeTime,
|
||
bool onlyVisible,
|
||
) async {
|
||
final rows = await AppDatabase.loadMessagesBefore(
|
||
_myId,
|
||
widget.chatId,
|
||
beforeTime: beforeTime,
|
||
limit: _historyPageSize,
|
||
onlyVisible: onlyVisible,
|
||
);
|
||
return CachedMessage.fromDbRowsAsync(rows);
|
||
}
|
||
|
||
int _prependOlder(List<CachedMessage> olderDesc) {
|
||
if (olderDesc.isEmpty) return 0;
|
||
final existing = _messages.map((m) => m.id).toSet();
|
||
final toAdd = <CachedMessage>[];
|
||
for (final m in olderDesc.reversed) {
|
||
if (existing.add(m.id)) toAdd.add(m);
|
||
}
|
||
if (toAdd.isEmpty) return 0;
|
||
_messages = [...toAdd, ..._messages];
|
||
_messagesRev.value++;
|
||
_syncReactionNotifiersFromMessages();
|
||
return toAdd.length;
|
||
}
|
||
|
||
void _persistSessionCache() {
|
||
if (_myId == 0 || _messages.isEmpty) return;
|
||
MessageSessionCache.save(
|
||
_myId,
|
||
widget.chatId,
|
||
_messages,
|
||
reachedStart: !_hasMoreHistory,
|
||
);
|
||
}
|
||
|
||
void _applyMergedMessages(
|
||
List<CachedMessage> decodedDesc, {
|
||
bool markLoaded = false,
|
||
}) {
|
||
final byId = <String, CachedMessage>{for (final m in _messages) m.id: m};
|
||
var changed = false;
|
||
for (final fresh in decodedDesc) {
|
||
final old = byId[fresh.id];
|
||
if (old == null) {
|
||
byId[fresh.id] = fresh;
|
||
changed = true;
|
||
} else if (!_sameMessage(old, fresh)) {
|
||
byId[fresh.id] = fresh;
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if (!changed && !markLoaded) return;
|
||
|
||
final merged = byId.values.toList()
|
||
..sort((a, b) {
|
||
final byTime = a.time.compareTo(b.time);
|
||
return byTime != 0 ? byTime : a.id.compareTo(b.id);
|
||
});
|
||
|
||
setState(() {
|
||
if (changed) {
|
||
_messages = merged;
|
||
_messagesRev.value++;
|
||
}
|
||
if (markLoaded) {
|
||
_isLoading = false;
|
||
_onLoadingFinished();
|
||
}
|
||
});
|
||
if (changed) {
|
||
_syncReactionNotifiersFromMessages();
|
||
_pruneReactionNotifiers();
|
||
_persistSessionCache();
|
||
}
|
||
}
|
||
|
||
void _syncReactionNotifiersFromMessages() {
|
||
for (final m in _messages) {
|
||
final info = m.payload?['reactionInfo'];
|
||
final value = info is Map ? Map<String, dynamic>.from(info) : null;
|
||
final existing = _reactionNotifiers[m.id];
|
||
if (existing == null) {
|
||
_reactionNotifiers[m.id] = ValueNotifier(value);
|
||
} else if (!_reactionsEqual(existing.value, value)) {
|
||
existing.value = value;
|
||
}
|
||
}
|
||
}
|
||
|
||
bool _reactionsEqual(Map<String, dynamic>? a, Map<String, dynamic>? b) {
|
||
if (identical(a, b)) return true;
|
||
if (a == null || b == null) return false;
|
||
if (a.length != b.length) return false;
|
||
for (final k in a.keys) {
|
||
if (a[k].toString() != b[k].toString()) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool _sameMessage(CachedMessage a, CachedMessage b) {
|
||
return a.id == b.id &&
|
||
a.time == b.time &&
|
||
a.status == b.status &&
|
||
a.text == b.text &&
|
||
a.senderId == b.senderId &&
|
||
a.deleted == b.deleted;
|
||
}
|
||
|
||
@override
|
||
void deactivate() {
|
||
_saveDraft();
|
||
super.deactivate();
|
||
}
|
||
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
if (state == AppLifecycleState.paused ||
|
||
state == AppLifecycleState.inactive) {
|
||
_saveDraft();
|
||
}
|
||
super.didChangeAppLifecycleState(state);
|
||
}
|
||
|
||
@override
|
||
void didChangeMetrics() {
|
||
super.didChangeMetrics();
|
||
final view = View.of(context);
|
||
final keyboardOpen = view.viewInsets.bottom / view.devicePixelRatio > 100;
|
||
if (_keyboardWasOpen && !keyboardOpen && _messageFocusNode.hasFocus) {
|
||
_messageFocusNode.unfocus();
|
||
}
|
||
_keyboardWasOpen = keyboardOpen;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_persistSessionCache();
|
||
if (_previewChat) {
|
||
unawaited(ChatsModule.subscribeChat(api, widget.chatId, subscribe: false));
|
||
}
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
ChatsModule.chatsChanged.removeListener(_onChatsBump);
|
||
_otherUnread.dispose();
|
||
_saveDraft();
|
||
_messageController.removeListener(_onTextChanged);
|
||
_scrollController.removeListener(_onScrollForDate);
|
||
_scrollController.removeListener(_maybeLoadMoreHistory);
|
||
AppVisualStyle.current.removeListener(_onVisualStyleChanged);
|
||
AppChatChrome.current.removeListener(_onVisualStyleChanged);
|
||
_composerHeight.dispose();
|
||
_floatingDateTimer?.cancel();
|
||
_floatingDateCurved.dispose();
|
||
_floatingDateAnimController.dispose();
|
||
_floatingDate.dispose();
|
||
_hasText.dispose();
|
||
_scheduledCount.dispose();
|
||
_showAttachmentPanel.removeListener(_onAttachPanelToggle);
|
||
_showAttachmentPanel.dispose();
|
||
_uploadSub?.cancel();
|
||
_pushSub?.cancel();
|
||
_messageEventSub?.cancel();
|
||
_connSub?.cancel();
|
||
_voiceTimer?.cancel();
|
||
_voiceAmpSub?.cancel();
|
||
_voiceRecorder?.dispose();
|
||
_noteTimer?.cancel();
|
||
_noteOverlay?.remove();
|
||
_noteRec.dispose();
|
||
_noteTextureId.dispose();
|
||
_videoNoteMode.dispose();
|
||
_noteCamReady.dispose();
|
||
_isRecordingNote.dispose();
|
||
_noteElapsedMs.dispose();
|
||
_noteCancelDrag.dispose();
|
||
_isRecordingVoice.dispose();
|
||
_voiceElapsedMs.dispose();
|
||
_voiceCancelDrag.dispose();
|
||
_voiceAmplitude.dispose();
|
||
_voiceWaveRev.dispose();
|
||
_voiceLocked.dispose();
|
||
_voiceLockDrag.dispose();
|
||
debugForceOffline.removeListener(_recomputeHeaderStatus);
|
||
for (final n in _reactionNotifiers.values) {
|
||
n.dispose();
|
||
}
|
||
_reactionNotifiers.clear();
|
||
for (final n in _photoUploadProgress.values) {
|
||
n.dispose();
|
||
}
|
||
_photoUploadProgress.clear();
|
||
ChatActivityStore.instance
|
||
.listenable(widget.chatId)
|
||
.removeListener(_recomputeHeaderStatus);
|
||
PresenceFetch.revision.removeListener(_onPresenceChanged);
|
||
_headerStatusNotifier.dispose();
|
||
_otherReadTime.dispose();
|
||
_messagesRev.dispose();
|
||
_finishPrankReveal();
|
||
_uploadStatus.dispose();
|
||
_attachAnim.dispose();
|
||
AppCommands.current.removeListener(_updateCommandPanel);
|
||
_commandAnim.dispose();
|
||
_selectionAnim.dispose();
|
||
_searchDebounce?.cancel();
|
||
_searchAnim.dispose();
|
||
_searchController.removeListener(_onSearchTextChanged);
|
||
_searchController.dispose();
|
||
_searchFocusNode.dispose();
|
||
_searchMode.dispose();
|
||
_searchResults.dispose();
|
||
_searchLoading.dispose();
|
||
_searchPerformed.dispose();
|
||
_selectedIds.dispose();
|
||
_commandMatches.dispose();
|
||
_messageController.dispose();
|
||
_messageFocusNode.removeListener(_onComposerFocusChanged);
|
||
_messageFocusNode.dispose();
|
||
_stickerTypingTimer?.cancel();
|
||
_stickerAnim.dispose();
|
||
_showStickerPanel.removeListener(_onStickerPanelToggle);
|
||
_showStickerPanel.dispose();
|
||
_scrollController.dispose();
|
||
_shimmerStartTimer?.cancel();
|
||
_shimmerController.dispose();
|
||
_replyTo.dispose();
|
||
_highlightMessageId.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onTextChanged() {
|
||
final newHasText = _messageController.text.trim().isNotEmpty;
|
||
if (newHasText != _hasText.value) {
|
||
_hasText.value = newHasText;
|
||
}
|
||
_updateCommandPanel();
|
||
}
|
||
|
||
List<SlashCommand> _matchingCommands(String raw) {
|
||
if (!AppCommands.current.value) return const [];
|
||
final text = raw.trimLeft();
|
||
if (!text.startsWith('/')) return const [];
|
||
if (text.contains(RegExp(r'\s'))) return const [];
|
||
final query = text.toLowerCase();
|
||
for (final c in kSlashCommands) {
|
||
if (!c.hidden && c.name.toLowerCase() == query) return const [];
|
||
}
|
||
return kSlashCommands
|
||
.where((c) => !c.hidden && c.name.toLowerCase().startsWith(query))
|
||
.toList(growable: false);
|
||
}
|
||
|
||
void _updateCommandPanel() {
|
||
final matches = _matchingCommands(_messageController.text);
|
||
final show = matches.isNotEmpty;
|
||
if (show && !listEquals(_commandMatches.value, matches)) {
|
||
_commandMatches.value = matches;
|
||
}
|
||
if (show == _commandPanelVisible) return;
|
||
_commandPanelVisible = show;
|
||
if (show) {
|
||
_commandAnim.forward();
|
||
} else {
|
||
_commandAnim.reverse();
|
||
}
|
||
}
|
||
|
||
void _onCommandSelected(SlashCommand c) {
|
||
final text = '${c.name} ';
|
||
_messageController.value = TextEditingValue(
|
||
text: text,
|
||
selection: TextSelection.collapsed(offset: text.length),
|
||
);
|
||
_messageFocusNode.requestFocus();
|
||
}
|
||
|
||
void _restoreDraft() {
|
||
if (_myId == 0 || _messageController.text.isNotEmpty) return;
|
||
final draft = DraftStore.instance.get(_myId, widget.chatId);
|
||
if (draft == null || draft.isEmpty) return;
|
||
_messageController.text = draft;
|
||
_messageController.selection =
|
||
TextSelection.collapsed(offset: draft.length);
|
||
}
|
||
|
||
void _saveDraft() {
|
||
if (_myId == 0) return;
|
||
unawaited(
|
||
DraftStore.instance.set(_myId, widget.chatId, _messageController.text),
|
||
);
|
||
}
|
||
|
||
void _onAttachPanelToggle() {
|
||
if (_showAttachmentPanel.value) {
|
||
_attachAnim.forward();
|
||
} else {
|
||
_attachAnim.reverse();
|
||
}
|
||
}
|
||
|
||
Widget _buildCommandPanel() {
|
||
return AnimatedBuilder(
|
||
animation: _commandAnim,
|
||
child: ValueListenableBuilder<List<SlashCommand>>(
|
||
valueListenable: _commandMatches,
|
||
builder: (context, matches, _) => CommandSuggestionsPanel(
|
||
commands: matches,
|
||
onSelected: _onCommandSelected,
|
||
),
|
||
),
|
||
builder: (context, child) {
|
||
final t = _commandAnim.value;
|
||
if (t == 0) return const SizedBox.shrink();
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||
child: IgnorePointer(
|
||
ignoring: t < 1,
|
||
child: Opacity(
|
||
opacity: t,
|
||
child: child,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
int _computeOtherReadTime() {
|
||
final c = chat;
|
||
if (c == null) return 0;
|
||
int otherReadTime = 0;
|
||
for (final entry in c.participants.entries) {
|
||
if (entry.key != _myId && entry.value > otherReadTime) {
|
||
otherReadTime = entry.value;
|
||
}
|
||
}
|
||
return otherReadTime;
|
||
}
|
||
|
||
void _syncOtherReadTime() {
|
||
final t = _computeOtherReadTime();
|
||
if (_otherReadTime.value != t) _otherReadTime.value = t;
|
||
}
|
||
|
||
void _checkPrankTrigger(CachedMessage msg) {
|
||
if (!AppPranks.current.value || _prankActive || _prankBubbleId != null) {
|
||
return;
|
||
}
|
||
if ((msg.text ?? '').trim().toUpperCase() != 'THE WORLD') return;
|
||
setState(() => _prankBubbleId = msg.id);
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) _runPrankReveal();
|
||
});
|
||
}
|
||
|
||
ThemeData _prankPinkTheme(ThemeData base) {
|
||
final cs = base.colorScheme;
|
||
return base.copyWith(
|
||
scaffoldBackgroundColor: const Color(0xFFFFF0F5),
|
||
colorScheme: cs.copyWith(
|
||
surface: const Color(0xFFFFF0F5),
|
||
surfaceContainerHigh: const Color(0xFFFFE3EC),
|
||
surfaceContainerHighest: const Color(0xFFFFD9E6),
|
||
primary: const Color(0xFFE8579A),
|
||
primaryContainer: const Color(0xFFFFD6E5),
|
||
onPrimaryContainer: const Color(0xFF7A1F4B),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _runPrankReveal() {
|
||
if (_prankActive) return;
|
||
final overlay = Navigator.of(context).overlay;
|
||
final captureCtx = _prankCaptureKey.currentContext;
|
||
final renderObject = captureCtx?.findRenderObject();
|
||
if (overlay == null || renderObject is! RenderRepaintBoundary) {
|
||
setState(() => _prankActive = true);
|
||
return;
|
||
}
|
||
|
||
Offset center;
|
||
final bubbleBox =
|
||
_prankBubbleKey.currentContext?.findRenderObject() as RenderBox?;
|
||
if (bubbleBox != null && bubbleBox.attached) {
|
||
center = bubbleBox.localToGlobal(bubbleBox.size.center(Offset.zero));
|
||
} else {
|
||
final size = MediaQuery.sizeOf(context);
|
||
center = Offset(size.width / 2, size.height / 2);
|
||
}
|
||
|
||
final ui.Image snapshot;
|
||
try {
|
||
final dpr = math.min(MediaQuery.of(context).devicePixelRatio, 2.0);
|
||
snapshot = renderObject.toImageSync(pixelRatio: dpr);
|
||
} catch (_) {
|
||
setState(() => _prankActive = true);
|
||
return;
|
||
}
|
||
|
||
_finishPrankReveal();
|
||
|
||
final controller = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 650),
|
||
);
|
||
final entry = ThemeRevealOverlay.build(
|
||
snapshot: snapshot,
|
||
center: center,
|
||
animation: controller,
|
||
);
|
||
|
||
_prankRevealController = controller;
|
||
_prankRevealEntry = entry;
|
||
_prankRevealImage = snapshot;
|
||
|
||
overlay.insert(entry);
|
||
setState(() => _prankActive = true);
|
||
Haptics.success();
|
||
|
||
WidgetsBinding.instance.endOfFrame.then((_) {
|
||
if (_prankRevealController != controller) return;
|
||
controller.forward().then((_) {
|
||
if (_prankRevealController != controller) return;
|
||
_finishPrankReveal();
|
||
}, onError: (_) {});
|
||
});
|
||
}
|
||
|
||
void _finishPrankReveal() {
|
||
_prankRevealEntry?.remove();
|
||
_prankRevealEntry = null;
|
||
_prankRevealController?.dispose();
|
||
_prankRevealController = null;
|
||
final img = _prankRevealImage;
|
||
_prankRevealImage = null;
|
||
if (img != null) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose());
|
||
}
|
||
}
|
||
|
||
String? _effectiveStatus(CachedMessage msg) {
|
||
if (msg.senderId != _myId) return null;
|
||
if (msg.status == 'sending' || msg.status == 'error') return msg.status;
|
||
if (chat == null) return 'sent';
|
||
final otherReadTime = _otherReadTime.value;
|
||
if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read';
|
||
return 'sent';
|
||
}
|
||
|
||
void _onIncomingPush(Packet packet) {
|
||
if (!mounted) return;
|
||
switch (packet.opcode) {
|
||
case Opcode.notifMark:
|
||
_onMessageRead(packet);
|
||
case Opcode.notifTyping:
|
||
_onTyping(packet);
|
||
case Opcode.notifMsgDelayed:
|
||
final p = packet.payload;
|
||
if (p is Map && p['chatId'] == widget.chatId) {
|
||
// lastDelayedUpdateTime — авторитетный признак от сервера:
|
||
// 0 — отложенных в чате не осталось, иначе они есть. Реагируем
|
||
// мгновенно по пушу, не дожидаясь повторного запроса.
|
||
final t = p['lastDelayedUpdateTime'];
|
||
if (t is int && t == 0) {
|
||
_scheduledCount.value = 0;
|
||
} else {
|
||
if (_scheduledCount.value == 0) _scheduledCount.value = 1;
|
||
_refreshScheduledCount();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void _markHasScheduled() {
|
||
if (_scheduledCount.value == 0) _scheduledCount.value = 1;
|
||
}
|
||
|
||
Future<void> _refreshScheduledCount() async {
|
||
if (_myId == 0) return;
|
||
try {
|
||
final list = await messagesModule.fetchDelayedMessages(
|
||
_myId,
|
||
widget.chatId,
|
||
);
|
||
if (mounted) _scheduledCount.value = list.length;
|
||
} catch (_) {}
|
||
}
|
||
|
||
void _bumpMessages() {
|
||
_combinedItemsCache = null;
|
||
_messagesRev.value++;
|
||
}
|
||
|
||
void _enterSelection(CachedMessage message) {
|
||
if (message.isControl) return;
|
||
Haptics.medium();
|
||
if (_selectedIds.value.contains(message.id)) return;
|
||
_selectedIds.value = {..._selectedIds.value, message.id};
|
||
_syncSelectionAnim();
|
||
}
|
||
|
||
void _toggleSelection(CachedMessage message) {
|
||
if (message.isControl) return;
|
||
final next = Set<String>.from(_selectedIds.value);
|
||
if (!next.remove(message.id)) next.add(message.id);
|
||
Haptics.selection();
|
||
_selectedIds.value = next;
|
||
_syncSelectionAnim();
|
||
}
|
||
|
||
void _clearSelection() {
|
||
if (_selectedIds.value.isEmpty) return;
|
||
_selectedIds.value = const {};
|
||
_syncSelectionAnim();
|
||
}
|
||
|
||
void _syncSelectionAnim() {
|
||
if (_selectedIds.value.isEmpty) {
|
||
_selectionAnim.reverse();
|
||
} else if (_selectionAnim.status != AnimationStatus.forward &&
|
||
_selectionAnim.value < 1) {
|
||
_selectionAnim.forward();
|
||
}
|
||
}
|
||
|
||
List<CachedMessage> _selectedMessages(Set<String> ids) =>
|
||
_messages.where((m) => ids.contains(m.id)).toList();
|
||
|
||
CachedMessage? _singleCopyableText(Set<String> ids) {
|
||
CachedMessage? found;
|
||
var textCount = 0;
|
||
for (final m in _messages) {
|
||
if (!ids.contains(m.id)) continue;
|
||
if ((m.text ?? '').isEmpty) continue;
|
||
if (++textCount > 1) return null;
|
||
found = m;
|
||
}
|
||
return found;
|
||
}
|
||
|
||
CachedMessage? _singleEditable(Set<String> ids) {
|
||
if (ids.length != 1) return null;
|
||
final list = _selectedMessages(ids);
|
||
if (list.isEmpty) return null;
|
||
return _canEditMessage(list.first) ? list.first : null;
|
||
}
|
||
|
||
void _copySelected(CachedMessage message) {
|
||
final text = message.text;
|
||
if (text == null || text.isEmpty) return;
|
||
Clipboard.setData(ClipboardData(text: text));
|
||
Haptics.tap();
|
||
showCustomNotification(context, 'Скопировано');
|
||
_clearSelection();
|
||
}
|
||
|
||
void _editSelected(CachedMessage message) {
|
||
_clearSelection();
|
||
_startEditMessage(message);
|
||
}
|
||
|
||
Future<void> _deleteSelected() async {
|
||
final msgs = _selectedMessages(_selectedIds.value);
|
||
if (msgs.isEmpty) return;
|
||
|
||
final serverMsgs =
|
||
msgs.where((m) => !m.id.startsWith('temp_')).toList();
|
||
if (serverMsgs.isEmpty) {
|
||
for (final m in msgs) {
|
||
_startDeleteAnimation(m.id);
|
||
}
|
||
_clearSelection();
|
||
return;
|
||
}
|
||
|
||
final canForEveryone = serverMsgs.every((m) => m.senderId == _myId);
|
||
final forEveryone = await _showDeleteMessageDialog(canForEveryone);
|
||
if (forEveryone == null || !mounted) return;
|
||
|
||
final ok = await messagesModule.deleteMessages(
|
||
widget.chatId,
|
||
serverMsgs.map((m) => m.id).toList(),
|
||
forEveryone: forEveryone,
|
||
);
|
||
if (!mounted) return;
|
||
if (!ok) {
|
||
Haptics.error();
|
||
showCustomNotification(context, 'Не удалось удалить сообщения');
|
||
return;
|
||
}
|
||
for (final m in msgs) {
|
||
_startDeleteAnimation(m.id);
|
||
}
|
||
_clearSelection();
|
||
}
|
||
|
||
void _replySelected() {
|
||
final msgs = _selectedMessages(_selectedIds.value);
|
||
if (msgs.isEmpty) return;
|
||
final message = msgs.first;
|
||
_clearSelection();
|
||
_startReply(message);
|
||
}
|
||
|
||
void _forwardSelected() {
|
||
final msgs = _selectedMessages(_selectedIds.value);
|
||
_clearSelection();
|
||
unawaited(_forwardMessages(msgs));
|
||
}
|
||
|
||
Future<void> _forwardMessages(List<CachedMessage> msgs) async {
|
||
final forwardable = msgs.where((m) => !m.id.startsWith('temp_')).toList();
|
||
if (forwardable.isEmpty) {
|
||
showCustomNotification(context, 'Нечего пересылать');
|
||
return;
|
||
}
|
||
|
||
final target = await openForwardScreen(
|
||
context: context,
|
||
messageCount: forwardable.length,
|
||
);
|
||
if (target == null || !mounted) return;
|
||
|
||
if (api.state != SessionState.online) {
|
||
showCustomNotification(context, 'Нет соединения');
|
||
return;
|
||
}
|
||
|
||
final ordered = [...forwardable]..sort((a, b) => a.time.compareTo(b.time));
|
||
|
||
if (target.chatId == widget.chatId) {
|
||
await _forwardIntoCurrentChat(ordered);
|
||
return;
|
||
}
|
||
|
||
final optimistic = await _seedForwardsToChat(target, ordered);
|
||
if (!mounted) return;
|
||
Haptics.send();
|
||
pushSwipeable(
|
||
context,
|
||
(_) => ChatScreen(
|
||
chatId: target.chatId,
|
||
name: target.name,
|
||
imageUrl: target.imageUrl,
|
||
chatType: target.chatType,
|
||
forwardRequest: ForwardRequest(
|
||
sourceChatId: widget.chatId,
|
||
optimistic: optimistic,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _forwardIntoCurrentChat(List<CachedMessage> sources) async {
|
||
final now = DateTime.now().millisecondsSinceEpoch;
|
||
final optimistic = <CachedMessage>[];
|
||
var i = 0;
|
||
for (final src in sources) {
|
||
final msg = MessagesModule.buildForwardMessage(
|
||
myId: _myId,
|
||
targetChatId: widget.chatId,
|
||
sourceChatId: widget.chatId,
|
||
source: src,
|
||
tempId: _nextTempId(),
|
||
time: now + i,
|
||
status: 'sending',
|
||
);
|
||
optimistic.add(msg);
|
||
_messages.add(msg);
|
||
unawaited(_persistOutgoing(msg));
|
||
i++;
|
||
}
|
||
_bumpMessages();
|
||
Haptics.send();
|
||
_scrollToBottom();
|
||
if (optimistic.isNotEmpty) {
|
||
final last = optimistic.last;
|
||
unawaited(
|
||
ChatsModule.applyOutgoing(
|
||
_myId,
|
||
widget.chatId,
|
||
messageId: last.id,
|
||
time: last.time,
|
||
text: MessagesModule.forwardPreviewText(last),
|
||
status: 'sending',
|
||
),
|
||
);
|
||
}
|
||
for (final opt in optimistic) {
|
||
await _sendOneForward(opt, widget.chatId);
|
||
}
|
||
}
|
||
|
||
Future<List<CachedMessage>> _seedForwardsToChat(
|
||
ForwardTarget target,
|
||
List<CachedMessage> sources,
|
||
) async {
|
||
await ChatsModule.ensureChatCached(api, _myId, target.chatId);
|
||
final now = DateTime.now().millisecondsSinceEpoch;
|
||
final optimistic = <CachedMessage>[];
|
||
var i = 0;
|
||
for (final src in sources) {
|
||
final msg = MessagesModule.buildForwardMessage(
|
||
myId: _myId,
|
||
targetChatId: target.chatId,
|
||
sourceChatId: widget.chatId,
|
||
source: src,
|
||
tempId: _nextTempId(),
|
||
time: now + i,
|
||
status: 'sending',
|
||
);
|
||
optimistic.add(msg);
|
||
await AppDatabase.saveMessages([msg.toDbRow()]);
|
||
i++;
|
||
}
|
||
final cached = MessageSessionCache.get(_myId, target.chatId);
|
||
if (cached != null) {
|
||
MessageSessionCache.save(
|
||
_myId,
|
||
target.chatId,
|
||
[...cached.messages, ...optimistic],
|
||
reachedStart: cached.reachedStart,
|
||
);
|
||
}
|
||
if (optimistic.isNotEmpty) {
|
||
final last = optimistic.last;
|
||
unawaited(
|
||
ChatsModule.applyOutgoing(
|
||
_myId,
|
||
target.chatId,
|
||
messageId: last.id,
|
||
time: last.time,
|
||
text: MessagesModule.forwardPreviewText(last),
|
||
status: 'sending',
|
||
),
|
||
);
|
||
}
|
||
return optimistic;
|
||
}
|
||
|
||
Future<void> _runForwardRequest() async {
|
||
final req = widget.forwardRequest;
|
||
if (req == null || _forwardRequestDone) return;
|
||
_forwardRequestDone = true;
|
||
for (final opt in req.optimistic) {
|
||
await _sendOneForward(opt, req.sourceChatId);
|
||
}
|
||
}
|
||
|
||
Future<void> _sendOneForward(CachedMessage optimistic, int sourceChatId) async {
|
||
final link = optimistic.payload?['link'];
|
||
final rawWireId = link is Map ? link['messageId'] : null;
|
||
final wireId = rawWireId is int ? rawWireId : null;
|
||
if (wireId == null) return;
|
||
try {
|
||
final realId = await messagesModule.forwardMessage(
|
||
widget.chatId,
|
||
sourceChatId,
|
||
wireId,
|
||
);
|
||
if (!mounted) return;
|
||
final sent = MessagesModule.reidentifyMessage(
|
||
optimistic,
|
||
realId.isNotEmpty ? realId : optimistic.id,
|
||
status: 'sent',
|
||
);
|
||
final index = _messages.indexWhere((m) => m.id == optimistic.id);
|
||
if (index != -1) {
|
||
_messages[index] = sent;
|
||
_bumpMessages();
|
||
}
|
||
unawaited(_persistOutgoing(sent, removeId: optimistic.id));
|
||
unawaited(
|
||
ChatsModule.applyOutgoing(
|
||
_myId,
|
||
widget.chatId,
|
||
messageId: sent.id,
|
||
time: sent.time,
|
||
text: MessagesModule.forwardPreviewText(sent),
|
||
status: 'sent',
|
||
),
|
||
);
|
||
} catch (_) {
|
||
final index = _messages.indexWhere((m) => m.id == optimistic.id);
|
||
if (index != -1 && mounted) {
|
||
_messages.removeAt(index);
|
||
_bumpMessages();
|
||
}
|
||
unawaited(AppDatabase.deleteMessage(_myId, widget.chatId, optimistic.id));
|
||
if (mounted) {
|
||
Haptics.error();
|
||
showCustomNotification(context, 'Не удалось переслать');
|
||
}
|
||
}
|
||
}
|
||
|
||
Widget _buildComposerArea(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final content = Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
AnimatedBuilder(
|
||
animation: _selectionAnim,
|
||
builder: (context, child) {
|
||
final t = Curves.easeOut.transform(
|
||
_selectionAnim.value.clamp(0.0, 1.0),
|
||
);
|
||
if (t == 0) return child!;
|
||
if (t == 1) return const SizedBox.shrink();
|
||
return ClipRect(
|
||
child: Align(
|
||
alignment: Alignment.topCenter,
|
||
heightFactor: 1 - t,
|
||
child: Transform.translate(
|
||
offset: Offset(0, 48 * t),
|
||
child: Opacity(opacity: 1 - t, child: child),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
AnimatedBuilder(
|
||
animation: _attachAnim,
|
||
builder: (context, _) {
|
||
if (_attachAnim.value == 0) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
final curve = _attachAnim.status == AnimationStatus.reverse
|
||
? Curves.easeIn
|
||
: Curves.easeOut;
|
||
final t = curve.transform(_attachAnim.value);
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||
child: ClipRect(
|
||
child: Align(
|
||
alignment: Alignment.bottomCenter,
|
||
heightFactor: t,
|
||
child: Opacity(
|
||
opacity: t,
|
||
child: AttachmentPanel(
|
||
onClose: () => _showAttachmentPanel.value = false,
|
||
onPickFile: _pickAndUploadFile,
|
||
onSendById: _sendFileById,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
_buildInputArea(context),
|
||
_buildStickerPanel(context),
|
||
],
|
||
),
|
||
),
|
||
AnimatedBuilder(
|
||
animation: _selectionAnim,
|
||
builder: (context, child) {
|
||
final t = Curves.easeOut.transform(
|
||
_selectionAnim.value.clamp(0.0, 1.0),
|
||
);
|
||
if (t == 0) return const SizedBox.shrink();
|
||
return ClipRect(
|
||
child: Align(
|
||
alignment: Alignment.bottomCenter,
|
||
heightFactor: t,
|
||
child: Opacity(opacity: t, child: child),
|
||
),
|
||
);
|
||
},
|
||
child: ValueListenableBuilder<Set<String>>(
|
||
valueListenable: _selectedIds,
|
||
builder: (context, selected, _) =>
|
||
_buildSelectionBottomBar(cs, selected),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
Widget wrapChrome(Widget child) {
|
||
if (AppChatChrome.current.value != ChatChromeStyle.blur) return child;
|
||
return _FrostedPanel(
|
||
tint: cs.surfaceContainerHigh.withValues(alpha: 0.55),
|
||
border: Border(
|
||
top: BorderSide(
|
||
color: cs.outlineVariant.withValues(alpha: 0.4),
|
||
width: 0.5,
|
||
),
|
||
),
|
||
child: child,
|
||
);
|
||
}
|
||
|
||
final base = wrapChrome(content);
|
||
return AnimatedBuilder(
|
||
animation: _searchAnim,
|
||
builder: (context, _) {
|
||
final s = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0));
|
||
if (s == 0) return base;
|
||
if (s >= 1) return const SizedBox.shrink();
|
||
return ClipRect(
|
||
child: Align(
|
||
alignment: Alignment.topCenter,
|
||
heightFactor: 1 - s,
|
||
child: Opacity(
|
||
opacity: 1 - s,
|
||
child: IgnorePointer(child: base),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildSelectionBottomBar(ColorScheme cs, Set<String> selected) {
|
||
final single = selected.length == 1;
|
||
return SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
child: Row(
|
||
children: [
|
||
if (single) ...[
|
||
Expanded(
|
||
child: _selectionActionPill(
|
||
cs,
|
||
icon: Symbols.reply,
|
||
label: 'Ответить',
|
||
iconLeading: false,
|
||
onTap: _replySelected,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
] else
|
||
const Spacer(),
|
||
Expanded(
|
||
child: _selectionActionPill(
|
||
cs,
|
||
icon: Symbols.forward,
|
||
label: 'Переслать',
|
||
iconLeading: true,
|
||
onTap: _forwardSelected,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _selectionActionPill(
|
||
ColorScheme cs, {
|
||
required IconData icon,
|
||
required String label,
|
||
required bool iconLeading,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
final textWidget = Text(
|
||
label,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: 'Outfit',
|
||
),
|
||
);
|
||
final iconWidget = Icon(icon, color: cs.onSurface, size: 22, weight: 500);
|
||
return GlossyPill(
|
||
onTap: onTap,
|
||
color: Color.alphaBlend(
|
||
cs.surfaceContainerHighest.withValues(alpha: 0.92),
|
||
cs.surface,
|
||
),
|
||
borderRadius: BorderRadius.circular(28),
|
||
depth: 8,
|
||
borderSide: BorderSide(
|
||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||
width: 0.5,
|
||
),
|
||
child: SizedBox(
|
||
height: 54,
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: iconLeading
|
||
? [iconWidget, const SizedBox(width: 8), textWidget]
|
||
: [textWidget, const SizedBox(width: 8), iconWidget],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
bool _canEditMessage(CachedMessage message) {
|
||
if (message.senderId != _myId) return false;
|
||
if (message.id.startsWith('temp_')) return false;
|
||
if (message.isControl) return false;
|
||
final status = message.status;
|
||
if (status == 'sending' || status == 'error') return false;
|
||
return true;
|
||
}
|
||
|
||
Future<void> _startEditMessage(CachedMessage message) async {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final controller = RichMessageController(text: message.text ?? '')
|
||
..setFormatRanges(message.formatRanges);
|
||
|
||
final saved = await showModalBottomSheet<bool>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: cs.surfaceContainerHigh,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||
),
|
||
builder: (sheetContext) => Padding(
|
||
padding: EdgeInsets.only(
|
||
left: 20,
|
||
right: 20,
|
||
top: 20,
|
||
bottom: MediaQuery.viewInsetsOf(sheetContext).bottom + 20,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'Изменить сообщение',
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: 'Outfit',
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextField(
|
||
controller: controller,
|
||
autofocus: true,
|
||
minLines: 1,
|
||
maxLines: 6,
|
||
style: TextStyle(color: cs.onSurface),
|
||
contextMenuBuilder: (ctx, state) =>
|
||
_formatContextMenu(controller, ctx, state),
|
||
decoration: InputDecoration(
|
||
hintText: 'Текст сообщения',
|
||
filled: true,
|
||
fillColor: cs.surfaceContainerHighest,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(14),
|
||
borderSide: BorderSide.none,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(sheetContext).pop(true),
|
||
child: const Text('Сохранить'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
|
||
if (saved != true || !mounted) {
|
||
controller.dispose();
|
||
return;
|
||
}
|
||
|
||
final rawText = controller.text;
|
||
final newText = rawText.trim();
|
||
final elements = _trimmedElements(controller, rawText, newText);
|
||
controller.dispose();
|
||
|
||
final oldElements = serializeFormatElements(
|
||
message.formatRanges.where((r) => composerFormats.contains(r.format)),
|
||
);
|
||
if (newText == (message.text ?? '') &&
|
||
_sameElements(elements, oldElements)) {
|
||
return;
|
||
}
|
||
|
||
final ok = await messagesModule.editMessage(
|
||
widget.chatId,
|
||
message.id,
|
||
text: newText,
|
||
elements: elements,
|
||
);
|
||
if (!mounted) return;
|
||
if (!ok) {
|
||
Haptics.error();
|
||
showCustomNotification(context, 'Не удалось изменить сообщение');
|
||
return;
|
||
}
|
||
|
||
final idx = _messages.indexWhere((m) => m.id == message.id);
|
||
if (idx != -1) {
|
||
final old = _messages[idx];
|
||
final newHistory = KometSettings.viewRedacted.value
|
||
? CachedMessage.appendEditHistory(
|
||
old.editHistory,
|
||
old.text,
|
||
DateTime.now().millisecondsSinceEpoch,
|
||
)
|
||
: old.editHistory;
|
||
final edited = CachedMessage(
|
||
id: old.id,
|
||
accountId: old.accountId,
|
||
chatId: old.chatId,
|
||
senderId: old.senderId,
|
||
text: newText.isEmpty ? null : newText,
|
||
time: old.time,
|
||
status: 'EDITED',
|
||
payload: {...?old.payload, 'elements': elements},
|
||
attachments: old.attachments,
|
||
isControl: old.isControl,
|
||
editHistory: newHistory,
|
||
);
|
||
_messages[idx] = edited;
|
||
_bumpMessages();
|
||
unawaited(_persistOutgoing(edited));
|
||
}
|
||
Haptics.send();
|
||
}
|
||
|
||
Future<void> _confirmDeleteMessage(CachedMessage message, bool isMe) async {
|
||
final isLocalOnly = message.id.startsWith('temp_');
|
||
final canForEveryone = isMe && !isLocalOnly;
|
||
|
||
if (isLocalOnly) {
|
||
_startDeleteAnimation(message.id);
|
||
return;
|
||
}
|
||
|
||
final forEveryone = await _showDeleteMessageDialog(canForEveryone);
|
||
if (forEveryone == null || !mounted) return;
|
||
|
||
final ok = await messagesModule.deleteMessages(
|
||
widget.chatId,
|
||
[message.id],
|
||
forEveryone: forEveryone,
|
||
);
|
||
if (!mounted) return;
|
||
if (!ok) {
|
||
Haptics.error();
|
||
showCustomNotification(context, 'Не удалось удалить сообщение');
|
||
return;
|
||
}
|
||
_startDeleteAnimation(message.id);
|
||
}
|
||
|
||
void _startDeleteAnimation(String messageId) {
|
||
if (!_deletingIds.add(messageId)) return;
|
||
Haptics.tap();
|
||
_bumpMessages();
|
||
}
|
||
|
||
Future<void> _finalizeDelete(String messageId) async {
|
||
if (!mounted) return;
|
||
_deletingIds.remove(messageId);
|
||
final idx = _messages.indexWhere((m) => m.id == messageId);
|
||
if (idx != -1) {
|
||
_messages.removeAt(idx);
|
||
_reactionNotifiers.remove(messageId)?.dispose();
|
||
}
|
||
_bumpMessages();
|
||
try {
|
||
await AppDatabase.deleteMessage(_myId, widget.chatId, messageId);
|
||
await ChatsModule.reconcileLastMessage(_myId, widget.chatId);
|
||
} catch (_) {}
|
||
}
|
||
|
||
Future<bool?> _showDeleteMessageDialog(bool canForEveryone) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
var alsoForEveryone = canForEveryone;
|
||
return showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) {
|
||
return StatefulBuilder(
|
||
builder: (ctx, setLocalState) {
|
||
return AlertDialog(
|
||
backgroundColor: cs.surfaceContainerHigh,
|
||
title: const Text('Удалить сообщение'),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Вы точно хотите удалить это сообщение?',
|
||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
|
||
),
|
||
if (canForEveryone) ...[
|
||
const SizedBox(height: 16),
|
||
InkWell(
|
||
onTap: () => setLocalState(
|
||
() => alsoForEveryone = !alsoForEveryone,
|
||
),
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Row(
|
||
children: [
|
||
Checkbox(
|
||
value: alsoForEveryone,
|
||
onChanged: (v) => setLocalState(
|
||
() => alsoForEveryone = v ?? false,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
'Также удалить для ${widget.name}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx),
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(
|
||
ctx,
|
||
canForEveryone && alsoForEveryone,
|
||
),
|
||
child: Text('Удалить', style: TextStyle(color: cs.error)),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _onMessageEvent(MessageEvent event) {
|
||
if (!mounted) return;
|
||
switch (event) {
|
||
case MessageAddedEvent(:final message):
|
||
if (message.senderId == _myId) return;
|
||
if (_messages.any((m) => m.id == message.id)) return;
|
||
_lastSentId = message.id;
|
||
_messages.add(message);
|
||
_bumpMessages();
|
||
_clearTyping(message.senderId);
|
||
Haptics.tap();
|
||
_scrollToBottom();
|
||
_checkPrankTrigger(message);
|
||
_markRead();
|
||
case MessageEditedEvent(:final message):
|
||
final idx = _messages.indexWhere((m) => m.id == message.id);
|
||
if (idx == -1) return;
|
||
_messages[idx] = message;
|
||
_bumpMessages();
|
||
case MessageSentEvent(:final tempId, :final message):
|
||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||
if (idx == -1) return;
|
||
_lastSentId = message.id;
|
||
_messages[idx] = message;
|
||
_bumpMessages();
|
||
case MessageRemovedEvent(:final messageId):
|
||
final idx = _messages.indexWhere((m) => m.id == messageId);
|
||
if (idx == -1) return;
|
||
_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;
|
||
}
|
||
}
|
||
|
||
Future<void> _loadOtherPresence() async {
|
||
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;
|
||
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}) {
|
||
final otherId = widget.chatId ^ _myId;
|
||
final showDot = widget.chatType == 'DIALOG' && _myId != 0 && otherId > 0;
|
||
return Stack(
|
||
children: [
|
||
avatar,
|
||
if (showDot)
|
||
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 _buildAppBar(ColorScheme cs) {
|
||
final glossy = AppVisualStyle.current.value == VisualStyle.glossy;
|
||
final searchT = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0));
|
||
final height = glossy
|
||
? ui.lerpDouble(_glossyHeaderHeight, _glossySearchHeight, searchT)!
|
||
: kToolbarHeight;
|
||
final chrome = AppChatChrome.current.value;
|
||
return AppBar(
|
||
backgroundColor: chrome == ChatChromeStyle.color
|
||
? (glossy ? Colors.transparent : cs.surfaceContainerHigh)
|
||
: Colors.transparent,
|
||
flexibleSpace: chrome == ChatChromeStyle.blur
|
||
? _FrostedPanel(
|
||
tint: cs.surfaceContainerHigh.withValues(alpha: 0.55),
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: cs.outlineVariant.withValues(alpha: 0.4),
|
||
width: 0.5,
|
||
),
|
||
),
|
||
child: const SizedBox.expand(),
|
||
)
|
||
: (chrome == ChatChromeStyle.none && !glossy)
|
||
? IgnorePointer(
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [
|
||
cs.surface,
|
||
cs.surface,
|
||
cs.surface.withValues(alpha: 0.0),
|
||
],
|
||
stops: const [0.0, 0.72, 1.0],
|
||
),
|
||
),
|
||
child: const SizedBox.expand(),
|
||
),
|
||
)
|
||
: null,
|
||
foregroundColor: cs.onSurface,
|
||
surfaceTintColor: Colors.transparent,
|
||
iconTheme: IconThemeData(color: cs.onSurface),
|
||
elevation: 0,
|
||
toolbarHeight: height,
|
||
automaticallyImplyLeading: false,
|
||
titleSpacing: 0,
|
||
centerTitle: false,
|
||
title: SizedBox(
|
||
height: height,
|
||
child: AnimatedBuilder(
|
||
animation: Listenable.merge([_selectionAnim, _searchAnim]),
|
||
builder: (context, _) {
|
||
final t = Curves.easeOut.transform(
|
||
_selectionAnim.value.clamp(0.0, 1.0),
|
||
);
|
||
final s = Curves.easeOut.transform(
|
||
_searchAnim.value.clamp(0.0, 1.0),
|
||
);
|
||
return ValueListenableBuilder<Set<String>>(
|
||
valueListenable: _selectedIds,
|
||
builder: (context, selected, _) => Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
if (t < 1 && s < 1)
|
||
IgnorePointer(
|
||
ignoring: t > 0.5 || s > 0.5,
|
||
child: Opacity(
|
||
opacity: (1 - t) * (1 - s),
|
||
child: Transform.translate(
|
||
offset: Offset(0, -height * 0.4 * t),
|
||
child: glossy
|
||
? _glossyHeaderRow(cs)
|
||
: _materialHeaderRow(cs),
|
||
),
|
||
),
|
||
),
|
||
if (t > 0)
|
||
IgnorePointer(
|
||
ignoring: t < 0.5,
|
||
child: Opacity(
|
||
opacity: t,
|
||
child: Transform.translate(
|
||
offset: Offset(0, height * 0.4 * (1 - t)),
|
||
child: _selectionTopBar(cs, selected, glossy),
|
||
),
|
||
),
|
||
),
|
||
if (s > 0)
|
||
IgnorePointer(
|
||
ignoring: s < 0.5,
|
||
child: Opacity(
|
||
opacity: s,
|
||
child: _searchTopBar(cs, glossy),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _searchTopBar(ColorScheme cs, bool glossy) {
|
||
final field = TextField(
|
||
controller: _searchController,
|
||
focusNode: _searchFocusNode,
|
||
textInputAction: TextInputAction.search,
|
||
onSubmitted: (value) {
|
||
_searchDebounce?.cancel();
|
||
_runSearch(value);
|
||
},
|
||
cursorColor: cs.primary,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 16,
|
||
fontFamily: 'Outfit',
|
||
),
|
||
decoration: InputDecoration(
|
||
hintText: 'Поиск...',
|
||
hintStyle: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 16,
|
||
fontFamily: 'Outfit',
|
||
),
|
||
border: InputBorder.none,
|
||
isDense: true,
|
||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||
),
|
||
);
|
||
|
||
final backBtn = IconButton(
|
||
icon: Icon(Symbols.arrow_back, weight: glossy ? 500 : 400,
|
||
color: cs.onSurface),
|
||
onPressed: _closeSearch,
|
||
);
|
||
final searchBtn = IconButton(
|
||
icon: AnimatedLottieIcon(
|
||
asset: AppAnimations.search,
|
||
color: cs.onSurface,
|
||
size: 24,
|
||
active: true,
|
||
animateOnMount: true,
|
||
),
|
||
onPressed: () {
|
||
_searchDebounce?.cancel();
|
||
_runSearch(_searchController.text);
|
||
},
|
||
);
|
||
|
||
if (!glossy) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||
child: Row(
|
||
children: [backBtn, Expanded(child: field), searchBtn],
|
||
),
|
||
);
|
||
}
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 6),
|
||
child: GlossyPill(
|
||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||
child: SizedBox(
|
||
height: 44,
|
||
child: Row(
|
||
children: [backBtn, Expanded(child: field), searchBtn],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _glossyHeaderRow(ColorScheme cs) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
|
||
child: Row(
|
||
children: [
|
||
_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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: GlossyPill(
|
||
onTap: () => Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => ChatInfoScreen(
|
||
chatId: widget.chatId,
|
||
name: widget.name,
|
||
imageUrl: widget.imageUrl,
|
||
chatType: widget.chatType,
|
||
),
|
||
),
|
||
),
|
||
padding: const EdgeInsets.fromLTRB(6, 6, 16, 6),
|
||
child: Row(
|
||
children: [
|
||
_withOnlineDot(
|
||
cs,
|
||
AvatarHero(
|
||
tag: 'chatAvatar_${widget.chatId}',
|
||
name: widget.name,
|
||
imageUrl: widget.imageUrl.isNotEmpty
|
||
? widget.imageUrl
|
||
: null,
|
||
child: 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(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Flexible(
|
||
child: Text(
|
||
widget.name,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: 'Outfit',
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
if (chat?.isOfficial ?? false) ...[
|
||
const SizedBox(width: 4),
|
||
Icon(
|
||
Symbols.verified,
|
||
color: cs.primary,
|
||
size: 16,
|
||
weight: 600,
|
||
fill: 1,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
ValueListenableBuilder<String>(
|
||
valueListenable: _headerStatusNotifier,
|
||
builder: (context, status, _) => Text(
|
||
status,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w400,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
GlossyPill(
|
||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||
child: SizedBox(
|
||
height: 56,
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ValueListenableBuilder<int>(
|
||
valueListenable: _scheduledCount,
|
||
builder: (_, count, _) => count > 0
|
||
? IconButton(
|
||
icon: Icon(
|
||
Symbols.schedule,
|
||
weight: 500,
|
||
color: cs.onSurface,
|
||
),
|
||
onPressed: _openScheduledMessages,
|
||
)
|
||
: const SizedBox.shrink(),
|
||
),
|
||
IconButton(
|
||
icon: Icon(Symbols.call, weight: 500, color: cs.onSurface),
|
||
onPressed: _startCall,
|
||
),
|
||
Builder(
|
||
builder: (btnContext) => IconButton(
|
||
icon: Icon(
|
||
Symbols.more_vert,
|
||
weight: 500,
|
||
color: cs.onSurface,
|
||
),
|
||
onPressed: () => _openChatMenu(btnContext),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _materialHeaderRow(ColorScheme cs) {
|
||
return Row(
|
||
children: [
|
||
_backWithBadge(
|
||
cs,
|
||
IconButton(
|
||
icon: Icon(
|
||
widget.embedded ? Symbols.close : Symbols.arrow_back,
|
||
weight: 400,
|
||
color: cs.onSurface,
|
||
),
|
||
onPressed: () {
|
||
if (widget.embedded) {
|
||
widget.onClose?.call();
|
||
} else {
|
||
Navigator.pop(context);
|
||
}
|
||
},
|
||
),
|
||
),
|
||
Expanded(
|
||
child: InkWell(
|
||
onTap: () => Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => ChatInfoScreen(
|
||
chatId: widget.chatId,
|
||
name: widget.name,
|
||
imageUrl: widget.imageUrl,
|
||
chatType: widget.chatType,
|
||
),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
_withOnlineDot(
|
||
cs,
|
||
AvatarHero(
|
||
tag: 'chatAvatar_${widget.chatId}',
|
||
name: widget.name,
|
||
imageUrl: widget.imageUrl.isNotEmpty
|
||
? widget.imageUrl
|
||
: null,
|
||
child: 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(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Flexible(
|
||
child: Text(
|
||
widget.name,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: 'Outfit',
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
if (chat?.isOfficial ?? false) ...[
|
||
const SizedBox(width: 4),
|
||
Icon(
|
||
Symbols.verified,
|
||
color: cs.primary,
|
||
size: 16,
|
||
weight: 600,
|
||
fill: 1,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
ValueListenableBuilder<String>(
|
||
valueListenable: _headerStatusNotifier,
|
||
builder: (context, status, _) => Text(
|
||
status,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w400,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
ValueListenableBuilder<int>(
|
||
valueListenable: _scheduledCount,
|
||
builder: (_, count, _) => count > 0
|
||
? IconButton(
|
||
icon: Icon(Symbols.schedule, weight: 400, color: cs.onSurface),
|
||
onPressed: _openScheduledMessages,
|
||
)
|
||
: const SizedBox.shrink(),
|
||
),
|
||
IconButton(
|
||
icon: Icon(Symbols.call, weight: 400, color: cs.onSurface),
|
||
onPressed: _startCall,
|
||
),
|
||
Builder(
|
||
builder: (btnContext) => IconButton(
|
||
icon: Icon(Symbols.more_vert, weight: 400, color: cs.onSurface),
|
||
onPressed: () => _openChatMenu(btnContext),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _selectionTopBar(ColorScheme cs, Set<String> selected, bool glossy) {
|
||
final count = selected.length;
|
||
final copyMsg = _singleCopyableText(selected);
|
||
final editMsg = _singleEditable(selected);
|
||
final label = 'Выбрано $count';
|
||
|
||
if (!glossy) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||
child: Row(
|
||
children: [
|
||
IconButton(
|
||
icon: Icon(Symbols.close, color: cs.onSurface),
|
||
onPressed: _clearSelection,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: 'Outfit',
|
||
),
|
||
),
|
||
),
|
||
if (copyMsg != null)
|
||
IconButton(
|
||
icon: Icon(Symbols.content_copy, color: cs.onSurface),
|
||
onPressed: () => _copySelected(copyMsg),
|
||
),
|
||
if (editMsg != null)
|
||
IconButton(
|
||
icon: Icon(Symbols.edit, color: cs.onSurface),
|
||
onPressed: () => _editSelected(editMsg),
|
||
),
|
||
IconButton(
|
||
icon: Icon(Symbols.delete, color: cs.onSurface),
|
||
onPressed: _deleteSelected,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget actionBtn(IconData icon, VoidCallback onTap) => IconButton(
|
||
icon: Icon(icon, weight: 500, color: cs.onSurface),
|
||
onPressed: onTap,
|
||
);
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 56,
|
||
height: 56,
|
||
child: GlossyPill(
|
||
onTap: _clearSelection,
|
||
child: Center(
|
||
child: Icon(
|
||
Symbols.close,
|
||
color: cs.onSurface,
|
||
weight: 500,
|
||
size: 24,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: GlossyPill(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
child: SizedBox(
|
||
height: 56,
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(
|
||
label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: 'Outfit',
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
GlossyPill(
|
||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||
child: SizedBox(
|
||
height: 56,
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (copyMsg != null)
|
||
actionBtn(Symbols.content_copy, () => _copySelected(copyMsg)),
|
||
if (editMsg != null)
|
||
actionBtn(Symbols.edit, () => _editSelected(editMsg)),
|
||
actionBtn(Symbols.delete, _deleteSelected),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _openChatMenu(BuildContext btnContext) {
|
||
final box = btnContext.findRenderObject() as RenderBox?;
|
||
if (box == null || !box.hasSize) return;
|
||
final anchorRect = box.localToGlobal(Offset.zero) & box.size;
|
||
showChatMenu(
|
||
context: context,
|
||
anchorRect: anchorRect,
|
||
items: [
|
||
ChatMenuItem(
|
||
icon: Symbols.volume_up,
|
||
label: 'Уведомления',
|
||
showChevron: true,
|
||
dividerAfter: true,
|
||
onTap: () {},
|
||
),
|
||
ChatMenuItem(
|
||
icon: Symbols.videocam,
|
||
label: 'Видеозвонок',
|
||
onTap: () {},
|
||
),
|
||
ChatMenuItem(icon: Symbols.search, label: 'Поиск', onTap: _openSearch),
|
||
ChatMenuItem(
|
||
icon: Symbols.wallpaper,
|
||
label: 'Изменить обои',
|
||
onTap: _openWallpaperSheet,
|
||
),
|
||
ChatMenuItem(
|
||
icon: Symbols.mop,
|
||
label: 'Очистить историю',
|
||
onTap: _clearHistory,
|
||
),
|
||
ChatMenuItem(
|
||
icon: Symbols.delete,
|
||
label: 'Удалить чат',
|
||
onTap: _deleteChat,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _loadWallpaper() async {
|
||
await ChatWallpaperStore.instance.load();
|
||
if (!mounted) return;
|
||
final wp = ChatWallpaperStore.instance.get(_myId, widget.chatId);
|
||
if (wp != _wallpaper) setState(() => _wallpaper = wp);
|
||
}
|
||
|
||
Future<void> _openWallpaperSheet() async {
|
||
if (_myId == 0) return;
|
||
final pick = await showChatWallpaperSheet(context, current: _wallpaper);
|
||
if (pick == null || !mounted) return;
|
||
final store = ChatWallpaperStore.instance;
|
||
switch (pick.type) {
|
||
case WallpaperPickType.none:
|
||
await store.clear(_myId, widget.chatId);
|
||
if (mounted) setState(() => _wallpaper = null);
|
||
break;
|
||
case WallpaperPickType.theme:
|
||
final theme = pick.theme;
|
||
if (theme == null) break;
|
||
final wp = await store.setTheme(_myId, widget.chatId, theme.id);
|
||
if (mounted) setState(() => _wallpaper = wp);
|
||
break;
|
||
case WallpaperPickType.gallery:
|
||
await _pickWallpaperFromGallery();
|
||
break;
|
||
}
|
||
}
|
||
|
||
Future<void> _pickWallpaperFromGallery() async {
|
||
final result = await FilePicker.platform.pickFiles(
|
||
type: FileType.image,
|
||
withData: true,
|
||
);
|
||
if (result == null || result.files.isEmpty) return;
|
||
final bytes = result.files.first.bytes;
|
||
if (bytes == null) {
|
||
if (mounted) showCustomNotification(context, 'Не удалось прочитать файл');
|
||
return;
|
||
}
|
||
if (!mounted) return;
|
||
final settings = await Navigator.of(context).push<WallpaperImageSettings>(
|
||
MaterialPageRoute(
|
||
builder: (_) => ChatWallpaperPreviewScreen(imageBytes: bytes),
|
||
),
|
||
);
|
||
if (settings == null || !mounted) return;
|
||
final wp = await ChatWallpaperStore.instance.setImage(
|
||
_myId,
|
||
widget.chatId,
|
||
bytes,
|
||
settings: settings,
|
||
);
|
||
if (!mounted) return;
|
||
if (wp == null) {
|
||
showCustomNotification(context, 'Не удалось сохранить обои');
|
||
return;
|
||
}
|
||
setState(() => _wallpaper = wp);
|
||
}
|
||
|
||
Future<bool?> _showConfirmDialog({
|
||
required String title,
|
||
required String body,
|
||
required String confirmLabel,
|
||
}) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
return showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: cs.surfaceContainerHigh,
|
||
title: Text(title),
|
||
content: Text(
|
||
body,
|
||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: Text(confirmLabel, style: TextStyle(color: cs.error)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _clearHistory() async {
|
||
final confirmed = await _showConfirmDialog(
|
||
title: 'Очистить историю',
|
||
body: 'Все сообщения в этом чате будут удалены без возможности '
|
||
'восстановления.',
|
||
confirmLabel: 'Очистить',
|
||
);
|
||
if (!mounted || confirmed != true) return;
|
||
final err = await ChatsModule.clearHistory(
|
||
api,
|
||
chatId: widget.chatId,
|
||
lastEventTime: chat?.lastEventTime ?? 0,
|
||
);
|
||
if (!mounted) return;
|
||
if (err != null) {
|
||
showCustomNotification(context, err);
|
||
return;
|
||
}
|
||
setState(() {
|
||
_messages = [];
|
||
_hasMoreHistory = false;
|
||
_combinedItemsCache = null;
|
||
});
|
||
_messagesRev.value++;
|
||
}
|
||
|
||
Future<void> _deleteChat() async {
|
||
final confirmed = await _showConfirmDialog(
|
||
title: 'Удалить чат',
|
||
body: 'Чат будет удалён вместе со всей перепиской.',
|
||
confirmLabel: 'Удалить',
|
||
);
|
||
if (!mounted || confirmed != true) return;
|
||
final err = await ChatsModule.deleteChat(
|
||
api,
|
||
chatId: widget.chatId,
|
||
lastEventTime: chat?.lastEventTime ?? 0,
|
||
forAll: false,
|
||
);
|
||
if (!mounted) return;
|
||
if (err != null) {
|
||
showCustomNotification(context, err);
|
||
return;
|
||
}
|
||
Navigator.of(context).pop();
|
||
}
|
||
|
||
Future<void> _startCall() async {
|
||
if (widget.chatType != 'DIALOG') {
|
||
showCustomNotification(context, 'Звонки доступны только в диалогах');
|
||
return;
|
||
}
|
||
// Звонок уже идёт (возможно, свёрнут) — просто открываем его экран снова.
|
||
final navigator = Navigator.of(context);
|
||
final active = CallController.instance.activeSession;
|
||
if (active != null) {
|
||
await navigator.push(
|
||
MaterialPageRoute(
|
||
builder: (_) => CallScreen(
|
||
name: widget.name,
|
||
avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null,
|
||
session: active,
|
||
),
|
||
),
|
||
);
|
||
_onCallScreenClosed();
|
||
return;
|
||
}
|
||
final peerId = widget.chatId ^ _myId;
|
||
if (peerId <= 0) return;
|
||
try {
|
||
final session = await CallController.instance.startOutgoing(peerId);
|
||
if (!mounted) return;
|
||
await navigator.push(
|
||
MaterialPageRoute(
|
||
builder: (_) => CallScreen(
|
||
name: widget.name,
|
||
avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null,
|
||
session: session,
|
||
),
|
||
),
|
||
);
|
||
_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.live(otherId);
|
||
if (p == null) return;
|
||
_otherStatus = (p['status'] as int?) ?? 0;
|
||
_otherSeenTime = p['seen'] as int?;
|
||
}
|
||
|
||
void _recomputeHeaderStatus() {
|
||
_headerStatusNotifier.value = _headerStatus();
|
||
}
|
||
|
||
String _headerStatus() {
|
||
final conn = connectionStatusLabel(api.state);
|
||
if (conn != null) return conn;
|
||
final activity = ChatActivityStore.instance.activity(widget.chatId);
|
||
if (activity != null) return activity.label;
|
||
if (widget.chatType == 'CHAT') {
|
||
final count = _participantsCount ?? chat?.participants.length ?? 0;
|
||
return '$count участников';
|
||
}
|
||
if (widget.chatType == 'CHANNEL') {
|
||
final count = _participantsCount ?? chat?.participants.length ?? 0;
|
||
return '$count подписчиков';
|
||
}
|
||
if (_otherStatus == 1) return 'В сети';
|
||
if (_otherStatus == 2 || _otherStatus == 3) return 'Был(-а) недавно';
|
||
final s = _otherSeenTime;
|
||
if (s != null && s > 0) return formatLastSeen(s);
|
||
return '';
|
||
}
|
||
|
||
void _onTyping(Packet packet) {
|
||
final payload = packet.payload;
|
||
if (payload is! Map) return;
|
||
if (payload['chatId'] != widget.chatId) return;
|
||
final userId = payload['userId'];
|
||
if (userId is! int || userId == _myId) return;
|
||
ChatActivityStore.instance.mark(
|
||
widget.chatId,
|
||
userId,
|
||
chatActivityFromType(payload['type']),
|
||
);
|
||
}
|
||
|
||
void _clearTyping(int userId) {
|
||
ChatActivityStore.instance.clearUser(widget.chatId, userId);
|
||
}
|
||
|
||
void _onMessageRead(Packet packet) {
|
||
final payload = packet.payload;
|
||
if (payload is! Map) return;
|
||
if (payload['chatId'] != widget.chatId) return;
|
||
final userId = payload['userId'];
|
||
if (userId is! int || userId == _myId) return;
|
||
final mark = payload['mark'];
|
||
if (mark is! int) return;
|
||
if (payload['setAsUnread'] == true) return;
|
||
final c = chat;
|
||
if (c == null) return;
|
||
if (c.participants[userId] == mark) return;
|
||
c.participants[userId] = mark;
|
||
_syncOtherReadTime();
|
||
}
|
||
|
||
static String _formatLabel(TextFormat format) {
|
||
switch (format) {
|
||
case TextFormat.strong:
|
||
return 'Жирный';
|
||
case TextFormat.emphasized:
|
||
return 'Курсив';
|
||
case TextFormat.underline:
|
||
return 'Подчёркнутый';
|
||
case TextFormat.strikethrough:
|
||
return 'Зачёркнутый';
|
||
case TextFormat.monospaced:
|
||
return 'Моноширинный';
|
||
case TextFormat.quote:
|
||
return 'Цитата';
|
||
case TextFormat.link:
|
||
return 'Ссылка';
|
||
}
|
||
}
|
||
|
||
Widget _formatContextMenu(
|
||
RichMessageController controller,
|
||
BuildContext context,
|
||
EditableTextState editableState,
|
||
) {
|
||
final selection = controller.selection;
|
||
final buttonItems = <ContextMenuButtonItem>[];
|
||
if (selection.isValid && !selection.isCollapsed) {
|
||
for (final format in composerFormats) {
|
||
final active = controller.isFormatActive(format);
|
||
buttonItems.add(
|
||
ContextMenuButtonItem(
|
||
label: '${active ? '✓ ' : ''}${_formatLabel(format)}',
|
||
onPressed: () {
|
||
controller.toggleFormat(format);
|
||
editableState.hideToolbar();
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
buttonItems.addAll(editableState.contextMenuButtonItems);
|
||
return AdaptiveTextSelectionToolbar.buttonItems(
|
||
anchors: editableState.contextMenuAnchors,
|
||
buttonItems: buttonItems,
|
||
);
|
||
}
|
||
|
||
static bool _sameElements(
|
||
List<Map<String, dynamic>> a,
|
||
List<Map<String, dynamic>> b,
|
||
) {
|
||
if (a.length != b.length) return false;
|
||
String canon(List<Map<String, dynamic>> els) {
|
||
final copy = [...els]..sort((x, y) {
|
||
final t = (x['type'] as String).compareTo(y['type'] as String);
|
||
return t != 0 ? t : (x['from'] as int).compareTo(y['from'] as int);
|
||
});
|
||
return copy
|
||
.map((e) => '${e['type']}:${e['from']}:${e['length']}')
|
||
.join(',');
|
||
}
|
||
|
||
return canon(a) == canon(b);
|
||
}
|
||
|
||
List<Map<String, dynamic>> _trimmedElements(
|
||
RichMessageController controller,
|
||
String rawText,
|
||
String text,
|
||
) {
|
||
final raw = controller.elementsForSend();
|
||
if (raw.isEmpty) return const [];
|
||
final leading = rawText.length - rawText.trimLeft().length;
|
||
final result = <Map<String, dynamic>>[];
|
||
for (final element in raw) {
|
||
var from = (element['from'] as int) - leading;
|
||
var length = element['length'] as int;
|
||
if (from < 0) {
|
||
length += from;
|
||
from = 0;
|
||
}
|
||
if (from >= text.length || length <= 0) continue;
|
||
if (from + length > text.length) length = text.length - from;
|
||
if (length <= 0) continue;
|
||
result.add({...element, 'from': from, 'length': length});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
Future<void> _sendMessage() async {
|
||
final rawText = _messageController.text;
|
||
final text = rawText.trim();
|
||
if (text.isEmpty || _myId == 0) return;
|
||
|
||
if (AppCommands.current.value && text.startsWith('/')) {
|
||
final command = findSlashCommand(text);
|
||
if (command == null) {
|
||
_messageController.clear();
|
||
_hasText.value = false;
|
||
showCustomNotification(context, 'ТАКОЙ КОМАНДЫ НЕТУ🚨🚨🚨');
|
||
return;
|
||
}
|
||
if (command.run != null) {
|
||
final args = commandArgs(text);
|
||
_messageController.clear();
|
||
_hasText.value = false;
|
||
unawaited(command.run!(_commandContext(args)));
|
||
return;
|
||
}
|
||
}
|
||
|
||
final tempId = _nextTempId();
|
||
final now = DateTime.now().millisecondsSinceEpoch;
|
||
final online = api.state == SessionState.online;
|
||
|
||
final reply = _replyTo.value;
|
||
final int? replyId = reply == null ? null : int.tryParse(reply.id);
|
||
Map<String, dynamic>? replyPayload;
|
||
if (reply != null && replyId != null) {
|
||
replyPayload = {
|
||
'link': {
|
||
'type': 'REPLY',
|
||
'chatId': widget.chatId,
|
||
'message': {
|
||
'id': replyId,
|
||
'sender': reply.senderId,
|
||
'text': reply.text,
|
||
'time': reply.time,
|
||
'attaches': reply.payload?['attaches'] ?? const [],
|
||
},
|
||
},
|
||
};
|
||
}
|
||
_replyTo.value = null;
|
||
|
||
final elements = _trimmedElements(_messageController, rawText, text);
|
||
final Map<String, dynamic>? composedPayload =
|
||
(replyPayload == null && elements.isEmpty)
|
||
? null
|
||
: {
|
||
...?replyPayload,
|
||
if (elements.isNotEmpty) 'elements': elements,
|
||
};
|
||
|
||
final composed = CachedMessage(
|
||
id: tempId,
|
||
accountId: _myId,
|
||
chatId: widget.chatId,
|
||
senderId: _myId,
|
||
text: text,
|
||
time: now,
|
||
status: online ? 'sending' : 'pending',
|
||
payload: composedPayload,
|
||
);
|
||
|
||
_hasText.value = false;
|
||
_lastSentId = tempId;
|
||
_messages.add(composed);
|
||
_messageController.clear();
|
||
if (DraftStore.instance.get(_myId, widget.chatId) != null) {
|
||
unawaited(DraftStore.instance.clear(_myId, widget.chatId));
|
||
}
|
||
_bumpMessages();
|
||
unawaited(_persistOutgoing(composed));
|
||
unawaited(ChatsModule.applyOutgoing(
|
||
_myId,
|
||
widget.chatId,
|
||
messageId: tempId,
|
||
time: now,
|
||
text: text,
|
||
status: composed.status ?? 'sending',
|
||
elements: elements,
|
||
));
|
||
|
||
// Instant tactile "whoosh" the moment the message leaves the composer,
|
||
// not after the network round-trip — feedback must feel immediate.
|
||
Haptics.send();
|
||
|
||
_scrollToBottom();
|
||
_checkPrankTrigger(composed);
|
||
|
||
if (!online) return;
|
||
|
||
try {
|
||
final actualId = await messagesModule.sendMessage(
|
||
_myId,
|
||
widget.chatId,
|
||
text,
|
||
replyToMessageId: replyId,
|
||
elements: elements,
|
||
);
|
||
|
||
final index = _messages.indexWhere((m) => m.id == tempId);
|
||
if (index != -1 && mounted) {
|
||
final sent = CachedMessage(
|
||
id: actualId.isNotEmpty ? actualId : tempId,
|
||
accountId: _myId,
|
||
chatId: widget.chatId,
|
||
senderId: _myId,
|
||
text: text,
|
||
time: now,
|
||
status: 'sent',
|
||
payload: composedPayload,
|
||
);
|
||
_messages[index] = sent;
|
||
_bumpMessages();
|
||
unawaited(_persistOutgoing(sent, removeId: tempId));
|
||
unawaited(ChatsModule.applyOutgoing(
|
||
_myId,
|
||
widget.chatId,
|
||
messageId: sent.id,
|
||
time: now,
|
||
text: text,
|
||
status: 'sent',
|
||
elements: elements,
|
||
));
|
||
}
|
||
|
||
if (chat == null) {
|
||
unawaited(
|
||
ChatsModule.refreshChats(api, [widget.chatId]).then((list) {
|
||
if (!mounted || list.isEmpty) return;
|
||
setState(() => chat = list.first);
|
||
_syncOtherReadTime();
|
||
}),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
final index = _messages.indexWhere((m) => m.id == tempId);
|
||
if (index != -1 && mounted) {
|
||
final queued = CachedMessage(
|
||
id: tempId,
|
||
accountId: _myId,
|
||
chatId: widget.chatId,
|
||
senderId: _myId,
|
||
text: text,
|
||
time: now,
|
||
status: 'pending',
|
||
payload: composedPayload,
|
||
);
|
||
_messages[index] = queued;
|
||
_bumpMessages();
|
||
unawaited(_persistOutgoing(queued));
|
||
unawaited(ChatsModule.applyOutgoing(
|
||
_myId,
|
||
widget.chatId,
|
||
messageId: tempId,
|
||
time: now,
|
||
text: text,
|
||
status: 'pending',
|
||
elements: elements,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
int? _resolveOtherId() {
|
||
if (widget.chatType != 'DIALOG' || _myId == 0) return null;
|
||
final id = widget.chatId ^ _myId;
|
||
return id > 0 ? id : null;
|
||
}
|
||
|
||
int _complaintTypeId(String type) {
|
||
switch (type) {
|
||
case 'CHANNEL':
|
||
return 5;
|
||
case 'CHAT':
|
||
return 4;
|
||
default:
|
||
return 3;
|
||
}
|
||
}
|
||
|
||
Future<List<({int id, String title})>> _loadReportReasons(int typeId) async {
|
||
final reasons = await ComplaintsModule.reasonsFor(api, typeId);
|
||
return reasons.map((r) => (id: r.reasonId, title: r.reasonTitle)).toList();
|
||
}
|
||
|
||
Future<bool> _reportMessage(
|
||
CachedMessage message,
|
||
int typeId,
|
||
int reasonId,
|
||
) async {
|
||
final messageIdNum = int.tryParse(message.id);
|
||
if (messageIdNum == null) {
|
||
if (mounted) {
|
||
showCustomNotification(context, 'Не удалось отправить жалобу');
|
||
}
|
||
return false;
|
||
}
|
||
final ok = await ComplaintsModule.sendComplaint(
|
||
api,
|
||
reasonId: reasonId,
|
||
typeId: typeId,
|
||
ids: [messageIdNum],
|
||
parentId: widget.chatId,
|
||
);
|
||
if (!mounted) return ok;
|
||
showCustomNotification(
|
||
context,
|
||
ok ? 'Жалоба отправлена' : 'Не удалось отправить жалобу',
|
||
);
|
||
return ok;
|
||
}
|
||
|
||
CachedMessage _replaceMessage(
|
||
int index, {
|
||
String? id,
|
||
String? text,
|
||
String? status,
|
||
}) {
|
||
final old = _messages[index];
|
||
final updated = CachedMessage(
|
||
id: id ?? old.id,
|
||
accountId: old.accountId,
|
||
chatId: old.chatId,
|
||
senderId: old.senderId,
|
||
text: text ?? old.text,
|
||
time: old.time,
|
||
status: status ?? old.status,
|
||
payload: old.payload,
|
||
attachments: old.attachments,
|
||
isControl: old.isControl,
|
||
editHistory: old.editHistory,
|
||
);
|
||
_messages[index] = updated;
|
||
_bumpMessages();
|
||
return updated;
|
||
}
|
||
|
||
Future<String> _postCommandMessage(String text) async {
|
||
if (!mounted || _myId == 0) return '';
|
||
final tempId = _nextTempId();
|
||
final now = DateTime.now().millisecondsSinceEpoch;
|
||
final online = api.state == SessionState.online;
|
||
final composed = CachedMessage(
|
||
id: tempId,
|
||
accountId: _myId,
|
||
chatId: widget.chatId,
|
||
senderId: _myId,
|
||
text: text,
|
||
time: now,
|
||
status: online ? 'sending' : 'pending',
|
||
);
|
||
_messages.add(composed);
|
||
_bumpMessages();
|
||
_scrollToBottom();
|
||
unawaited(_persistOutgoing(composed));
|
||
unawaited(ChatsModule.applyOutgoing(
|
||
_myId,
|
||
widget.chatId,
|
||
messageId: tempId,
|
||
time: now,
|
||
text: text,
|
||
status: composed.status ?? 'sending',
|
||
));
|
||
if (!online) return tempId;
|
||
try {
|
||
final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text);
|
||
final realId = actualId.isNotEmpty ? actualId : tempId;
|
||
final i = _messages.indexWhere((m) => m.id == tempId);
|
||
if (i != -1) {
|
||
final sent = _replaceMessage(i, id: realId, status: 'sent');
|
||
unawaited(_persistOutgoing(sent, removeId: tempId));
|
||
unawaited(ChatsModule.applyOutgoing(
|
||
_myId,
|
||
widget.chatId,
|
||
messageId: realId,
|
||
time: now,
|
||
text: text,
|
||
status: 'sent',
|
||
));
|
||
}
|
||
return realId;
|
||
} catch (_) {
|
||
return tempId;
|
||
}
|
||
}
|
||
|
||
Future<void> _updateCommandMessage(String id, String text) async {
|
||
if (id.isEmpty) return;
|
||
final i = _messages.indexWhere((m) => m.id == id);
|
||
if (i != -1) {
|
||
final edited = _replaceMessage(i, text: text, status: 'EDITED');
|
||
unawaited(_persistOutgoing(edited));
|
||
}
|
||
if (!id.startsWith('temp_')) {
|
||
await messagesModule.editMessage(widget.chatId, id, text: text);
|
||
}
|
||
}
|
||
|
||
CommandContext _commandContext(String args) => CommandContext(
|
||
accountId: _myId,
|
||
chatId: widget.chatId,
|
||
otherUserId: _resolveOtherId(),
|
||
args: args,
|
||
messages: messagesModule,
|
||
isOnline: () => api.state == SessionState.online,
|
||
isActive: () => mounted,
|
||
notify: (message, {duration}) {
|
||
if (mounted) showCustomNotification(context, message, duration: duration);
|
||
},
|
||
postMessage: _postCommandMessage,
|
||
updateMessage: _updateCommandMessage,
|
||
);
|
||
|
||
Future<void> _scheduleMessage() async {
|
||
final text = _messageController.text.trim();
|
||
if (text.isEmpty || _myId == 0) return;
|
||
|
||
final when = await _pickScheduleTime();
|
||
if (when == null || !mounted) return;
|
||
|
||
try {
|
||
await messagesModule.sendMessage(
|
||
_myId,
|
||
widget.chatId,
|
||
text,
|
||
scheduledTime: when.millisecondsSinceEpoch,
|
||
);
|
||
if (!mounted) return;
|
||
_hasText.value = false;
|
||
_messageController.clear();
|
||
Haptics.send();
|
||
_markHasScheduled();
|
||
showCustomNotification(
|
||
context,
|
||
'Запланировано на ${formatDateTimeWords(when)}',
|
||
);
|
||
} catch (_) {
|
||
if (!mounted) return;
|
||
Haptics.error();
|
||
showCustomNotification(context, 'Не удалось запланировать сообщение');
|
||
}
|
||
}
|
||
|
||
Future<DateTime?> _pickScheduleTime() => showScheduleTimePicker(context);
|
||
|
||
void _openScheduledMessages() {
|
||
Navigator.of(context)
|
||
.push(
|
||
MaterialPageRoute(
|
||
builder: (_) => ScheduledMessagesScreen(
|
||
chatId: widget.chatId,
|
||
accountId: _myId,
|
||
chatName: widget.name,
|
||
),
|
||
),
|
||
)
|
||
.then((_) {
|
||
if (mounted) _refreshScheduledCount();
|
||
});
|
||
}
|
||
|
||
Future<void> _persistOutgoing(CachedMessage msg, {String? removeId}) async {
|
||
try {
|
||
if (removeId != null && removeId != msg.id) {
|
||
await AppDatabase.deleteMessage(_myId, widget.chatId, removeId);
|
||
}
|
||
await AppDatabase.saveMessages([msg.toDbRow()]);
|
||
} catch (_) {}
|
||
}
|
||
|
||
Future<void> _loadGroupSenderNames() async {
|
||
if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return;
|
||
|
||
final unknownIds = <int>{};
|
||
for (final msg in _messages) {
|
||
if (msg.isControl) continue;
|
||
final id = msg.senderId;
|
||
if (id == 0 || id == _myId) continue;
|
||
if (ContactCache.get(id) == null) unknownIds.add(id);
|
||
}
|
||
if (unknownIds.isEmpty) return;
|
||
|
||
final resolved = await messagesModule.ensureContactNames(unknownIds);
|
||
if (resolved && mounted) _bumpMessages();
|
||
}
|
||
|
||
Future<void> _loadForwardedSenderNames() async {
|
||
final forwardIds = <int>{};
|
||
for (final msg in _messages) {
|
||
if (msg.attachments != null) {
|
||
for (final a in msg.attachments!) {
|
||
if (a is ForwardedMessageAttachment) {
|
||
if (a.originalSenderName == null &&
|
||
ContactCache.get(a.originalSenderId) == null) {
|
||
forwardIds.add(a.originalSenderId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (forwardIds.isEmpty) return;
|
||
|
||
final resolved = <int, ({String name, String? avatar})>{};
|
||
for (final id in forwardIds) {
|
||
final name = await messagesModule.searchContactById(id);
|
||
if (name != null) {
|
||
resolved[id] = (name: name, avatar: ContactCache.getAvatar(id));
|
||
}
|
||
}
|
||
if (resolved.isEmpty || !mounted) return;
|
||
|
||
var anyChanged = false;
|
||
for (var i = 0; i < _messages.length; i++) {
|
||
final msg = _messages[i];
|
||
final attaches = msg.attachments;
|
||
if (attaches == null) continue;
|
||
|
||
var msgChanged = false;
|
||
final newAttaches = attaches.map((a) {
|
||
if (a is ForwardedMessageAttachment &&
|
||
a.originalSenderName == null &&
|
||
resolved.containsKey(a.originalSenderId)) {
|
||
final r = resolved[a.originalSenderId]!;
|
||
msgChanged = true;
|
||
return ForwardedMessageAttachment(
|
||
originalSenderId: a.originalSenderId,
|
||
originalSenderName: r.name,
|
||
originalSenderAvatar: r.avatar,
|
||
originalMessageId: a.originalMessageId,
|
||
originalTime: a.originalTime,
|
||
originalText: a.originalText,
|
||
originalChatId: a.originalChatId,
|
||
originalAttachments: a.originalAttachments,
|
||
originalContact: a.originalContact,
|
||
);
|
||
}
|
||
return a;
|
||
}).toList();
|
||
|
||
if (!msgChanged) continue;
|
||
anyChanged = true;
|
||
_messages[i] = CachedMessage(
|
||
id: msg.id,
|
||
accountId: msg.accountId,
|
||
chatId: msg.chatId,
|
||
senderId: msg.senderId,
|
||
text: msg.text,
|
||
time: msg.time,
|
||
status: msg.status,
|
||
payload: msg.payload,
|
||
attachments: newAttaches,
|
||
);
|
||
}
|
||
|
||
if (anyChanged) {
|
||
_bumpMessages();
|
||
}
|
||
}
|
||
|
||
void _scrollToBottom() {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (_scrollController.hasClients) {
|
||
_scrollController.animateTo(
|
||
_scrollController.position.minScrollExtent,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
void _startReply(CachedMessage message) {
|
||
_replyTo.value = message;
|
||
_messageFocusNode.requestFocus();
|
||
}
|
||
|
||
void _cancelReply() {
|
||
_replyTo.value = null;
|
||
}
|
||
|
||
void _openSenderProfile(int senderId) {
|
||
if (senderId == 0 || senderId == _myId) return;
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => ContactProfileScreen(
|
||
contactId: senderId,
|
||
initialName: ContactCache.get(senderId),
|
||
initialAvatarUrl: ContactCache.getAvatar(senderId),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _openStickerPack(StickerAttachment sticker) {
|
||
final stickerId = int.tryParse(sticker.stickerId ?? '');
|
||
if (stickerId == null) {
|
||
showCustomNotification(context, 'Стикерпак недоступен');
|
||
return;
|
||
}
|
||
showStickerPackSheet(
|
||
context,
|
||
stickerId: stickerId,
|
||
knownSetId: int.tryParse(sticker.stickerPackId ?? ''),
|
||
);
|
||
}
|
||
|
||
void _jumpToMessage(String messageId) {
|
||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||
if (index == -1) {
|
||
showCustomNotification(context, 'Сообщение не загружено');
|
||
return;
|
||
}
|
||
|
||
final key = _keyForMessage(messageId);
|
||
final ctx = key.currentContext;
|
||
if (ctx != null) {
|
||
Scrollable.ensureVisible(
|
||
ctx,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOut,
|
||
alignment: 0.4,
|
||
);
|
||
}
|
||
|
||
_highlightMessageId.value = messageId;
|
||
Future.delayed(const Duration(milliseconds: 1400), () {
|
||
if (_highlightMessageId.value == messageId) {
|
||
_highlightMessageId.value = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
final Map<String, GlobalKey> _messageKeys = {};
|
||
|
||
GlobalKey _keyForMessage(String messageId) =>
|
||
_messageKeys.putIfAbsent(messageId, () => GlobalKey());
|
||
|
||
void _openSearch() {
|
||
if (_searchMode.value || _selectionMode) return;
|
||
_searchMode.value = true;
|
||
_searchAnim.forward();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted && _searchMode.value) _searchFocusNode.requestFocus();
|
||
});
|
||
}
|
||
|
||
void _closeSearch() {
|
||
if (!_searchMode.value) return;
|
||
_searchDebounce?.cancel();
|
||
_searchSeq++;
|
||
_searchFocusNode.unfocus();
|
||
_searchMode.value = false;
|
||
_searchAnim.reverse();
|
||
_searchController.clear();
|
||
_searchResults.value = const [];
|
||
_searchLoading.value = false;
|
||
_searchPerformed.value = false;
|
||
}
|
||
|
||
void _onSearchTextChanged() {
|
||
final query = _searchController.text.trim();
|
||
_searchDebounce?.cancel();
|
||
if (query.isEmpty) {
|
||
_searchSeq++;
|
||
_searchResults.value = const [];
|
||
_searchLoading.value = false;
|
||
_searchPerformed.value = false;
|
||
return;
|
||
}
|
||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
||
_runSearch(query);
|
||
});
|
||
}
|
||
|
||
Future<void> _runSearch(String query) async {
|
||
final trimmed = query.trim();
|
||
if (trimmed.isEmpty) return;
|
||
final seq = ++_searchSeq;
|
||
_searchLoading.value = true;
|
||
List<Map<String, dynamic>> raw;
|
||
try {
|
||
raw = await messagesModule.searchMessages(widget.chatId, trimmed);
|
||
} catch (e) {
|
||
logger.e('Search error: $e');
|
||
raw = const [];
|
||
}
|
||
if (!mounted || seq != _searchSeq) return;
|
||
final results = raw
|
||
.map(_MessageSearchResult.fromRaw)
|
||
.whereType<_MessageSearchResult>()
|
||
.toList();
|
||
_searchResults.value = results;
|
||
_searchLoading.value = false;
|
||
_searchPerformed.value = true;
|
||
}
|
||
|
||
Future<void> _openSearchResult(_MessageSearchResult result) async {
|
||
_closeSearch();
|
||
await WidgetsBinding.instance.endOfFrame;
|
||
if (!mounted) return;
|
||
|
||
if (!_messages.any((m) => m.id == result.id)) {
|
||
var guard = 0;
|
||
while (mounted &&
|
||
guard < 60 &&
|
||
_hasMoreHistory &&
|
||
!_messages.any((m) => m.id == result.id) &&
|
||
(_messages.isEmpty || _messages.first.time > result.time)) {
|
||
guard++;
|
||
final before = _messages.isEmpty ? 0 : _messages.first.time;
|
||
await _loadMoreHistory();
|
||
if (!mounted) return;
|
||
final after = _messages.isEmpty ? 0 : _messages.first.time;
|
||
if (after == before) break;
|
||
}
|
||
if (!mounted) return;
|
||
await WidgetsBinding.instance.endOfFrame;
|
||
if (!mounted) return;
|
||
}
|
||
|
||
_scrollToLoadedMessage(result.id);
|
||
}
|
||
|
||
void _scrollToLoadedMessage(String messageId) {
|
||
if (!_scrollController.hasClients) return;
|
||
final items = _buildCombinedItems();
|
||
final pos = items.indexWhere(
|
||
(it) => it is _MessageItem && it.message.id == messageId,
|
||
);
|
||
if (pos == -1) {
|
||
showCustomNotification(context, 'Сообщение не загружено');
|
||
return;
|
||
}
|
||
|
||
var below = 0.0;
|
||
for (var i = pos + 1; i < items.length; i++) {
|
||
below += items[i] is _DateSeparatorItem ? 44.0 : _avgMessageHeight;
|
||
}
|
||
final maxExtent = _scrollController.position.maxScrollExtent;
|
||
final estimate = below.clamp(0.0, maxExtent).toDouble();
|
||
_scrollController.jumpTo(estimate);
|
||
|
||
_highlightMessageId.value = messageId;
|
||
Future.delayed(const Duration(milliseconds: 1600), () {
|
||
if (_highlightMessageId.value == messageId) {
|
||
_highlightMessageId.value = null;
|
||
}
|
||
});
|
||
WidgetsBinding.instance.addPostFrameCallback(
|
||
(_) => _ensureVisibleRetry(messageId, 0),
|
||
);
|
||
}
|
||
|
||
void _ensureVisibleRetry(String messageId, int attempt) {
|
||
if (!mounted) return;
|
||
final ctx = _keyForMessage(messageId).currentContext;
|
||
if (ctx != null) {
|
||
Scrollable.ensureVisible(
|
||
ctx,
|
||
duration: const Duration(milliseconds: 280),
|
||
curve: Curves.easeOut,
|
||
alignment: 0.4,
|
||
);
|
||
return;
|
||
}
|
||
if (attempt >= 6) return;
|
||
WidgetsBinding.instance.addPostFrameCallback(
|
||
(_) => _ensureVisibleRetry(messageId, attempt + 1),
|
||
);
|
||
}
|
||
|
||
String _searchSenderName(int senderId) {
|
||
if (senderId == _myId) return 'Вы';
|
||
final cached = ContactCache.get(senderId);
|
||
if (cached != null && cached.isNotEmpty) return cached;
|
||
if (widget.chatType == 'DIALOG') return widget.name;
|
||
return 'Пользователь';
|
||
}
|
||
|
||
String? _searchSenderAvatar(int senderId) {
|
||
final cached = ContactCache.getAvatar(senderId);
|
||
if (cached != null && cached.isNotEmpty) return cached;
|
||
if (senderId != _myId &&
|
||
widget.chatType == 'DIALOG' &&
|
||
widget.imageUrl.isNotEmpty) {
|
||
return widget.imageUrl;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Widget _buildHighlightedText(
|
||
String text,
|
||
List<String> highlights,
|
||
ColorScheme cs,
|
||
) {
|
||
final baseStyle = TextStyle(color: cs.onSurface, fontSize: 15);
|
||
final terms = highlights
|
||
.where((h) => h.trim().isNotEmpty)
|
||
.map((h) => h.toLowerCase())
|
||
.toSet();
|
||
if (text.isEmpty || terms.isEmpty) {
|
||
return Text(
|
||
text,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: baseStyle,
|
||
);
|
||
}
|
||
|
||
final lower = text.toLowerCase();
|
||
final ranges = <List<int>>[];
|
||
for (final term in terms) {
|
||
var start = 0;
|
||
while (true) {
|
||
final idx = lower.indexOf(term, start);
|
||
if (idx < 0) break;
|
||
ranges.add([idx, idx + term.length]);
|
||
start = idx + term.length;
|
||
}
|
||
}
|
||
if (ranges.isEmpty) {
|
||
return Text(
|
||
text,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: baseStyle,
|
||
);
|
||
}
|
||
|
||
ranges.sort((a, b) => a[0].compareTo(b[0]));
|
||
final merged = <List<int>>[];
|
||
for (final r in ranges) {
|
||
if (merged.isNotEmpty && r[0] <= merged.last[1]) {
|
||
merged.last[1] = math.max(merged.last[1], r[1]);
|
||
} else {
|
||
merged.add([r[0], r[1]]);
|
||
}
|
||
}
|
||
|
||
final highlightStyle = baseStyle.copyWith(
|
||
color: cs.primary,
|
||
fontWeight: FontWeight.w600,
|
||
backgroundColor: cs.primary.withValues(alpha: 0.18),
|
||
);
|
||
final spans = <TextSpan>[];
|
||
var cursor = 0;
|
||
for (final r in merged) {
|
||
if (r[0] > cursor) {
|
||
spans.add(TextSpan(text: text.substring(cursor, r[0])));
|
||
}
|
||
spans.add(
|
||
TextSpan(text: text.substring(r[0], r[1]), style: highlightStyle),
|
||
);
|
||
cursor = r[1];
|
||
}
|
||
if (cursor < text.length) {
|
||
spans.add(TextSpan(text: text.substring(cursor)));
|
||
}
|
||
|
||
return Text.rich(
|
||
TextSpan(style: baseStyle, children: spans),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
);
|
||
}
|
||
|
||
Widget _buildSearchResultTile(_MessageSearchResult r, ColorScheme cs) {
|
||
final name = _searchSenderName(r.senderId);
|
||
final date = formatDateWords(DateTime.fromMillisecondsSinceEpoch(r.time));
|
||
return InkWell(
|
||
onTap: () => _openSearchResult(r),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
KometAvatar(
|
||
name: name,
|
||
imageUrl: _searchSenderAvatar(r.senderId),
|
||
size: 44,
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: cs.primary,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: 'Outfit',
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
date,
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 2),
|
||
_buildHighlightedText(r.text, r.highlights, cs),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSearchOverlay(ColorScheme cs) {
|
||
return AnimatedBuilder(
|
||
animation: _searchAnim,
|
||
builder: (context, _) {
|
||
final s = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0));
|
||
if (s == 0) return const SizedBox.shrink();
|
||
final chrome = AppChatChrome.current.value;
|
||
final topPad = chrome == ChatChromeStyle.color
|
||
? 0.0
|
||
: MediaQuery.paddingOf(context).top;
|
||
return Positioned.fill(
|
||
child: IgnorePointer(
|
||
ignoring: s < 0.5,
|
||
child: Opacity(
|
||
opacity: s,
|
||
child: Container(
|
||
color: cs.surface,
|
||
padding: EdgeInsets.only(top: topPad),
|
||
child: _buildSearchResultsContent(cs),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildSearchResultsContent(ColorScheme cs) {
|
||
return ValueListenableBuilder<bool>(
|
||
valueListenable: _searchLoading,
|
||
builder: (context, loading, _) =>
|
||
ValueListenableBuilder<List<_MessageSearchResult>>(
|
||
valueListenable: _searchResults,
|
||
builder: (context, results, _) {
|
||
if (results.isNotEmpty) {
|
||
return ListView.builder(
|
||
keyboardDismissBehavior:
|
||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||
padding: EdgeInsets.only(
|
||
top: 4,
|
||
bottom: MediaQuery.paddingOf(context).bottom + 16,
|
||
),
|
||
itemCount: results.length,
|
||
itemBuilder: (context, index) =>
|
||
_buildSearchResultTile(results[index], cs),
|
||
);
|
||
}
|
||
if (loading) {
|
||
return Center(
|
||
child: SizedBox(
|
||
width: 26,
|
||
height: 26,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2.4,
|
||
color: cs.onSurfaceVariant,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return ValueListenableBuilder<bool>(
|
||
valueListenable: _searchPerformed,
|
||
builder: (context, performed, _) {
|
||
if (!performed) return const SizedBox.shrink();
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Text(
|
||
'Поиск ничего не вернул...',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 16,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
List<Object> _buildCombinedItems() {
|
||
final key = Object.hash(_messagesRev.value, _messages.length);
|
||
final cached = _combinedItemsCache;
|
||
if (cached != null && _combinedItemsKey == key) return cached;
|
||
|
||
final List<Object> items = [];
|
||
final Set<int> usedDates = {};
|
||
|
||
for (int i = 0; i < _messages.length; i++) {
|
||
final msg = _messages[i];
|
||
final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time);
|
||
final dayMillis = DateTime(
|
||
msgDate.year,
|
||
msgDate.month,
|
||
msgDate.day,
|
||
).millisecondsSinceEpoch;
|
||
|
||
bool needSeparator = i == 0;
|
||
if (!needSeparator) {
|
||
final prevDate = DateTime.fromMillisecondsSinceEpoch(
|
||
_messages[i - 1].time,
|
||
);
|
||
final prevDayMillis = DateTime(
|
||
prevDate.year,
|
||
prevDate.month,
|
||
prevDate.day,
|
||
).millisecondsSinceEpoch;
|
||
needSeparator = dayMillis != prevDayMillis;
|
||
}
|
||
|
||
if (needSeparator) {
|
||
_separatorKeys.putIfAbsent(dayMillis, () => GlobalKey());
|
||
usedDates.add(dayMillis);
|
||
items.add(
|
||
_DateSeparatorItem(
|
||
DateTime.fromMillisecondsSinceEpoch(dayMillis),
|
||
_separatorKeys[dayMillis]!,
|
||
),
|
||
);
|
||
}
|
||
|
||
items.add(_MessageItem(msg, i));
|
||
}
|
||
|
||
_separatorKeys.removeWhere((k, _) => !usedDates.contains(k));
|
||
_combinedItemsCache = items;
|
||
_combinedItemsKey = key;
|
||
return items;
|
||
}
|
||
|
||
void _onScrollForDate() {
|
||
if (!_scrollController.hasClients) return;
|
||
|
||
_floatingDateTimer?.cancel();
|
||
_floatingDateTimer = Timer(const Duration(seconds: 1), () {
|
||
if (mounted) _floatingDateAnimController.reverse();
|
||
});
|
||
|
||
if (_floatingDateScheduled) return;
|
||
_floatingDateScheduled = true;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_floatingDateScheduled = false;
|
||
_updateFloatingDate();
|
||
});
|
||
}
|
||
|
||
void _updateFloatingDate() {
|
||
if (!mounted || _separatorKeys.isEmpty) return;
|
||
DateTime? result;
|
||
|
||
final listRenderBox = _listKey.currentContext?.findRenderObject();
|
||
if (listRenderBox is! RenderBox) return;
|
||
|
||
_separatorKeys.forEach((dayMillis, gkey) {
|
||
final ctx = gkey.currentContext;
|
||
if (ctx == null) return;
|
||
final box = ctx.findRenderObject();
|
||
if (box is! RenderBox) return;
|
||
final pos = box.localToGlobal(Offset.zero, ancestor: listRenderBox);
|
||
if (pos.dy + box.size.height < 4) {
|
||
final date = DateTime.fromMillisecondsSinceEpoch(dayMillis);
|
||
if (result == null || date.isAfter(result!)) {
|
||
result = date;
|
||
}
|
||
}
|
||
});
|
||
|
||
if (result == null) return;
|
||
|
||
final bool dateChanged = result != _floatingDate.value;
|
||
_floatingDate.value = result;
|
||
|
||
if (dateChanged) {
|
||
_floatingDateAnimController.forward(from: 0);
|
||
} else {
|
||
_floatingDateAnimController.forward();
|
||
}
|
||
}
|
||
|
||
String _formatDateLabel(DateTime date) {
|
||
final now = DateTime.now();
|
||
final today = DateTime(now.year, now.month, now.day);
|
||
final yesterday = today.subtract(const Duration(days: 1));
|
||
final d = DateTime(date.year, date.month, date.day);
|
||
|
||
if (d == today) return 'Сегодня';
|
||
if (d == yesterday) return 'Вчера';
|
||
|
||
const months = [
|
||
'января',
|
||
'февраля',
|
||
'марта',
|
||
'апреля',
|
||
'мая',
|
||
'июня',
|
||
'июля',
|
||
'августа',
|
||
'сентября',
|
||
'октября',
|
||
'ноября',
|
||
'декабря',
|
||
];
|
||
if (date.year == now.year) {
|
||
return '${date.day} ${months[date.month - 1]}';
|
||
}
|
||
return '${date.day} ${months[date.month - 1]} ${date.year}';
|
||
}
|
||
|
||
Widget _buildDateSeparatorWidget(
|
||
BuildContext context,
|
||
DateTime date, {
|
||
Key? key,
|
||
bool floating = false,
|
||
}) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
return Padding(
|
||
key: key,
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
child: Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: cs.surfaceContainerHighest,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text(
|
||
_formatDateLabel(date),
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 12,
|
||
fontStyle: floating ? FontStyle.normal : FontStyle.italic,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = _prankActive
|
||
? _prankPinkTheme(Theme.of(context))
|
||
: Theme.of(context);
|
||
final cs = theme.colorScheme;
|
||
final underlap = AppChatChrome.current.value != ChatChromeStyle.color;
|
||
|
||
// TODO: Локализация
|
||
// TODO: Cклонения
|
||
final mq = MediaQuery.of(context);
|
||
final bottomInset = _keyboardReserve > 0
|
||
? math.max(mq.viewInsets.bottom, _keyboardReserve)
|
||
: mq.viewInsets.bottom;
|
||
return ListenableBuilder(
|
||
listenable: Listenable.merge([_selectedIds, _searchMode]),
|
||
builder: (context, child) => PopScope(
|
||
canPop: _selectedIds.value.isEmpty && !_searchMode.value,
|
||
onPopInvokedWithResult: (didPop, _) {
|
||
if (didPop) return;
|
||
if (_searchMode.value) {
|
||
_closeSearch();
|
||
} else {
|
||
_clearSelection();
|
||
}
|
||
},
|
||
child: child!,
|
||
),
|
||
child: MediaQuery(
|
||
data: mq.copyWith(
|
||
viewInsets: mq.viewInsets.copyWith(bottom: bottomInset),
|
||
),
|
||
child: Theme(
|
||
data: theme,
|
||
child: RepaintBoundary(
|
||
key: _prankCaptureKey,
|
||
child: ValueListenableBuilder<bool>(
|
||
valueListenable: AppSwipeBackDesktop.current,
|
||
builder: (context, desktopSwipe, child) => SwipeToPop(
|
||
enabled: widget.embedded && desktopSwipe,
|
||
onPop: widget.onClose,
|
||
child: child!,
|
||
),
|
||
child: AnimatedBuilder(
|
||
animation: _searchAnim,
|
||
child: underlap ? _buildUnderlapBody() : _buildColorBody(),
|
||
builder: (context, body) => Scaffold(
|
||
backgroundColor: cs.surface,
|
||
extendBodyBehindAppBar: underlap,
|
||
appBar: _buildAppBar(cs),
|
||
body: body,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildColorBody() {
|
||
final cs = Theme.of(context).colorScheme;
|
||
return Column(
|
||
children: [
|
||
Expanded(
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
if (_wallpaper != null)
|
||
Positioned.fill(
|
||
child: ChatWallpaperView(wallpaper: _wallpaper!),
|
||
),
|
||
Positioned.fill(
|
||
child: _isLoading && _messages.isEmpty
|
||
? _buildShimmerLoading()
|
||
: _buildMessagesList(),
|
||
),
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: _buildCommandPanel(),
|
||
),
|
||
_buildSearchOverlay(cs),
|
||
],
|
||
),
|
||
),
|
||
_buildComposerArea(context),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildUnderlapBody() {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final vignette = AppChatChrome.current.value == ChatChromeStyle.none;
|
||
return Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
if (_wallpaper != null)
|
||
Positioned.fill(
|
||
child: ChatWallpaperView(wallpaper: _wallpaper!),
|
||
),
|
||
Positioned.fill(
|
||
child: _isLoading && _messages.isEmpty
|
||
? _buildShimmerLoading()
|
||
: _buildMessagesList(),
|
||
),
|
||
_buildSearchOverlay(cs),
|
||
if (vignette) ...[
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: _buildEdgeVignette(cs, top: true),
|
||
),
|
||
ValueListenableBuilder<double>(
|
||
valueListenable: _composerHeight,
|
||
builder: (context, height, _) => Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: _buildEdgeVignette(cs, top: false, height: height),
|
||
),
|
||
),
|
||
],
|
||
ValueListenableBuilder<double>(
|
||
valueListenable: _composerHeight,
|
||
builder: (context, height, _) => Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: height,
|
||
child: _buildCommandPanel(),
|
||
),
|
||
),
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: Builder(
|
||
builder: (context) => MediaQuery.removePadding(
|
||
context: context,
|
||
removeTop: true,
|
||
child: _MeasureSize(
|
||
onHeight: (value) => _composerHeight.value = value,
|
||
child: _buildComposerArea(context),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildEdgeVignette(ColorScheme cs, {required bool top, double? height}) {
|
||
final double resolved;
|
||
if (height != null) {
|
||
resolved = height;
|
||
} else {
|
||
final glossy = AppVisualStyle.current.value == VisualStyle.glossy;
|
||
resolved = MediaQuery.paddingOf(context).top +
|
||
(glossy ? _glossyHeaderHeight : kToolbarHeight);
|
||
}
|
||
return IgnorePointer(
|
||
child: Container(
|
||
height: resolved,
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: top ? Alignment.topCenter : Alignment.bottomCenter,
|
||
end: top ? Alignment.bottomCenter : Alignment.topCenter,
|
||
colors: [cs.surface, cs.surface.withValues(alpha: 0.0)],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMessagesList() {
|
||
return ValueListenableBuilder<int>(
|
||
valueListenable: _messagesRev,
|
||
builder: (context, _, _) => _buildMessagesListContent(),
|
||
);
|
||
}
|
||
|
||
EdgeInsets _messagesListPadding(BuildContext context) {
|
||
if (AppChatChrome.current.value == ChatChromeStyle.color) {
|
||
return const EdgeInsets.symmetric(vertical: 8);
|
||
}
|
||
final topInset = MediaQuery.paddingOf(context).top;
|
||
return EdgeInsets.only(
|
||
top: topInset + 8,
|
||
bottom: _composerHeight.value + 8,
|
||
);
|
||
}
|
||
|
||
double _floatingDateTop(BuildContext context) {
|
||
final glossy = AppVisualStyle.current.value == VisualStyle.glossy;
|
||
if (AppChatChrome.current.value == ChatChromeStyle.color) {
|
||
return glossy ? 2 : 4;
|
||
}
|
||
return MediaQuery.paddingOf(context).top +
|
||
(glossy ? _glossyHeaderHeight - 16 : kToolbarHeight) +
|
||
4;
|
||
}
|
||
|
||
Widget _buildLoadMoreIndicator() {
|
||
final cs = Theme.of(context).colorScheme;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
child: Center(
|
||
child: SizedBox(
|
||
width: 22,
|
||
height: 22,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2.2,
|
||
color: cs.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMessagesListContent() {
|
||
if (_messages.isEmpty) {
|
||
return Center(
|
||
child: Text(
|
||
'No messages yet',
|
||
style: TextStyle(
|
||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
final items = _buildCombinedItems();
|
||
|
||
return Stack(
|
||
key: _listKey,
|
||
children: [
|
||
ListenableBuilder(
|
||
listenable: Listenable.merge([_otherReadTime, _composerHeight]),
|
||
builder: (context, _) => ValueListenableBuilder<double>(
|
||
valueListenable: AppCacheExtent.current,
|
||
builder: (context, cacheExtent, _) => ListView.builder(
|
||
controller: _scrollController,
|
||
reverse: true,
|
||
padding: _messagesListPadding(context),
|
||
cacheExtent: cacheExtent,
|
||
itemCount: items.length + (_isLoadingMore ? 1 : 0),
|
||
itemBuilder: (context, index) {
|
||
if (index >= items.length) {
|
||
return _buildLoadMoreIndicator();
|
||
}
|
||
final item = items[items.length - 1 - index];
|
||
|
||
if (item is _DateSeparatorItem) {
|
||
return _buildDateSeparatorWidget(
|
||
context,
|
||
item.date,
|
||
key: item.key,
|
||
);
|
||
}
|
||
|
||
final msgItem = item as _MessageItem;
|
||
final message = msgItem.message;
|
||
final msgIndex = msgItem.index;
|
||
final isMe = message.senderId == _myId;
|
||
final prevMessage = msgIndex > 0
|
||
? _messages[msgIndex - 1]
|
||
: null;
|
||
final nextMessage = msgIndex < _messages.length - 1
|
||
? _messages[msgIndex + 1]
|
||
: null;
|
||
|
||
final bubble = MessageBubble(
|
||
message: message,
|
||
isMe: isMe,
|
||
myId: _myId,
|
||
prevMessage: prevMessage,
|
||
nextMessage: nextMessage,
|
||
chatType: chat?.type ?? 'CHAT',
|
||
overrideStatus: _effectiveStatus(message),
|
||
reactionsListenable: _reactionNotifierFor(message),
|
||
uploadProgress: _photoProgressFor(message),
|
||
onReplyTap: _jumpToMessage,
|
||
onAvatarTap: _openSenderProfile,
|
||
onStickerTap: _openStickerPack,
|
||
);
|
||
|
||
final canReport = !isMe && !message.isControl;
|
||
final reportTypeId = _complaintTypeId(
|
||
chat?.type ?? widget.chatType,
|
||
);
|
||
|
||
final pressable = _SelectableMessageRow(
|
||
message: message,
|
||
isMe: isMe,
|
||
selectedIds: _selectedIds,
|
||
selectionAnim: _selectionAnim,
|
||
isSelectionActive: () => _selectionMode,
|
||
onToggleSelection: () => _toggleSelection(message),
|
||
onEnterSelection: () => _enterSelection(message),
|
||
onDelete: () => _confirmDeleteMessage(message, isMe),
|
||
onEdit: _canEditMessage(message)
|
||
? () => _startEditMessage(message)
|
||
: null,
|
||
onReply: message.isControl
|
||
? null
|
||
: () => _startReply(message),
|
||
onForward: message.isControl
|
||
? null
|
||
: () => _forwardMessages([message]),
|
||
onMarkUnread: message.isControl
|
||
? null
|
||
: () => _markMessageUnread(message),
|
||
loadReportReasons: canReport
|
||
? () => _loadReportReasons(reportTypeId)
|
||
: null,
|
||
onReport: canReport
|
||
? (reasonId) =>
|
||
_reportMessage(message, reportTypeId, reasonId)
|
||
: null,
|
||
child: bubble,
|
||
);
|
||
|
||
final isChannel =
|
||
(chat?.type ?? widget.chatType) == 'CHANNEL';
|
||
final swipeable = (message.isControl || isChannel)
|
||
? pressable
|
||
: _SwipeToReply(
|
||
isMe: isMe,
|
||
onReply: () => _startReply(message),
|
||
child: pressable,
|
||
);
|
||
|
||
final Widget child;
|
||
if (_deletingIds.contains(message.id)) {
|
||
child = _DeletingMessageAnimation(
|
||
key: ValueKey('del_${message.id}'),
|
||
onComplete: () => _finalizeDelete(message.id),
|
||
child: IgnorePointer(child: swipeable),
|
||
);
|
||
} else if (message.id == _lastSentId) {
|
||
child = _SentMessageAnimation(
|
||
key: ValueKey('anim_${message.id}'),
|
||
onComplete: () {
|
||
if (mounted) {
|
||
_lastSentId = null;
|
||
_bumpMessages();
|
||
}
|
||
},
|
||
child: swipeable,
|
||
);
|
||
} else {
|
||
child = swipeable;
|
||
}
|
||
|
||
final highlightable = ValueListenableBuilder<String?>(
|
||
valueListenable: _highlightMessageId,
|
||
builder: (context, hl, c) => AnimatedContainer(
|
||
duration: const Duration(milliseconds: 250),
|
||
color: hl == message.id
|
||
? Theme.of(
|
||
context,
|
||
).colorScheme.primary.withValues(alpha: 0.12)
|
||
: Colors.transparent,
|
||
child: c,
|
||
),
|
||
child: child,
|
||
);
|
||
|
||
final builtItem = RepaintBoundary(
|
||
key: ValueKey('msg_${message.id}'),
|
||
child: KeyedSubtree(
|
||
key: _keyForMessage(message.id),
|
||
child: highlightable,
|
||
),
|
||
);
|
||
return message.id == _prankBubbleId
|
||
? KeyedSubtree(key: _prankBubbleKey, child: builtItem)
|
||
: builtItem;
|
||
},
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
top: _floatingDateTop(context),
|
||
left: 0,
|
||
right: 0,
|
||
child: IgnorePointer(
|
||
child: ValueListenableBuilder<DateTime?>(
|
||
valueListenable: _floatingDate,
|
||
builder: (context, date, _) {
|
||
if (date == null) return const SizedBox.shrink();
|
||
return AnimatedBuilder(
|
||
animation: _floatingDateCurved,
|
||
builder: (context, child) {
|
||
final t = _floatingDateCurved.value;
|
||
return Opacity(
|
||
opacity: t,
|
||
child: Transform.scale(
|
||
scale: 0.82 + 0.18 * t,
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
child: _buildDateSeparatorWidget(
|
||
context,
|
||
date,
|
||
floating: true,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildShimmerLoading() {
|
||
return AnimatedBuilder(
|
||
animation: _shimmerController,
|
||
builder: (context, child) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final placeholder = cs.surfaceContainerHighest;
|
||
final opacity = 0.3 + (0.4 * _shimmerController.value);
|
||
return ListView.builder(
|
||
padding: const EdgeInsets.all(16),
|
||
itemCount: 8,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
itemBuilder: (context, index) {
|
||
final hasImage = index % 3 == 0;
|
||
final hasReactions = index % 2 == 0;
|
||
final width1 = 60.0 + (index * 15 % 50);
|
||
final width2 = 120.0 + (index * 25 % 80);
|
||
|
||
return Opacity(
|
||
opacity: opacity,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(bottom: 16.0),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: placeholder,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: width1,
|
||
height: 10,
|
||
decoration: BoxDecoration(
|
||
color: placeholder,
|
||
borderRadius: BorderRadius.circular(5),
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Container(
|
||
width: width2,
|
||
height: 32,
|
||
decoration: BoxDecoration(
|
||
color: placeholder,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
),
|
||
if (hasImage) ...[
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
width: double.infinity,
|
||
height: 120,
|
||
decoration: BoxDecoration(
|
||
color: placeholder,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
),
|
||
],
|
||
if (hasReactions) ...[
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: List.generate(
|
||
3,
|
||
(i) => Container(
|
||
width: 32,
|
||
height: 16,
|
||
margin: const EdgeInsets.only(right: 6),
|
||
decoration: BoxDecoration(
|
||
color: placeholder,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
static const int _voiceMinMs = 800;
|
||
static const double _voiceCancelThreshold = 110;
|
||
static const double _voiceLockThreshold = 90;
|
||
|
||
Future<void> _startVoiceRecording() async {
|
||
if (_isRecordingVoice.value || _myId == 0) return;
|
||
_voiceStopRequested = false;
|
||
final rec = _voiceRecorder ??= AudioRecorder();
|
||
try {
|
||
final AudioEncoder encoder;
|
||
final String ext;
|
||
// Предпочитаем собственный кодер (libopus → Ogg/Opus): его формат сервер
|
||
// гарантированно принимает. Нативный Opus от record (напр. на Android)
|
||
// CDN не дообрабатывает — остаётся attachment.not.ready.
|
||
if (await OpusOggEncoder.ensureAvailable() &&
|
||
await rec.isEncoderSupported(AudioEncoder.wav)) {
|
||
encoder = AudioEncoder.wav;
|
||
ext = 'wav';
|
||
_voiceTranscode = true;
|
||
} else if (await rec.isEncoderSupported(AudioEncoder.opus)) {
|
||
encoder = AudioEncoder.opus;
|
||
ext = 'ogg';
|
||
_voiceTranscode = false;
|
||
} else {
|
||
if (mounted) {
|
||
showCustomNotification(
|
||
context,
|
||
'Голосовые сообщения недоступны на этой платформе',
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (!await rec.hasPermission()) {
|
||
if (mounted) showCustomNotification(context, 'Нет доступа к микрофону');
|
||
return;
|
||
}
|
||
final dir = await getTemporaryDirectory();
|
||
final path =
|
||
'${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.$ext';
|
||
_voiceAmps.clear();
|
||
_voiceCancelled = false;
|
||
_voicePath = path;
|
||
await rec.start(
|
||
RecordConfig(
|
||
encoder: encoder,
|
||
numChannels: 1,
|
||
sampleRate: 48000,
|
||
),
|
||
path: path,
|
||
);
|
||
if (!mounted) {
|
||
try {
|
||
await rec.stop();
|
||
} catch (_) {}
|
||
return;
|
||
}
|
||
_voiceStopwatch
|
||
..reset()
|
||
..start();
|
||
_voiceElapsedMs.value = 0;
|
||
_voiceCancelDrag.value = 0;
|
||
_voiceLocked.value = false;
|
||
_voiceLockDrag.value = 0;
|
||
_isRecordingVoice.value = true;
|
||
FocusManager.instance.primaryFocus?.unfocus();
|
||
Haptics.send();
|
||
_voiceTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||
_voiceElapsedMs.value = _voiceStopwatch.elapsedMilliseconds;
|
||
});
|
||
_voiceAmpSub = rec
|
||
.onAmplitudeChanged(const Duration(milliseconds: 70))
|
||
.listen((amp) {
|
||
final norm = ((amp.current + 45) / 45).clamp(0.0, 1.0);
|
||
_voiceAmps.add(norm);
|
||
_voiceAmplitude.value = norm;
|
||
_voiceWaveRev.value++;
|
||
});
|
||
if (_voiceStopRequested) {
|
||
_voiceStopRequested = false;
|
||
await _stopVoiceRecording(cancel: false);
|
||
}
|
||
} catch (_) {
|
||
_isRecordingVoice.value = false;
|
||
if (mounted) showCustomNotification(context, 'Не удалось начать запись');
|
||
}
|
||
}
|
||
|
||
void _handleVoiceDrag(Offset offsetFromOrigin) {
|
||
if (!_isRecordingVoice.value || _voiceLocked.value) return;
|
||
|
||
final lock = (-offsetFromOrigin.dy / _voiceLockThreshold).clamp(0.0, 1.0);
|
||
_voiceLockDrag.value = lock;
|
||
if (lock >= 1.0) {
|
||
_voiceLocked.value = true;
|
||
_voiceLockDrag.value = 0;
|
||
_voiceCancelDrag.value = 0;
|
||
Haptics.send();
|
||
return;
|
||
}
|
||
|
||
final drag = (-offsetFromOrigin.dx / _voiceCancelThreshold).clamp(0.0, 1.0);
|
||
_voiceCancelDrag.value = drag;
|
||
if (drag >= 1.0 && !_voiceCancelled) {
|
||
_voiceCancelled = true;
|
||
Haptics.error();
|
||
_stopVoiceRecording(cancel: true);
|
||
}
|
||
}
|
||
|
||
void _handleVoiceEnd() {
|
||
if (_voiceLocked.value) return;
|
||
_stopVoiceRecording(cancel: false);
|
||
}
|
||
|
||
Future<void> _stopVoiceRecording({required bool cancel}) async {
|
||
if (!_isRecordingVoice.value) {
|
||
_voiceStopRequested = true;
|
||
return;
|
||
}
|
||
final rec = _voiceRecorder;
|
||
if (rec == null) {
|
||
_isRecordingVoice.value = false;
|
||
return;
|
||
}
|
||
|
||
_voiceTimer?.cancel();
|
||
_voiceTimer = null;
|
||
await _voiceAmpSub?.cancel();
|
||
_voiceAmpSub = null;
|
||
_voiceStopwatch.stop();
|
||
final elapsed = _voiceStopwatch.elapsedMilliseconds;
|
||
_isRecordingVoice.value = false;
|
||
_voiceCancelDrag.value = 0;
|
||
_voiceAmplitude.value = 0;
|
||
_voiceLocked.value = false;
|
||
_voiceLockDrag.value = 0;
|
||
|
||
String? path;
|
||
try {
|
||
path = await rec.stop();
|
||
} catch (_) {}
|
||
path ??= _voicePath;
|
||
final amps = List<double>.from(_voiceAmps);
|
||
_voiceAmps.clear();
|
||
|
||
final shouldCancel = cancel || _voiceCancelled || elapsed < _voiceMinMs;
|
||
if (shouldCancel || path == null) {
|
||
if (path != null) {
|
||
try {
|
||
await File(path).delete();
|
||
} catch (_) {}
|
||
}
|
||
return;
|
||
}
|
||
|
||
var file = File(path);
|
||
if (_voiceTranscode) {
|
||
final ogg = await _transcodeWavToOgg(file);
|
||
if (ogg == null) {
|
||
if (mounted) {
|
||
showCustomNotification(context, 'Не удалось закодировать запись');
|
||
}
|
||
return;
|
||
}
|
||
file = ogg;
|
||
}
|
||
await _sendVoice(file, elapsed, amps);
|
||
}
|
||
|
||
Future<File?> _transcodeWavToOgg(File wav) async {
|
||
try {
|
||
final bytes = await wav.readAsBytes();
|
||
final ogg = await OpusOggEncoder.wavToOggOpus(bytes);
|
||
try {
|
||
await wav.delete();
|
||
} catch (_) {}
|
||
if (ogg == null) return null;
|
||
final oggPath = '${wav.path.substring(0, wav.path.length - 3)}ogg';
|
||
final out = File(oggPath);
|
||
await out.writeAsBytes(ogg, flush: true);
|
||
return out;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Uint8List _buildWave(List<double> amps, {int bars = 80}) {
|
||
final out = Uint8List(bars);
|
||
if (amps.isEmpty) return out;
|
||
for (var i = 0; i < bars; i++) {
|
||
final start = (i * amps.length / bars).floor();
|
||
final end = (((i + 1) * amps.length / bars).ceil()).clamp(
|
||
start + 1,
|
||
amps.length,
|
||
);
|
||
var peak = 0.0;
|
||
for (var j = start; j < end; j++) {
|
||
if (amps[j] > peak) peak = amps[j];
|
||
}
|
||
out[i] = (peak * 120).round().clamp(0, 120);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
Future<void> _sendVoice(File file, int durationMs, List<double> amps) async {
|
||
if (_myId == 0) {
|
||
try {
|
||
await file.delete();
|
||
} catch (_) {}
|
||
return;
|
||
}
|
||
final wave = _buildWave(amps);
|
||
final tempId = _nextTempId();
|
||
final progress = ValueNotifier<List<double>>(const [0]);
|
||
_photoUploadProgress[tempId] = progress;
|
||
_messages.add(
|
||
CachedMessage(
|
||
id: tempId,
|
||
accountId: _myId,
|
||
chatId: widget.chatId,
|
||
senderId: _myId,
|
||
time: DateTime.now().millisecondsSinceEpoch,
|
||
status: 'sending',
|
||
attachments: [AudioAttachment(duration: durationMs)],
|
||
),
|
||
);
|
||
_lastSentId = tempId;
|
||
_bumpMessages();
|
||
Haptics.send();
|
||
_scrollToBottom();
|
||
|
||
try {
|
||
try {
|
||
final len = await file.length();
|
||
final head = await file
|
||
.openRead(0, 80)
|
||
.fold<List<int>>([], (a, b) => a..addAll(b));
|
||
final hex = head.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||
final ascii = String.fromCharCodes(
|
||
head.map((b) => (b >= 32 && b < 127) ? b : 46),
|
||
);
|
||
logger.w('VOICE size=$len hex=$hex ascii=$ascii');
|
||
} catch (_) {}
|
||
final info = await messagesModule.requestAudioUploadUrl();
|
||
if (info == null || info.url.isEmpty) throw Exception('no_url');
|
||
|
||
final ok = await fileUploader.uploadMediaFile(
|
||
Uri.parse(info.url),
|
||
file,
|
||
onProgress: (sent, total) {
|
||
if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)];
|
||
},
|
||
);
|
||
if (!ok) throw Exception('upload_failed');
|
||
if (!mounted) {
|
||
_disposePhotoProgress(tempId);
|
||
return;
|
||
}
|
||
|
||
final serverMsg = await messagesModule.sendAudioMessage(
|
||
widget.chatId,
|
||
info.token,
|
||
duration: durationMs,
|
||
wave: wave,
|
||
);
|
||
if (!mounted) {
|
||
_disposePhotoProgress(tempId);
|
||
return;
|
||
}
|
||
if (serverMsg == null) throw Exception('send_failed');
|
||
|
||
final real = CachedMessage.fromPushPayload(_myId, widget.chatId, serverMsg);
|
||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||
if (idx != -1) {
|
||
_messages[idx] = real;
|
||
_bumpMessages();
|
||
unawaited(_persistOutgoing(real, removeId: tempId));
|
||
}
|
||
_disposePhotoProgress(tempId);
|
||
} catch (_) {
|
||
if (mounted) {
|
||
_failPhotoMessage(tempId);
|
||
} else {
|
||
_disposePhotoProgress(tempId);
|
||
}
|
||
} finally {
|
||
try {
|
||
await file.delete();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
// ── Видеосообщения-кружки ──────────────────────────────────────────
|
||
Future<void> _toggleComposerMode() async {
|
||
final toVideo = !_videoNoteMode.value;
|
||
_videoNoteMode.value = toVideo;
|
||
Haptics.tap();
|
||
if (toVideo) {
|
||
await _initNoteCamera();
|
||
} else {
|
||
await _disposeNoteCamera();
|
||
}
|
||
}
|
||
|
||
Future<void> _initNoteCamera() async {
|
||
if (_noteRec.textureId != null) return;
|
||
if (!_noteRec.isAvailable) {
|
||
if (mounted) showCustomNotification(context, 'Камера недоступна');
|
||
return;
|
||
}
|
||
try {
|
||
final ok = await _noteRec.init();
|
||
if (!ok) {
|
||
if (mounted) showCustomNotification(context, 'Камера недоступна');
|
||
return;
|
||
}
|
||
if (!mounted || !_videoNoteMode.value) {
|
||
await _disposeNoteCamera();
|
||
return;
|
||
}
|
||
_noteTextureId.value = _noteRec.textureId;
|
||
_noteCamReady.value = true;
|
||
} catch (e) {
|
||
logger.w('initNoteCamera: $e');
|
||
if (mounted) showCustomNotification(context, 'Камера недоступна');
|
||
}
|
||
}
|
||
|
||
Future<void> _disposeNoteCamera() async {
|
||
_noteCamReady.value = false;
|
||
_noteTextureId.value = null;
|
||
await _noteRec.dispose();
|
||
}
|
||
|
||
Future<void> _startNoteRecording() async {
|
||
if (_isRecordingNote.value) return;
|
||
_noteStopRequested = false;
|
||
if (_noteRec.textureId == null) {
|
||
await _initNoteCamera();
|
||
return;
|
||
}
|
||
try {
|
||
final ok = await _noteRec.start();
|
||
if (!ok) {
|
||
_isRecordingNote.value = false;
|
||
return;
|
||
}
|
||
if (!mounted) {
|
||
await _noteRec.stop();
|
||
return;
|
||
}
|
||
_noteStopwatch
|
||
..reset()
|
||
..start();
|
||
_noteElapsedMs.value = 0;
|
||
_noteCancelDrag.value = 0;
|
||
_noteCancelled = false;
|
||
_isRecordingNote.value = true;
|
||
FocusManager.instance.primaryFocus?.unfocus();
|
||
Haptics.send();
|
||
_noteTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||
_noteElapsedMs.value = _noteStopwatch.elapsedMilliseconds;
|
||
});
|
||
_showNoteOverlay();
|
||
if (_noteStopRequested) {
|
||
_noteStopRequested = false;
|
||
await _stopNoteRecording(cancel: false);
|
||
}
|
||
} catch (e) {
|
||
logger.w('startNoteRecording: $e');
|
||
_isRecordingNote.value = false;
|
||
}
|
||
}
|
||
|
||
void _handleNoteDrag(Offset offsetFromOrigin) {
|
||
if (!_isRecordingNote.value) return;
|
||
final drag = (-offsetFromOrigin.dx / _voiceCancelThreshold).clamp(0.0, 1.0);
|
||
_noteCancelDrag.value = drag;
|
||
if (drag >= 1.0 && !_noteCancelled) {
|
||
_noteCancelled = true;
|
||
Haptics.error();
|
||
_stopNoteRecording(cancel: true);
|
||
}
|
||
}
|
||
|
||
void _handleNoteEnd() => _stopNoteRecording(cancel: false);
|
||
|
||
Future<void> _stopNoteRecording({required bool cancel}) async {
|
||
if (!_isRecordingNote.value) {
|
||
_noteStopRequested = true;
|
||
return;
|
||
}
|
||
_noteTimer?.cancel();
|
||
_noteTimer = null;
|
||
_noteStopwatch.stop();
|
||
final elapsed = _noteStopwatch.elapsedMilliseconds;
|
||
_isRecordingNote.value = false;
|
||
_noteCancelDrag.value = 0;
|
||
_hideNoteOverlay();
|
||
|
||
final path = await _noteRec.stop();
|
||
|
||
final shouldCancel = cancel || _noteCancelled || elapsed < _voiceMinMs;
|
||
if (shouldCancel || path == null) {
|
||
if (path != null) {
|
||
try {
|
||
await File(path).delete();
|
||
} catch (_) {}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Файл уже квадратный 480×480 (нативная запись) — шлём как есть.
|
||
await _sendVideoNote(File(path), elapsed);
|
||
}
|
||
|
||
Future<void> _sendVideoNote(File file, int durationMs) async {
|
||
if (_myId == 0) {
|
||
try {
|
||
await file.delete();
|
||
} catch (_) {}
|
||
return;
|
||
}
|
||
final tempId = _nextTempId();
|
||
final progress = ValueNotifier<List<double>>(const [0]);
|
||
_photoUploadProgress[tempId] = progress;
|
||
_messages.add(
|
||
CachedMessage(
|
||
id: tempId,
|
||
accountId: _myId,
|
||
chatId: widget.chatId,
|
||
senderId: _myId,
|
||
time: DateTime.now().millisecondsSinceEpoch,
|
||
status: 'sending',
|
||
attachments: [VideoAttachment(duration: durationMs, videoType: 1)],
|
||
),
|
||
);
|
||
_lastSentId = tempId;
|
||
_bumpMessages();
|
||
Haptics.send();
|
||
_scrollToBottom();
|
||
|
||
try {
|
||
final info = await messagesModule.requestVideoNoteUploadUrl();
|
||
if (info == null || info.url.isEmpty) throw Exception('no_url');
|
||
final ok = await fileUploader.uploadMediaFile(
|
||
Uri.parse(info.url),
|
||
file,
|
||
onProgress: (sent, total) {
|
||
if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)];
|
||
},
|
||
);
|
||
if (!ok) throw Exception('upload_failed');
|
||
if (!mounted) {
|
||
_disposePhotoProgress(tempId);
|
||
return;
|
||
}
|
||
final serverMsg = await messagesModule.sendVideoNoteMessage(
|
||
widget.chatId,
|
||
info.token,
|
||
duration: durationMs,
|
||
);
|
||
if (!mounted) {
|
||
_disposePhotoProgress(tempId);
|
||
return;
|
||
}
|
||
if (serverMsg == null) throw Exception('send_failed');
|
||
final real = CachedMessage.fromPushPayload(_myId, widget.chatId, serverMsg);
|
||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||
if (idx != -1) {
|
||
_messages[idx] = real;
|
||
_bumpMessages();
|
||
unawaited(_persistOutgoing(real, removeId: tempId));
|
||
}
|
||
_disposePhotoProgress(tempId);
|
||
} catch (_) {
|
||
if (mounted) {
|
||
_failPhotoMessage(tempId);
|
||
} else {
|
||
_disposePhotoProgress(tempId);
|
||
}
|
||
} finally {
|
||
try {
|
||
await file.delete();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
OverlayEntry? _noteOverlay;
|
||
|
||
void _showNoteOverlay() {
|
||
_noteOverlay?.remove();
|
||
_noteOverlay = OverlayEntry(
|
||
builder: (context) {
|
||
final texId = _noteTextureId.value;
|
||
return Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: Container(
|
||
color: Colors.black.withValues(alpha: 0.55),
|
||
alignment: Alignment.center,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ClipOval(
|
||
child: SizedBox(
|
||
width: 260,
|
||
height: 260,
|
||
child: texId != null
|
||
? Texture(textureId: texId)
|
||
: Container(color: Colors.black),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
ValueListenableBuilder<int>(
|
||
valueListenable: _noteElapsedMs,
|
||
builder: (context, ms, _) => Text(
|
||
_formatVoiceElapsed(ms),
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 18,
|
||
fontFeatures: [ui.FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
ValueListenableBuilder<double>(
|
||
valueListenable: _noteCancelDrag,
|
||
builder: (context, drag, _) => Opacity(
|
||
opacity: (0.5 + drag * 0.5).clamp(0.0, 1.0),
|
||
child: const Text(
|
||
'‹ влево — отмена',
|
||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
final overlay = Overlay.of(context, rootOverlay: true);
|
||
overlay.insert(_noteOverlay!);
|
||
}
|
||
|
||
void _hideNoteOverlay() {
|
||
_noteOverlay?.remove();
|
||
_noteOverlay = null;
|
||
}
|
||
|
||
String _formatVoiceElapsed(int ms) {
|
||
final totalSec = ms ~/ 1000;
|
||
final m = (totalSec ~/ 60).toString();
|
||
final s = (totalSec % 60).toString().padLeft(2, '0');
|
||
final ds = ((ms % 1000) ~/ 100).toString();
|
||
return '$m:$s,$ds';
|
||
}
|
||
|
||
Widget _recordingButtonVisual({
|
||
required Widget pill,
|
||
required ColorScheme cs,
|
||
required bool active,
|
||
}) {
|
||
return TweenAnimationBuilder<double>(
|
||
tween: Tween(begin: 0.0, end: active ? 1.0 : 0.0),
|
||
duration: const Duration(milliseconds: 220),
|
||
curve: Curves.easeOut,
|
||
builder: (context, a, _) {
|
||
if (a <= 0.001) return pill;
|
||
return ValueListenableBuilder<double>(
|
||
valueListenable: _voiceAmplitude,
|
||
builder: (context, amp, _) => TweenAnimationBuilder<double>(
|
||
tween: Tween(begin: 0.0, end: amp),
|
||
duration: const Duration(milliseconds: 110),
|
||
builder: (context, v, _) {
|
||
final glow = a * (88.0 + v * 76.0);
|
||
return Stack(
|
||
clipBehavior: Clip.none,
|
||
alignment: Alignment.center,
|
||
children: [
|
||
Positioned(
|
||
left: 27 - glow / 2,
|
||
top: 27 - glow / 2,
|
||
child: Container(
|
||
width: glow,
|
||
height: glow,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: cs.error.withValues(alpha: a * (0.16 + v * 0.12)),
|
||
),
|
||
),
|
||
),
|
||
_voiceLockChip(cs),
|
||
Transform.scale(
|
||
scale: 1.0 + a * 0.14 + a * v * 0.24,
|
||
child: pill,
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _voiceLockChip(ColorScheme cs) {
|
||
return Positioned(
|
||
bottom: 62,
|
||
child: ValueListenableBuilder<double>(
|
||
valueListenable: _voiceLockDrag,
|
||
builder: (context, lock, _) => Opacity(
|
||
opacity: (0.5 + lock * 0.5).clamp(0.0, 1.0),
|
||
child: Transform.translate(
|
||
offset: Offset(0, lock * 12),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 6),
|
||
decoration: BoxDecoration(
|
||
color: Color.alphaBlend(
|
||
cs.surfaceContainerHighest.withValues(alpha: 0.96),
|
||
cs.surface,
|
||
),
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.15),
|
||
blurRadius: 6,
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Symbols.lock,
|
||
size: 16,
|
||
color: lock > 0.6 ? cs.primary : cs.onSurfaceVariant,
|
||
),
|
||
Icon(
|
||
Symbols.keyboard_arrow_up,
|
||
size: 14,
|
||
color: cs.onSurfaceVariant,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildVoiceRecordingIndicator(ColorScheme cs) {
|
||
return Container(
|
||
color: Color.alphaBlend(
|
||
cs.surfaceContainerHighest.withValues(alpha: 0.92),
|
||
cs.surface,
|
||
),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Row(
|
||
children: [
|
||
ValueListenableBuilder<double>(
|
||
valueListenable: _voiceAmplitude,
|
||
builder: (context, amp, child) => TweenAnimationBuilder<double>(
|
||
tween: Tween(begin: 0, end: amp),
|
||
duration: const Duration(milliseconds: 120),
|
||
builder: (context, v, child) => Transform.scale(
|
||
scale: 1.0 + v * 0.7,
|
||
child: child,
|
||
),
|
||
child: child,
|
||
),
|
||
child: _RecordingDot(color: cs.error),
|
||
),
|
||
const SizedBox(width: 12),
|
||
ValueListenableBuilder<int>(
|
||
valueListenable: _voiceElapsedMs,
|
||
builder: (context, ms, _) => Text(
|
||
_formatVoiceElapsed(ms),
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 16,
|
||
fontFeatures: const [ui.FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: ValueListenableBuilder<double>(
|
||
valueListenable: _voiceCancelDrag,
|
||
builder: (context, drag, _) {
|
||
if (drag > 0.01) {
|
||
return Opacity(
|
||
opacity: (0.45 + drag * 0.55).clamp(0.0, 1.0),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
Icon(
|
||
Symbols.arrow_back,
|
||
size: 16,
|
||
color: cs.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
'Отмена',
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
return SizedBox(
|
||
height: 26,
|
||
child: ValueListenableBuilder<int>(
|
||
valueListenable: _voiceWaveRev,
|
||
builder: (context, _, _) => CustomPaint(
|
||
size: Size.infinite,
|
||
painter: _LiveWavePainter(
|
||
amps: _voiceAmps,
|
||
color: cs.primary.withValues(alpha: 0.85),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
ValueListenableBuilder<bool>(
|
||
valueListenable: _voiceLocked,
|
||
builder: (context, locked, _) => locked
|
||
? GestureDetector(
|
||
onTap: () => _stopVoiceRecording(cancel: true),
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||
child: Icon(Symbols.delete, size: 22, color: cs.error),
|
||
),
|
||
)
|
||
: Text(
|
||
'‹ влево — отмена',
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||
fontSize: 11,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInputArea(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85);
|
||
|
||
if (widget.chatType == "CHANNEL") {
|
||
return SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||
child: GlossyPill(
|
||
onTap: () {},
|
||
color: Color.alphaBlend(
|
||
cs.surfaceContainerHighest.withValues(alpha: 0.92),
|
||
cs.surface,
|
||
),
|
||
borderRadius: BorderRadius.circular(28),
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
depth: 8,
|
||
borderSide: BorderSide(
|
||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||
width: 0.5,
|
||
),
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
child: Center(
|
||
child: Text(
|
||
'Отключить уведомления',
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_buildReplyPreview(cs),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12.0,
|
||
vertical: 8.0,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Expanded(
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
constraints: const BoxConstraints(
|
||
minHeight: 54,
|
||
maxHeight: 180,
|
||
),
|
||
child: GlossyPill(
|
||
color: Color.alphaBlend(
|
||
cs.surfaceContainerHighest.withValues(alpha: 0.92),
|
||
cs.surface,
|
||
),
|
||
borderRadius: BorderRadius.circular(28),
|
||
depth: 8,
|
||
borderSide: BorderSide(
|
||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||
width: 0.5,
|
||
),
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
AnimatedBuilder(
|
||
animation: _attachAnim,
|
||
builder: (context, child) {
|
||
final t = _attachAnim.value;
|
||
return IgnorePointer(
|
||
ignoring: t > 0.5,
|
||
child: Opacity(
|
||
opacity: (1 - t).clamp(0.0, 1.0),
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: _toggleStickerPanel,
|
||
child: Icon(
|
||
Symbols.face,
|
||
color: mutedIcon,
|
||
size: 24,
|
||
weight: 400,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Focus(
|
||
onKeyEvent: (node, event) {
|
||
if (event is KeyDownEvent &&
|
||
event.logicalKey ==
|
||
LogicalKeyboardKey.enter &&
|
||
!HardwareKeyboard
|
||
.instance
|
||
.isShiftPressed) {
|
||
if (_hasText.value) _sendMessage();
|
||
return KeyEventResult.handled;
|
||
}
|
||
return KeyEventResult.ignored;
|
||
},
|
||
child: TextField(
|
||
controller: _messageController,
|
||
focusNode: _messageFocusNode,
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontSize: 16,
|
||
),
|
||
maxLines: null,
|
||
keyboardType: TextInputType.multiline,
|
||
textAlignVertical: TextAlignVertical.center,
|
||
contextMenuBuilder: (ctx, state) =>
|
||
_formatContextMenu(
|
||
_messageController,
|
||
ctx,
|
||
state,
|
||
),
|
||
decoration: InputDecoration(
|
||
hintText: 'Message',
|
||
hintStyle: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 16,
|
||
),
|
||
border: InputBorder.none,
|
||
isDense: true,
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
vertical: 14,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
_AttachButton(
|
||
hasText: _hasText,
|
||
onOpen: _openAttachmentSheet,
|
||
onLongOpen: _openAttachmentSheetScheduled,
|
||
uploadStatus: _uploadStatus,
|
||
mutedIcon: mutedIcon,
|
||
cs: cs,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: SizedBox(
|
||
height: 54,
|
||
child: AnimatedBuilder(
|
||
animation: _attachAnim,
|
||
builder: (context, child) {
|
||
final t = _attachAnim.value;
|
||
return IgnorePointer(
|
||
ignoring: t < 0.5,
|
||
child: Opacity(
|
||
opacity: t.clamp(0.0, 1.0),
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
child: _HistoryStrip(
|
||
anim: _attachAnim,
|
||
cs: cs,
|
||
onTapEntry: _sendHistoryFile,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Positioned.fill(
|
||
child: ValueListenableBuilder<bool>(
|
||
valueListenable: _isRecordingVoice,
|
||
builder: (context, recording, _) => IgnorePointer(
|
||
ignoring: !recording,
|
||
child: AnimatedSlide(
|
||
offset: recording
|
||
? Offset.zero
|
||
: const Offset(0.06, 0),
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOutCubic,
|
||
child: AnimatedOpacity(
|
||
opacity: recording ? 1 : 0,
|
||
duration: const Duration(milliseconds: 180),
|
||
curve: Curves.easeOut,
|
||
child: _buildVoiceRecordingIndicator(cs),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
AnimatedBuilder(
|
||
animation: _attachAnim,
|
||
builder: (context, child) {
|
||
final t = _attachAnim.value;
|
||
return ClipRect(
|
||
clipper: _ButtonClipper(t),
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
widthFactor: (1 - t).clamp(0.0, 1.0),
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(width: 8),
|
||
AnimatedBuilder(
|
||
animation: _attachAnim,
|
||
builder: (context, child) {
|
||
final t = _attachAnim.value;
|
||
return Transform.translate(
|
||
offset: Offset(t * 80, 0),
|
||
child: Opacity(
|
||
opacity: (1 - t * 1.5).clamp(0.0, 1.0),
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
child: ValueListenableBuilder<bool>(
|
||
valueListenable: _hasText,
|
||
builder: (context, hasText, _) =>
|
||
ValueListenableBuilder<bool>(
|
||
valueListenable: _voiceLocked,
|
||
builder: (context, locked, _) =>
|
||
ValueListenableBuilder<bool>(
|
||
valueListenable: _isRecordingVoice,
|
||
builder: (context, recording, _) =>
|
||
ValueListenableBuilder<bool>(
|
||
valueListenable: _videoNoteMode,
|
||
builder: (context, videoMode, _) {
|
||
final sendMode = hasText || locked;
|
||
final pill = GlossyPill(
|
||
color: sendMode
|
||
? cs.primary
|
||
: recording
|
||
? cs.error
|
||
: cs.surfaceContainerHighest,
|
||
borderRadius:
|
||
BorderRadius.circular(27),
|
||
onTap: hasText
|
||
? _sendMessage
|
||
: locked
|
||
? () => _stopVoiceRecording(
|
||
cancel: false,
|
||
)
|
||
: null,
|
||
onLongPress: hasText
|
||
? _scheduleMessage
|
||
: null,
|
||
depth: 8,
|
||
child: SizedBox(
|
||
width: 54,
|
||
height: 54,
|
||
child: Center(
|
||
child: Icon(
|
||
sendMode
|
||
? Symbols.send
|
||
: videoMode
|
||
? Symbols.videocam
|
||
: Symbols.mic,
|
||
color: sendMode
|
||
? cs.onPrimary
|
||
: recording
|
||
? cs.onError
|
||
: cs.onSurface,
|
||
size: 24,
|
||
weight: 400,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
final visual = _recordingButtonVisual(
|
||
pill: pill,
|
||
cs: cs,
|
||
active: recording && !locked,
|
||
);
|
||
return GestureDetector(
|
||
onTap: sendMode
|
||
? null
|
||
: _toggleComposerMode,
|
||
onLongPressStart: sendMode
|
||
? null
|
||
: (_) => videoMode
|
||
? _startNoteRecording()
|
||
: _startVoiceRecording(),
|
||
onLongPressMoveUpdate: sendMode
|
||
? null
|
||
: (d) => videoMode
|
||
? _handleNoteDrag(
|
||
d.offsetFromOrigin,
|
||
)
|
||
: _handleVoiceDrag(
|
||
d.offsetFromOrigin,
|
||
),
|
||
onLongPressEnd: sendMode
|
||
? null
|
||
: (_) => videoMode
|
||
? _handleNoteEnd()
|
||
: _handleVoiceEnd(),
|
||
child: visual,
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildReplyPreview(ColorScheme cs) {
|
||
return ValueListenableBuilder<CachedMessage?>(
|
||
valueListenable: _replyTo,
|
||
builder: (context, reply, _) {
|
||
if (reply == null) return const SizedBox.shrink();
|
||
final name = reply.senderId == _myId
|
||
? 'Вы'
|
||
: (ContactCache.get(reply.senderId) ?? 'Сообщение');
|
||
final info = ReplyInfo(
|
||
senderId: reply.senderId,
|
||
text: reply.text,
|
||
attachments: reply.attachments,
|
||
);
|
||
final preview = info.previewText();
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 6, 8, 2),
|
||
child: Row(
|
||
children: [
|
||
Icon(Symbols.reply, size: 20, color: cs.primary),
|
||
const SizedBox(width: 10),
|
||
Container(width: 2, height: 34, color: cs.primary),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Ответ $name',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: cs.primary,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
if (preview.isNotEmpty)
|
||
Text(
|
||
preview,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Symbols.close, size: 20),
|
||
color: cs.onSurfaceVariant,
|
||
onPressed: _cancelReply,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
String _addOptimisticFileMessage(FileAttachment attachment) {
|
||
final now = DateTime.now().millisecondsSinceEpoch;
|
||
final tempId = _nextTempId();
|
||
final msg = CachedMessage(
|
||
id: tempId,
|
||
accountId: _myId,
|
||
chatId: widget.chatId,
|
||
senderId: _myId,
|
||
time: now,
|
||
status: 'sending',
|
||
attachments: [attachment],
|
||
);
|
||
_lastSentId = tempId;
|
||
_messages.add(msg);
|
||
_bumpMessages();
|
||
Haptics.send();
|
||
_scrollToBottom();
|
||
return tempId;
|
||
}
|
||
|
||
void _updateFileMessageStatus(
|
||
String tempId,
|
||
String status, {
|
||
FileAttachment? attachment,
|
||
}) {
|
||
if (!mounted) return;
|
||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||
if (idx == -1) return;
|
||
final old = _messages[idx];
|
||
_messages[idx] = CachedMessage(
|
||
id: tempId,
|
||
accountId: old.accountId,
|
||
chatId: old.chatId,
|
||
senderId: old.senderId,
|
||
text: old.text,
|
||
time: old.time,
|
||
status: status,
|
||
payload: old.payload,
|
||
attachments: attachment != null ? [attachment] : old.attachments,
|
||
);
|
||
_bumpMessages();
|
||
}
|
||
|
||
Future<void> _sendHistoryFile(FileHistoryEntry entry) async {
|
||
final tempId = _addOptimisticFileMessage(
|
||
FileAttachment(
|
||
fileId: entry.fileId,
|
||
fileToken: entry.token,
|
||
name: entry.filename,
|
||
size: entry.size,
|
||
),
|
||
);
|
||
_showAttachmentPanel.value = false;
|
||
try {
|
||
final ok = await messagesModule.sendFileMessage(
|
||
widget.chatId,
|
||
entry.fileId,
|
||
token: entry.token,
|
||
);
|
||
_updateFileMessageStatus(tempId, ok ? 'sent' : 'error');
|
||
} catch (_) {
|
||
_updateFileMessageStatus(tempId, 'error');
|
||
}
|
||
}
|
||
|
||
Future<bool> _sendFileById(int fileId) async {
|
||
final tempId = _addOptimisticFileMessage(FileAttachment(fileId: fileId));
|
||
try {
|
||
final ok = await messagesModule.sendFileMessage(widget.chatId, fileId);
|
||
if (!mounted) return ok;
|
||
if (ok) {
|
||
FileHistoryCache.add(
|
||
FileHistoryEntry(fileId: fileId, sentAt: DateTime.now()),
|
||
);
|
||
_updateFileMessageStatus(tempId, 'sent');
|
||
_showAttachmentPanel.value = false;
|
||
} else {
|
||
_updateFileMessageStatus(tempId, 'error');
|
||
showCustomNotification(context, 'Ошибка отправки');
|
||
}
|
||
return ok;
|
||
} catch (e) {
|
||
_updateFileMessageStatus(tempId, 'error');
|
||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
Future<void> _openAttachmentSheetScheduled() async {
|
||
final when = await _pickScheduleTime();
|
||
if (when == null || !mounted) return;
|
||
await _openAttachmentSheet(scheduledTime: when.millisecondsSinceEpoch);
|
||
}
|
||
|
||
Future<void> _openAttachmentSheet({int? scheduledTime}) async {
|
||
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
|
||
final hadKeyboard = keyboard > 0;
|
||
if (hadKeyboard) {
|
||
setState(() => _keyboardReserve = keyboard);
|
||
}
|
||
FocusManager.instance.primaryFocus?.unfocus();
|
||
await showAttachmentSheet(
|
||
context,
|
||
title: widget.name,
|
||
onSend: scheduledTime == null
|
||
? _sendPhotos
|
||
: (picked, caption) =>
|
||
_sendScheduledPhotos(picked, caption, scheduledTime),
|
||
onPickFile: scheduledTime == null
|
||
? _pickAndUploadFile
|
||
: () => _pickAndUploadFile(scheduledTime: scheduledTime),
|
||
onShareLocation: _shareLocation,
|
||
onCreatePoll: _createPoll,
|
||
);
|
||
if (!mounted || !hadKeyboard) return;
|
||
_messageFocusNode.requestFocus();
|
||
await Future.delayed(const Duration(milliseconds: 350));
|
||
if (mounted) setState(() => _keyboardReserve = 0);
|
||
}
|
||
|
||
Future<void> _sendPhotos(List<PickedPhoto> picked, String caption) async {
|
||
if (_myId == 0) return;
|
||
final videos = picked.where((ph) => ph.item.isVideo).toList();
|
||
final photos = picked.where((ph) => !ph.item.isVideo).toList();
|
||
if (photos.isEmpty && videos.isEmpty) return;
|
||
|
||
for (var i = 0; i < videos.length; i++) {
|
||
final cap = (photos.isEmpty && i == 0) ? caption : '';
|
||
await _sendVideo(videos[i], cap);
|
||
}
|
||
if (photos.isEmpty) return;
|
||
|
||
final files = <File>[];
|
||
final attachments = <PhotoAttachment>[];
|
||
for (final photo in photos) {
|
||
final edited = photo.editedFile;
|
||
final file =
|
||
edited ?? photo.item.localFile ?? await photo.item.originFile();
|
||
if (file == null) continue;
|
||
final dim = edited != null
|
||
? await imageFileDimensions(edited)
|
||
: await photo.item.dimensions();
|
||
files.add(file);
|
||
attachments.add(
|
||
PhotoAttachment(localPath: file.path, width: dim?.$1, height: dim?.$2),
|
||
);
|
||
}
|
||
if (files.isEmpty || !mounted) return;
|
||
|
||
final tempId = _nextTempId();
|
||
final now = DateTime.now().millisecondsSinceEpoch;
|
||
final progress = ValueNotifier<List<double>>(
|
||
List<double>.filled(files.length, 0),
|
||
);
|
||
_photoUploadProgress[tempId] = progress;
|
||
|
||
_messages.add(
|
||
CachedMessage(
|
||
id: tempId,
|
||
accountId: _myId,
|
||
chatId: widget.chatId,
|
||
senderId: _myId,
|
||
text: caption.isEmpty ? null : caption,
|
||
time: now,
|
||
status: 'sending',
|
||
attachments: attachments,
|
||
),
|
||
);
|
||
_lastSentId = tempId;
|
||
_bumpMessages();
|
||
Haptics.send();
|
||
_scrollToBottom();
|
||
|
||
try {
|
||
final tokens = await Future.wait(
|
||
List.generate(
|
||
files.length,
|
||
(i) => _uploadOnePhoto(files[i], i, progress),
|
||
),
|
||
);
|
||
if (!mounted) {
|
||
_disposePhotoProgress(tempId);
|
||
return;
|
||
}
|
||
if (tokens.any((t) => t == null)) {
|
||
_failPhotoMessage(tempId);
|
||
return;
|
||
}
|
||
|
||
progress.value = List<double>.filled(files.length, 1);
|
||
|
||
final serverMsg = await messagesModule.sendPhotoMessage(
|
||
widget.chatId,
|
||
tokens.cast<String>(),
|
||
caption: caption.isEmpty ? null : caption,
|
||
);
|
||
if (!mounted) {
|
||
_disposePhotoProgress(tempId);
|
||
return;
|
||
}
|
||
if (serverMsg == null) {
|
||
_failPhotoMessage(tempId);
|
||
return;
|
||
}
|
||
|
||
final real = CachedMessage.fromPushPayload(
|
||
_myId,
|
||
widget.chatId,
|
||
serverMsg,
|
||
);
|
||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||
if (idx != -1) {
|
||
_messages[idx] = real;
|
||
_bumpMessages();
|
||
unawaited(_persistOutgoing(real));
|
||
}
|
||
_disposePhotoProgress(tempId);
|
||
} catch (e) {
|
||
if (mounted) {
|
||
_failPhotoMessage(tempId);
|
||
} else {
|
||
_disposePhotoProgress(tempId);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _sendVideo(
|
||
PickedPhoto video,
|
||
String caption, {
|
||
int? scheduledTime,
|
||
}) async {
|
||
if (_myId == 0) return;
|
||
final file =
|
||
video.editedFile ??
|
||
video.item.localFile ??
|
||
await video.item.originFile();
|
||
if (file == null || !mounted) return;
|
||
|
||
final scheduled = scheduledTime != null;
|
||
final durationMs = video.item.duration?.inMilliseconds;
|
||
|
||
String? tempId;
|
||
ValueNotifier<List<double>>? progress;
|
||
if (scheduled) {
|
||
showCustomNotification(context, 'Загрузка…');
|
||
} else {
|
||
tempId = _nextTempId();
|
||
progress = ValueNotifier<List<double>>(const [0]);
|
||
_photoUploadProgress[tempId] = progress;
|
||
_messages.add(
|
||
CachedMessage(
|
||
id: tempId,
|
||
accountId: _myId,
|
||
chatId: widget.chatId,
|
||
senderId: _myId,
|
||
text: caption.isEmpty ? null : caption,
|
||
time: DateTime.now().millisecondsSinceEpoch,
|
||
status: 'sending',
|
||
attachments: [VideoAttachment(duration: durationMs)],
|
||
),
|
||
);
|
||
_lastSentId = tempId;
|
||
_bumpMessages();
|
||
Haptics.send();
|
||
_scrollToBottom();
|
||
}
|
||
|
||
final progressNotifier = progress;
|
||
try {
|
||
final info = await messagesModule.requestVideoUploadUrl();
|
||
if (info == null || info.url.isEmpty) throw Exception('no_url');
|
||
|
||
final ok = await fileUploader.uploadVideoFile(
|
||
Uri.parse(info.url),
|
||
file,
|
||
onProgress: progressNotifier == null
|
||
? null
|
||
: (sent, total) {
|
||
if (total > 0) {
|
||
progressNotifier.value = [(sent / total).clamp(0.0, 1.0)];
|
||
}
|
||
},
|
||
);
|
||
if (!ok) throw Exception('upload_failed');
|
||
if (!mounted) {
|
||
if (tempId != null) _disposePhotoProgress(tempId);
|
||
return;
|
||
}
|
||
|
||
final serverMsg = await messagesModule.sendVideoMessage(
|
||
widget.chatId,
|
||
info.token,
|
||
caption: caption.isEmpty ? null : caption,
|
||
scheduledTime: scheduledTime,
|
||
);
|
||
if (!mounted) {
|
||
if (tempId != null) _disposePhotoProgress(tempId);
|
||
return;
|
||
}
|
||
if (serverMsg == null) throw Exception('send_failed');
|
||
|
||
if (scheduled) {
|
||
Haptics.send();
|
||
_markHasScheduled();
|
||
showCustomNotification(
|
||
context,
|
||
'Запланировано на '
|
||
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
|
||
);
|
||
} else {
|
||
final real = CachedMessage.fromPushPayload(
|
||
_myId,
|
||
widget.chatId,
|
||
serverMsg,
|
||
);
|
||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||
if (idx != -1) {
|
||
_messages[idx] = real;
|
||
_bumpMessages();
|
||
unawaited(_persistOutgoing(real, removeId: tempId));
|
||
}
|
||
_disposePhotoProgress(tempId!);
|
||
}
|
||
} catch (_) {
|
||
if (!mounted) {
|
||
if (tempId != null) _disposePhotoProgress(tempId);
|
||
return;
|
||
}
|
||
if (scheduled) {
|
||
Haptics.error();
|
||
showCustomNotification(context, 'Не удалось запланировать видео');
|
||
} else {
|
||
_failPhotoMessage(tempId!);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _sendScheduledPhotos(
|
||
List<PickedPhoto> picked,
|
||
String caption,
|
||
int scheduledTime,
|
||
) async {
|
||
if (_myId == 0) return;
|
||
final videos = picked.where((ph) => ph.item.isVideo).toList();
|
||
final photos = picked.where((ph) => !ph.item.isVideo).toList();
|
||
if (photos.isEmpty && videos.isEmpty) return;
|
||
|
||
for (var i = 0; i < videos.length; i++) {
|
||
final cap = (photos.isEmpty && i == 0) ? caption : '';
|
||
await _sendVideo(videos[i], cap, scheduledTime: scheduledTime);
|
||
}
|
||
if (photos.isEmpty) return;
|
||
|
||
final files = <File>[];
|
||
for (final photo in photos) {
|
||
final edited = photo.editedFile;
|
||
final file =
|
||
edited ?? photo.item.localFile ?? await photo.item.originFile();
|
||
if (file != null) files.add(file);
|
||
}
|
||
if (files.isEmpty || !mounted) return;
|
||
|
||
showCustomNotification(context, 'Загрузка…');
|
||
final progress = ValueNotifier<List<double>>(
|
||
List<double>.filled(files.length, 0),
|
||
);
|
||
try {
|
||
final tokens = await Future.wait(
|
||
List.generate(
|
||
files.length,
|
||
(i) => _uploadOnePhoto(files[i], i, progress),
|
||
),
|
||
);
|
||
if (!mounted) return;
|
||
if (tokens.any((t) => t == null)) {
|
||
showCustomNotification(context, 'Не удалось загрузить фото');
|
||
return;
|
||
}
|
||
|
||
final result = await messagesModule.sendPhotoMessage(
|
||
widget.chatId,
|
||
tokens.cast<String>(),
|
||
caption: caption.isEmpty ? null : caption,
|
||
scheduledTime: scheduledTime,
|
||
);
|
||
if (!mounted) return;
|
||
if (result != null) {
|
||
Haptics.send();
|
||
_markHasScheduled();
|
||
showCustomNotification(
|
||
context,
|
||
'Запланировано на '
|
||
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
|
||
);
|
||
} else {
|
||
showCustomNotification(context, 'Не удалось запланировать');
|
||
}
|
||
} catch (_) {
|
||
if (mounted) {
|
||
Haptics.error();
|
||
showCustomNotification(context, 'Ошибка при загрузке');
|
||
}
|
||
} finally {
|
||
progress.dispose();
|
||
}
|
||
}
|
||
|
||
Future<void> _sendAttachMessage(
|
||
List<MessageAttachment> optimistic,
|
||
Future<Map<String, dynamic>?> Function() send,
|
||
) async {
|
||
if (_myId == 0) return;
|
||
final tempId = _nextTempId();
|
||
final now = DateTime.now().millisecondsSinceEpoch;
|
||
|
||
final tempMessage = CachedMessage(
|
||
id: tempId,
|
||
accountId: _myId,
|
||
chatId: widget.chatId,
|
||
senderId: _myId,
|
||
time: now,
|
||
status: 'sending',
|
||
attachments: optimistic,
|
||
);
|
||
_messages.add(tempMessage);
|
||
_lastSentId = tempId;
|
||
_bumpMessages();
|
||
Haptics.send();
|
||
_scrollToBottom();
|
||
|
||
try {
|
||
final serverMsg = await send();
|
||
if (!mounted) return;
|
||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||
if (idx == -1) return;
|
||
if (serverMsg == null) {
|
||
_updateFileMessageStatus(tempId, 'error');
|
||
showCustomNotification(context, 'Ошибка отправки');
|
||
return;
|
||
}
|
||
final real = CachedMessage.fromPushPayload(_myId, widget.chatId, serverMsg);
|
||
_messages[idx] = real;
|
||
_bumpMessages();
|
||
unawaited(_persistOutgoing(real, removeId: tempId));
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
_updateFileMessageStatus(tempId, 'error');
|
||
showCustomNotification(context, 'Ошибка: $e');
|
||
}
|
||
}
|
||
|
||
void _toggleStickerPanel() {
|
||
if (_showStickerPanel.value) {
|
||
_showStickerPanel.value = false;
|
||
_messageFocusNode.requestFocus();
|
||
return;
|
||
}
|
||
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
|
||
if (keyboard > 120) _stickerPanelHeight = keyboard;
|
||
FocusManager.instance.primaryFocus?.unfocus();
|
||
_showStickerPanel.value = true;
|
||
}
|
||
|
||
void _onComposerFocusChanged() {
|
||
if (_messageFocusNode.hasFocus && _showStickerPanel.value) {
|
||
_showStickerPanel.value = false;
|
||
}
|
||
}
|
||
|
||
void _onStickerPanelToggle() {
|
||
if (_showStickerPanel.value) {
|
||
_stickerAnim.forward();
|
||
_sendStickerTyping();
|
||
_stickerTypingTimer?.cancel();
|
||
_stickerTypingTimer = Timer.periodic(
|
||
const Duration(seconds: 4),
|
||
(_) => _sendStickerTyping(),
|
||
);
|
||
} else {
|
||
_stickerAnim.reverse();
|
||
_stickerTypingTimer?.cancel();
|
||
_stickerTypingTimer = null;
|
||
}
|
||
}
|
||
|
||
void _sendStickerTyping() {
|
||
if (KometSettings.ghostMode.value) return;
|
||
messagesModule.sendTyping(widget.chatId, 'STICKER');
|
||
}
|
||
|
||
Future<void> _sendSticker(StickerItem sticker) async {
|
||
_showStickerPanel.value = false;
|
||
await _sendAttachMessage(
|
||
[
|
||
StickerAttachment(
|
||
stickerId: sticker.id.toString(),
|
||
baseUrl: sticker.url,
|
||
lottieUrl: sticker.lottieUrl,
|
||
width: sticker.width,
|
||
height: sticker.height,
|
||
),
|
||
],
|
||
() => messagesModule.sendStickerMessage(widget.chatId, sticker.id),
|
||
);
|
||
}
|
||
|
||
Widget _buildStickerPanel(BuildContext context) {
|
||
return AnimatedBuilder(
|
||
animation: _stickerAnim,
|
||
child: StickerPanel(
|
||
height: _stickerPanelHeight,
|
||
onStickerTap: _sendSticker,
|
||
),
|
||
builder: (context, child) {
|
||
final t = Curves.easeOutCubic.transform(
|
||
_stickerAnim.value.clamp(0.0, 1.0),
|
||
);
|
||
if (t == 0) return const SizedBox.shrink();
|
||
return ClipRect(
|
||
child: Align(
|
||
alignment: Alignment.topCenter,
|
||
heightFactor: t,
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Future<void> _shareLocation() async {
|
||
final position = await _resolveCurrentPosition();
|
||
if (position == null || !mounted) return;
|
||
final lat = position.latitude;
|
||
final lon = position.longitude;
|
||
await _sendAttachMessage(
|
||
[LocationAttachment(latitude: lat, longitude: lon, zoom: 15)],
|
||
() => messagesModule.sendLocationMessage(widget.chatId, lat, lon),
|
||
);
|
||
}
|
||
|
||
Future<Position?> _resolveCurrentPosition() async {
|
||
try {
|
||
if (!await Geolocator.isLocationServiceEnabled()) {
|
||
if (mounted) showCustomNotification(context, 'Включите геолокацию');
|
||
return null;
|
||
}
|
||
var permission = await Geolocator.checkPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
permission = await Geolocator.requestPermission();
|
||
}
|
||
if (permission == LocationPermission.denied ||
|
||
permission == LocationPermission.deniedForever) {
|
||
if (mounted) showCustomNotification(context, 'Нет доступа к геолокации');
|
||
return null;
|
||
}
|
||
return await Geolocator.getCurrentPosition(
|
||
locationSettings: const LocationSettings(
|
||
accuracy: LocationAccuracy.high,
|
||
),
|
||
);
|
||
} catch (e) {
|
||
if (mounted) showCustomNotification(context, 'Не удалось получить геопозицию');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<void> _createPoll() async {
|
||
final draft = await showCreatePollSheet(context);
|
||
if (draft == null || !mounted) return;
|
||
await _sendAttachMessage(
|
||
[PollAttachment(pollId: 0, title: draft.title)],
|
||
() => messagesModule.sendPollMessage(
|
||
widget.chatId,
|
||
draft.title,
|
||
draft.answers,
|
||
multiple: draft.multiple,
|
||
anonymous: draft.anonymous,
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<String?> _uploadOnePhoto(
|
||
File file,
|
||
int index,
|
||
ValueNotifier<List<double>> progress,
|
||
) async {
|
||
final url = await messagesModule.requestPhotoUploadUrl();
|
||
if (url == null || url.isEmpty) return null;
|
||
return fileUploader.uploadPhoto(
|
||
Uri.parse(url),
|
||
file,
|
||
filename: _photoFilename(file),
|
||
onProgress: (sent, total) {
|
||
if (total <= 0) return;
|
||
final next = List<double>.from(progress.value);
|
||
if (index < next.length) {
|
||
next[index] = (sent / total).clamp(0.0, 1.0);
|
||
progress.value = next;
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
String _photoFilename(File file) {
|
||
final segments = file.uri.pathSegments;
|
||
final name = segments.isNotEmpty ? segments.last : '';
|
||
return name.isNotEmpty ? name : 'photo.jpg';
|
||
}
|
||
|
||
void _failPhotoMessage(String tempId) {
|
||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||
if (idx != -1) {
|
||
final old = _messages[idx];
|
||
_messages[idx] = CachedMessage(
|
||
id: old.id,
|
||
accountId: old.accountId,
|
||
chatId: old.chatId,
|
||
senderId: old.senderId,
|
||
text: old.text,
|
||
time: old.time,
|
||
status: 'error',
|
||
attachments: old.attachments,
|
||
);
|
||
_bumpMessages();
|
||
}
|
||
_disposePhotoProgress(tempId);
|
||
Haptics.error();
|
||
}
|
||
|
||
void _disposePhotoProgress(String tempId) {
|
||
_photoUploadProgress.remove(tempId)?.dispose();
|
||
}
|
||
|
||
Future<void> _pickAndUploadFile({int? scheduledTime}) async {
|
||
final result = await FilePicker.platform.pickFiles();
|
||
if (result == null || result.files.isEmpty) return;
|
||
final file = result.files.first;
|
||
if (file.path == null) return;
|
||
|
||
_showAttachmentPanel.value = false;
|
||
_uploadStatus.value = _UploadStatus(active: true, total: file.size);
|
||
|
||
final scheduled = scheduledTime != null;
|
||
final tempId = scheduled
|
||
? null
|
||
: _addOptimisticFileMessage(
|
||
FileAttachment(name: file.name, size: file.size),
|
||
);
|
||
|
||
UploadNotificationService.start(file.name);
|
||
|
||
var notifLastSent = 0;
|
||
var notifLastMs = DateTime.now().millisecondsSinceEpoch;
|
||
var notifSpeedBps = 0;
|
||
var notifLastPercent = -1;
|
||
|
||
void stopNotif() => UploadNotificationService.stop();
|
||
|
||
_uploadSub?.cancel();
|
||
_uploadSub = fileUploader
|
||
.upload(
|
||
chatId: widget.chatId,
|
||
file: File(file.path!),
|
||
filename: file.name,
|
||
totalSize: file.size,
|
||
scheduledTime: scheduledTime,
|
||
)
|
||
.listen(
|
||
(event) {
|
||
if (!mounted) return;
|
||
switch (event) {
|
||
case UploadProgress(:final sent, :final total):
|
||
_uploadStatus.value = _UploadStatus(
|
||
active: true,
|
||
sent: sent,
|
||
total: total,
|
||
);
|
||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||
final elapsed = nowMs - notifLastMs;
|
||
if (elapsed >= 500) {
|
||
notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed)
|
||
.round();
|
||
notifLastSent = sent;
|
||
notifLastMs = nowMs;
|
||
}
|
||
final percent = total > 0 ? (sent * 100 ~/ total) : 0;
|
||
if (percent != notifLastPercent) {
|
||
notifLastPercent = percent;
|
||
UploadNotificationService.update(
|
||
filename: file.name,
|
||
progressPercent: percent,
|
||
speedBps: notifSpeedBps,
|
||
);
|
||
}
|
||
case UploadDone(:final fileId, :final token, :final url):
|
||
stopNotif();
|
||
FileHistoryCache.add(
|
||
FileHistoryEntry(
|
||
fileId: fileId,
|
||
url: url,
|
||
token: token,
|
||
filename: file.name,
|
||
size: file.size,
|
||
sentAt: DateTime.now(),
|
||
),
|
||
);
|
||
if (scheduled) {
|
||
Haptics.send();
|
||
showCustomNotification(
|
||
context,
|
||
'Запланировано на '
|
||
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
|
||
);
|
||
} else {
|
||
_updateFileMessageStatus(
|
||
tempId!,
|
||
'sent',
|
||
attachment: FileAttachment(
|
||
fileId: fileId,
|
||
fileToken: token,
|
||
name: file.name,
|
||
size: file.size,
|
||
),
|
||
);
|
||
}
|
||
case UploadError(:final message):
|
||
stopNotif();
|
||
showCustomNotification(context, 'Ошибка: $message');
|
||
if (tempId != null) _updateFileMessageStatus(tempId, 'error');
|
||
}
|
||
},
|
||
onDone: () {
|
||
if (!mounted) return;
|
||
stopNotif();
|
||
if (tempId != null) {
|
||
final inFlight = _messages.firstWhere(
|
||
(m) => m.id == tempId,
|
||
orElse: () => CachedMessage(
|
||
id: '',
|
||
accountId: 0,
|
||
chatId: 0,
|
||
senderId: 0,
|
||
time: 0,
|
||
),
|
||
);
|
||
if (inFlight.id == tempId && inFlight.status == 'sending') {
|
||
_updateFileMessageStatus(tempId, 'error');
|
||
}
|
||
}
|
||
_uploadStatus.value = const _UploadStatus();
|
||
_uploadSub = null;
|
||
},
|
||
onError: (Object e) {
|
||
if (!mounted) return;
|
||
stopNotif();
|
||
showCustomNotification(context, 'Ошибка: $e');
|
||
if (tempId != null) _updateFileMessageStatus(tempId, 'error');
|
||
_uploadStatus.value = const _UploadStatus();
|
||
_uploadSub = null;
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AttachButton extends StatelessWidget {
|
||
final ValueNotifier<bool> hasText;
|
||
final VoidCallback onOpen;
|
||
final VoidCallback onLongOpen;
|
||
final ValueNotifier<_UploadStatus> uploadStatus;
|
||
final Color mutedIcon;
|
||
final ColorScheme cs;
|
||
|
||
const _AttachButton({
|
||
required this.hasText,
|
||
required this.onOpen,
|
||
required this.onLongOpen,
|
||
required this.uploadStatus,
|
||
required this.mutedIcon,
|
||
required this.cs,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListenableBuilder(
|
||
listenable: Listenable.merge([hasText, uploadStatus]),
|
||
builder: (context, _) {
|
||
final isText = hasText.value;
|
||
final status = uploadStatus.value;
|
||
final iconColor = status.awaitingResponse
|
||
? cs.primary
|
||
: (status.active
|
||
? cs.onSurfaceVariant.withValues(alpha: 0.5)
|
||
: mutedIcon);
|
||
final disabled = isText || status.active;
|
||
final onTap = disabled ? null : onOpen;
|
||
final onLongPress = disabled ? null : onLongOpen;
|
||
return AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
width: isText ? 0 : 36,
|
||
child: AnimatedOpacity(
|
||
duration: const Duration(milliseconds: 200),
|
||
opacity: isText ? 0 : 1,
|
||
child: isText
|
||
? const SizedBox.shrink()
|
||
: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
onLongPress: onLongPress,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(left: 12),
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
if (status.active)
|
||
SizedBox(
|
||
width: 30,
|
||
height: 30,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
value: status.progressValue,
|
||
color: cs.primary,
|
||
),
|
||
),
|
||
Icon(
|
||
Symbols.attachment,
|
||
color: iconColor,
|
||
size: 22,
|
||
weight: 400,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _HistoryStrip extends StatelessWidget {
|
||
final Animation<double> anim;
|
||
final ColorScheme cs;
|
||
final Future<void> Function(FileHistoryEntry entry) onTapEntry;
|
||
|
||
const _HistoryStrip({
|
||
required this.anim,
|
||
required this.cs,
|
||
required this.onTapEntry,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ValueListenableBuilder<List<FileHistoryEntry>>(
|
||
valueListenable: FileHistoryCache.notifier,
|
||
builder: (context, history, _) {
|
||
if (history.isEmpty) {
|
||
return Center(
|
||
child: AnimatedBuilder(
|
||
animation: anim,
|
||
builder: (context, _) {
|
||
final v = anim.value.clamp(0.0, 1.0);
|
||
return Opacity(
|
||
opacity: v,
|
||
child: Text(
|
||
'история пуста...',
|
||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
return ListView.builder(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
itemCount: history.length,
|
||
itemBuilder: (ctx, idx) {
|
||
final e = history[idx];
|
||
final startInterval = (idx * 0.05).clamp(0.0, 0.45);
|
||
return AnimatedBuilder(
|
||
animation: anim,
|
||
builder: (context, child) {
|
||
final raw = ((anim.value - startInterval) / 0.45).clamp(
|
||
0.0,
|
||
1.0,
|
||
);
|
||
final v = Curves.easeOutCubic.transform(raw);
|
||
return Opacity(
|
||
opacity: v,
|
||
child: Transform.translate(
|
||
offset: Offset(-14 * (1 - v), 0),
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
child: Container(
|
||
width: 54,
|
||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||
decoration: BoxDecoration(
|
||
color: cs.surfaceContainerLow,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(
|
||
color: cs.outlineVariant.withValues(alpha: 0.3),
|
||
),
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
Positioned.fill(
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => onTapEntry(e),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
_iconForFilename(e.filename),
|
||
color: cs.onSurfaceVariant,
|
||
size: 22,
|
||
),
|
||
const SizedBox(height: 2),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 3,
|
||
),
|
||
child: Text(
|
||
_labelForEntry(e),
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 9,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
maxLines: 1,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
top: -2,
|
||
right: -2,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => FileHistoryCache.remove(e.fileId),
|
||
child: Container(
|
||
width: 18,
|
||
height: 18,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: cs.surfaceContainerHighest,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||
width: 0.5,
|
||
),
|
||
),
|
||
child: Icon(
|
||
Symbols.close,
|
||
size: 12,
|
||
color: cs.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
String _labelForEntry(FileHistoryEntry e) {
|
||
final n = e.filename;
|
||
if (n == null || n.isEmpty) return e.fileId.toString();
|
||
final lastDot = n.lastIndexOf('.');
|
||
return lastDot > 0 ? n.substring(0, lastDot) : n;
|
||
}
|
||
|
||
IconData _iconForFilename(String? name) {
|
||
if (name == null || !name.contains('.')) return Symbols.description;
|
||
final ext = name.split('.').last.toLowerCase();
|
||
switch (ext) {
|
||
case 'jpg':
|
||
case 'jpeg':
|
||
case 'png':
|
||
case 'gif':
|
||
case 'webp':
|
||
case 'bmp':
|
||
case 'heic':
|
||
case 'heif':
|
||
return Symbols.image;
|
||
case 'mp4':
|
||
case 'mov':
|
||
case 'avi':
|
||
case 'mkv':
|
||
case 'webm':
|
||
case '3gp':
|
||
return Symbols.movie;
|
||
case 'mp3':
|
||
case 'wav':
|
||
case 'ogg':
|
||
case 'flac':
|
||
case 'm4a':
|
||
case 'aac':
|
||
return Symbols.audio_file;
|
||
case 'pdf':
|
||
return Symbols.picture_as_pdf;
|
||
case 'zip':
|
||
case 'rar':
|
||
case '7z':
|
||
case 'tar':
|
||
case 'gz':
|
||
return Symbols.folder_zip;
|
||
case 'doc':
|
||
case 'docx':
|
||
case 'txt':
|
||
case 'rtf':
|
||
case 'odt':
|
||
case 'md':
|
||
return Symbols.article;
|
||
case 'xls':
|
||
case 'xlsx':
|
||
case 'csv':
|
||
return Symbols.table_chart;
|
||
case 'ppt':
|
||
case 'pptx':
|
||
return Symbols.slideshow;
|
||
case 'dart':
|
||
case 'js':
|
||
case 'ts':
|
||
case 'py':
|
||
case 'java':
|
||
case 'kt':
|
||
case 'swift':
|
||
case 'cpp':
|
||
case 'c':
|
||
case 'h':
|
||
case 'rs':
|
||
case 'go':
|
||
case 'rb':
|
||
case 'php':
|
||
case 'html':
|
||
case 'css':
|
||
case 'json':
|
||
case 'xml':
|
||
case 'yaml':
|
||
case 'yml':
|
||
return Symbols.code;
|
||
default:
|
||
return Symbols.description;
|
||
}
|
||
}
|
||
|
||
class _SwipeToReply extends StatefulWidget {
|
||
final Widget child;
|
||
final bool isMe;
|
||
final VoidCallback onReply;
|
||
|
||
const _SwipeToReply({
|
||
required this.child,
|
||
required this.isMe,
|
||
required this.onReply,
|
||
});
|
||
|
||
@override
|
||
State<_SwipeToReply> createState() => _SwipeToReplyState();
|
||
}
|
||
|
||
class _SwipeToReplyState extends State<_SwipeToReply>
|
||
with SingleTickerProviderStateMixin {
|
||
static const double _maxDrag = 72.0;
|
||
static const double _triggerThreshold = 56.0;
|
||
|
||
late final AnimationController _springBack;
|
||
double _dragX = 0.0;
|
||
double _springFrom = 0.0;
|
||
bool _triggered = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_springBack = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 200),
|
||
)..addListener(() {
|
||
final t = Curves.easeOut.transform(_springBack.value);
|
||
setState(() => _dragX = _springFrom * (1 - t));
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_springBack.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onDragUpdate(DragUpdateDetails d) {
|
||
if (_springBack.isAnimating) _springBack.stop();
|
||
var next = _dragX + d.delta.dx;
|
||
if (next > 0) next = 0;
|
||
if (next < -_maxDrag) next = -_maxDrag;
|
||
final wasTriggered = _triggered;
|
||
_triggered = next <= -_triggerThreshold;
|
||
if (_triggered && !wasTriggered) Haptics.medium();
|
||
setState(() => _dragX = next);
|
||
}
|
||
|
||
void _onDragEnd(DragEndDetails d) {
|
||
if (_triggered) widget.onReply();
|
||
_triggered = false;
|
||
_springFrom = _dragX;
|
||
_springBack.forward(from: 0);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0);
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onHorizontalDragUpdate: _onDragUpdate,
|
||
onHorizontalDragEnd: _onDragEnd,
|
||
child: Stack(
|
||
alignment: Alignment.centerRight,
|
||
children: [
|
||
Positioned(
|
||
right: 16,
|
||
child: Opacity(
|
||
opacity: progress,
|
||
child: Transform.scale(
|
||
scale: 0.6 + 0.4 * progress,
|
||
child: Container(
|
||
padding: const EdgeInsets.all(6),
|
||
decoration: BoxDecoration(
|
||
color: cs.surfaceContainerHighest,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(Symbols.reply, size: 20, color: cs.primary),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Transform.translate(
|
||
offset: Offset(_dragX, 0),
|
||
child: widget.child,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SelectableMessageRow extends StatefulWidget {
|
||
final Widget child;
|
||
final CachedMessage message;
|
||
final bool isMe;
|
||
final ValueListenable<Set<String>> selectedIds;
|
||
final Animation<double> selectionAnim;
|
||
final bool Function() isSelectionActive;
|
||
final VoidCallback onToggleSelection;
|
||
final VoidCallback onEnterSelection;
|
||
final VoidCallback onDelete;
|
||
final VoidCallback? onEdit;
|
||
final VoidCallback? onReply;
|
||
final VoidCallback? onForward;
|
||
final VoidCallback? onMarkUnread;
|
||
final Future<List<({int id, String title})>> Function()? loadReportReasons;
|
||
final Future<bool> Function(int reasonId)? onReport;
|
||
|
||
const _SelectableMessageRow({
|
||
required this.child,
|
||
required this.message,
|
||
required this.isMe,
|
||
required this.selectedIds,
|
||
required this.selectionAnim,
|
||
required this.isSelectionActive,
|
||
required this.onToggleSelection,
|
||
required this.onEnterSelection,
|
||
required this.onDelete,
|
||
this.onEdit,
|
||
this.onReply,
|
||
this.onForward,
|
||
this.onMarkUnread,
|
||
this.loadReportReasons,
|
||
this.onReport,
|
||
});
|
||
|
||
@override
|
||
State<_SelectableMessageRow> createState() => _SelectableMessageRowState();
|
||
}
|
||
|
||
class _SelectableMessageRowState extends State<_SelectableMessageRow> {
|
||
static const double _gutterWidth = 40;
|
||
|
||
final GlobalKey _boundaryKey = GlobalKey();
|
||
Offset? _lastTapDown;
|
||
|
||
void _openMenu() {
|
||
final ctx = _boundaryKey.currentContext;
|
||
if (ctx == null) return;
|
||
final renderObject = ctx.findRenderObject();
|
||
if (renderObject is! RenderRepaintBoundary) return;
|
||
|
||
final origin = renderObject.localToGlobal(Offset.zero);
|
||
final rect = origin & renderObject.size;
|
||
final rawDpr = MediaQuery.of(ctx).devicePixelRatio;
|
||
final dpr = rawDpr > 2.0 ? 2.0 : rawDpr;
|
||
|
||
final ui.Image snapshot;
|
||
try {
|
||
snapshot = renderObject.toImageSync(pixelRatio: dpr);
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
|
||
Haptics.tap();
|
||
|
||
final controller = MessageActionsController();
|
||
showMessageActions(
|
||
context: ctx,
|
||
snapshot: snapshot,
|
||
originRect: rect,
|
||
tapPoint: _lastTapDown ?? rect.center,
|
||
isMe: widget.isMe,
|
||
messageText: widget.message.text,
|
||
controller: controller,
|
||
style: AppMessageActionsStyle.current.value,
|
||
interaction: MessageActionsInteraction.tap,
|
||
editHistory: widget.message.editHistory,
|
||
loadReportReasons: widget.loadReportReasons,
|
||
onReport: widget.onReport,
|
||
onDelete: widget.onDelete,
|
||
onEdit: widget.onEdit,
|
||
onReply: widget.onReply,
|
||
onForward: widget.onForward,
|
||
onMarkUnread: widget.onMarkUnread,
|
||
onDispose: controller.dispose,
|
||
);
|
||
}
|
||
|
||
void _onSecondaryTapDown(TapDownDetails details) {
|
||
final ctx = _boundaryKey.currentContext;
|
||
if (ctx == null) return;
|
||
final renderObject = ctx.findRenderObject();
|
||
if (renderObject is! RenderRepaintBoundary) return;
|
||
|
||
final origin = renderObject.localToGlobal(Offset.zero);
|
||
final rect = origin & renderObject.size;
|
||
|
||
final controller = MessageActionsController();
|
||
showMessageActions(
|
||
context: ctx,
|
||
originRect: rect,
|
||
tapPoint: details.globalPosition,
|
||
isMe: widget.isMe,
|
||
messageText: widget.message.text,
|
||
controller: controller,
|
||
style: MessageActionsStyle.list,
|
||
interaction: MessageActionsInteraction.click,
|
||
editHistory: widget.message.editHistory,
|
||
loadReportReasons: widget.loadReportReasons,
|
||
onReport: widget.onReport,
|
||
onDelete: widget.onDelete,
|
||
onEdit: widget.onEdit,
|
||
onReply: widget.onReply,
|
||
onForward: widget.onForward,
|
||
onMarkUnread: widget.onMarkUnread,
|
||
onDispose: controller.dispose,
|
||
);
|
||
}
|
||
|
||
void _handleTap() {
|
||
if (widget.isSelectionActive()) {
|
||
widget.onToggleSelection();
|
||
} else {
|
||
_openMenu();
|
||
}
|
||
}
|
||
|
||
void _handleLongPress() {
|
||
if (widget.isSelectionActive()) {
|
||
widget.onToggleSelection();
|
||
} else {
|
||
widget.onEnterSelection();
|
||
}
|
||
}
|
||
|
||
Widget _buildCheckCircle(bool selected, ColorScheme cs) {
|
||
return AnimatedContainer(
|
||
duration: const Duration(milliseconds: 160),
|
||
curve: Curves.easeOut,
|
||
width: 24,
|
||
height: 24,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: selected ? cs.primary : Colors.transparent,
|
||
border: Border.all(
|
||
color: selected
|
||
? cs.primary
|
||
: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||
width: 2,
|
||
),
|
||
),
|
||
child: selected
|
||
? Icon(Symbols.check, size: 16, weight: 700, color: cs.onPrimary)
|
||
: null,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (widget.message.isControl) return widget.child;
|
||
final cs = Theme.of(context).colorScheme;
|
||
|
||
return AnimatedBuilder(
|
||
animation: widget.selectionAnim,
|
||
builder: (context, _) {
|
||
final t = Curves.easeOut.transform(
|
||
widget.selectionAnim.value.clamp(0.0, 1.0),
|
||
);
|
||
return ValueListenableBuilder<Set<String>>(
|
||
valueListenable: widget.selectedIds,
|
||
builder: (context, selected, _) {
|
||
final isSelected = selected.contains(widget.message.id);
|
||
final active = selected.isNotEmpty;
|
||
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTapDown: (d) => _lastTapDown = d.globalPosition,
|
||
onTap: _handleTap,
|
||
onLongPress: _handleLongPress,
|
||
onSecondaryTapDown: active ? null : _onSecondaryTapDown,
|
||
child: ColoredBox(
|
||
color: isSelected
|
||
? cs.primary.withValues(alpha: 0.10)
|
||
: Colors.transparent,
|
||
child: Stack(
|
||
children: [
|
||
RepaintBoundary(
|
||
key: _boundaryKey,
|
||
child: IgnorePointer(
|
||
ignoring: active,
|
||
child: Padding(
|
||
padding: EdgeInsets.only(left: _gutterWidth * t),
|
||
child: widget.child,
|
||
),
|
||
),
|
||
),
|
||
if (t > 0)
|
||
Positioned(
|
||
left: 8,
|
||
bottom: 10,
|
||
child: Opacity(
|
||
opacity: t,
|
||
child: _buildCheckCircle(isSelected, cs),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DeletingMessageAnimation extends StatefulWidget {
|
||
final Widget child;
|
||
final VoidCallback onComplete;
|
||
|
||
const _DeletingMessageAnimation({
|
||
super.key,
|
||
required this.child,
|
||
required this.onComplete,
|
||
});
|
||
|
||
@override
|
||
State<_DeletingMessageAnimation> createState() =>
|
||
_DeletingMessageAnimationState();
|
||
}
|
||
|
||
class _DeletingMessageAnimationState extends State<_DeletingMessageAnimation>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _ctrl;
|
||
late final Animation<double> _opacity;
|
||
late final Animation<double> _scale;
|
||
late final Animation<double> _collapse;
|
||
bool _fired = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_ctrl = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 280),
|
||
);
|
||
_opacity = Tween<double>(begin: 1, end: 0).animate(
|
||
CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6)),
|
||
);
|
||
_scale = Tween<double>(begin: 1, end: 0.82).animate(
|
||
CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6)),
|
||
);
|
||
_collapse = Tween<double>(begin: 1, end: 0).animate(
|
||
CurvedAnimation(parent: _ctrl, curve: const Interval(0.35, 1.0, curve: Curves.easeInOut)),
|
||
);
|
||
_ctrl.forward().whenComplete(() {
|
||
if (_fired) return;
|
||
_fired = true;
|
||
widget.onComplete();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizeTransition(
|
||
sizeFactor: _collapse,
|
||
axisAlignment: 0.0,
|
||
child: FadeTransition(
|
||
opacity: _opacity,
|
||
child: ScaleTransition(
|
||
scale: _scale,
|
||
alignment: Alignment.center,
|
||
child: widget.child,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SentMessageAnimation extends StatefulWidget {
|
||
final Widget child;
|
||
final VoidCallback onComplete;
|
||
|
||
const _SentMessageAnimation({
|
||
super.key,
|
||
required this.child,
|
||
required this.onComplete,
|
||
});
|
||
|
||
@override
|
||
State<_SentMessageAnimation> createState() => _SentMessageAnimationState();
|
||
}
|
||
|
||
class _SentMessageAnimationState extends State<_SentMessageAnimation>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _ctrl;
|
||
late final Animation<double> _opacity;
|
||
late final Animation<double> _slide;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_ctrl = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 220),
|
||
);
|
||
_opacity = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
|
||
_slide = Tween<double>(
|
||
begin: 16,
|
||
end: 0,
|
||
).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||
_ctrl.forward().whenComplete(widget.onComplete);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AnimatedBuilder(
|
||
animation: _ctrl,
|
||
builder: (context, child) => Opacity(
|
||
opacity: _opacity.value,
|
||
child: Transform.translate(
|
||
offset: Offset(0, _slide.value),
|
||
child: child,
|
||
),
|
||
),
|
||
child: widget.child,
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
),
|
||
);
|
||
}
|
||
}
|