package tui import ( "errors" "fmt" "strconv" "strings" "time" "punchcard/internal/reports" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type suggestionType int const ( noSuggestions suggestionType = iota suggestClients suggestProjects suggestReportType ) type FormField struct { textinput.Model label string suggestions suggestionType } func (ff FormField) Update(msg tea.Msg) (FormField, tea.Cmd) { field, cmd := ff.Model.Update(msg) ff.Model = field return ff, cmd } func newTimestampField(label string) FormField { f := FormField{ Model: textinput.New(), label: label, } f.Validate = func(s string) error { if _, err := time.Parse(time.DateTime, s); err != nil { return fmt.Errorf("timestamps must be written like \"%s\"", time.DateTime) } return nil } return f } func newOptionalTimestampField(label string) FormField { f := FormField{ Model: textinput.New(), label: label, } f.Validate = func(s string) error { if s == "" { return nil } if _, err := time.Parse(time.DateTime, s); err != nil { return fmt.Errorf("timestamps must be written like \"%s\"", time.DateTime) } return nil } return f } func newOptionalFloatField(label string) FormField { f := FormField{ Model: textinput.New(), label: label, } f.Validate = func(s string) error { if s == "" { return nil } _, err := strconv.ParseFloat(s, 64) if err != nil { return errors.New("numerical values only") } return nil } return f } func newDateRangeField(label string) FormField { f := FormField{ Model: textinput.New(), label: label, } f.Validate = func(s string) error { if s == "" { return errors.New("date range is required") } _, err := reports.ParseDateRange(s) if err != nil { return fmt.Errorf("invalid date range: %v", err) } return nil } return f } func newReportTypeField(label string) FormField { f := FormField{ Model: textinput.New(), label: label, suggestions: suggestReportType, } f.Validate = func(s string) error { switch strings.ToLower(s) { case "invoice", "timesheet", "unified": return nil } return errors.New("pick one of invoice, timesheet, or unified") } return f } type Form struct { fields []FormField selIdx int err error SelectedStyle *lipgloss.Style UnselectedStyle *lipgloss.Style } func NewForm(fields []FormField) Form { return Form{fields: fields} } func (f Form) Error() error { for _, field := range f.fields { if field.Err != nil { return field.Err } } return nil } func NewEntryEditorForm() Form { form := NewForm([]FormField{ newTimestampField("Start time"), newOptionalTimestampField("End time"), {Model: textinput.New(), label: "Client", suggestions: suggestClients}, {Model: textinput.New(), label: "Project", suggestions: suggestProjects}, {Model: textinput.New(), label: "Description"}, newOptionalFloatField("Hourly Rate"), }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func NewClientForm() Form { form := NewForm([]FormField{ {Model: textinput.New(), label: "Name"}, {Model: textinput.New(), label: "Email"}, newOptionalFloatField("Hourly Rate"), }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func NewProjectForm() Form { form := NewForm([]FormField{ {Model: textinput.New(), label: "Name"}, {Model: textinput.New(), label: "Client", suggestions: suggestClients}, newOptionalFloatField("Hourly Rate"), }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func NewHistoryFilterForm() Form { form := NewForm([]FormField{ newDateRangeField("Date Range"), {Model: textinput.New(), label: "Client (optional)", suggestions: suggestClients}, {Model: textinput.New(), label: "Project (optional)", suggestions: suggestProjects}, }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func NewGenerateReportForm() Form { form := NewForm([]FormField{ newReportTypeField("Report Type"), newDateRangeField("Date Range"), {Model: textinput.New(), label: "Client", suggestions: suggestClients}, {Model: textinput.New(), label: "Project (optional)", suggestions: suggestProjects}, {Model: textinput.New(), label: "Output Path (optional)"}, {Model: textinput.New(), label: "Timezone (optional)"}, }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func (f Form) Update(msg tea.Msg) (Form, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { case "tab": f.fields[f.selIdx].Blur() f.selIdx = (f.selIdx + 1) % len(f.fields) return f, f.fields[f.selIdx].Focus() case "shift+tab": f.fields[f.selIdx].Blur() f.selIdx-- if f.selIdx < 0 { f.selIdx += len(f.fields) } return f, f.fields[f.selIdx].Focus() } } field, cmd := f.fields[f.selIdx].Update(msg) f.fields[f.selIdx] = field return f, cmd } func (f Form) View() string { content := "" if f.err != nil { content += errorStyle.Render(f.err.Error()) + "\n\n" } for i, field := range f.fields { if i > 0 { content += "\n\n" } content += field.label + ":\n" style := f.UnselectedStyle if i == f.selIdx { style = f.SelectedStyle } if style != nil { content += style.Render(field.View()) } else { content += field.View() } if field.Err != nil { content += "\n" + errorStyle.Render(field.Err.Error()) } } return content } func (f *Form) SetSuggestions(m AppModel) { for i := range f.fields { ff := &f.fields[i] switch ff.suggestions { case suggestClients: clients := make([]string, len(m.projectsBox.clients)) for i, cl := range m.projectsBox.clients { clients[i] = cl.Name } ff.SetSuggestions(clients) ff.ShowSuggestions = true case suggestProjects: projNames := make([]string, 0, 10) for _, cl := range m.projectsBox.clients { for _, proj := range m.projectsBox.projects[cl.ID] { projNames = append(projNames, proj.Name) } } ff.SetSuggestions(projNames) ff.ShowSuggestions = true case suggestReportType: ff.SetSuggestions([]string{"Invoice", "Timesheet", "Unified"}) ff.ShowSuggestions = true } } } var ( modalFocusedInputStyle = lipgloss.NewStyle(). Border(lipgloss.DoubleBorder()). BorderForeground(lipgloss.Color("238")) modalBlurredInputStyle = lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("238")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) )