feat(chat): пикер времени барабаном + кнопка отложенных по наличию

This commit is contained in:
klockky
2026-06-13 09:51:51 +00:00
parent ebc22cd8bd
commit 10d5586512
3 changed files with 306 additions and 78 deletions
+58 -50
View File
@@ -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<ChatScreen>
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers =
{};
final Map<String, ValueNotifier<List<double>>> _photoUploadProgress = {};
final ValueNotifier<int> _scheduledCount = ValueNotifier(0);
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
_photoUploadProgress[m.id];
@@ -194,7 +196,10 @@ class _ChatScreenState extends State<ChatScreen>
_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<ChatScreen>
if (widget.chatType == 'DIALOG') {
unawaited(_loadOtherPresence());
}
unawaited(_refreshScheduledCount());
await _loadRemainingHistory();
}
@@ -478,6 +484,7 @@ class _ChatScreenState extends State<ChatScreen>
_floatingDateAnimController.dispose();
_floatingDate.dispose();
_hasText.dispose();
_scheduledCount.dispose();
_showAttachmentPanel.removeListener(_onAttachPanelToggle);
_showAttachmentPanel.dispose();
_uploadSub?.cancel();
@@ -669,9 +676,25 @@ class _ChatScreenState extends State<ChatScreen>
_onMessageRead(packet);
case Opcode.notifTyping:
_onTyping(packet);
case Opcode.notifMsgDelayed:
final p = packet.payload;
if (p is Map && p['chatId'] == widget.chatId) {
_refreshScheduledCount();
}
}
}
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++;
@@ -1066,9 +1089,14 @@ class _ChatScreenState extends State<ChatScreen>
],
),
actions: [
IconButton(
icon: const Icon(Symbols.schedule, weight: 400),
onPressed: _openScheduledMessages,
ValueListenableBuilder<int>(
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<ChatScreen>
}
}
Future<DateTime?> _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<DateTime?> _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<void> _persistOutgoing(CachedMessage msg, {String? removeId}) async {
@@ -1802,13 +1805,18 @@ class _ChatScreenState extends State<ChatScreen>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
Symbols.schedule,
weight: 500,
color: cs.onSurface,
),
onPressed: _openScheduledMessages,
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(
@@ -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<ScheduledMessagesScreen> {
});
}
Future<DateTime?> _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<DateTime?> _pickTime(DateTime initial) =>
showScheduleTimePicker(context, initial: initial, title: 'Когда отправить');
Future<void> _edit(CachedMessage msg) async {
final controller = TextEditingController(text: msg.text ?? '');
@@ -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<String> _weekdayShort = [
'пн',
'вт',
'ср',
'чт',
'пт',
'сб',
'вс',
];
String _two(int n) => n.toString().padLeft(2, '0');
/// Барабан выбора времени отправки («Отправить позже»): три колонки —
/// день, час, минута. Возвращает выбранный момент в будущем или null.
Future<DateTime?> showScheduleTimePicker(
BuildContext context, {
DateTime? initial,
String title = 'Отправить позже',
}) {
return showModalBottomSheet<DateTime>(
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),
),
),
),
),
),
);
}
}