logbook/cmd/logbook/main.go

322 lines
10 KiB
Go

package main
import (
"image"
"image/color"
"log"
"os"
"time"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/io/event"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
)
type Page int
const (
PageLanding Page = iota
PageWorkoutReview
PagePerformance
PageMeasurements
)
type AppState struct {
Theme *material.Theme
CurrentPage Page
BtnLanding widget.Clickable
BtnWorkoutReview widget.Clickable
BtnPerformance widget.Clickable
BtnMeasurements widget.Clickable
}
func main() {
go func() {
w := new(app.Window)
w.Option(app.Title("Logbook Review Mockup"))
w.Option(app.Size(unit.Dp(400), unit.Dp(700)))
if err := loop(w); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
app.Main()
}
func loop(w *app.Window) error {
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
var ops op.Ops
state := &AppState{
Theme: th,
}
events := make(chan event.Event)
acks := make(chan struct{})
go func() {
for {
ev := w.Event()
events <- ev
<-acks
if _, ok := ev.(app.DestroyEvent); ok {
return
}
}
}()
for {
select {
case e := <-events:
switch e:= e.(type) {
case app.DestroyEvent:
acks <- struct{}{}
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
state.Layout(gtx)
e.Frame(gtx.Ops)
}
acks <- struct{}{}
}
}
}
func (state *AppState) Layout(gtx layout.Context) layout.Dimensions {
return layout.Flex{
Axis: layout.Vertical,
}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
switch state.CurrentPage {
case PageLanding:
return LandingPage(state, gtx)
case PageWorkoutReview:
return WorkoutReviewPage(state, gtx)
case PagePerformance:
return PerformancePage(state, gtx)
case PageMeasurements:
return MeasurementsPage(state, gtx)
default:
return layout.Dimensions{}
}
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return state.BottomNav(gtx)
}),
)
}
func (state *AppState) BottomNav(gtx layout.Context) layout.Dimensions {
th := state.Theme
labels := []string{"Home", "Workouts", "Performance", "Measurements"}
clicks := []*widget.Clickable{
&state.BtnLanding, &state.BtnWorkoutReview, &state.BtnPerformance, &state.BtnMeasurements,
}
// Measure button widths using a dry run
btnWidths := make([]int, len(labels))
btnHeights := make([]int, len(labels))
for i, label := range labels {
btn := material.Button(th, clicks[i], label)
// Use a scratch context with unconstrained width
mctx := gtx
mctx.Constraints = layout.Constraints{
Min: image.Pt(0, 0),
Max: image.Pt(gtx.Dp(1000), gtx.Constraints.Max.Y),
}
dims := btn.Layout(mctx)
btnWidths[i] = dims.Size.X
btnHeights[i] = dims.Size.Y
}
gap := gtx.Dp(8)
totalWidth := btnWidths[0] + btnWidths[1] + btnWidths[2] + btnWidths[3] + gap*3
if totalWidth <= gtx.Constraints.Max.X {
// All buttons fit in one row
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnLanding.Clicked(gtx) {
state.CurrentPage = PageLanding
}
return material.Button(th, &state.BtnLanding, "Home").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnWorkoutReview.Clicked(gtx) {
state.CurrentPage = PageWorkoutReview
}
return material.Button(th, &state.BtnWorkoutReview, "Workouts").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnPerformance.Clicked(gtx) {
state.CurrentPage = PagePerformance
}
return material.Button(th, &state.BtnPerformance, "Performance").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnMeasurements.Clicked(gtx) {
state.CurrentPage = PageMeasurements
}
return material.Button(th, &state.BtnMeasurements, "Measurements").Layout(gtx)
}),
)
} else {
// Two rows, two buttons per row
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnLanding.Clicked(gtx) {
state.CurrentPage = PageLanding
}
return material.Button(th, &state.BtnLanding, "Home").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnWorkoutReview.Clicked(gtx) {
state.CurrentPage = PageWorkoutReview
}
return material.Button(th, &state.BtnWorkoutReview, "Workouts").Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnPerformance.Clicked(gtx) {
state.CurrentPage = PagePerformance
}
return material.Button(th, &state.BtnPerformance, "Performance").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnMeasurements.Clicked(gtx) {
state.CurrentPage = PageMeasurements
}
return material.Button(th, &state.BtnMeasurements, "Measurements").Layout(gtx)
}),
)
}),
)
}
}
func LandingPage(state *AppState, gtx layout.Context) layout.Dimensions {
th := state.Theme
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(th, "Next Planned Workout").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Body1(th, "Date: "+time.Now().Add(24*time.Hour).Format("2006-01-02")).Layout),
layout.Rigid(material.Body1(th, "Type: Upper Body").Layout),
layout.Rigid(material.Body1(th, "Exercises: Bench Press, Row, Curl").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(material.Button(th, new(widget.Clickable), "Modify Plan").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(th, new(widget.Clickable), "Start Session").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(32)}.Layout),
layout.Rigid(material.H6(th, "Quick Stats").Layout),
layout.Rigid(material.Body2(th, "Bodyweight: 175 lb").Layout),
layout.Rigid(material.Body2(th, "Current 1RM (Bench): 225 lb").Layout),
)
})
}
func WorkoutReviewPage(state *AppState, gtx layout.Context) layout.Dimensions {
th := state.Theme
workouts := []struct {
Date string
Type string
Exercises string
Note string
}{
{"2025-05-13", "Upper Body", "Bench Press, Row, Curl", "Felt strong, PR on bench."},
{"2025-05-10", "Lower Body", "Squat, Deadlift", "Tired, but completed all sets."},
}
var list layout.List
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(th, "Past Workouts").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return list.Layout(gtx, len(workouts), func(gtx layout.Context, i int) layout.Dimensions {
w := workouts[i]
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
// Use a colored rectangle instead of material.Card
dr := image.Rect(0, 0, gtx.Constraints.Max.X, 70)
paint.FillShape(gtx.Ops, color.NRGBA{R: 0xee, G: 0xee, B: 0xff, A: 0xff}, clip.Rect(dr).Op())
return layout.Inset{Left: unit.Dp(8), Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body1(th, w.Date+" - "+w.Type).Layout),
layout.Rigid(material.Body2(th, w.Exercises).Layout),
layout.Rigid(material.Caption(th, w.Note).Layout),
)
})
})
})
}),
)
})
}
func PerformancePage(state *AppState, gtx layout.Context) layout.Dimensions {
th := state.Theme
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(th, "Bench Press 1RM Trend").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
// Just a colored rectangle as a placeholder for a graph
cs := gtx.Constraints
sz := cs.Constrain(image.Pt(300, 100))
dr := image.Rectangle{Max: sz}
paintColor := color.NRGBA{R: 0x80, G: 0x80, B: 0xff, A: 0xff}
paint.FillShape(gtx.Ops, paintColor, clip.Rect(dr).Op())
return layout.Dimensions{Size: sz}
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Body2(th, "Actual (blue), Predicted (red)").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(material.Body2(th, "Best: 225 lb (Aug)").Layout),
)
})
}
func MeasurementsPage(state *AppState, gtx layout.Context) layout.Dimensions {
th := state.Theme
dates := []string{"2025-05-01", "2025-05-08", "2025-05-15"}
weights := []string{"174.8", "175.2", "175.0"}
waist := []string{"34.1", "34.0", "33.9"}
var list layout.List
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(th, "Measurements").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return list.Layout(gtx, len(dates), func(gtx layout.Context, i int) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(material.Body2(th, dates[i]).Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Body2(th, weights[i]+" lb").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Body2(th, waist[i]+" in").Layout),
)
})
}),
)
})
}