176 lines
4.5 KiB
Go
176 lines
4.5 KiB
Go
|
|
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)
|
|||
|
|
}
|