phase 0.3
This commit is contained in:
commit
4a94bfc4e7
52 changed files with 11653 additions and 0 deletions
176
internal/ui/contracts.go
Normal file
176
internal/ui/contracts.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue