Files
tgex-backend/telegram_bot/screens/date_time_picker.go
2026-01-03 12:41:33 +03:00

502 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package screens
import (
"fmt"
"strings"
"time"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
)
const (
pickerViewCalendar = "calendar"
pickerViewMonths = "months"
pickerViewTimeHour = "time_hour"
pickerViewTimeMin = "time_minute"
pickerViewConfirm = "confirm"
dtpCallbackPrefix = "dtp:"
)
var mskLocation = time.FixedZone("MSK", 3*60*60)
type DateTimePickerConfig struct {
Title string
Key string
IncludeTime bool
Selected *time.Time
BackState bot.State
}
type DateTimeSelectionTarget interface {
SetDateTimeSelection(key string, value time.Time)
}
type DateTimePicker struct {
Title string
Key string
IncludeTime bool
BackState bot.State
view string
year int
month time.Month
selectedDate *time.Time
selectedHour *int
selectedMinute *int
}
func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker {
now := time.Now().In(mskLocation)
year := now.Year()
month := now.Month()
var selected *time.Time
if cfg.Selected != nil {
value := cfg.Selected.In(mskLocation)
selected = &value
year = value.Year()
month = value.Month()
}
return &DateTimePicker{
Title: cfg.Title,
Key: cfg.Key,
IncludeTime: cfg.IncludeTime,
BackState: cfg.BackState,
view: pickerViewCalendar,
year: year,
month: month,
selectedDate: selected,
}
}
func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
text := fmt.Sprintf("<i>… %s</i>\n\n<b>%s</b>\n\n", s.Title, s.Title)
switch s.view {
case pickerViewCalendar:
text += s.renderCalendarTitle()
b.Render(text, s.calendarKeyboard(), mode)
case pickerViewMonths:
text += "Выберите месяц\n\n"
b.Render(text, s.monthsKeyboard(), mode)
case pickerViewTimeHour:
text += fmt.Sprintf("Выбрано: <b>%s</b>\n\n", s.formatTimePreview(false))
text += "Выберите час\n\n"
b.Render(text, s.timeHourKeyboard(), mode)
case pickerViewTimeMin:
text += fmt.Sprintf("Выбрано: <b>%s</b>\n\n", s.formatTimePreview(false))
text += "Выберите минуты\n\n"
b.Render(text, s.timeMinuteKeyboard(), mode)
case pickerViewConfirm:
text += s.renderSelectedDateTime()
b.Render(text, s.confirmKeyboard(), mode)
}
}
func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
if data == "back" {
s.handleBack(b)
return
}
if data == "cancel" {
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
return
}
if !strings.HasPrefix(data, dtpCallbackPrefix) {
s.Enter(b, bot.EditMessage)
return
}
payload := strings.TrimPrefix(data, dtpCallbackPrefix)
switch {
case payload == "month_prev":
s.prevMonth()
case payload == "month_next":
s.nextMonth()
case payload == "open_months":
s.view = pickerViewMonths
case payload == "year_prev":
s.year--
case payload == "year_next":
s.year++
case strings.HasPrefix(payload, "month:"):
month := parseInt(strings.TrimPrefix(payload, "month:"))
if month >= 1 && month <= 12 {
s.month = time.Month(month)
s.view = pickerViewCalendar
}
case strings.HasPrefix(payload, "day:"):
s.handleDaySelect(payload)
case strings.HasPrefix(payload, "hour:"):
s.handleHourSelect(payload)
case strings.HasPrefix(payload, "min:"):
s.handleMinuteSelect(payload)
case payload == "confirm":
s.confirmSelection(b)
return
}
s.Enter(b, bot.EditMessage)
}
func (s *DateTimePicker) handleBack(b *bot.Bot) {
switch s.view {
case pickerViewMonths:
s.view = pickerViewCalendar
case pickerViewTimeHour:
s.view = pickerViewCalendar
case pickerViewTimeMin:
s.view = pickerViewTimeHour
case pickerViewConfirm:
if s.IncludeTime {
s.view = pickerViewTimeMin
} else {
s.view = pickerViewCalendar
}
default:
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
return
}
}
s.Enter(b, bot.EditMessage)
}
func (s *DateTimePicker) renderCalendarTitle() string {
header := monthName(s.month)
if s.selectedDate != nil {
header = fmt.Sprintf("%s\nВыбрано: %s", header, s.selectedDate.In(mskLocation).Format("02.01.2006"))
}
return header + "\n\n"
}
func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, bot.Row(
bot.Button("«", dtpCallbackPrefix+"year_prev"),
bot.Button(fmt.Sprintf("%d", s.year), "empty"),
bot.Button("»", dtpCallbackPrefix+"year_next"),
))
rows = append(rows, bot.Row(
bot.Button("←", dtpCallbackPrefix+"month_prev"),
bot.Button(monthName(s.month), dtpCallbackPrefix+"open_months"),
bot.Button("→", dtpCallbackPrefix+"month_next"),
))
rows = append(rows, bot.Row(
bot.Button("Пн", "empty"),
bot.Button("Вт", "empty"),
bot.Button("Ср", "empty"),
bot.Button("Чт", "empty"),
bot.Button("Пт", "empty"),
bot.Button("Сб", "empty"),
bot.Button("Вс", "empty"),
))
firstOfMonth := time.Date(s.year, s.month, 1, 0, 0, 0, 0, mskLocation)
weekday := int(firstOfMonth.Weekday())
if weekday == 0 {
weekday = 7
}
daysInMonth := daysInMonth(s.year, s.month)
today := time.Now().In(mskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, mskLocation)
var row []echotron.InlineKeyboardButton
for i := 1; i < weekday; i++ {
row = append(row, bot.Button(" ", "empty"))
}
for day := 1; day <= daysInMonth; day++ {
date := time.Date(s.year, s.month, day, 0, 0, 0, 0, mskLocation)
if date.Before(todayDate) {
row = append(row, bot.Button("-", "empty"))
} else {
label := fmt.Sprintf("%d", day)
row = append(row, bot.Button(label, fmt.Sprintf("%sday:%04d-%02d-%02d", dtpCallbackPrefix, s.year, int(s.month), day)))
}
if len(row) == 7 {
rows = append(rows, row)
row = nil
}
}
if len(row) > 0 {
for len(row) < 7 {
row = append(row, bot.Button(" ", "empty"))
}
rows = append(rows, row)
}
rows = append(rows, bot.Row(
bot.Button("← Назад", "back"),
bot.Button("Отмена", "cancel"),
))
return bot.Keyboard(rows...)
}
func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, bot.Row(
bot.Button("«", dtpCallbackPrefix+"year_prev"),
bot.Button(fmt.Sprintf("%d", s.year), "empty"),
bot.Button("»", dtpCallbackPrefix+"year_next"),
))
months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"}
for i := 0; i < 12; i += 3 {
rows = append(rows, bot.Row(
bot.Button(months[i], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)),
bot.Button(months[i+1], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)),
bot.Button(months[i+2], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)),
))
}
rows = append(rows, bot.Row(
bot.Button("← Назад", "back"),
))
return bot.Keyboard(rows...)
}
func (s *DateTimePicker) timeHourKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
for i := 0; i < 24; i += 4 {
rows = append(rows, bot.Row(
s.hourButton(i),
s.hourButton(i+1),
s.hourButton(i+2),
s.hourButton(i+3),
))
}
rows = append(rows, bot.Row(bot.Button("← Назад", "back")))
return bot.Keyboard(rows...)
}
func (s *DateTimePicker) timeMinuteKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
for i := 0; i < len(minutes); i += 4 {
rows = append(rows, bot.Row(
s.minuteButton(minutes[i]),
s.minuteButton(minutes[i+1]),
s.minuteButton(minutes[i+2]),
s.minuteButton(minutes[i+3]),
))
}
rows = append(rows, bot.Row(bot.Button("← Назад", "back")))
return bot.Keyboard(rows...)
}
func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
return bot.Keyboard(
bot.Row(
bot.Button("← Назад", "back"),
bot.Button("Готово", dtpCallbackPrefix+"confirm"),
),
)
}
func (s *DateTimePicker) renderSelectedDateTime() string {
if s.selectedDate == nil {
return "Дата не выбрана"
}
hour := 0
minute := 0
if s.selectedHour != nil {
hour = *s.selectedHour
}
if s.selectedMinute != nil {
minute = *s.selectedMinute
}
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, mskLocation)
return fmt.Sprintf("Выбрано: <b>%s</b>\n\nНажмите «Готово»", value.Format("02.01.2006 15:04"))
}
func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string {
hour := "__"
minute := "__"
if s.selectedHour != nil {
hour = fmt.Sprintf("%02d", *s.selectedHour)
} else if !showPlaceholders {
hour = "00"
}
if s.selectedMinute != nil {
minute = fmt.Sprintf("%02d", *s.selectedMinute)
} else if !showPlaceholders {
minute = "00"
}
return fmt.Sprintf("%s:%s", hour, minute)
}
func (s *DateTimePicker) handleDaySelect(payload string) {
datePart := strings.TrimPrefix(payload, "day:")
parsed, err := time.ParseInLocation("2006-01-02", datePart, mskLocation)
if err != nil {
return
}
s.selectedDate = &parsed
if s.IncludeTime {
s.view = pickerViewTimeHour
s.selectedHour = nil
s.selectedMinute = nil
} else {
s.view = pickerViewConfirm
}
}
func (s *DateTimePicker) handleHourSelect(payload string) {
hour := parseInt(strings.TrimPrefix(payload, "hour:"))
if hour < 0 || hour > 23 {
return
}
if s.isHourDisabled(hour) {
return
}
s.selectedHour = &hour
s.view = pickerViewTimeMin
}
func (s *DateTimePicker) handleMinuteSelect(payload string) {
minute := parseInt(strings.TrimPrefix(payload, "min:"))
if minute < 0 || minute > 59 {
return
}
if s.isMinuteDisabled(minute) {
return
}
s.selectedMinute = &minute
s.view = pickerViewConfirm
}
func (s *DateTimePicker) confirmSelection(b *bot.Bot) {
if s.selectedDate == nil {
return
}
hour := 0
minute := 0
if s.selectedHour != nil {
hour = *s.selectedHour
}
if s.selectedMinute != nil {
minute = *s.selectedMinute
}
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, mskLocation)
if target, ok := s.BackState.(DateTimeSelectionTarget); ok {
target.SetDateTimeSelection(s.Key, value)
}
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
}
func (s *DateTimePicker) hourButton(hour int) echotron.InlineKeyboardButton {
label := fmt.Sprintf("%02d", hour)
if s.isHourDisabled(hour) {
return bot.Button("-", "empty")
}
if s.selectedHour != nil && *s.selectedHour == hour {
label = "● " + label
}
return bot.Button(label, fmt.Sprintf("%shour:%02d", dtpCallbackPrefix, hour))
}
func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton {
label := fmt.Sprintf("%02d", minute)
if s.isMinuteDisabled(minute) {
return bot.Button("-", "empty")
}
if s.selectedMinute != nil && *s.selectedMinute == minute {
label = "● " + label
}
return bot.Button(label, fmt.Sprintf("%smin:%02d", dtpCallbackPrefix, minute))
}
func (s *DateTimePicker) isHourDisabled(hour int) bool {
if s.selectedDate == nil {
return false
}
today := time.Now().In(mskLocation)
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, mskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, mskLocation)
if date.After(todayDate) {
return false
}
return hour < today.Hour()
}
func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
if s.selectedDate == nil || s.selectedHour == nil {
return false
}
today := time.Now().In(mskLocation)
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, mskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, mskLocation)
if date.After(todayDate) {
return false
}
if *s.selectedHour > today.Hour() {
return false
}
return minute < today.Minute()
}
func (s *DateTimePicker) prevMonth() {
if s.month == time.January {
s.month = time.December
s.year--
} else {
s.month--
}
}
func (s *DateTimePicker) nextMonth() {
if s.month == time.December {
s.month = time.January
s.year++
} else {
s.month++
}
}
func daysInMonth(year int, month time.Month) int {
return time.Date(year, month+1, 0, 0, 0, 0, 0, mskLocation).Day()
}
func monthName(month time.Month) string {
names := []string{
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
}
if int(month) < 1 || int(month) > len(names) {
return ""
}
return names[int(month)-1]
}
func parseInt(value string) int {
result := 0
for _, r := range value {
if r < '0' || r > '9' {
return 0
}
result = result*10 + int(r-'0')
}
return result
}
func (s *DateTimePicker) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return }