From 10d55865127f605b42c139fbb20d938992a0d025 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 13 Jun 2026 09:50:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=D0=BF=D0=B8=D0=BA=D0=B5=D1=80=20?= =?UTF-8?q?=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=B1=D0=B0=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=B0=D0=BD=D0=BE=D0=BC=20+=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0=20=D0=BE=D1=82=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BE=20=D0=BD=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 108 ++++---- .../chats/scheduled_messages_screen.dart | 31 +-- .../widgets/schedule_time_picker.dart | 245 ++++++++++++++++++ 3 files changed, 306 insertions(+), 78 deletions(-) create mode 100644 lib/frontend/widgets/schedule_time_picker.dart diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index c35de32..2fcb198 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -43,6 +43,7 @@ import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; import '../../widgets/attachment/attachment_sheet.dart'; import '../../widgets/swipe_to_pop.dart'; +import '../../widgets/schedule_time_picker.dart'; import 'scheduled_messages_screen.dart'; class _UploadStatus { @@ -110,6 +111,7 @@ class _ChatScreenState extends State final Map?>> _reactionNotifiers = {}; final Map>> _photoUploadProgress = {}; + final ValueNotifier _scheduledCount = ValueNotifier(0); ValueListenable>? _photoProgressFor(CachedMessage m) => _photoUploadProgress[m.id]; @@ -194,7 +196,10 @@ class _ChatScreenState extends State _showAttachmentPanel.addListener(_onAttachPanelToggle); _pushSub = api.pushStream .where( - (p) => p.opcode == Opcode.notifMark || p.opcode == Opcode.notifTyping, + (p) => + p.opcode == Opcode.notifMark || + p.opcode == Opcode.notifTyping || + p.opcode == Opcode.notifMsgDelayed, ) .listen(_onIncomingPush); _messageEventSub = ChatsModule.messageEvents @@ -330,6 +335,7 @@ class _ChatScreenState extends State if (widget.chatType == 'DIALOG') { unawaited(_loadOtherPresence()); } + unawaited(_refreshScheduledCount()); await _loadRemainingHistory(); } @@ -478,6 +484,7 @@ class _ChatScreenState extends State _floatingDateAnimController.dispose(); _floatingDate.dispose(); _hasText.dispose(); + _scheduledCount.dispose(); _showAttachmentPanel.removeListener(_onAttachPanelToggle); _showAttachmentPanel.dispose(); _uploadSub?.cancel(); @@ -669,9 +676,25 @@ class _ChatScreenState extends State _onMessageRead(packet); case Opcode.notifTyping: _onTyping(packet); + case Opcode.notifMsgDelayed: + final p = packet.payload; + if (p is Map && p['chatId'] == widget.chatId) { + _refreshScheduledCount(); + } } } + Future _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++; @@ -1066,9 +1089,14 @@ class _ChatScreenState extends State ], ), actions: [ - IconButton( - icon: const Icon(Symbols.schedule, weight: 400), - onPressed: _openScheduledMessages, + ValueListenableBuilder( + valueListenable: _scheduledCount, + builder: (_, count, _) => count > 0 + ? IconButton( + icon: const Icon(Symbols.schedule, weight: 400), + onPressed: _openScheduledMessages, + ) + : const SizedBox.shrink(), ), IconButton( icon: const Icon(Symbols.call, weight: 400), @@ -1339,47 +1367,22 @@ class _ChatScreenState extends State } } - Future _pickScheduleTime() async { - final now = DateTime.now(); - final suggested = now.add(const Duration(hours: 1)); - final date = await showDatePicker( - context: context, - initialDate: suggested, - firstDate: now, - lastDate: now.add(const Duration(days: 365)), - ); - if (date == null || !mounted) return null; - final time = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(suggested), - ); - if (time == null) return null; - final result = DateTime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - ); - if (!result.isAfter(DateTime.now())) { - if (mounted) { - showCustomNotification(context, 'Время должно быть в будущем'); - } - return null; - } - return result; - } + Future _pickScheduleTime() => showScheduleTimePicker(context); void _openScheduledMessages() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ScheduledMessagesScreen( - chatId: widget.chatId, - accountId: _myId, - chatName: widget.name, - ), - ), - ); + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => ScheduledMessagesScreen( + chatId: widget.chatId, + accountId: _myId, + chatName: widget.name, + ), + ), + ) + .then((_) { + if (mounted) _refreshScheduledCount(); + }); } Future _persistOutgoing(CachedMessage msg, {String? removeId}) async { @@ -1802,13 +1805,18 @@ class _ChatScreenState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: Icon( - Symbols.schedule, - weight: 500, - color: cs.onSurface, - ), - onPressed: _openScheduledMessages, + ValueListenableBuilder( + valueListenable: _scheduledCount, + builder: (_, count, _) => count > 0 + ? IconButton( + icon: Icon( + Symbols.schedule, + weight: 500, + color: cs.onSurface, + ), + onPressed: _openScheduledMessages, + ) + : const SizedBox.shrink(), ), IconButton( icon: Icon( diff --git a/lib/frontend/screens/chats/scheduled_messages_screen.dart b/lib/frontend/screens/chats/scheduled_messages_screen.dart index a557e8a..87b369f 100644 --- a/lib/frontend/screens/chats/scheduled_messages_screen.dart +++ b/lib/frontend/screens/chats/scheduled_messages_screen.dart @@ -12,6 +12,7 @@ import '../../../core/utils/haptics.dart'; import '../../../main.dart'; import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/schedule_time_picker.dart'; class ScheduledMessagesScreen extends StatefulWidget { final int chatId; @@ -69,34 +70,8 @@ class _ScheduledMessagesScreenState extends State { }); } - Future _pickTime(DateTime initial) async { - final now = DateTime.now(); - final base = initial.isAfter(now) ? initial : now.add(const Duration(hours: 1)); - final date = await showDatePicker( - context: context, - initialDate: base, - firstDate: now, - lastDate: now.add(const Duration(days: 365)), - ); - if (date == null || !mounted) return null; - final time = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(base), - ); - if (time == null) return null; - final result = DateTime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - ); - if (!result.isAfter(DateTime.now())) { - if (mounted) showCustomNotification(context, 'Время должно быть в будущем'); - return null; - } - return result; - } + Future _pickTime(DateTime initial) => + showScheduleTimePicker(context, initial: initial, title: 'Когда отправить'); Future _edit(CachedMessage msg) async { final controller = TextEditingController(text: msg.text ?? ''); diff --git a/lib/frontend/widgets/schedule_time_picker.dart b/lib/frontend/widgets/schedule_time_picker.dart new file mode 100644 index 0000000..61b5037 --- /dev/null +++ b/lib/frontend/widgets/schedule_time_picker.dart @@ -0,0 +1,245 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../../core/utils/format.dart'; +import 'custom_notification.dart'; + +const List _weekdayShort = [ + 'пн', + 'вт', + 'ср', + 'чт', + 'пт', + 'сб', + 'вс', +]; + +String _two(int n) => n.toString().padLeft(2, '0'); + +/// Барабан выбора времени отправки («Отправить позже»): три колонки — +/// день, час, минута. Возвращает выбранный момент в будущем или null. +Future showScheduleTimePicker( + BuildContext context, { + DateTime? initial, + String title = 'Отправить позже', +}) { + return showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => _ScheduleSheet(initial: initial, title: title), + ); +} + +class _ScheduleSheet extends StatefulWidget { + final DateTime? initial; + final String title; + + const _ScheduleSheet({required this.initial, required this.title}); + + @override + State<_ScheduleSheet> createState() => _ScheduleSheetState(); +} + +class _ScheduleSheetState extends State<_ScheduleSheet> { + static const int _dayCount = 366; + + late final DateTime _today; + late final FixedExtentScrollController _dayCtrl; + late final FixedExtentScrollController _hourCtrl; + late final FixedExtentScrollController _minuteCtrl; + + late int _dayIndex; + late int _hour; + late int _minute; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _today = DateTime(now.year, now.month, now.day); + final base = (widget.initial != null && widget.initial!.isAfter(now)) + ? widget.initial! + : now.add(const Duration(minutes: 1)); + + _dayIndex = DateTime( + base.year, + base.month, + base.day, + ).difference(_today).inDays.clamp(0, _dayCount - 1); + _hour = base.hour; + _minute = base.minute; + + _dayCtrl = FixedExtentScrollController(initialItem: _dayIndex); + _hourCtrl = FixedExtentScrollController(initialItem: _hour); + _minuteCtrl = FixedExtentScrollController(initialItem: _minute); + } + + @override + void dispose() { + _dayCtrl.dispose(); + _hourCtrl.dispose(); + _minuteCtrl.dispose(); + super.dispose(); + } + + String _dayLabel(int index) { + if (index == 0) return 'Сегодня'; + if (index == 1) return 'Завтра'; + final d = _today.add(Duration(days: index)); + return '${_weekdayShort[d.weekday - 1]}, ${d.day} ${kRuMonthsShort[d.month - 1]}.'; + } + + DateTime get _selected => DateTime( + _today.year, + _today.month, + _today.day + _dayIndex, + _hour, + _minute, + ); + + String get _buttonLabel { + final s = _selected; + final day = _dayIndex == 0 + ? 'сегодня' + : _dayIndex == 1 + ? 'завтра' + : '${s.day} ${kRuMonthsShort[s.month - 1]}'; + return 'Отправить $day в ${formatClock(s)}'; + } + + void _confirm() { + final result = _selected; + if (!result.isAfter(DateTime.now())) { + showCustomNotification(context, 'Время должно быть в будущем'); + return; + } + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + widget.title, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 190, + child: Row( + children: [ + _wheel( + cs: cs, + controller: _dayCtrl, + count: _dayCount, + flex: 3, + align: Alignment.centerLeft, + onChanged: (i) => _dayIndex = i, + label: _dayLabel, + ), + _wheel( + cs: cs, + controller: _hourCtrl, + count: 24, + onChanged: (i) => _hour = i, + label: _two, + ), + _wheel( + cs: cs, + controller: _minuteCtrl, + count: 60, + onChanged: (i) => _minute = i, + label: _two, + ), + ], + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + onPressed: _confirm, + child: Text( + _buttonLabel, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _wheel({ + required ColorScheme cs, + required FixedExtentScrollController controller, + required int count, + required void Function(int) onChanged, + required String Function(int) label, + int flex = 1, + Alignment align = Alignment.center, + }) { + return Expanded( + flex: flex, + child: CupertinoPicker( + scrollController: controller, + itemExtent: 40, + squeeze: 1.1, + diameterRatio: 1.5, + backgroundColor: Colors.transparent, + selectionOverlay: CupertinoPickerDefaultSelectionOverlay( + background: cs.primary.withValues(alpha: 0.07), + ), + onSelectedItemChanged: (i) => setState(() => onChanged(i)), + children: List.generate( + count, + (i) => Align( + alignment: align, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Text( + label(i), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: cs.onSurface, fontSize: 18), + ), + ), + ), + ), + ), + ); + } +}