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_panel.dart';
|
||||||
import '../../widgets/attachment/attachment_sheet.dart';
|
import '../../widgets/attachment/attachment_sheet.dart';
|
||||||
import '../../widgets/swipe_to_pop.dart';
|
import '../../widgets/swipe_to_pop.dart';
|
||||||
|
import '../../widgets/schedule_time_picker.dart';
|
||||||
import 'scheduled_messages_screen.dart';
|
import 'scheduled_messages_screen.dart';
|
||||||
|
|
||||||
class _UploadStatus {
|
class _UploadStatus {
|
||||||
@@ -110,6 +111,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers =
|
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers =
|
||||||
{};
|
{};
|
||||||
final Map<String, ValueNotifier<List<double>>> _photoUploadProgress = {};
|
final Map<String, ValueNotifier<List<double>>> _photoUploadProgress = {};
|
||||||
|
final ValueNotifier<int> _scheduledCount = ValueNotifier(0);
|
||||||
|
|
||||||
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
|
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
|
||||||
_photoUploadProgress[m.id];
|
_photoUploadProgress[m.id];
|
||||||
@@ -194,7 +196,10 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
||||||
_pushSub = api.pushStream
|
_pushSub = api.pushStream
|
||||||
.where(
|
.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);
|
.listen(_onIncomingPush);
|
||||||
_messageEventSub = ChatsModule.messageEvents
|
_messageEventSub = ChatsModule.messageEvents
|
||||||
@@ -330,6 +335,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
if (widget.chatType == 'DIALOG') {
|
if (widget.chatType == 'DIALOG') {
|
||||||
unawaited(_loadOtherPresence());
|
unawaited(_loadOtherPresence());
|
||||||
}
|
}
|
||||||
|
unawaited(_refreshScheduledCount());
|
||||||
await _loadRemainingHistory();
|
await _loadRemainingHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +484,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_floatingDateAnimController.dispose();
|
_floatingDateAnimController.dispose();
|
||||||
_floatingDate.dispose();
|
_floatingDate.dispose();
|
||||||
_hasText.dispose();
|
_hasText.dispose();
|
||||||
|
_scheduledCount.dispose();
|
||||||
_showAttachmentPanel.removeListener(_onAttachPanelToggle);
|
_showAttachmentPanel.removeListener(_onAttachPanelToggle);
|
||||||
_showAttachmentPanel.dispose();
|
_showAttachmentPanel.dispose();
|
||||||
_uploadSub?.cancel();
|
_uploadSub?.cancel();
|
||||||
@@ -669,9 +676,25 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_onMessageRead(packet);
|
_onMessageRead(packet);
|
||||||
case Opcode.notifTyping:
|
case Opcode.notifTyping:
|
||||||
_onTyping(packet);
|
_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() {
|
void _bumpMessages() {
|
||||||
_combinedItemsCache = null;
|
_combinedItemsCache = null;
|
||||||
_messagesRev.value++;
|
_messagesRev.value++;
|
||||||
@@ -1066,9 +1089,14 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
ValueListenableBuilder<int>(
|
||||||
icon: const Icon(Symbols.schedule, weight: 400),
|
valueListenable: _scheduledCount,
|
||||||
onPressed: _openScheduledMessages,
|
builder: (_, count, _) => count > 0
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Symbols.schedule, weight: 400),
|
||||||
|
onPressed: _openScheduledMessages,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.call, weight: 400),
|
icon: const Icon(Symbols.call, weight: 400),
|
||||||
@@ -1339,47 +1367,22 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DateTime?> _pickScheduleTime() async {
|
Future<DateTime?> _pickScheduleTime() => showScheduleTimePicker(context);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openScheduledMessages() {
|
void _openScheduledMessages() {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context)
|
||||||
MaterialPageRoute(
|
.push(
|
||||||
builder: (_) => ScheduledMessagesScreen(
|
MaterialPageRoute(
|
||||||
chatId: widget.chatId,
|
builder: (_) => ScheduledMessagesScreen(
|
||||||
accountId: _myId,
|
chatId: widget.chatId,
|
||||||
chatName: widget.name,
|
accountId: _myId,
|
||||||
),
|
chatName: widget.name,
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
)
|
||||||
|
.then((_) {
|
||||||
|
if (mounted) _refreshScheduledCount();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _persistOutgoing(CachedMessage msg, {String? removeId}) async {
|
Future<void> _persistOutgoing(CachedMessage msg, {String? removeId}) async {
|
||||||
@@ -1802,13 +1805,18 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
ValueListenableBuilder<int>(
|
||||||
icon: Icon(
|
valueListenable: _scheduledCount,
|
||||||
Symbols.schedule,
|
builder: (_, count, _) => count > 0
|
||||||
weight: 500,
|
? IconButton(
|
||||||
color: cs.onSurface,
|
icon: Icon(
|
||||||
),
|
Symbols.schedule,
|
||||||
onPressed: _openScheduledMessages,
|
weight: 500,
|
||||||
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
|
onPressed: _openScheduledMessages,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../../core/utils/haptics.dart';
|
|||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
import '../../widgets/confirm_dialog.dart';
|
import '../../widgets/confirm_dialog.dart';
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
|
import '../../widgets/schedule_time_picker.dart';
|
||||||
|
|
||||||
class ScheduledMessagesScreen extends StatefulWidget {
|
class ScheduledMessagesScreen extends StatefulWidget {
|
||||||
final int chatId;
|
final int chatId;
|
||||||
@@ -69,34 +70,8 @@ class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DateTime?> _pickTime(DateTime initial) async {
|
Future<DateTime?> _pickTime(DateTime initial) =>
|
||||||
final now = DateTime.now();
|
showScheduleTimePicker(context, initial: initial, title: 'Когда отправить');
|
||||||
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<void> _edit(CachedMessage msg) async {
|
Future<void> _edit(CachedMessage msg) async {
|
||||||
final controller = TextEditingController(text: msg.text ?? '');
|
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