package ui import ( "fmt" "strings" "time" "woke/db" "woke/player" "woke/scheduler" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // View modes type viewMode int const ( viewMain viewMode = iota viewForm viewConfirmDelete ) // Messages type tickMsg time.Time type alarmFiredMsg scheduler.AlarmEvent // Model is the main bubbletea model. type Model struct { store *db.Store scheduler *scheduler.Scheduler player *player.Player // State alarms []db.Alarm cursor int mode viewMode width int height int now time.Time // Alarm firing state firingAlarm *db.Alarm firingBlink bool // Form state form *formModel // Error/status statusMsg string } func NewModel(store *db.Store, sched *scheduler.Scheduler, pl *player.Player) Model { return Model{ store: store, scheduler: sched, player: pl, now: time.Now(), } } func (m Model) Init() tea.Cmd { return tea.Batch( tickEverySecond(), listenForAlarms(m.scheduler), m.loadAlarms, ) } func tickEverySecond() tea.Cmd { return tea.Every(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } func listenForAlarms(sched *scheduler.Scheduler) tea.Cmd { return func() tea.Msg { event := <-sched.Events() return alarmFiredMsg(event) } } func (m *Model) loadAlarms() tea.Msg { alarms, err := m.store.ListAlarms() if err != nil { return nil } m.alarms = alarms return nil } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil case tickMsg: m.now = time.Time(msg) if m.firingAlarm != nil { m.firingBlink = !m.firingBlink } return m, tickEverySecond() case alarmFiredMsg: alarm := msg.Alarm m.firingAlarm = &alarm m.firingBlink = true m.player.Play(alarm.SoundPath) m.refreshAlarms() return m, listenForAlarms(m.scheduler) case tea.KeyMsg: return m.handleKey(msg) } // Forward to form if active if m.mode == viewForm && m.form != nil { return m.form.Update(msg, &m) } return m, nil } func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() // Global: quit if key == "ctrl+c" { return m, tea.Quit } // If alarm is firing, handle dismiss/snooze if m.firingAlarm != nil { switch key { case "enter", " ", "d": m.player.Stop() m.firingAlarm = nil m.statusMsg = "Alarm dismissed" case "s": m.player.Stop() m.firingAlarm = nil m.statusMsg = "Snoozed for 5 minutes (not yet implemented)" // TODO: implement snooze by creating a one-shot alarm 5min from now } return m, nil } // Form mode if m.mode == viewForm && m.form != nil { return m.form.HandleKey(msg, &m) } // Confirm delete mode if m.mode == viewConfirmDelete { switch key { case "y", "Y": if m.cursor < len(m.alarms) { _ = m.store.DeleteAlarm(m.alarms[m.cursor].ID) m.refreshAlarms() if m.cursor >= len(m.alarms) && m.cursor > 0 { m.cursor-- } m.statusMsg = "Alarm deleted" } m.mode = viewMain case "n", "N", "escape", "esc": m.mode = viewMain m.statusMsg = "" } return m, nil } // Main view keys switch key { case "q": return m, tea.Quit case "j", "down": if m.cursor < len(m.alarms)-1 { m.cursor++ } case "k", "up": if m.cursor > 0 { m.cursor-- } case "g": m.cursor = 0 case "G": if len(m.alarms) > 0 { m.cursor = len(m.alarms) - 1 } case " ": // Toggle enabled if m.cursor < len(m.alarms) { _ = m.store.ToggleAlarm(m.alarms[m.cursor].ID) m.refreshAlarms() } case "a": m.form = newFormModel(nil) m.mode = viewForm m.statusMsg = "" case "e": if m.cursor < len(m.alarms) { alarm := m.alarms[m.cursor] m.form = newFormModel(&alarm) m.mode = viewForm m.statusMsg = "" } case "d": if m.cursor < len(m.alarms) { m.mode = viewConfirmDelete m.statusMsg = fmt.Sprintf("Delete '%s'? (y/n)", m.alarms[m.cursor].Name) } } return m, nil } func (m *Model) refreshAlarms() { alarms, err := m.store.ListAlarms() if err == nil { m.alarms = alarms } } func (m Model) View() string { if m.width == 0 { return "Loading..." } var sections []string // Big clock clockStr := RenderBigClock(m.now) if m.firingAlarm != nil && m.firingBlink { clockStr = ClockAlarmStyle.Render(clockStr) } else { clockStr = ClockStyle.Render(clockStr) } clockStr = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, clockStr) sections = append(sections, clockStr) // Date line dateLine := StatusStyle.Render(m.now.Format("Monday, 02 January 2006")) dateLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, dateLine) sections = append(sections, dateLine) // Firing alarm overlay if m.firingAlarm != nil { firingText := fmt.Sprintf("🔔 ALARM: %s 🔔", m.firingAlarm.Name) if m.firingAlarm.Description != "" { firingText += "\n" + m.firingAlarm.Description } firingText += "\n\n[Enter/Space/d] Dismiss [s] Snooze" styled := AlarmFiringStyle.Render(firingText) styled = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, styled) sections = append(sections, "", styled) } // Divider divider := DividerStyle.Render(strings.Repeat("─", min(m.width, 80))) divider = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, divider) sections = append(sections, "", divider, "") // Form view if m.mode == viewForm && m.form != nil { formView := m.form.View() formView = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, formView) sections = append(sections, formView) } else { // Alarm list alarmList := m.renderAlarmList() alarmList = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, alarmList) sections = append(sections, alarmList) } // Status message if m.statusMsg != "" { status := StatusStyle.Render(m.statusMsg) sections = append(sections, "", lipgloss.PlaceHorizontal(m.width, lipgloss.Center, status)) } // Help bar at the bottom help := m.renderHelp() sections = append(sections, "", lipgloss.PlaceHorizontal(m.width, lipgloss.Center, help)) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Top, strings.Join(sections, "\n"), ) } func (m Model) renderAlarmList() string { if len(m.alarms) == 0 { return StatusStyle.Render("No alarms. Press 'a' to add one.") } title := TitleStyle.Render("Alarms") var lines []string lines = append(lines, title, "") for i, a := range m.alarms { cursor := " " if i == m.cursor { cursor = "▸ " } status := "●" var style lipgloss.Style if a.Enabled { style = AlarmEnabledStyle } else { style = AlarmDisabledStyle status = "○" } if i == m.cursor { style = AlarmSelectedStyle } trigger := a.Time if a.Trigger != "once" { trigger = a.Trigger } line := fmt.Sprintf("%s%s %s %-20s %s", cursor, status, trigger, a.Name, a.Description) lines = append(lines, style.Render(line)) } return strings.Join(lines, "\n") } func (m Model) renderHelp() string { if m.mode == viewForm { return HelpStyle.Render("Tab: next field Shift+Tab: prev field Enter: save Esc: cancel") } if m.mode == viewConfirmDelete { return HelpStyle.Render("y: confirm delete n: cancel") } return HelpStyle.Render("j/k: navigate a: add e: edit d: delete space: toggle q: quit") } func min(a, b int) int { if a < b { return a } return b }