feat(chat): пикер времени барабаном + кнопка отложенных по наличию
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user