package ui import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "skyrama-tui/internal/model" "skyrama-tui/internal/service" "skyrama-tui/internal/style" ) // ─── Messages ───────────────────────────────────────────────────────────────── type ContractsLoadedMsg struct{ Contracts []model.Contract } type ContractsErrMsg struct{ Err error } // ─── ContractsModel ─────────────────────────────────────────────────────────── type ContractsModel struct { client *service.GameService airportID uint32 contracts []model.Contract cursor int statusMsg string focused bool width int height int } func NewContractsModel(client *service.GameService, airportID uint32) ContractsModel { return ContractsModel{client: client, airportID: airportID} } func (m ContractsModel) Init() tea.Cmd { return m.load() } func (m ContractsModel) Update(msg tea.Msg) (ContractsModel, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height case ContractsLoadedMsg: m.contracts = msg.Contracts if m.cursor >= len(m.contracts) { m.cursor = clampMin(len(m.contracts)-1, 0) } case ContractsErrMsg: m.statusMsg = style.Error.String() + msg.Err.Error() case tea.KeyMsg: if !m.focused { break } switch msg.String() { case "up", "k": if m.cursor > 0 { m.cursor-- } case "down", "j": if m.cursor < len(m.contracts)-1 { m.cursor++ } case "r": m.statusMsg = "" return m, m.load() } } return m, nil } func (m ContractsModel) View() string { header := style.PanelHeader.Render("📋 Active Contracts") statusLine := "" if m.statusMsg != "" { statusLine = "\n" + m.statusMsg + "\n" } if len(m.contracts) == 0 { return lipgloss.JoinVertical(lipgloss.Left, header, statusLine, "", style.Muted.Render("No active contracts right now."), ) } rows := make([]string, len(m.contracts)) for i, c := range m.contracts { rows[i] = m.renderContract(c, i == m.cursor) } help := style.HelpKey.Render("↑↓ / jk") + style.HelpDesc.Render(" navigate ") + style.HelpKey.Render("r") + style.HelpDesc.Render(" refresh") return lipgloss.JoinVertical(lipgloss.Left, header, statusLine, strings.Join(rows, "\n"), "", style.Muted.Render(help), ) } func (m ContractsModel) renderContract(c model.Contract, selected bool) string { pct := c.ProgressPct() barW := 24 bar := style.ResourceBar(int(c.CurrentAmountDelivered), int(c.RequiredAmount), barW, style.ColorGreen) expiresIn := time.Until(c.ExpiresAt).Round(time.Minute) urgency := style.Muted if expiresIn < 30*time.Minute { urgency = style.Error } else if expiresIn < 2*time.Hour { urgency = style.Warning } inner := lipgloss.JoinVertical(lipgloss.Left, style.Bold.Render(c.Title), style.Muted.Render(fmt.Sprintf("Deliver %d × %s", c.RequiredAmount, c.ProductName)), lipgloss.JoinHorizontal(lipgloss.Top, bar, style.Muted.Render(fmt.Sprintf(" %d/%d (%d%%)", c.CurrentAmountDelivered, c.RequiredAmount, pct)), ), urgency.Render(fmt.Sprintf("⏰ Expires in %s", fmtContractDuration(expiresIn))), ) if selected { return style.PanelFocused.Render(inner) } return style.Panel.Render(inner) } // ─── Exported for parent AppModel ───────────────────────────────────────────── // Load is the exported version for use by parent AppModel. func (m ContractsModel) Load() tea.Cmd { return m.load() } func (m ContractsModel) load() tea.Cmd { client := m.client airportID := m.airportID return func() tea.Msg { contracts, err := client.GetActiveContracts(airportID) if err != nil { return ContractsErrMsg{Err: err} } return ContractsLoadedMsg{Contracts: contracts} } } // ─── Helpers ────────────────────────────────────────────────────────────────── func clampMin(v, min int) int { if v < min { return min } return v } func fmtContractDuration(d time.Duration) string { if d <= 0 { return "expired" } h := int(d.Hours()) m := int(d.Minutes()) % 60 if h > 0 { return fmt.Sprintf("%dh %dm", h, m) } return fmt.Sprintf("%dm", m) }