kanban

Kanban client in Gio
Log | Files | Refs | README | LICENSE

commit 6c05603f522ff84f681f92b7c6b0b4c724542f2d
parent 4a8516305247741be83fc202b449fe54b68d543c
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Tue,  3 Nov 2020 13:19:16 +0800

feat: gui structure

Diffstat:
A.gitignore | 2++
Acmd/kanban/main.go | 506+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago.sum | 43+++++++++++++++++++++++++++++++++++++++++++
Aicons/icons.go | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akanban.go | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 724 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1 @@ +.vscode +\ No newline at end of file diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go @@ -0,0 +1,506 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "log" + "os" + + "git.sr.ht/~jackmordaunt/kanban" + + "gioui.org/f32" + "gioui.org/font/gofont" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "git.sr.ht/~jackmordaunt/kanban/icons" + "git.sr.ht/~whereswaldon/materials" + + "gioui.org/app" + "gioui.org/io/system" + "gioui.org/layout" + "gioui.org/op" +) + +func main() { + w := app.NewWindow(app.Title("Kanban")) + th := material.NewTheme(gofont.Collection()) + ui := &UI{ + Window: w, + Th: th, + Engine: &kanban.Engine{}, + // TODO: render dynamically from storage. + Panels: []Panel{ + { + Label: "Todo", + Color: color.RGBA{R: 200, G: 200, B: 200, A: 255}, + Thickness: unit.Dp(50), + }, + { + Label: "In Progress", + Color: color.RGBA{G: 100, B: 200, A: 255}, + Thickness: unit.Dp(50), + }, + { + Label: "Testing", + Color: color.RGBA{R: 200, G: 100, A: 255}, + Thickness: unit.Dp(50), + }, + { + Label: "Done", + Color: color.RGBA{R: 50, G: 200, B: 100, A: 255}, + Thickness: unit.Dp(50), + }, + }, + } + go func() { + if err := ui.Loop(); err != nil { + log.Fatalf("error: %v", err) + } + os.Exit(0) + }() + app.Main() +} + +type ( + C = layout.Context + D = layout.Dimensions +) + +// UI is the high level object that contains all global state. +// Anything that needs to integrate with the external system is allocated on +// this object. +type UI struct { + *app.Window + Engine *kanban.Engine + Th *material.Theme + Panels []Panel + Tickets []Ticket + Modal layout.Widget + TicketForm TicketForm +} + +func (ui *UI) Loop() error { + var ops op.Ops + for { + switch event := (<-ui.Events()).(type) { + case system.DestroyEvent: + return event.Err + case system.ClipboardEvent: + fmt.Printf("clipboard: %v\n", event.Text) + case *system.CommandEvent: + // TODO: integrate with command events. + case system.FrameEvent: + gtx := layout.NewContext(&ops, event) + ui.Update(gtx) + ui.Layout(gtx) + event.Frame(gtx.Ops) + } + } +} + +func (ui *UI) Update(gtx C) { + for ii := range ui.Panels { + panel := &ui.Panels[ii] + if panel.CreateTicket.Clicked() { + ui.Modal = func(gtx C) D { + return ui.TicketForm.Layout(gtx, ui.Th, panel.Label) + } + } + } + if ui.TicketForm.Submit.Clicked() { + ticket, err := ui.TicketForm.Validate() + if err != nil { + fmt.Printf("error: %s", err) + return + } + if err := ui.Engine.Assign(ui.TicketForm.Stage, ticket); err != nil { + fmt.Printf("error: %s", err) + return + } + ui.TicketForm = TicketForm{} + ui.Modal = nil + } + if ui.TicketForm.Cancel.Clicked() { + ui.TicketForm = TicketForm{} + ui.Modal = nil + } +} + +func (ui *UI) Layout(gtx C) D { + return layout.Stack{}.Layout( + gtx, + layout.Stacked(func(gtx C) D { + var panels = make([]layout.FlexChild, len(ui.Panels)) + for ii := range ui.Panels { + panel := &ui.Panels[ii] + panels[ii] = layout.Flexed(1, func(gtx C) D { + panel := panel + stage, _ := ui.Engine.Stage(panel.Label) + var cards = make([]layout.Widget, len(stage.Tickets)) + for ii, ticket := range stage.Tickets { + ticket := ticket + cards[ii] = func(gtx C) D { + // TODO: ticket state needs to live somewhere. + return (&TicketStyle{Ticket: ticket}).Layout(gtx, ui.Th) + } + } + return panel.Layout(gtx, ui.Th, cards...) + }) + } + return layout.Flex{ + Axis: layout.Horizontal, + Spacing: layout.SpaceEvenly, + }.Layout( + gtx, + panels..., + ) + }), + layout.Expanded(func(gtx C) D { + if ui.Modal == nil { + return D{} + } + return Modal(gtx, ui.Th, "Add Ticket", func(gtx C) D { + return ui.Modal(gtx) + }) + }), + ) +} + +// Modal renders content centered with decorations. +func Modal(gtx C, th *material.Theme, title string, content layout.Widget) D { + return Centered(gtx, func(gtx C) D { + gtx.Constraints.Max.X = int(float32(gtx.Constraints.Max.X) * 0.8) + return layout.Stack{}.Layout( + gtx, + layout.Expanded(func(gtx C) D { + return Rect{ + Color: color.RGBA{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.RGBA{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, title).Layout(gtx) + }) + }), + ) + }), + layout.Rigid(func(gtx C) D { + return inset.Layout(gtx, func(gtx C) D { + return content(gtx) + }) + }), + ) + }), + ) + }) +} + +func Centered(gtx C, content layout.Widget) D { + return layout.Stack{}.Layout( + gtx, + layout.Stacked(func(gtx C) D { + return Rect{ + Size: layout.FPt(gtx.Constraints.Max), + Color: color.RGBA{A: 200}, + }.Layout(gtx) + }), + layout.Stacked(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 { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Flexed(1, func(gtx C) D { + return D{Size: gtx.Constraints.Min} + }), + layout.Rigid(func(gtx C) D { + return content(gtx) + }), + layout.Flexed(1, func(gtx C) D { + return D{Size: gtx.Constraints.Min} + }), + ) + }), + layout.Flexed(1, func(gtx C) D { + return D{Size: gtx.Constraints.Min} + }), + ) + }), + ) +} + +// TicketForm renders the form for ticket information. +type TicketForm struct { + Stage string + Title materials.TextField + Category materials.TextField + Summary materials.TextField + Details materials.TextField + Submit widget.Clickable + Cancel widget.Clickable +} + +func (form TicketForm) Validate() (kanban.Ticket, error) { + ticket := kanban.Ticket{ + Title: form.Title.Text(), + Details: form.Details.Text(), + Summary: form.Summary.Text(), + Category: form.Category.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.Category.Layout(gtx, th, "Category") + }), + 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 { + return material.Button(th, &form.Cancel, "Cancel").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) + }), + ) + }) + }), + ) +} + +// 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.RGBA + Size f32.Point + Radii float32 +} + +// Layout renders the Rect into the provided context +func (r Rect) Layout(gtx C) D { + paint.FillShape(gtx.Ops, clip.UniformRRect(f32.Rectangle{Max: r.Size}, r.Radii).Op(gtx.Ops), r.Color) + return layout.Dimensions{Size: image.Pt(int(r.Size.X), int(r.Size.Y))} +} + +// Panel can hold cards. +// Has a title and action bar. +type Panel struct { + Label string + Color color.RGBA + Thickness unit.Value + CreateTicket widget.Clickable + layout.List +} + +func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.Widget) D { + return widget.Border{ + Color: color.RGBA{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 { + gtx.Constraints.Max = image.Point{ + X: gtx.Px(unit.Dp(10)), + Y: gtx.Px(unit.Dp(10)), + } + return material.IconButton(th, &p.CreateTicket, icons.ContentAdd).Layout(gtx) + }), + ) + }) + }), + ) + }), + layout.Flexed(1, func(gtx C) D { + return layout.Stack{}.Layout( + gtx, + layout.Expanded(func(gtx C) D { + return Rect{ + Color: color.RGBA{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 { + gtx.Constraints.Max.Y = gtx.Px(unit.Dp(100)) + return widget.Border{ + Width: unit.Dp(0.5), + Color: color.RGBA{A: 200}, + }.Layout(gtx, func(gtx C) D { + return tickets[ii](gtx) + }) + }) + }) + }), + ) + }), + ) + }) +} + +// TicketStyle renders a ticket control. +type TicketStyle struct { + kanban.Ticket +} + +type Ticket struct { + MoveButton widget.Clickable +} + +func (t *TicketStyle) Layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Horizontal, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + // Side bar with controls + return Rect{ + Color: color.RGBA{G: 100, B: 200, A: 255}, + Size: f32.Point{ + X: float32(gtx.Px(unit.Dp(15))), + Y: float32(gtx.Constraints.Max.Y), + }, + }.Layout(gtx) + }), + layout.Flexed(1, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout( + gtx, + layout.Flexed(1, func(gtx C) D { + // content + return layout.Inset{ + Top: 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(2), + }.Layout(gtx, func(gtx C) D { + th := *th + th.Color.Text = materials.AlphaMultiply(th.Color.Text, 200) + return material.Label(&th, unit.Dp(14), t.Category).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) + }) + }), + ) + }) + }), + layout.Rigid(func(gtx C) D { + // bottom controls + return Rect{ + Color: color.RGBA{A: 100}, + Size: f32.Point{ + X: float32(gtx.Constraints.Max.X), + Y: float32(gtx.Px(unit.Dp(15))), + }, + }.Layout(gtx) + }), + ) + }), + ) +} diff --git a/go.sum b/go.sum @@ -0,0 +1,43 @@ +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +gioui.org v0.0.0-20201022165755-672555d3d076/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw= +gioui.org v0.0.0-20201101161300-a55302a49867 h1:Tq3Z4wJ1QLJ552s9VXPk339cSvF9iNZYDJ6Fv4IyGHA= +gioui.org v0.0.0-20201101161300-a55302a49867/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw= +git.sr.ht/~whereswaldon/materials v0.0.0-20201101220027-a518d42fd730 h1:pAOILGMbZpo3BzS0NXX41bCd4yhIzlG00fkXE1ItN/E= +git.sr.ht/~whereswaldon/materials v0.0.0-20201101220027-a518d42fd730/go.mod h1:kLyZfWzOWp15bbVIiZXdNHBg+lluQq66KnB67d70O0U= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3 h1:n9HxLrNxWWtEb1cA950nuEEj3QnKbtsCJ6KjcgisNUs= +golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= +golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925 h1:5XVKs2rlCg8EFyRcvO8/XFwYxh1oKJO1Q3X5vttIf9c= +golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925/go.mod h1:1phAWC201xIgDyaFpmDeZkgf70Q4Pd/CNqfRtVPtxNw= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34= +golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200801110659-972c09e46d76 h1:U7GPaoQyQmX+CBRWXKrvRzWTbd+slqeSh8uARsIyhAw= +golang.org/x/image v0.0.0-20200801110659-972c09e46d76/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/icons/icons.go b/icons/icons.go @@ -0,0 +1,76 @@ +package icons + +import ( + "gioui.org/widget" + "golang.org/x/exp/shiny/materialdesign/icons" +) + +var BackIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.NavigationArrowBack) + return icon +}() + +var ClearIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.ContentClear) + return icon +}() + +var ReplyIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.ContentReply) + return icon +}() + +var CancelReplyIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.NavigationCancel) + return icon +}() + +var SendReplyIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.ContentSend) + return icon +}() + +var ContentAdd *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.ContentAdd) + return icon +}() + +var CopyIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.ContentContentCopy) + return icon +}() + +var PasteIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.ContentContentPaste) + return icon +}() + +var FilterIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.ContentFilterList) + return icon +}() + +var MenuIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.NavigationMenu) + return icon +}() + +var ServerIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.ActionDNS) + return icon +}() + +var SettingsIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.ActionSettings) + return icon +}() + +var ChatIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.CommunicationChat) + return icon +}() + +var IdentityIcon *widget.Icon = func() *widget.Icon { + icon, _ := widget.NewIcon(icons.ActionPermIdentity) + return icon +}() diff --git a/kanban.go b/kanban.go @@ -0,0 +1,97 @@ +package kanban + +// Kanban engine that drives the model. +type Engine struct { + stages []Stage +} + +// Stages returns a list of stages. +func (eng Engine) Stages() ([]Stage, error) { + return eng.stages, nil +} + +// Stage returns a stage by the given name. +// Creates an empty stage if it doesn't exist. +func (eng *Engine) Stage(name string) (Stage, error) { + for _, stage := range eng.stages { + if stage.Name == name { + return stage, nil + } + } + eng.stages = append(eng.stages, Stage{Name: name}) + return eng.stages[len(eng.stages)-1], nil +} + +func (eng *Engine) Move(stage string, ticket ID) error { + return nil +} + +func (eng *Engine) Assign(stage string, ticket Ticket) error { + ticket.ID = eng.nextID() + for ii := range eng.stages { + s := &eng.stages[ii] + if s.Name == stage { + s.Tickets = append(s.Tickets, ticket) + return nil + } + } + eng.stages = append(eng.stages, Stage{ + Name: stage, + Tickets: []Ticket{ticket}, + }) + return nil +} + +func (eng *Engine) Delete(ticket ID) error { + for _, s := range eng.stages { + for ii := range s.Tickets { + if s.Tickets[ii].ID == ticket { + s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...) + return nil + } + } + } + return nil +} + +func (eng *Engine) Update(ticket Ticket) error { + for _, s := range eng.stages { + for ii := range s.Tickets { + if s.Tickets[ii].ID == ticket.ID { + s.Tickets[ii] = ticket + return nil + } + } + } + return nil +} + +func (eng *Engine) nextID() ID { + var max int + for _, stage := range eng.stages { + for _, t := range stage.Tickets { + if int(t.ID) > max { + max = int(t.ID) + } + } + } + return ID(max + 1) +} + +type ID int + +// Stage in the kanban pipeline, can hold a number of tickets. +type Stage struct { + Name string + Tickets []Ticket +} + +// Ticket in a stage. +type Ticket struct { + ID ID + Title string + Category string + Summary string + Details string + References []ID +}