502 lines
13 KiB
Go
502 lines
13 KiB
Go
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 }
|