commit de1bc0162742c5af2b7db1635a4c9ac0e9c3e9aa
parent a0e097c56ab4948663919d0d1994cd45c9888c95
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date: Wed, 10 Feb 2021 18:12:12 +0800
ref: restructure; pull code into packages
Diffstat:
14 files changed, 1334 insertions(+), 1480 deletions(-)
diff --git a/cmd/bundle/main.go b/cmd/bundle/main.go
@@ -1,213 +0,0 @@
-// bundle is a tool for creating OS specific installable packages for Windows,
-// macOS and Linux.
-package main
-
-import (
- "fmt"
- "image/png"
- "io"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
-
- "github.com/jackmordaunt/icns"
-)
-
-func main() {
- if err := func() error {
- if len(os.Args) < 2 {
- return fmt.Errorf("specify a platform {windows,macos,linux}")
- }
- binary, err := build("cmd/kanban")
- if err != nil {
- return fmt.Errorf("building: %w", err)
- }
- platform := os.Args[1]
- switch platform {
- case "macos":
- if err := bundleMacOS("dist/Kanban.app", binary, "res/icon.png", "res/darwin/Info.plist"); err != nil {
- return fmt.Errorf("bundling macos: %w", err)
- }
- case "windows":
- if err := bundleWindows("dist/Kanban.exe", binary, "res/icon.png", ""); err != nil {
- return fmt.Errorf("bundling windows: %w", err)
- }
- case "linux":
- if err := bundleLinux("dist/Kanban", binary, "res/icon.png"); err != nil {
- return fmt.Errorf("bundling linux: %w", err)
- }
- default:
- return fmt.Errorf("platform %q not supported", platform)
- }
- return nil
- }(); err != nil {
- fmt.Printf("error: %v", err)
- }
-}
-
-// bundleMacOS creates a macOS .app bundleMacOS on disk rooted at dest.
-// All paramaters are filepaths.
-// NB: Will clobber destination if it is a directory, or error if it is a file.
-func bundleMacOS(dest, binary, icon, plist string) error {
- var (
- contents = filepath.Join(dest, "Contents")
- macos = filepath.Join(contents, "MacOS")
- resources = filepath.Join(contents, "Resources")
- )
- m, err := os.Stat(dest)
- if os.IsNotExist(err) || m.IsDir() {
- os.RemoveAll(dest)
- if err := os.MkdirAll(dest, 0777); err != nil {
- return fmt.Errorf("preparing destination: %w", err)
- }
- } else if !m.IsDir() {
- return fmt.Errorf("destination %q: not a directory", dest)
- }
- if err := os.MkdirAll(macos, 0777); err != nil {
- return fmt.Errorf("preparing directory: %w", err)
- }
- if err := os.MkdirAll(resources, 0777); err != nil {
- return fmt.Errorf("preparing directory: %w", err)
- }
- if err := cp(binary, filepath.Join(macos, "kanban")); err != nil {
- return fmt.Errorf("copying binary: %w", err)
- }
- if err := cp(plist, filepath.Join(contents, "Info.plist")); err != nil {
- return fmt.Errorf("copying plist: %w", err)
- }
- if err := convertIcon(icon, filepath.Join(resources, "kanban.icns")); err != nil {
- return fmt.Errorf("converting icon to icns: %w", err)
- }
- switch runtime.GOOS {
- case "linux":
- if err := run(
- "genisoimage",
- "-V", "Kanban",
- "-D",
- "-R",
- "-apple",
- "-no-pad",
- "-o", "Kanban.dmg",
- filepath.Dir(dest),
- ); err != nil {
- return fmt.Errorf("genisoimage: %w", err)
- }
- case "darwin":
- // dmg: | $(DMG_NAME)
- // $(DMG_NAME): $(APP_NAME)
- // @echo "Packing disk image..."
- // @ln -sf /Applications $(DMG_DIR)/Applications
- // @hdiutil create $(DMG_DIR)/$(DMG_NAME) \
- // -volname "Kanban" \
- // -fs HFS+ \
- // -srcfolder $(APP_DIR) \
- // -ov -format UDZO
- // @echo "Packed '$@' in '$(APP_DIR)'"
- if err := run(
- "hdiutil",
- "create",
- filepath.Join(filepath.Dir(dest), "Kanban.dmg"),
- "-volname", "Kanban",
- "-fs", "HFS+",
- "-srcfolder", dest,
- "-ov", "-format", "UDZO",
- ); err != nil {
- return fmt.Errorf("hdiutil: %w", err)
- }
- case "windows":
- return fmt.Errorf("cannot create dmg on windows yet")
- default:
- return fmt.Errorf("cannot create dmg on %q", runtime.GOOS)
- }
- return nil
-}
-
-func bundleWindows(dest, binary, icon, manifest string) error {
- return fmt.Errorf("unimplemented")
-}
-
-func bundleLinux(dest, binary, icon string) error {
- return fmt.Errorf("unimplemented")
-}
-
-// convertIcon converts the source png to icon and returns a path to it.
-func convertIcon(src, dst string) error {
- srcf, err := os.Open(src)
- if err != nil {
- return fmt.Errorf("opening source file: %w", err)
- }
- defer srcf.Close()
- img, err := png.Decode(srcf)
- if err != nil {
- return fmt.Errorf("decoding source png: %w", err)
- }
- dstf, err := os.OpenFile(dst, os.O_CREATE|os.O_RDWR, 0644)
- if err != nil {
- return fmt.Errorf("opening destination file: %w", err)
- }
- defer dstf.Close()
- if err := icns.Encode(dstf, img); err != nil {
- return fmt.Errorf("encoding icns: %w", err)
- }
- return nil
-}
-
-// build the Go program rooted at path and returns a path to it.
-func build(path string) (string, error) {
- path, err := filepath.Abs(path)
- if err != nil {
- return "", fmt.Errorf("resolving absolute path: %w", err)
- }
- if err := run("go", "build", "-o", "dist/kanban", path); err != nil {
- return "", err
- }
- return "dist/kanban", nil
-}
-
-// run the specified command and return any error.
-func run(cmd string, args ...string) error {
- if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
- return fmt.Errorf("running command %q: %v: %w", cmd, string(out), err)
- }
- return nil
-}
-
-// cp copies src file to destination.
-// If destination is a directory, the file will be copied into it.
-// If destination doesn't exist it will be created as a file.
-// If destination is a file an error will be returned.
-func cp(src, dst string) error {
- if src == "" || dst == "" {
- return nil
- }
- var err error
- src, err = filepath.Abs(src)
- if err != nil {
- return fmt.Errorf("resolving path: %w", err)
- }
- dst, err = filepath.Abs(dst)
- if err != nil {
- return fmt.Errorf("resolving path: %w", err)
- }
- srcf, err := os.Open(src)
- if err != nil {
- return fmt.Errorf("opening %q: %w", src, err)
- }
- defer srcf.Close()
- _, err = os.Stat(filepath.Dir(dst))
- if os.IsNotExist(err) {
- if err := os.MkdirAll(filepath.Dir(dst), 0777); err != nil {
- return fmt.Errorf("preparing %q: %w", filepath.Dir(dst), err)
- }
- }
- dstf, err := os.OpenFile(dst, os.O_CREATE|os.O_RDWR, 0777)
- if err != nil {
- return fmt.Errorf("creating %q: %w", dst, err)
- }
- defer dstf.Close()
- if _, err := io.Copy(dstf, srcf); err != nil {
- return fmt.Errorf("copying data: %w", err)
- }
- return nil
-}
diff --git a/cmd/kanban/control/card.go b/cmd/kanban/control/card.go
@@ -0,0 +1,61 @@
+package control
+
+import (
+ "image/color"
+
+ "gioui.org/f32"
+ "gioui.org/layout"
+ "gioui.org/unit"
+ "gioui.org/widget/material"
+ "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util"
+)
+
+// Card lays the content out with a title for context.
+type Card struct {
+ Title string
+}
+
+func (c Card) Layout(gtx C, th *material.Theme, w layout.Widget) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Expanded(func(gtx C) D {
+ return util.Rect{
+ Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
+ Size: layout.FPt(gtx.Constraints.Min),
+ Radii: 4,
+ }.Layout(gtx)
+ }),
+ layout.Stacked(func(gtx C) D {
+ inset := layout.UniformInset(unit.Dp(10))
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Expanded(func(gtx C) D {
+ return util.Rect{
+ Color: color.NRGBA{A: 100},
+ Size: f32.Point{
+ X: float32(gtx.Constraints.Max.X),
+ Y: float32(gtx.Constraints.Min.Y),
+ },
+ }.Layout(gtx)
+ }),
+ layout.Stacked(func(gtx C) D {
+ return inset.Layout(gtx, func(gtx C) D {
+ return material.H6(th, c.Title).Layout(gtx)
+ })
+ }),
+ )
+ }),
+ layout.Rigid(func(gtx C) D {
+ return inset.Layout(gtx, func(gtx C) D {
+ return w(gtx)
+ })
+ }),
+ )
+ }),
+ )
+}
diff --git a/cmd/kanban/control/panel.go b/cmd/kanban/control/panel.go
@@ -0,0 +1,107 @@
+package control
+
+import (
+ "image/color"
+
+ "gioui.org/f32"
+ "gioui.org/layout"
+ "gioui.org/unit"
+ "gioui.org/widget"
+ "gioui.org/widget/material"
+ "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util"
+ "git.sr.ht/~jackmordaunt/kanban/icons"
+)
+
+type (
+ C = layout.Context
+ D = layout.Dimensions
+)
+
+// Panel can hold cards.
+// One panel per stage in the kanban pipeline.
+// Has a title and action bar.
+type Panel struct {
+ Label string
+ Color color.NRGBA
+ Thickness unit.Value
+ CreateTicket widget.Clickable
+
+ layout.List
+}
+
+func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.ListElement) D {
+ return widget.Border{
+ Color: color.NRGBA{A: 200},
+ Width: unit.Dp(0.5),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Expanded(func(gtx C) D {
+ return util.Rect{
+ Size: f32.Point{
+ X: layout.FPt(gtx.Constraints.Max).X,
+ Y: float32(gtx.Px(p.Thickness)),
+ },
+ Color: p.Color,
+ }.Layout(gtx)
+ }),
+ layout.Stacked(func(gtx C) D {
+ return layout.Inset{
+ Left: unit.Dp(10),
+ Right: unit.Dp(15),
+ Top: unit.Dp(12),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ Alignment: layout.Middle,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return material.H6(th, p.Label).Layout(gtx)
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ layout.Rigid(func(gtx C) D {
+ return util.Button(
+ &p.CreateTicket,
+ util.WithIcon(icons.ContentAdd),
+ util.WithSize(unit.Dp(15)),
+ util.WithInset(layout.UniformInset(unit.Dp(6))),
+ util.WithBgColor(color.NRGBA{}),
+ util.WithIconColor(th.Fg),
+ ).Layout(gtx)
+ }),
+ )
+ })
+ }),
+ )
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Expanded(func(gtx C) D {
+ return util.Rect{
+ Color: color.NRGBA{R: 240, G: 240, B: 240, A: 255},
+ Size: layout.FPt(gtx.Constraints.Max),
+ }.Layout(gtx)
+ }),
+ layout.Stacked(func(gtx C) D {
+ p.List.Axis = layout.Vertical
+ return p.List.Layout(gtx, len(tickets), func(gtx C, ii int) D {
+ return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx C) D {
+ return tickets[ii](gtx, ii)
+ })
+
+ })
+ }),
+ )
+ }),
+ )
+ })
+}
diff --git a/cmd/kanban/control/rail.go b/cmd/kanban/control/rail.go
@@ -0,0 +1,88 @@
+package control
+
+import (
+ "image/color"
+ "unsafe"
+
+ "gioui.org/layout"
+ "gioui.org/unit"
+ "gioui.org/widget"
+ "gioui.org/widget/material"
+ "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/state"
+ "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util"
+)
+
+// Rail is an interactive side rail with a list of widget items that can be
+// selected.
+//
+// Typically used as navigation or contextual actions.
+//
+// Rail is stateful.
+type Rail struct {
+ layout.List
+ Map state.Map
+}
+
+// RailChild is an item that renders in a rail.
+type RailChild struct {
+ Name string
+ W layout.Widget
+}
+
+// Destination is a rail item that represents a navigatable object.
+// Destinations are pab+dded by default.
+func Destination(name string, w layout.Widget) RailChild {
+ return RailChild{
+ Name: name,
+ W: w,
+ }
+}
+
+func (r *Rail) next(key string) *widget.Clickable {
+ return (*widget.Clickable)(r.Map.New(key, unsafe.Pointer(&widget.Clickable{})))
+}
+
+// Selected reports which rail child was selected, if any.
+// Reports the first click encountered.
+func (r *Rail) Selected() (string, bool) {
+ for k, v := r.Map.Next(); r.Map.More(); k, v = r.Map.Next() {
+ if (*widget.Clickable)(v).Clicked() {
+ return k, true
+ }
+ }
+ return "", false
+}
+
+// Layout the rail with the given items.
+func (r *Rail) Layout(gtx C, action layout.Widget, items ...RailChild) D {
+ r.List.Axis = layout.Vertical
+ r.List.Alignment = layout.Middle
+ r.Map.Begin()
+ return layout.Flex{
+ Axis: layout.Vertical,
+ Alignment: layout.Middle,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return action(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return layout.UniformInset(unit.Dp(5)).Layout(gtx, func(gtx C) D {
+ return util.Div{
+ Color: color.NRGBA{R: 220, B: 220, G: 220, A: 255},
+ Length: unit.Px(float32(gtx.Constraints.Max.X)),
+ Thickness: unit.Dp(1),
+ Axis: layout.Horizontal,
+ }.Layout(gtx)
+ })
+ }),
+ layout.Rigid(func(gtx C) D {
+ return r.List.Layout(gtx, len(items), func(gtx C, ii int) D {
+ rc := items[ii]
+ return material.Clickable(gtx, r.next(rc.Name), func(gtx C) D {
+ return rc.W(gtx)
+ })
+ })
+ }),
+ )
+}
diff --git a/cmd/kanban/layout.go b/cmd/kanban/layout.go
@@ -3,68 +3,17 @@ package main
import (
"image/color"
- "gioui.org/f32"
"gioui.org/layout"
"gioui.org/unit"
- "gioui.org/widget/material"
+ "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util"
)
-// Card lays the content out with a title for context.
-type Card struct {
- Title string
-}
-
-func (c Card) Layout(gtx C, th *material.Theme, w layout.Widget) D {
- return layout.Stack{}.Layout(
- gtx,
- layout.Expanded(func(gtx C) D {
- return Rect{
- Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
- Size: layout.FPt(gtx.Constraints.Min),
- Radii: 4,
- }.Layout(gtx)
- }),
- layout.Stacked(func(gtx C) D {
- inset := layout.UniformInset(unit.Dp(10))
- return layout.Flex{
- Axis: layout.Vertical,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return layout.Stack{}.Layout(
- gtx,
- layout.Expanded(func(gtx C) D {
- return Rect{
- Color: color.NRGBA{A: 100},
- Size: f32.Point{
- X: float32(gtx.Constraints.Max.X),
- Y: float32(gtx.Constraints.Min.Y),
- },
- }.Layout(gtx)
- }),
- layout.Stacked(func(gtx C) D {
- return inset.Layout(gtx, func(gtx C) D {
- return material.H6(th, c.Title).Layout(gtx)
- })
- }),
- )
- }),
- layout.Rigid(func(gtx C) D {
- return inset.Layout(gtx, func(gtx C) D {
- return w(gtx)
- })
- }),
- )
- }),
- )
-}
-
// Modal renders content centered on a translucent scrim.
func Modal(gtx C, w layout.Widget) D {
return layout.Stack{}.Layout(
gtx,
layout.Stacked(func(gtx C) D {
- return Rect{
+ return util.Rect{
Size: layout.FPt(gtx.Constraints.Max),
Color: color.NRGBA{A: 200},
}.Layout(gtx)
diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go
@@ -1,24 +1,31 @@
+// @Note data lifecycle idea: frame by frame sync
+// 1. load data at start of frame; pass it in to the ui context
+// 2. save data at end of frame, every frame
+// 3. mutate data with plain methods knowing that mutations will be saved at a known point
+//
+// let data be heirarchical eg projects -> stages -> tickets
+//
+// How are we accessing the data mostly?
+// Active Project gets loaded every frame.
+// All stages and tickets for the active project need to be read every frame.
+// Mutations occur async.
package main
import (
"fmt"
- "image"
- "image/color"
"log"
"os"
"path/filepath"
- "strconv"
- "time"
- "unsafe"
"git.sr.ht/~jackmordaunt/kanban"
"github.com/asdine/storm/v3"
- "gioui.org/f32"
"gioui.org/font/gofont"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
+ "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/control"
+ "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/state"
"git.sr.ht/~jackmordaunt/kanban/icons"
"gioui.org/app"
@@ -26,7 +33,6 @@ import (
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
- "gioui.org/x/component"
)
func main() {
@@ -37,9 +43,6 @@ func main() {
if err != nil {
return nil, fmt.Errorf("opening data file: %w", err)
}
- if err := db.Init(&kanban.Stage{}); err != nil {
- return nil, err
- }
if err := db.Init(&kanban.Ticket{}); err != nil {
return nil, err
}
@@ -54,34 +57,9 @@ func main() {
defer db.Close()
go func() {
ui := UI{
- Window: app.NewWindow(app.Title("Kanban")),
- Th: material.NewTheme(gofont.Collection()),
- Kanban: &kanban.Kanban{
- Store: db,
- },
- // // TODO: render dynamically from storage.
- // Panels: []Panel{
- // {
- // Label: "Todo",
- // Color: color.NRGBA{R: 0x91, G: 0x81, B: 0x8a, A: 220},
- // Thickness: unit.Dp(50),
- // },
- // {
- // Label: "In Progress",
- // Color: color.NRGBA{R: 0, G: 100, B: 200, A: 220},
- // Thickness: unit.Dp(50),
- // },
- // {
- // Label: "Testing",
- // Color: color.NRGBA{R: 200, G: 100, B: 0, A: 220},
- // Thickness: unit.Dp(50),
- // },
- // {
- // Label: "Done",
- // Color: color.NRGBA{R: 50, G: 200, B: 100, A: 220},
- // Thickness: unit.Dp(50),
- // },
- // },
+ Window: app.NewWindow(app.Title("Kanban")),
+ Th: material.NewTheme(gofont.Collection()),
+ Storage: &kanban.StormStorer{DB: db},
}
if err := ui.Loop(); err != nil {
log.Fatalf("error: %v", err)
@@ -101,24 +79,25 @@ type (
// this object.
type UI struct {
*app.Window
- Kanban *kanban.Kanban
- Th *material.Theme
-
- // ActiveProject is the project being operated on.
- ActiveProject kanban.ID
-
- Panels []Panel
- Rail Rail
- TicketStates Map
+ Storage kanban.Storer
+ Th *material.Theme
+ Project *kanban.Project
+
+ // @Todo panels shouldn't be stateful.
+ Panels []control.Panel
+ Rail control.Rail
+ TicketStates state.Map
Modal layout.Widget
TicketForm TicketForm
TicketDetails TicketDetails
DeleteDialog DeleteDialog
- FocusedTicket struct {
- ID kanban.ID
- Index int
- Stage kanban.ID
- }
+
+ // FocusedTicket struct {
+ // ID kanban.ID
+ // Index int
+ // Stage kanban.ID
+ // }
+
CreateProjectButton widget.Clickable
ProjectForm ProjectForm
}
@@ -139,6 +118,14 @@ func (ui *UI) Loop() error {
event.Frame(gtx.Ops)
}
}
+ return ui.Shutdown()
+}
+
+// Shutdown does cleanup.
+func (ui UI) Shutdown() error {
+ if err := ui.Storage.Save(ui.Project); err != nil {
+ return fmt.Errorf("saving project: %v", err)
+ }
return nil
}
@@ -152,14 +139,15 @@ func (ui *UI) Update(gtx C) {
case key.NameEscape:
ui.Clear()
case key.NameEnter, key.NameReturn:
- var (
- t kanban.Ticket
- )
- if err := ui.Kanban.Store.Find("ID", ui.FocusedTicket, &t); err != nil {
- fmt.Printf("error: %v\n", err)
- } else {
- ui.InspectTicket(t)
- }
+ // @Cleanup
+ // var (
+ // t kanban.Ticket
+ // )
+ // if err := ui.Project.Find("ID", ui.FocusedTicket, &t); err != nil {
+ // fmt.Printf("error: %v\n", err)
+ // } else {
+ // ui.InspectTicket(t)
+ // }
case key.NameDownArrow:
ui.Refocus(NextTicket)
case key.NameUpArrow:
@@ -183,17 +171,13 @@ func (ui *UI) Update(gtx C) {
continue
}
if t.NextButton.Clicked() {
- if err := ui.Kanban.Progress(t.ID); err != nil {
- fmt.Printf("error: %s\n", err)
- }
+ ui.Project.ProgressTicket(t.Ticket)
}
if t.PrevButton.Clicked() {
- if err := ui.Kanban.Regress(t.ID); err != nil {
- fmt.Printf("error: %s\n", err)
- }
+ ui.Project.RegressTicket(t.Ticket)
}
if t.EditButton.Clicked() {
- ui.EditTicket(t.Ticket)
+ ui.EditTicket(&t.Ticket)
}
if t.DeleteButton.Clicked() {
ui.DeleteTicket(t.Ticket)
@@ -202,39 +186,32 @@ func (ui *UI) Update(gtx C) {
ui.InspectTicket(t.Ticket)
}
}
- if ui.TicketForm.Submit.Clicked() {
- ticket, err := ui.TicketForm.Validate()
- if err != nil {
- fmt.Printf("error: %s\n", err)
- return
- }
- if assign := ui.TicketForm.Stage != ""; assign {
- if err := ui.Kanban.Assign(ui.TicketForm.Stage, ticket); err != nil {
- fmt.Printf("error: assigning ticket: %s\n", err)
- return
- }
- } else {
- if err := ui.Kanban.Update(ticket); err != nil {
- fmt.Printf("error: updating ticket: %s\n", err)
- return
- }
- }
+ if ui.TicketForm.SubmitBtn.Clicked() {
+ // @todo handle create/update ambiguity.
+ _ = ui.TicketForm.Submit()
+ // if err != nil {
+ // fmt.Printf("error: %s\n", err)
+ // } else {
+ // if assign := ui.TicketForm.Stage != ""; assign {
+ // ui.Project.AssignTicket(ui.TicketForm.Stage, ticket)
+ // } else {
+ // ui.Project.Update(ticket)
+ // }
+ // }
ui.Clear()
}
- if ui.TicketForm.Cancel.Clicked() {
+ if ui.TicketForm.CancelBtn.Clicked() {
ui.Clear()
}
if ui.DeleteDialog.Ok.Clicked() {
- if err := ui.Kanban.Finalize(ui.DeleteDialog.ID); err != nil {
- fmt.Printf("error: %s\n", err)
- }
+ ui.Project.FinalizeTicket(ui.DeleteDialog.Ticket)
ui.Clear()
}
if ui.DeleteDialog.Cancel.Clicked() {
ui.Clear()
}
if ui.TicketDetails.Edit.Clicked() {
- ui.EditTicket(ui.TicketDetails.Ticket)
+ ui.EditTicket(&ui.TicketDetails.Ticket)
}
if ui.TicketDetails.Cancel.Clicked() {
ui.Clear()
@@ -246,16 +223,22 @@ func (ui *UI) Update(gtx C) {
ui.Clear()
}
if ui.ProjectForm.Submit.Clicked() {
- if err := ui.Kanban.Store.Save(&kanban.Project{
+ if err := ui.Storage.Create(&kanban.Project{
Name: ui.ProjectForm.Name.Text(),
}); err != nil {
- log.Printf("saving new project: %v", err)
+ log.Printf("creating new project: %v", err)
}
+ ui.Clear()
}
if p, ok := ui.Rail.Selected(); ok {
- if projectID, err := strconv.Atoi(p); err == nil {
- ui.ActiveProject = kanban.ID(projectID)
+ project, ok, err := ui.Storage.Load(p)
+ if err != nil {
+ log.Printf("loading project %q: %v", p, err)
}
+ if ok {
+ ui.Project = project
+ }
+
}
}
@@ -264,42 +247,41 @@ func (ui *UI) Layout(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(
gtx,
layout.Rigid(func(gtx C) D {
- // @Todo: render "active" destination in rail.
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
gtx.Constraints.Max.X = gtx.Px(unit.Dp(80))
gtx.Constraints.Min.X = 0
var (
- projects []kanban.Project
- rc []RailChild
+ rc []control.RailChild
)
- if err := ui.Kanban.Store.AllByIndex("ID", &projects); err != nil {
- log.Printf("error: loading projects: %v", err)
- }
- for _, p := range projects {
- p := p
- rc = append(rc, Destination(p.ID.String(), func(gtx C) D {
- return layout.Stack{
- Alignment: layout.Center,
- }.Layout(
- gtx,
- layout.Stacked(func(gtx C) D {
- return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx C) D {
- return material.Label(ui.Th, unit.Dp(16), p.Name).Layout(gtx)
- })
- }),
- layout.Expanded(func(gtx C) D {
- cs := gtx.Constraints
- if p.ID == ui.ActiveProject {
- return Rect{
- Color: color.NRGBA{A: 100},
- Size: f32.Pt(float32(cs.Max.X), float32(cs.Min.Y)),
- }.Layout(gtx)
- }
- return D{Size: image.Point{X: cs.Max.X, Y: cs.Min.Y}}
- }),
- )
- }))
- }
+ // @cleanup
+ // if err := ui.Project.AllByIndex("ID", &projects); err != nil {
+ // log.Printf("error: loading projects: %v", err)
+ // }
+ // for _, p := range projects {
+ // p := p
+ // rc = append(rc, Destination(p.ID.String(), func(gtx C) D {
+ // return layout.Stack{
+ // Alignment: layout.Center,
+ // }.Layout(
+ // gtx,
+ // layout.Stacked(func(gtx C) D {
+ // return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx C) D {
+ // return material.Label(ui.Th, unit.Dp(16), p.Name).Layout(gtx)
+ // })
+ // }),
+ // layout.Expanded(func(gtx C) D {
+ // cs := gtx.Constraints
+ // if p.ID == ui.ActiveProject {
+ // return util.Rect{
+ // Color: color.NRGBA{A: 100},
+ // Size: f32.Pt(float32(cs.Max.X), float32(cs.Min.Y)),
+ // }.Layout(gtx)
+ // }
+ // return D{Size: image.Point{X: cs.Max.X, Y: cs.Min.Y}}
+ // }),
+ // )
+ // }))
+ // }
return ui.Rail.Layout(
gtx,
func(gtx C) D {
@@ -317,46 +299,47 @@ func (ui *UI) Layout(gtx C) D {
return layout.Stack{}.Layout(
gtx,
layout.Stacked(func(gtx C) D {
- if ui.ActiveProject.None() {
+ if ui.Project == nil {
return D{}
}
ui.TicketStates.Begin()
- var (
- project kanban.Project
- stage kanban.Stage
- ticket kanban.Ticket
- t *Ticket
- panels []layout.FlexChild
- )
- // @fixme show project creation hint when there are no projects.
- if err := ui.Kanban.Store.One("ID", ui.ActiveProject, &project); err != nil {
- log.Printf("error: project %v", err)
- }
- for _, id := range project.Stages {
- if err := ui.Kanban.Store.One("ID", id, &stage); err != nil {
- log.Printf("error: stage %v", err)
- }
- // render the stage panel.
- for _, id := range stage.Tickets {
- if err := ui.Kanban.Store.One("ID", id, &ticket); err != nil {
- log.Printf("error: ticket %v", err)
- }
- t = (*Ticket)(ui.TicketStates.New(strconv.Itoa(int(id)), unsafe.Pointer(&Ticket{})))
- t.Ticket = ticket
- t.Stage = stage.Name
- panels = append(panels, layout.Flexed(1, func(gtx C) D {
- if ui.FocusedTicket.ID == id {
- return widget.Border{
- Color: color.NRGBA{B: 200, A: 200},
- Width: unit.Dp(2),
- }.Layout(gtx, func(gtx C) D {
- return t.Layout(gtx, ui.Th)
- })
- }
- return t.Layout(gtx, ui.Th)
- }))
- }
- }
+ var panels []layout.FlexChild
+ // @cleanup
+ // var (
+ // project kanban.Project
+ // stage kanban.Stage
+ // ticket kanban.Ticket
+ // t *Ticket
+ // )
+ // // @fixme show project creation hint when there are no projects.
+ // if err := ui.Project.One("ID", ui.ActiveProject, &project); err != nil {
+ // log.Printf("error: project %v", err)
+ // }
+ // for _, id := range project.Stages {
+ // if err := ui.Project.One("ID", id, &stage); err != nil {
+ // log.Printf("error: stage %v", err)
+ // }
+ // // render the stage panel.
+ // for _, id := range stage.Tickets {
+ // if err := ui.Project.One("ID", id, &ticket); err != nil {
+ // log.Printf("error: ticket %v", err)
+ // }
+ // t = (*Ticket)(ui.TicketStates.New(strconv.Itoa(int(id)), unsafe.Pointer(&Ticket{})))
+ // t.Ticket = ticket
+ // t.Stage = stage.Name
+ // panels = append(panels, layout.Flexed(1, func(gtx C) D {
+ // if ui.FocusedTicket.ID == id {
+ // return widget.Border{
+ // Color: color.NRGBA{B: 200, A: 200},
+ // Width: unit.Dp(2),
+ // }.Layout(gtx, func(gtx C) D {
+ // return t.Layout(gtx, ui.Th)
+ // })
+ // }
+ // return t.Layout(gtx, ui.Th)
+ // }))
+ // }
+ // }
return layout.Flex{
Axis: layout.Horizontal,
Spacing: layout.SpaceEvenly,
@@ -394,11 +377,11 @@ func (ui *UI) Refocus(d Direction) {
// project kanban.Project
// stage kanban.Stage
// )
- // if err := ui.Kanban.Store.Find("ID", ui.ActiveProject, &project); err != nil {
+ // if err := ui.Project.Find("ID", ui.ActiveProject, &project); err != nil {
// log.Printf("error: %v", err)
// return
// }
- // if err := ui.Kanban.Store.Find("ID", projet.St)
+ // if err := ui.Project.Find("ID", projet.St)
// for {
// switch d {
@@ -445,18 +428,19 @@ func (ui *UI) Clear() {
ui.Modal = nil
ui.TicketForm = TicketForm{}
ui.ProjectForm = ProjectForm{}
- ui.FocusedTicket = struct {
- ID kanban.ID
- Index int
- Stage kanban.ID
- }{}
+ // @cleanup
+ // ui.FocusedTicket = struct {
+ // ID kanban.ID
+ // Index int
+ // Stage kanban.ID
+ // }{}
}
// InspectTicket opens the ticket details card for the given ticket.
func (ui *UI) InspectTicket(t kanban.Ticket) {
ui.TicketDetails.Ticket = t
ui.Modal = func(gtx C) D {
- return Card{
+ return control.Card{
Title: fmt.Sprintf("%q", t.Title),
}.Layout(gtx, ui.Th, func(gtx C) D {
return ui.TicketDetails.Layout(gtx, ui.Th)
@@ -465,10 +449,10 @@ func (ui *UI) InspectTicket(t kanban.Ticket) {
}
// EditTicket opens the ticket form for editing ticket data.
-func (ui *UI) EditTicket(t kanban.Ticket) {
+func (ui *UI) EditTicket(t *kanban.Ticket) {
ui.TicketForm.Set(t)
ui.Modal = func(gtx C) D {
- return Card{
+ return control.Card{
Title: "Edit Ticket",
}.Layout(gtx, ui.Th, func(gtx C) D {
return ui.TicketForm.Layout(gtx, ui.Th, "")
@@ -479,7 +463,7 @@ func (ui *UI) EditTicket(t kanban.Ticket) {
// AddTicket opens the ticket form for creating ticket data.
func (ui *UI) AddTicket(stage string) {
ui.Modal = func(gtx C) D {
- return Card{
+ return control.Card{
Title: "Add Ticket",
}.Layout(gtx, ui.Th, func(gtx C) D {
return ui.TicketForm.Layout(gtx, ui.Th, stage)
@@ -490,7 +474,7 @@ func (ui *UI) AddTicket(stage string) {
// CreatTicket opens the project creation dialog.
func (ui *UI) CreateProject() {
ui.Modal = func(gtx C) D {
- return Card{
+ return control.Card{
Title: "Create a new Project",
}.Layout(gtx, ui.Th, func(gtx C) D {
return ui.ProjectForm.Layout(gtx, ui.Th)
@@ -502,537 +486,10 @@ func (ui *UI) CreateProject() {
func (ui *UI) DeleteTicket(t kanban.Ticket) {
ui.DeleteDialog.Ticket = t
ui.Modal = func(gtx C) D {
- return Card{
+ return control.Card{
Title: "Delete Ticket",
}.Layout(gtx, ui.Th, func(gtx C) D {
return ui.DeleteDialog.Layout(gtx, ui.Th)
})
}
}
-
-// TicketForm renders the form for ticket information.
-//
-// TODO: tab navigation through form fields.
-type TicketForm struct {
- Stage string
- Data kanban.Ticket
- Title component.TextField
- Summary component.TextField
- Details component.TextField
- Submit widget.Clickable
- Cancel widget.Clickable
-}
-
-func (form *TicketForm) Set(t kanban.Ticket) {
- form.Data = t
- form.Title.SetText(t.Title)
- form.Summary.SetText(t.Summary)
- form.Details.SetText(t.Details)
- // form.References.SetText(t.References)
-}
-
-// Validate the inputs.
-// Note: No actual validation is done yet.
-func (form TicketForm) Validate() (kanban.Ticket, error) {
- ticket := kanban.Ticket{
- Entity: kanban.Entity{
- ID: form.Data.ID,
- Created: form.Data.Created,
- },
- Title: form.Title.Text(),
- Details: form.Details.Text(),
- Summary: form.Summary.Text(),
- }
- return ticket, nil
-}
-
-func (form *TicketForm) Layout(gtx C, th *material.Theme, stage string) D {
- form.Stage = stage
- return layout.Flex{
- Axis: layout.Vertical,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return form.Title.Layout(gtx, th, "Title")
- }),
- layout.Rigid(func(gtx C) D {
- return form.Summary.Layout(gtx, th, "Summary")
- }),
- layout.Rigid(func(gtx C) D {
- return form.Details.Layout(gtx, th, "Details")
- }),
- layout.Rigid(func(gtx C) D {
- gtx.Constraints.Min.X = gtx.Constraints.Max.X
- return layout.Inset{
- Top: unit.Dp(10),
- }.Layout(gtx, func(gtx C) D {
- return layout.Flex{
- Axis: layout.Horizontal,
- }.Layout(
- gtx,
- layout.Flexed(1, func(gtx C) D {
- return D{Size: gtx.Constraints.Min}
- }),
- layout.Rigid(func(gtx C) D {
- btn := material.Button(th, &form.Cancel, "Cancel")
- btn.Color = th.Bg
- btn.Background = color.NRGBA{}
- return btn.Layout(gtx)
- }),
- layout.Rigid(func(gtx C) D {
- return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}}
- }),
- layout.Rigid(func(gtx C) D {
- return material.Button(th, &form.Submit, "Submit").Layout(gtx)
- }),
- )
- })
- }),
- )
-}
-
-// DeleteDialog prompts the user with an option to delete a ticket.
-type DeleteDialog struct {
- kanban.Ticket
- Ok widget.Clickable
- Cancel widget.Clickable
-}
-
-func (d *DeleteDialog) Layout(gtx C, th *material.Theme) D {
- return layout.Flex{
- Axis: layout.Vertical,
- Alignment: layout.Middle,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return layout.Center.Layout(gtx, func(gtx C) D {
- return material.Body1(
- th,
- fmt.Sprintf("Are you sure you want to delete ticket %q?", d.Title),
- ).Layout(gtx)
- })
- }),
- layout.Rigid(func(gtx C) D {
- gtx.Constraints.Min.X = gtx.Constraints.Max.X
- return layout.Inset{
- Top: unit.Dp(10),
- }.Layout(gtx, func(gtx C) D {
- return layout.Flex{
- Axis: layout.Horizontal,
- }.Layout(
- gtx,
- layout.Flexed(1, func(gtx C) D {
- return D{Size: gtx.Constraints.Min}
- }),
- layout.Rigid(func(gtx C) D {
- btn := material.Button(th, &d.Cancel, "Cancel")
- btn.Color = th.Bg
- btn.Background = color.NRGBA{}
- return btn.Layout(gtx)
- }),
- layout.Rigid(func(gtx C) D {
- return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}}
- }),
- layout.Rigid(func(gtx C) D {
- btn := material.Button(th, &d.Ok, "Delete")
- btn.Background = color.NRGBA{R: 200, A: 255}
- return btn.Layout(gtx)
- }),
- )
- })
- }),
- )
-}
-
-// Panel can hold cards.
-// One panel per stage in the kanban pipeline.
-// Has a title and action bar.
-type Panel struct {
- Label string
- Color color.NRGBA
- Thickness unit.Value
- CreateTicket widget.Clickable
-
- layout.List
-}
-
-func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.ListElement) D {
- return widget.Border{
- Color: color.NRGBA{A: 200},
- Width: unit.Dp(0.5),
- }.Layout(gtx, func(gtx C) D {
- return layout.Flex{
- Axis: layout.Vertical,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return layout.Stack{}.Layout(
- gtx,
- layout.Expanded(func(gtx C) D {
- return Rect{
- Size: f32.Point{
- X: layout.FPt(gtx.Constraints.Max).X,
- Y: float32(gtx.Px(p.Thickness)),
- },
- Color: p.Color,
- }.Layout(gtx)
- }),
- layout.Stacked(func(gtx C) D {
- return layout.Inset{
- Left: unit.Dp(10),
- Right: unit.Dp(15),
- Top: unit.Dp(12),
- }.Layout(gtx, func(gtx C) D {
- return layout.Flex{
- Axis: layout.Horizontal,
- Alignment: layout.Middle,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return material.H6(th, p.Label).Layout(gtx)
- }),
- layout.Flexed(1, func(gtx C) D {
- return D{Size: gtx.Constraints.Min}
- }),
- layout.Rigid(func(gtx C) D {
- return Button(
- &p.CreateTicket,
- WithIcon(icons.ContentAdd),
- WithSize(unit.Dp(15)),
- WithInset(layout.UniformInset(unit.Dp(6))),
- WithBgColor(color.NRGBA{}),
- WithIconColor(th.Fg),
- ).Layout(gtx)
- }),
- )
- })
- }),
- )
- }),
- layout.Flexed(1, func(gtx C) D {
- return layout.Stack{}.Layout(
- gtx,
- layout.Expanded(func(gtx C) D {
- return Rect{
- Color: color.NRGBA{R: 240, G: 240, B: 240, A: 255},
- Size: layout.FPt(gtx.Constraints.Max),
- }.Layout(gtx)
- }),
- layout.Stacked(func(gtx C) D {
- p.List.Axis = layout.Vertical
- return p.List.Layout(gtx, len(tickets), func(gtx C, ii int) D {
- return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx C) D {
- return tickets[ii](gtx, ii)
- })
-
- })
- }),
- )
- }),
- )
- })
-}
-
-// Ticket renders a ticket control.
-type Ticket struct {
- kanban.Ticket
- Stage string
- NextButton widget.Clickable
- PrevButton widget.Clickable
- EditButton widget.Clickable
- DeleteButton widget.Clickable
- Content widget.Clickable
-}
-
-// Layout the ticket card.
-//
-// The layouting here was actually quite tricky because `layout.List` simulates
-// an infinite Y axis. That means you can just specify a max Y constraint. This
-// makes expanding stacked content vertically impossible with a naive use of
-// `layout.Stack`.
-//
-// To get around this I used a macro and manually stacked things sized exactly
-// to the content, rather than the maximum Y.
-func (t *Ticket) Layout(gtx C, th *material.Theme) D {
- var (
- barThickness = unit.Dp(25)
- sideBarColor = color.NRGBA{R: 50, G: 50, B: 50, A: 255}
- bottomBarColor = color.NRGBA{R: 220, G: 220, B: 220, A: 255}
- minContentSize = gtx.Px(unit.Dp(150))
- )
- return widget.Border{
- Width: unit.Dp(0.5),
- Color: color.NRGBA{A: 200},
- }.Layout(gtx, func(gtx C) D {
- dims := layout.Inset{
- Left: unit.Dp(25),
- }.Layout(gtx, func(gtx C) D {
- return layout.Flex{
- Axis: layout.Vertical,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- gtx.Constraints.Min.Y = minContentSize
- return t.content(gtx, th)
- }),
- layout.Rigid(func(gtx C) D {
- return t.bottomBar(
- gtx,
- th,
- image.Point{
- X: gtx.Constraints.Max.X,
- Y: gtx.Px(barThickness),
- },
- bottomBarColor,
- )
- }),
- )
- })
- t.sideBar(
- gtx,
- image.Point{
- X: gtx.Px(barThickness),
- Y: dims.Size.Y,
- },
- sideBarColor,
- )
- return dims
- })
-}
-
-func (t *Ticket) content(gtx C, th *material.Theme) D {
- macro := op.Record(gtx.Ops)
- dims := layout.Inset{
- Top: unit.Dp(5),
- Bottom: unit.Dp(5),
- Left: unit.Dp(10),
- Right: unit.Dp(10),
- }.Layout(gtx, func(gtx C) D {
- return layout.Flex{
- Axis: layout.Vertical,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return material.Label(th, unit.Dp(20), t.Title).Layout(gtx)
- }),
- layout.Rigid(func(gtx C) D {
- return layout.Inset{Top: unit.Dp(10)}.Layout(gtx, func(gtx C) D {
- return material.Body1(th, t.Summary).Layout(gtx)
- })
- }),
- )
- })
- call := macro.Stop()
- layout.Stack{}.Layout(
- gtx,
- layout.Stacked(func(gtx C) D {
- return Rect{
- Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
- Size: layout.FPt(image.Point{
- X: gtx.Constraints.Max.X,
- Y: dims.Size.Y,
- }),
- }.Layout(gtx)
-
- }),
- layout.Expanded(func(gtx C) D {
- return t.Content.Layout(gtx)
- }),
- )
- call.Add(gtx.Ops)
- return dims
-}
-
-func (t *Ticket) bottomBar(gtx C, th *material.Theme, sz image.Point, c color.NRGBA) D {
- return layout.Stack{}.Layout(
- gtx,
- layout.Expanded(func(gtx C) D {
- return Rect{
- Color: c,
- Size: layout.FPt(sz),
- }.Layout(gtx)
- }),
- layout.Stacked(func(gtx C) D {
- return layout.Flex{
- Axis: layout.Horizontal,
- Alignment: layout.Middle,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return layout.Inset{
- Left: unit.Px(10),
- }.Layout(gtx, func(gtx C) D {
- return material.Label(th, unit.Dp(10), func() string {
- d := time.Since(t.Created)
- d = d.Round(time.Minute)
- h := d / time.Hour
- d -= h * time.Hour
- m := d / time.Minute
- return fmt.Sprintf("%02d:%02d", h, m)
- }()).Layout(gtx)
- })
- }),
- layout.Flexed(1, func(gtx C) D {
- return D{Size: gtx.Constraints.Min}
- }),
- layout.Rigid(func(gtx C) D {
- return Button(
- &t.PrevButton,
- WithIcon(icons.BackIcon),
- WithSize(unit.Dp(12)),
- WithInset(layout.UniformInset(unit.Dp(6))),
- WithIconColor(color.NRGBA{R: 0, G: 0, B: 0, A: 255}),
- WithBgColor(c),
- ).Layout(gtx)
- }),
- layout.Rigid(func(gtx C) D {
- return Button(
- &t.NextButton,
- WithIcon(icons.ForwardIcon),
- WithSize(unit.Dp(12)),
- WithInset(layout.UniformInset(unit.Dp(6))),
- WithIconColor(color.NRGBA{R: 0, G: 0, B: 0, A: 255}),
- WithBgColor(c),
- ).Layout(gtx)
- }),
- )
- }),
- )
-}
-
-func (t *Ticket) sideBar(gtx C, sz image.Point, c color.NRGBA) D {
- return layout.Stack{}.Layout(
- gtx,
- layout.Stacked(func(gtx C) D {
- Rect{
- Color: c,
- Size: layout.FPt(sz),
- }.Layout(gtx)
- return D{}
- }),
- layout.Stacked(func(gtx C) D {
- return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D {
- return layout.Flex{
- Axis: layout.Vertical,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return Button(
- &t.EditButton,
- WithIcon(icons.ContentEdit),
- WithSize(unit.Dp(16)),
- WithInset(layout.UniformInset(unit.Dp(2))),
- WithIconColor(color.NRGBA{R: 255, G: 255, B: 255, A: 255}),
- WithBgColor(c),
- ).Layout(gtx)
- }),
- layout.Rigid(func(gtx C) D {
- return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx C) D {
- return Button(
- &t.DeleteButton,
- WithIcon(icons.ContentDelete),
- WithSize(unit.Dp(16)),
- WithInset(layout.UniformInset(unit.Dp(2))),
- WithIconColor(color.NRGBA{R: 255, G: 255, B: 255, A: 255}),
- WithBgColor(c),
- ).Layout(gtx)
- })
- }),
- )
- })
- }),
- )
-}
-
-// TicketDetails renders the read-only long form details of a ticket.
-type TicketDetails struct {
- kanban.Ticket
- Edit widget.Clickable
- Cancel widget.Clickable
-}
-
-func (t *TicketDetails) Layout(gtx C, th *material.Theme) D {
- return layout.Flex{
- Axis: layout.Vertical,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return material.Body1(th, t.Summary).Layout(gtx)
- }),
- layout.Rigid(func(gtx C) D {
- return material.Body1(th, t.Details).Layout(gtx)
- }),
- layout.Rigid(func(gtx C) D {
- gtx.Constraints.Min.X = gtx.Constraints.Max.X
- return layout.Inset{
- Top: unit.Dp(10),
- }.Layout(gtx, func(gtx C) D {
- return layout.Flex{
- Axis: layout.Horizontal,
- }.Layout(
- gtx,
- layout.Flexed(1, func(gtx C) D {
- return D{Size: gtx.Constraints.Min}
- }),
- layout.Rigid(func(gtx C) D {
- btn := material.Button(th, &t.Cancel, "Cancel")
- btn.Color = th.Fg
- btn.Background = color.NRGBA{}
- return btn.Layout(gtx)
- }),
- layout.Rigid(func(gtx C) D {
- return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}}
- }),
- layout.Rigid(func(gtx C) D {
- return material.Button(th, &t.Edit, "Edit").Layout(gtx)
- }),
- )
- })
- }),
- )
-}
-
-// ProjectForm renders a form for manipulating projects.
-type ProjectForm struct {
- Name component.TextField
- Submit widget.Clickable
- Cancel widget.Clickable
-}
-
-func (form *ProjectForm) Layout(gtx C, th *material.Theme) D {
- return layout.Flex{
- Axis: layout.Vertical,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return form.Name.Layout(gtx, th, "Project Name")
- }),
- layout.Rigid(func(gtx C) D {
- gtx.Constraints.Min.X = gtx.Constraints.Max.X
- return layout.Inset{
- Top: unit.Dp(10),
- }.Layout(gtx, func(gtx C) D {
- return layout.Flex{
- Axis: layout.Horizontal,
- }.Layout(
- gtx,
- layout.Flexed(1, func(gtx C) D {
- return D{Size: gtx.Constraints.Min}
- }),
- layout.Rigid(func(gtx C) D {
- btn := material.Button(th, &form.Cancel, "Cancel")
- btn.Color = th.Fg
- btn.Background = color.NRGBA{}
- return btn.Layout(gtx)
- }),
- layout.Rigid(func(gtx C) D {
- return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}}
- }),
- layout.Rigid(func(gtx C) D {
- return material.Button(th, &form.Submit, "Submit").Layout(gtx)
- }),
- )
- })
- }),
- )
-}
diff --git a/cmd/kanban/map.go b/cmd/kanban/map.go
@@ -1,76 +0,0 @@
-package main
-
-import "unsafe"
-
-// Map of arbitrary data to hold unordered state for `layout.List` items.
-// This allows Gio programs to re-use a buffer of states for lists items in
-// between frames. It is a grow-only buffer that expects entries to stabilise.
-//
-// This is designed along 2 constraints:
-// 1. Performance
-// 2. Type ambiguity
-//
-// Since Go doesn't have generics, I decided to give the caller type control
-// by using `unsafe.Pointer`.
-//
-// The caller only has to ensure that the type they initialise it with is the
-// type they attempt to cast out of it.
-// Since the scope of use is small, this invariant is straightforward to uphold.
-//
-// Nonetheless, this style of API is primarily motivated by re-use concerns when
-// using common patterns in Gio (specifically `layout.List` state management).
-// The static approach would be to copy-paste the same structures with different
-// types every time you have list state to manage.
-//
-// In light of Go generics incoming, this may become a moot issue. In the meantime
-// this remains an experimental API that functions as expected.
-type Map struct {
- data map[string]unsafe.Pointer
- index []string
- current int
-}
-
-// Begin prepares the map to be accessed.
-// Require to reset iteration state each frame.
-func (m *Map) Begin() {
- m.current = 0
- if m.data == nil {
- m.data = make(map[string]unsafe.Pointer)
- }
-}
-
-// New returns a value for the provided key.
-// In the case no value exists, the initializer is used as the default value.
-// The initializer is the value that will be returned from the map.
-// Take care when casting it.
-//
-// v := (*T)(m.New("foo", &T{}))
-//
-func (m *Map) New(k string, init unsafe.Pointer) unsafe.Pointer {
- if _, ok := m.data[k]; !ok {
- m.data[k] = init
- m.index = append(m.index, k)
- }
- return m.data[k]
-}
-
-// Next iterates over the collection, returning the key-value pair.
-//
-// for key, value := m.Next(); m.More(); key, value = m.Next() {
-// t := (*T)(v)
-// }
-//
-func (m *Map) Next() (key string, value unsafe.Pointer) {
- if m.current >= len(m.index) {
- return key, value
- }
- defer func() { m.current++ }()
- key = m.index[m.current]
- value = m.data[key]
- return key, value
-}
-
-// More reports whether there is more data to iterate.
-func (m *Map) More() bool {
- return m.current <= len(m.index)-1
-}
diff --git a/cmd/kanban/state/map.go b/cmd/kanban/state/map.go
@@ -0,0 +1,76 @@
+package state
+
+import "unsafe"
+
+// Map of arbitrary data to hold unordered state for `layout.List` items.
+// This allows Gio programs to re-use a buffer of states for lists items in
+// between frames. It is a grow-only buffer that expects entries to stabilise.
+//
+// This is designed along 2 constraints:
+// 1. Performance
+// 2. Type ambiguity
+//
+// Since Go doesn't have generics, I decided to give the caller type control
+// by using `unsafe.Pointer`.
+//
+// The caller only has to ensure that the type they initialise it with is the
+// type they attempt to cast out of it.
+// Since the scope of use is small, this invariant is straightforward to uphold.
+//
+// Nonetheless, this style of API is primarily motivated by re-use concerns when
+// using common patterns in Gio (specifically `layout.List` state management).
+// The static approach would be to copy-paste the same structures with different
+// types every time you have list state to manage.
+//
+// In light of Go generics incoming, this may become a moot issue. In the meantime
+// this remains an experimental API that functions as expected.
+type Map struct {
+ data map[string]unsafe.Pointer
+ index []string
+ current int
+}
+
+// Begin prepares the map to be accessed.
+// Require to reset iteration state each frame.
+func (m *Map) Begin() {
+ m.current = 0
+ if m.data == nil {
+ m.data = make(map[string]unsafe.Pointer)
+ }
+}
+
+// New returns a value for the provided key.
+// In the case no value exists, the initializer is used as the default value.
+// The initializer is the value that will be returned from the map.
+// Take care when casting it.
+//
+// v := (*T)(m.New("foo", &T{}))
+//
+func (m *Map) New(k string, init unsafe.Pointer) unsafe.Pointer {
+ if _, ok := m.data[k]; !ok {
+ m.data[k] = init
+ m.index = append(m.index, k)
+ }
+ return m.data[k]
+}
+
+// Next iterates over the collection, returning the key-value pair.
+//
+// for key, value := m.Next(); m.More(); key, value = m.Next() {
+// t := (*T)(v)
+// }
+//
+func (m *Map) Next() (key string, value unsafe.Pointer) {
+ if m.current >= len(m.index) {
+ return key, value
+ }
+ defer func() { m.current++ }()
+ key = m.index[m.current]
+ value = m.data[key]
+ return key, value
+}
+
+// More reports whether there is more data to iterate.
+func (m *Map) More() bool {
+ return m.current <= len(m.index)-1
+}
diff --git a/cmd/kanban/util/util.go b/cmd/kanban/util/util.go
@@ -0,0 +1,141 @@
+package util
+
+import (
+ "image"
+ "image/color"
+
+ "gioui.org/f32"
+ "gioui.org/layout"
+ "gioui.org/op/clip"
+ "gioui.org/op/paint"
+ "gioui.org/unit"
+ "gioui.org/widget"
+ "gioui.org/widget/material"
+)
+
+type (
+ C = layout.Context
+ D = layout.Dimensions
+)
+
+// Rect creates a rectangle of the provided background color with
+// Dimensions specified by size and a corner radius (on all corners)
+// specified by radii.
+type Rect struct {
+ Color color.NRGBA
+ Size f32.Point
+ Radii float32
+}
+
+// Layout renders the Rect into the provided context
+func (r Rect) Layout(gtx C) D {
+ paint.FillShape(
+ gtx.Ops,
+ r.Color,
+ clip.UniformRRect(
+ f32.Rectangle{Max: r.Size},
+ r.Radii,
+ ).Op(gtx.Ops),
+ )
+ return layout.Dimensions{
+ Size: image.Pt(int(r.Size.X), int(r.Size.Y)),
+ }
+}
+
+// Button renders a clickable button.
+func Button(
+ state *widget.Clickable,
+ opt ...ButtonOption,
+) ButtonStyle {
+ btn := ButtonStyle{}
+ btn.IconButtonStyle.Button = state
+ btn.ButtonStyle.Button = state
+ for _, opt := range opt {
+ opt(&btn)
+ }
+ return btn
+}
+
+// ButtonStyle provides a unified api for both icon buttons and text buttons.
+type ButtonStyle struct {
+ material.IconButtonStyle
+ material.ButtonStyle
+}
+
+func (btn ButtonStyle) Layout(gtx C) D {
+ if btn.Icon != nil {
+ return btn.IconButtonStyle.Layout(gtx)
+ }
+ return btn.ButtonStyle.Layout(gtx)
+}
+
+type ButtonOption func(*ButtonStyle)
+
+func WithSize(sz unit.Value) ButtonOption {
+ return func(btn *ButtonStyle) {
+ btn.Size = sz
+ }
+}
+
+func WithIconColor(c color.NRGBA) ButtonOption {
+ return func(btn *ButtonStyle) {
+ btn.IconButtonStyle.Color = c
+ btn.ButtonStyle.Color = c
+ }
+}
+
+func WithBgColor(c color.NRGBA) ButtonOption {
+ return func(btn *ButtonStyle) {
+ btn.IconButtonStyle.Background = c
+ btn.ButtonStyle.Background = c
+ }
+}
+
+func WithIcon(icon *widget.Icon) ButtonOption {
+ return func(btn *ButtonStyle) {
+ btn.Icon = icon
+ }
+}
+
+// func WithText(txt string) ButtonOption {
+// return func(btn *ButtonStyle) {
+// btn.Text = txt
+// }
+// }
+
+func WithInset(inset layout.Inset) ButtonOption {
+ return func(btn *ButtonStyle) {
+ btn.IconButtonStyle.Inset = inset
+ btn.ButtonStyle.Inset = inset
+ }
+}
+
+// Div is a visual divider: a colored line with a thickness.
+type Div struct {
+ Thickness unit.Value
+ Length unit.Value
+ Axis layout.Axis
+ Color color.NRGBA
+}
+
+func (d Div) Layout(gtx C) D {
+ // Draw a line as a very thin rectangle.
+ var sz image.Point
+ switch d.Axis {
+ case layout.Horizontal:
+ sz = image.Point{
+ X: gtx.Px(d.Length),
+ Y: gtx.Px(d.Thickness),
+ }
+ case layout.Vertical:
+ sz = image.Point{
+ X: gtx.Px(d.Thickness),
+ Y: gtx.Px(d.Length),
+ }
+ }
+ return Rect{
+ Color: d.Color,
+ Size: layout.FPt(sz),
+ Radii: 0,
+ }.Layout(gtx)
+}
diff --git a/cmd/kanban/widget.go b/cmd/kanban/widget.go
@@ -1,212 +0,0 @@
-package main
-
-import (
- "image"
- "image/color"
- "unsafe"
-
- "gioui.org/f32"
- "gioui.org/layout"
- "gioui.org/op/clip"
- "gioui.org/op/paint"
- "gioui.org/unit"
- "gioui.org/widget"
- "gioui.org/widget/material"
-)
-
-// Rect creates a rectangle of the provided background color with
-// Dimensions specified by size and a corner radius (on all corners)
-// specified by radii.
-type Rect struct {
- Color color.NRGBA
- Size f32.Point
- Radii float32
-}
-
-// Layout renders the Rect into the provided context
-func (r Rect) Layout(gtx C) D {
- paint.FillShape(
- gtx.Ops,
- r.Color,
- clip.UniformRRect(
- f32.Rectangle{Max: r.Size},
- r.Radii,
- ).Op(gtx.Ops),
- )
- return layout.Dimensions{
- Size: image.Pt(int(r.Size.X), int(r.Size.Y)),
- }
-}
-
-// Button renders a clickable button.
-func Button(
- state *widget.Clickable,
- opt ...ButtonOption,
-) ButtonStyle {
- btn := ButtonStyle{}
- btn.IconButtonStyle.Button = state
- btn.ButtonStyle.Button = state
- for _, opt := range opt {
- opt(&btn)
- }
- return btn
-}
-
-// ButtonStyle provides a unified api for both icon buttons and text buttons.
-type ButtonStyle struct {
- material.IconButtonStyle
- material.ButtonStyle
-}
-
-func (btn ButtonStyle) Layout(gtx C) D {
- if btn.Icon != nil {
- return btn.IconButtonStyle.Layout(gtx)
- }
- return btn.ButtonStyle.Layout(gtx)
-}
-
-type ButtonOption func(*ButtonStyle)
-
-func WithSize(sz unit.Value) ButtonOption {
- return func(btn *ButtonStyle) {
- btn.Size = sz
- }
-}
-
-func WithIconColor(c color.NRGBA) ButtonOption {
- return func(btn *ButtonStyle) {
- btn.IconButtonStyle.Color = c
- btn.ButtonStyle.Color = c
- }
-}
-
-func WithBgColor(c color.NRGBA) ButtonOption {
- return func(btn *ButtonStyle) {
- btn.IconButtonStyle.Background = c
- btn.ButtonStyle.Background = c
- }
-}
-
-func WithIcon(icon *widget.Icon) ButtonOption {
- return func(btn *ButtonStyle) {
- btn.Icon = icon
- }
-}
-
-// func WithText(txt string) ButtonOption {
-// return func(btn *ButtonStyle) {
-// btn.Text = txt
-// }
-// }
-
-func WithInset(inset layout.Inset) ButtonOption {
- return func(btn *ButtonStyle) {
- btn.IconButtonStyle.Inset = inset
- btn.ButtonStyle.Inset = inset
- }
-}
-
-// Rail is an interactive side rail with a list of widget items that can be
-// selected.
-//
-// Typically used as navigation or contextual actions.
-//
-// Rail is stateful.
-type Rail struct {
- layout.List
- Map Map
-}
-
-// RailChild is an item that renders in a rail.
-type RailChild struct {
- Name string
- W layout.Widget
-}
-
-// Destination is a rail item that represents a navigatable object.
-// Destinations are pab+dded by default.
-func Destination(name string, w layout.Widget) RailChild {
- return RailChild{
- Name: name,
- W: w,
- }
-}
-
-func (r *Rail) next(key string) *widget.Clickable {
- return (*widget.Clickable)(r.Map.New(key, unsafe.Pointer(&widget.Clickable{})))
-}
-
-// Selected reports which rail child was selected, if any.
-// Reports the first click encountered.
-func (r *Rail) Selected() (string, bool) {
- for k, v := r.Map.Next(); r.Map.More(); k, v = r.Map.Next() {
- if (*widget.Clickable)(v).Clicked() {
- return k, true
- }
- }
- return "", false
-}
-
-// Layout the rail with the given items.
-func (r *Rail) Layout(gtx C, action layout.Widget, items ...RailChild) D {
- r.List.Axis = layout.Vertical
- r.List.Alignment = layout.Middle
- r.Map.Begin()
- return layout.Flex{
- Axis: layout.Vertical,
- Alignment: layout.Middle,
- }.Layout(
- gtx,
- layout.Rigid(func(gtx C) D {
- return action(gtx)
- }),
- layout.Rigid(func(gtx C) D {
- return layout.UniformInset(unit.Dp(5)).Layout(gtx, func(gtx C) D {
- return Div{
- Color: color.NRGBA{R: 220, B: 220, G: 220, A: 255},
- Length: unit.Px(float32(gtx.Constraints.Max.X)),
- Thickness: unit.Dp(1),
- Axis: layout.Horizontal,
- }.Layout(gtx)
- })
- }),
- layout.Rigid(func(gtx C) D {
- return r.List.Layout(gtx, len(items), func(gtx C, ii int) D {
- rc := items[ii]
- return material.Clickable(gtx, r.next(rc.Name), func(gtx C) D {
- return rc.W(gtx)
- })
- })
- }),
- )
-}
-
-// Div is a visual divider: a colored line with a thickness.
-type Div struct {
- Thickness unit.Value
- Length unit.Value
- Axis layout.Axis
- Color color.NRGBA
-}
-
-func (d Div) Layout(gtx C) D {
- // Draw a line as a very thin rectangle.
- var sz image.Point
- switch d.Axis {
- case layout.Horizontal:
- sz = image.Point{
- X: gtx.Px(d.Length),
- Y: gtx.Px(d.Thickness),
- }
- case layout.Vertical:
- sz = image.Point{
- X: gtx.Px(d.Thickness),
- Y: gtx.Px(d.Length),
- }
- }
- return Rect{
- Color: d.Color,
- Size: layout.FPt(sz),
- Radii: 0,
- }.Layout(gtx)
-}
diff --git a/cmd/kanban/widgets.go b/cmd/kanban/widgets.go
@@ -0,0 +1,451 @@
+package main
+
+import (
+ "fmt"
+ "image"
+ "image/color"
+ "time"
+
+ "gioui.org/layout"
+ "gioui.org/op"
+ "gioui.org/unit"
+ "gioui.org/widget"
+ "gioui.org/widget/material"
+ "gioui.org/x/component"
+ "git.sr.ht/~jackmordaunt/kanban"
+ "git.sr.ht/~jackmordaunt/kanban/cmd/kanban/util"
+ "git.sr.ht/~jackmordaunt/kanban/icons"
+)
+
+// TicketForm renders the form for ticket information.
+//
+// @Todo use form pattern from avisha.
+type TicketForm struct {
+ *kanban.Ticket
+ Stage string
+ Title component.TextField
+ Summary component.TextField
+ Details component.TextField
+ SubmitBtn widget.Clickable
+ CancelBtn widget.Clickable
+}
+
+func (f *TicketForm) Set(t *kanban.Ticket) {
+ f.Ticket = t
+ f.Title.SetText(t.Title)
+ f.Summary.SetText(t.Summary)
+ f.Details.SetText(t.Details)
+}
+
+// Submit validates inputs and writes to the ticket.
+// @Todo validation.
+func (f TicketForm) Submit() error {
+ *f.Ticket = kanban.Ticket{
+ Title: f.Title.Text(),
+ Summary: f.Summary.Text(),
+ Details: f.Details.Text(),
+ }
+ return nil
+}
+
+func (form *TicketForm) Layout(gtx C, th *material.Theme, stage string) D {
+ form.Stage = stage
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return form.Title.Layout(gtx, th, "Title")
+ }),
+ layout.Rigid(func(gtx C) D {
+ return form.Summary.Layout(gtx, th, "Summary")
+ }),
+ layout.Rigid(func(gtx C) D {
+ return form.Details.Layout(gtx, th, "Details")
+ }),
+ layout.Rigid(func(gtx C) D {
+ gtx.Constraints.Min.X = gtx.Constraints.Max.X
+ return layout.Inset{
+ Top: unit.Dp(10),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ }.Layout(
+ gtx,
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ layout.Rigid(func(gtx C) D {
+ btn := material.Button(th, &form.CancelBtn, "Cancel")
+ btn.Color = th.Bg
+ btn.Background = color.NRGBA{}
+ return btn.Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}}
+ }),
+ layout.Rigid(func(gtx C) D {
+ return material.Button(th, &form.SubmitBtn, "Submit").Layout(gtx)
+ }),
+ )
+ })
+ }),
+ )
+}
+
+// ProjectForm renders a form for manipulating projects.
+type ProjectForm struct {
+ Name component.TextField
+ Submit widget.Clickable
+ Cancel widget.Clickable
+}
+
+func (form *ProjectForm) Layout(gtx C, th *material.Theme) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return form.Name.Layout(gtx, th, "Project Name")
+ }),
+ layout.Rigid(func(gtx C) D {
+ gtx.Constraints.Min.X = gtx.Constraints.Max.X
+ return layout.Inset{
+ Top: unit.Dp(10),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ }.Layout(
+ gtx,
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ layout.Rigid(func(gtx C) D {
+ btn := material.Button(th, &form.Cancel, "Cancel")
+ btn.Color = th.Fg
+ btn.Background = color.NRGBA{}
+ return btn.Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}}
+ }),
+ layout.Rigid(func(gtx C) D {
+ return material.Button(th, &form.Submit, "Submit").Layout(gtx)
+ }),
+ )
+ })
+ }),
+ )
+}
+
+// DeleteDialog prompts the user with an option to delete a ticket.
+type DeleteDialog struct {
+ kanban.Ticket
+ Ok widget.Clickable
+ Cancel widget.Clickable
+}
+
+func (d *DeleteDialog) Layout(gtx C, th *material.Theme) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ Alignment: layout.Middle,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return layout.Center.Layout(gtx, func(gtx C) D {
+ return material.Body1(
+ th,
+ fmt.Sprintf("Are you sure you want to delete ticket %q?", d.Title),
+ ).Layout(gtx)
+ })
+ }),
+ layout.Rigid(func(gtx C) D {
+ gtx.Constraints.Min.X = gtx.Constraints.Max.X
+ return layout.Inset{
+ Top: unit.Dp(10),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ }.Layout(
+ gtx,
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ layout.Rigid(func(gtx C) D {
+ btn := material.Button(th, &d.Cancel, "Cancel")
+ btn.Color = th.Bg
+ btn.Background = color.NRGBA{}
+ return btn.Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}}
+ }),
+ layout.Rigid(func(gtx C) D {
+ btn := material.Button(th, &d.Ok, "Delete")
+ btn.Background = color.NRGBA{R: 200, A: 255}
+ return btn.Layout(gtx)
+ }),
+ )
+ })
+ }),
+ )
+}
+
+// Ticket renders a ticket control.
+type Ticket struct {
+ kanban.Ticket
+ Stage string
+ NextButton widget.Clickable
+ PrevButton widget.Clickable
+ EditButton widget.Clickable
+ DeleteButton widget.Clickable
+ Content widget.Clickable
+}
+
+// Layout the ticket card.
+//
+// The layouting here was actually quite tricky because `layout.List` simulates
+// an infinite Y axis. That means you can just specify a max Y constraint. This
+// makes expanding stacked content vertically impossible with a naive use of
+// `layout.Stack`.
+//
+// To get around this I used a macro and manually stacked things sized exactly
+// to the content, rather than the maximum Y.
+func (t *Ticket) Layout(gtx C, th *material.Theme) D {
+ var (
+ barThickness = unit.Dp(25)
+ sideBarColor = color.NRGBA{R: 50, G: 50, B: 50, A: 255}
+ bottomBarColor = color.NRGBA{R: 220, G: 220, B: 220, A: 255}
+ minContentSize = gtx.Px(unit.Dp(150))
+ )
+ return widget.Border{
+ Width: unit.Dp(0.5),
+ Color: color.NRGBA{A: 200},
+ }.Layout(gtx, func(gtx C) D {
+ dims := layout.Inset{
+ Left: unit.Dp(25),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ gtx.Constraints.Min.Y = minContentSize
+ return t.content(gtx, th)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return t.bottomBar(
+ gtx,
+ th,
+ image.Point{
+ X: gtx.Constraints.Max.X,
+ Y: gtx.Px(barThickness),
+ },
+ bottomBarColor,
+ )
+ }),
+ )
+ })
+ t.sideBar(
+ gtx,
+ image.Point{
+ X: gtx.Px(barThickness),
+ Y: dims.Size.Y,
+ },
+ sideBarColor,
+ )
+ return dims
+ })
+}
+
+func (t *Ticket) content(gtx C, th *material.Theme) D {
+ macro := op.Record(gtx.Ops)
+ dims := layout.Inset{
+ Top: unit.Dp(5),
+ Bottom: unit.Dp(5),
+ Left: unit.Dp(10),
+ Right: unit.Dp(10),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return material.Label(th, unit.Dp(20), t.Title).Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return layout.Inset{Top: unit.Dp(10)}.Layout(gtx, func(gtx C) D {
+ return material.Body1(th, t.Summary).Layout(gtx)
+ })
+ }),
+ )
+ })
+ call := macro.Stop()
+ layout.Stack{}.Layout(
+ gtx,
+ layout.Stacked(func(gtx C) D {
+ return util.Rect{
+ Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
+ Size: layout.FPt(image.Point{
+ X: gtx.Constraints.Max.X,
+ Y: dims.Size.Y,
+ }),
+ }.Layout(gtx)
+
+ }),
+ layout.Expanded(func(gtx C) D {
+ return t.Content.Layout(gtx)
+ }),
+ )
+ call.Add(gtx.Ops)
+ return dims
+}
+
+func (t *Ticket) bottomBar(gtx C, th *material.Theme, sz image.Point, c color.NRGBA) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Expanded(func(gtx C) D {
+ return util.Rect{
+ Color: c,
+ Size: layout.FPt(sz),
+ }.Layout(gtx)
+ }),
+ layout.Stacked(func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ Alignment: layout.Middle,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return layout.Inset{
+ Left: unit.Px(10),
+ }.Layout(gtx, func(gtx C) D {
+ return material.Label(th, unit.Dp(10), func() string {
+ d := time.Since(t.Created)
+ d = d.Round(time.Minute)
+ h := d / time.Hour
+ d -= h * time.Hour
+ m := d / time.Minute
+ return fmt.Sprintf("%02d:%02d", h, m)
+ }()).Layout(gtx)
+ })
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ layout.Rigid(func(gtx C) D {
+ return util.Button(
+ &t.PrevButton,
+ util.WithIcon(icons.BackIcon),
+ util.WithSize(unit.Dp(12)),
+ util.WithInset(layout.UniformInset(unit.Dp(6))),
+ util.WithIconColor(color.NRGBA{R: 0, G: 0, B: 0, A: 255}),
+ util.WithBgColor(c),
+ ).Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return util.Button(
+ &t.NextButton,
+ util.WithIcon(icons.ForwardIcon),
+ util.WithSize(unit.Dp(12)),
+ util.WithInset(layout.UniformInset(unit.Dp(6))),
+ util.WithIconColor(color.NRGBA{R: 0, G: 0, B: 0, A: 255}),
+ util.WithBgColor(c),
+ ).Layout(gtx)
+ }),
+ )
+ }),
+ )
+}
+
+func (t *Ticket) sideBar(gtx C, sz image.Point, c color.NRGBA) D {
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Stacked(func(gtx C) D {
+ util.Rect{
+ Color: c,
+ Size: layout.FPt(sz),
+ }.Layout(gtx)
+ return D{}
+ }),
+ layout.Stacked(func(gtx C) D {
+ return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return util.Button(
+ &t.EditButton,
+ util.WithIcon(icons.ContentEdit),
+ util.WithSize(unit.Dp(16)),
+ util.WithInset(layout.UniformInset(unit.Dp(2))),
+ util.WithIconColor(color.NRGBA{R: 255, G: 255, B: 255, A: 255}),
+ util.WithBgColor(c),
+ ).Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx C) D {
+ return util.Button(
+ &t.DeleteButton,
+ util.WithIcon(icons.ContentDelete),
+ util.WithSize(unit.Dp(16)),
+ util.WithInset(layout.UniformInset(unit.Dp(2))),
+ util.WithIconColor(color.NRGBA{R: 255, G: 255, B: 255, A: 255}),
+ util.WithBgColor(c),
+ ).Layout(gtx)
+ })
+ }),
+ )
+ })
+ }),
+ )
+}
+
+// TicketDetails renders the read-only long form details of a ticket.
+type TicketDetails struct {
+ kanban.Ticket
+ Edit widget.Clickable
+ Cancel widget.Clickable
+}
+
+func (t *TicketDetails) Layout(gtx C, th *material.Theme) D {
+ return layout.Flex{
+ Axis: layout.Vertical,
+ }.Layout(
+ gtx,
+ layout.Rigid(func(gtx C) D {
+ return material.Body1(th, t.Summary).Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return material.Body1(th, t.Details).Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ gtx.Constraints.Min.X = gtx.Constraints.Max.X
+ return layout.Inset{
+ Top: unit.Dp(10),
+ }.Layout(gtx, func(gtx C) D {
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ }.Layout(
+ gtx,
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Min}
+ }),
+ layout.Rigid(func(gtx C) D {
+ btn := material.Button(th, &t.Cancel, "Cancel")
+ btn.Color = th.Fg
+ btn.Background = color.NRGBA{}
+ return btn.Layout(gtx)
+ }),
+ layout.Rigid(func(gtx C) D {
+ return D{Size: image.Point{X: gtx.Px(unit.Dp(10))}}
+ }),
+ layout.Rigid(func(gtx C) D {
+ return material.Button(th, &t.Edit, "Edit").Layout(gtx)
+ }),
+ )
+ })
+ }),
+ )
+}
diff --git a/go.mod b/go.mod
@@ -6,9 +6,6 @@ require (
gioui.org v0.0.0-20210127212131-b698c8ed8229
gioui.org/x v0.0.0-20210120222453-b55819bc712b
github.com/asdine/storm/v3 v3.2.1
- github.com/jackmordaunt/icns v1.0.0
- github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
- github.com/pkg/errors v0.9.1 // indirect
go.etcd.io/bbolt v1.3.5 // indirect
golang.org/x/exp v0.0.0-20210126221216-84987778548c
golang.org/x/image v0.0.0-20201208152932-35266b937fa6 // indirect
diff --git a/go.sum b/go.sum
@@ -21,17 +21,11 @@ github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/jackmordaunt/icns v1.0.0 h1:RYSxplerf/l/DUd09AHtITwckkv/mqjVv4DjYdPmAMQ=
-github.com/jackmordaunt/icns v1.0.0/go.mod h1:7TTQVEuGzVVfOPPlLNHJIkzA6CoV7aH1Dv9dW351oOo=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
diff --git a/kanban.go b/kanban.go
@@ -3,277 +3,233 @@ package kanban
import (
"errors"
"fmt"
- "strconv"
+ "strings"
"time"
"github.com/asdine/storm/v3"
)
-// Kanban manipulates the model.
-type Kanban struct {
- // Data access layer for querying and mutating data.
- Store *storm.DB
+// Project
+// - represents some project that can be broken down into to discrete tasks, described by a name
+// - each project has it's own arbitrary pipeline of stages with which tickets move through left-to-right
+// - contains an ordered list of stages
+// - stages are re-orderable
+// - can be renamed
+// - can be deleted
+//
+// Stage
+// - represents an important part in the lifecycle of a task, described by a name
+// - contains an ordered list of tickets
+// - tickets are re-orderable
+// - tickets can advance back and forth between stages, typically linearly
+// - can be renamed
+// - can be deleted
+//
+// Ticket
+// - contains information about a task for a project
+// - is unique to a Project and sits within one of it's stages
+// - cannot occupy more than one stage
+// - can be edited
+// - can be deleted
+
+type IProject interface {
+ MakeStage(stage string)
+ ListStages() []Stage
+ MoveStage(stage string, dir Direction) bool
+ AssignTicket(stage string, t Ticket)
+ ProgressTicket(t Ticket)
+ RegressTicket(t Ticket)
+ ListTickets(stage string) []Ticket
+ MoveTicket(t Ticket, dir Direction) bool
+ FinalizeTicket(t Ticket)
}
-// ID is a unique identifier encoded as an integer.
-type ID int
-
-// Entity is unique schema object that changes over time.
-type Entity struct {
- ID ID `storm:"id,index,increment"`
- Created time.Time
+// Storage handles serialization of Project entities.
+type Storer interface {
+ Create(p *Project) error
+ Save(p *Project) error
+ Load(name string) (*Project, bool, error)
+ List() ([]*Project, error)
}
// Project is a context for a given set of tickets.
type Project struct {
- Entity `storm:"inline"`
- Name string `storm:"unique"`
- // Stages lists stage IDs in order.
- Stages Stages
+ // Name of project, must be unique.
+ Name string
+ // Stages owned by this project.
+ Stages Stages
+ Finalized []Ticket
}
-type Stages []ID
+var _ IProject = (*Project)(nil)
-func (stages *Stages) Swap(id ID, dir Direction) {
- for ii := range *stages {
- if (*stages)[ii] == id {
- if bounds := ii + dir.Next(); bounds < 0 || bounds > len(*stages)-1 {
- return
- }
- (*stages)[ii], (*stages)[ii+dir.Next()] = (*stages)[ii+dir.Next()], (*stages)[ii]
- }
- }
+// MakeStage assigns a ticket to the given stage.
+func (p *Project) MakeStage(name string) {
+ p.Stages = append(p.Stages, Stage{
+ Name: name,
+ })
}
-// Stage in the kanban pipeline, can hold a number of tickets.
-type Stage struct {
- Entity `storm:"inline"`
- Name string
- // Tickest lists ticket IDs in order.
- Tickets []ID // @Todo abstract into "reorderable list", to use with project stage list as well.
+func (p *Project) ListStages() []Stage {
+ return p.Stages
}
-func (s *Stage) Assign(ticket ID) {
- for _, t := range s.Tickets {
- if t == ticket {
- return
+func (p *Project) MoveStage(name string, dir Direction) bool {
+ return p.Stages.Swap(name, dir)
+}
+
+// AssignTicket assigns a ticket to the given stage.
+func (p *Project) AssignTicket(stage string, ticket Ticket) {
+ p.Stages.Find(stage).Assign(ticket)
+}
+
+// ProgressTicket moves a ticket to the "next" stage.
+func (p *Project) ProgressTicket(ticket Ticket) {
+ for ii, s := range p.Stages {
+ if s.Contains(ticket) {
+ // @todo bounds check
+ p.Stages[ii+1].Assign(s.Take(ticket))
+ break
}
}
- s.Tickets = append(s.Tickets, ticket)
}
-func (s *Stage) UnAssign(ticket ID) {
- for ii, t := range s.Tickets {
- if t == ticket {
- s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...)
+// RegressTicket moves a ticket to the "previous" stage.
+func (p *Project) RegressTicket(ticket Ticket) {
+ for ii, s := range p.Stages {
+ if s.Contains(ticket) {
+ // @todo bounds check
+ p.Stages[ii-1].Assign(s.Take(ticket))
+ break
}
}
}
-// FinalisedTicket is an inactive ticket kept for analytic purposes.
-type FinalisedTicket = Ticket
-
-// Ticket in a stage.
-type Ticket struct {
- Entity `storm:"inline"`
- Project ID
- Stage ID
+// MoveTicket within a stage.
+func (p *Project) MoveTicket(ticket Ticket, dir Direction) bool {
+ // @implement
+ return false
+}
- // Title of the ticket.
- Title string
- // Summary contains short and concise overview of the ticket.
- Summary string
- // Details contains the full details of the ticket.
- Details string
+func (p *Project) ListTickets(stage string) []Ticket {
+ return p.Stages.Find(stage).Tickets
}
-// ListStages returns a list of stages.
-func (k Kanban) ListStages(projectID ID) (stages []Stage, err error) {
- var (
- project Project
- )
- if err := k.Store.Find("ID", projectID, &project); err != nil {
- return nil, fmt.Errorf("loading project: %v", err)
- }
- for _, stageID := range project.Stages {
- var (
- stage Stage
- )
- if err := k.Store.Find("ID", stageID, &stage); err != nil {
- return stages, fmt.Errorf("loading stage: %v", err)
+// StageForTicket returns the stage containing the specified ticket.
+func (p *Project) StageForTicket(ticket Ticket) *Stage {
+ for ii, s := range p.Stages {
+ if s.Contains(ticket) {
+ return &p.Stages[ii]
}
- stages = append(stages, stage)
}
- return stages, nil
+ return &Stage{}
}
-// NextStage returns the stage that follows the specified one.
-func (k Kanban) NextStage(projectID ID, current ID) (string, error) {
- stage, err := k.NextStageForDirection(projectID, current, Forward)
- return stage.Name, err
+// FinalizeTicket renders the ticket "complete" ad moves it into an archive.
+func (p *Project) FinalizeTicket(t Ticket) {
+ for _, s := range p.Stages {
+ if s.Contains(t) {
+ s.UnAssign(t)
+ p.Finalized = append(p.Finalized, t)
+ break
+ }
+ }
}
-// NextStage returns the stage that preceeds the specified one.
-func (k Kanban) PreviousStage(projectID ID, current ID) (string, error) {
- stage, err := k.NextStageForDirection(projectID, current, Backward)
- return stage.Name, err
+// Stage in the kanban pipeline, can hold a number of tickets.
+type Stage struct {
+ Name string
+ Tickets []Ticket
}
-// NextStageForDirection gets the next stage in the given direction.
-func (k Kanban) NextStageForDirection(projectID ID, current ID, dir Direction) (next Stage, err error) {
- var (
- project Project
- )
- if err := k.Store.Find("ID", projectID, &project); err != nil {
- return Stage{}, fmt.Errorf("finding project: %v", err)
- }
- for ii, stage := range project.Stages {
- if stage == current {
- // @Todo bounds check.
- return next, k.Store.Find("ID", project.Stages[ii+dir.Next()], &next)
+// Assign appends a ticket id to the stage.
+func (s *Stage) Assign(ticket Ticket) {
+ for _, t := range s.Tickets {
+ if t == ticket {
+ return
}
}
- return next, err
+ s.Tickets = append(s.Tickets, ticket)
}
-// MoveStage moves a stage one place in the given direction.
-func (k Kanban) MoveStage(projectID ID, id ID, dir Direction) error {
- var (
- project Project
- )
- if err := k.Store.Find("ID", projectID, &project); err != nil {
- return fmt.Errorf("finding project: %v", err)
- }
- project.Stages.Swap(id, dir)
- if err := k.Store.Save(&project); err != nil {
- return fmt.Errorf("saving project: %v", err)
+// UnAssign removes a ticket id from the stage.
+func (s *Stage) UnAssign(ticket Ticket) {
+ for ii, t := range s.Tickets {
+ if t == ticket {
+ s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...)
+ }
}
- return nil
}
-// Stage returns a stage by the given name.
-// Creates an empty stage if it doesn't exist.
-func (k *Kanban) Stage(name string) (stage Stage, err error) {
- err = k.Store.Find("Name", name, &stage)
- if errors.Is(err, storm.ErrNotFound) {
- if err := k.Store.Save(&stage); err != nil {
- return stage, err
- }
- return k.Stage(name)
- }
- return stage, err
-}
-
-// Move a ticket to the specified stage.
-// Assigns to the bottom of the target stage.
-func (k *Kanban) Move(stageID ID, ticketID ID) error {
- var (
- ticket Ticket
- currentStage Stage
- targetStage Stage
- )
- if err := k.Store.Find("ID", ticketID, &ticket); err != nil {
- return err
- }
- if err := k.Store.Find("ID", ticket.Stage, ¤tStage); err != nil {
- return err
- }
- if err := k.Store.Find("ID", stageID, &targetStage); err != nil {
- return err
- }
- ticket.Stage = targetStage.ID
- currentStage.UnAssign(ticketID)
- targetStage.Assign(ticketID)
- if err := k.Store.Save(&ticket); err != nil {
- return err
- }
- if err := k.Store.Save(¤tStage); err != nil {
- return err
+// Stages is a list of Stage.
+type Stages []Stage
+
+// Swap the specified stage in the given direction.
+// Returns false when at a boundary, and therefore no swap can occur.
+func (stages *Stages) Swap(stage string, dir Direction) bool {
+ ii, ok := stages.Index(stage)
+ if !ok {
+ return false
}
- if err := k.Store.Save(&targetStage); err != nil {
- return err
+ if bounds := ii + dir.Next(); bounds < 0 || bounds > len(*stages)-1 {
+ return false
}
- return nil
+ (*stages)[ii], (*stages)[ii+dir.Next()] = (*stages)[ii+dir.Next()], (*stages)[ii]
+ return true
}
-// Progress a ticket to the next stage.
-func (k *Kanban) Progress(ticketID ID) error {
- var (
- ticket Ticket
- project Project
- stageID ID
- )
- if err := k.Store.Find("ID", ticketID, &ticket); err != nil {
- return err
- }
- if err := k.Store.Find("ID", ticket.Project, &project); err != nil {
- return err
- }
- for ii, id := range project.Stages {
- if id == ticket.Stage {
- stageID = project.Stages[ii+1]
+// Find stage by name.
+func (stages *Stages) Find(name string) *Stage {
+ for ii, s := range *stages {
+ if s.Name == name {
+ return &(*stages)[ii]
}
}
- return k.Move(stageID, ticket.ID)
-}
-
-// Regress a ticket to the previous stage.
-func (k *Kanban) Regress(ticketID ID) error {
- var (
- ticket Ticket
- project Project
- stageID ID
- )
- if err := k.Store.Find("ID", ticketID, &ticket); err != nil {
- return err
- }
- if err := k.Store.Find("ID", ticket.Project, &project); err != nil {
- return err
- }
- for ii, id := range project.Stages {
- if id == ticket.Stage {
- stageID = project.Stages[ii-1]
+ return &Stage{}
+}
+
+// Index returns the index postition for the stage, false if no stage exists.
+func (stages *Stages) Index(name string) (int, bool) {
+ for ii, s := range *stages {
+ if s.Name == name {
+ return ii, true
}
}
- return k.Move(stageID, ticket.ID)
+ return 0, false
}
-// Assign a ticket to a stage.
-func (k *Kanban) Assign(name string, ticket Ticket) error {
- var (
- stage Stage
- )
- if err := k.Store.Find("Name", name, &stage); err != nil {
- return fmt.Errorf("finding stage %q: %v", name, err)
- }
- stage.Assign(ticket.ID)
- ticket.Stage = stage.ID
- if err := k.Store.Save(&ticket); err != nil {
- return fmt.Errorf("saving ticket: %v", err)
- }
- if err := k.Store.Update(&stage); err != nil {
- return fmt.Errorf("saving stage: %v", err)
+// Take the specified ticket, if it exists.
+// Removes it from the stage.
+func (s *Stage) Take(ticket Ticket) Ticket {
+ for ii, t := range s.Tickets {
+ if t == ticket {
+ s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...)
+ return t
+ }
}
- return nil
+ return Ticket{}
}
-// Finalize a ticket.
-// Either the ticket was completed, made irrelevant, or faulty in some manner.
-func (k *Kanban) Finalize(ticketID ID) error {
- var (
- ticket Ticket
- )
- if err := k.Store.Find("ID", ticketID, &ticket); err != nil {
- return fmt.Errorf("ticket not exist: %v", err)
- }
- if err := k.Store.DeleteStruct(&ticket); err != nil {
- return fmt.Errorf("deleting active ticket: %v", err)
+// Contains returns true if the specified ticket exists in the stage.
+func (s *Stage) Contains(ticket Ticket) bool {
+ for _, t := range s.Tickets {
+ if t == ticket {
+ return true
+ }
}
- return k.Store.Save(FinalisedTicket(ticket))
+ return false
}
-func (k *Kanban) Update(ticket Ticket) error {
- return k.Store.Update(&ticket)
+// Ticket in a stage.
+type Ticket struct {
+ // Title of the ticket.
+ Title string
+ // Summary contains short and concise overview of the ticket.
+ Summary string
+ // Details contains the full details of the ticket.
+ Details string
+ // Created when the ticket was created.
+ Created time.Time
}
// Direction encodes mutually exclusive directions.
@@ -306,11 +262,89 @@ func (dir Direction) Invert() Direction {
return dir
}
-// None reports whether the ID represents a valid entity or is a zero value.
-func (id ID) None() bool {
- return id < 1
+// MapStorer implements in-memory storage for Projects.
+type MapStorer struct {
+ Data map[string]Project
+ Err error
+}
+
+var _ Storer = (*MapStorer)(nil)
+
+func (s *MapStorer) Create(p *Project) error {
+ if len(strings.TrimSpace(p.Name)) == 0 {
+ return fmt.Errorf("project name required")
+ }
+ if _, ok := s.Data[p.Name]; ok {
+ return fmt.Errorf("project %q exists", p.Name)
+ }
+ s.Data[p.Name] = *p
+ return nil
+}
+
+func (s *MapStorer) Save(p *Project) error {
+ if _, ok := s.Data[p.Name]; ok {
+ s.Data[p.Name] = *p
+ } else {
+ return fmt.Errorf("project %q does not exist", p.Name)
+ }
+ return nil
+}
+
+func (s *MapStorer) Load(name string) (*Project, bool, error) {
+ if p, ok := s.Data[name]; ok {
+ return &p, ok, nil
+ }
+ return nil, false, nil
+}
+
+func (s *MapStorer) List() (list []*Project, err error) {
+ for _, p := range s.Data {
+ list = append(list, &p)
+ }
+ return list, nil
+}
+
+// StormStorer implements Project storage using storm db.
+type StormStorer struct {
+ DB *storm.DB
+}
+
+var _ Storer = (*StormStorer)(nil)
+
+func (s *StormStorer) Create(p *Project) error {
+ if len(strings.TrimSpace(p.Name)) == 0 {
+ return fmt.Errorf("project name required")
+ }
+ return s.DB.Save((*struct {
+ Name string `storm:"id,unique,index"`
+ Stages Stages
+ Finalized []Ticket
+ })(p))
+}
+
+func (s *StormStorer) Save(p *Project) error {
+ if len(strings.TrimSpace(p.Name)) == 0 {
+ return fmt.Errorf("project name required")
+ }
+ return s.DB.Update((*struct {
+ Name string `storm:"id,unique,index"`
+ Stages Stages
+ Finalized []Ticket
+ })(p))
+}
+
+func (s *StormStorer) Load(name string) (*Project, bool, error) {
+ var p Project
+ if err := s.DB.Find("Name", name, &p); err != nil {
+ if errors.Is(err, storm.ErrNotFound) {
+ return &p, false, nil
+ } else {
+ return &p, false, err
+ }
+ }
+ return &p, true, nil
}
-func (id ID) String() string {
- return strconv.Itoa(int(id))
+func (s *StormStorer) List() (list []*Project, err error) {
+ return list, s.DB.All(&list)
}