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("… › %s\n\n%s\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("Выбрано: %s\n\n", s.formatTimePreview(false)) text += "Выберите час\n\n" b.Render(text, s.timeHourKeyboard(), mode) case pickerViewTimeMin: text += fmt.Sprintf("Выбрано: %s\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("Выбрано: %s\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 }