A appwidget/apptheme/amount-editor.go => appwidget/apptheme/amount-editor.go +35 -0
@@ 0,0 1,35 @@
+package apptheme
+
+import (
+ "gioui.org/layout"
+ "gioui.org/unit"
+ "gioui.org/widget"
+ "gioui.org/widget/material"
+)
+
+// EditorStyle configures the presentation of an editor.
+type EditorStyle struct {
+ Box BoxStyle
+ EditorStyle material.EditorStyle
+}
+
+// TagEditor configures a default EditorStyle.
+func Editor(th *Theme, state *widget.Editor, hint string) EditorStyle {
+ state.SingleLine = true
+ return EditorStyle{
+ EditorStyle: material.Editor(th.Th, state, hint),
+ Box: BoxStyle{
+ BorderColor: th.Surface3.Contrast,
+ BorderWidth: unit.Dp(1),
+ Padding: layout.UniformInset(8),
+ Margin: layout.UniformInset(2),
+ Rounding: unit.Dp(8),
+ Bg: th.Surface3.Base,
+ },
+ }
+}
+
+// Layout the editor.
+func (s EditorStyle) Layout(gtx C) D {
+ return s.Box.Layout(gtx, s.EditorStyle.Layout)
+}
A appwidget/apptheme/tx-form.go => appwidget/apptheme/tx-form.go +117 -0
@@ 0,0 1,117 @@
+package apptheme
+
+import (
+ "gioui.org/layout"
+ "gioui.org/widget"
+ "gioui.org/widget/material"
+ "git.sr.ht/~whereswaldon/rosebud/appwidget"
+)
+
+type TxRowStyle struct {
+ Amount EditorStyle
+ Account TxEditorStyle
+ Message material.LabelStyle
+}
+
+func Row(th *Theme, state *appwidget.TxRow) TxRowStyle {
+ t := TxRowStyle{
+ Account: TxEditor(th, &state.Account, "Account"),
+ Amount: Editor(th, &state.Amount, state.Hint),
+ Message: material.Body2(th.Th, state.Message),
+ }
+
+ if len(state.Message) > 0 {
+ t.Account.Box.BorderColor = th.Danger.Base
+ t.Amount.Box.BorderColor = th.Danger.Base
+ t.Message.Color = th.Danger.Base
+ }
+
+ t.Amount.EditorStyle.Editor.Filter = "1234567890-."
+
+ return t
+}
+
+func (t TxRowStyle) Layout(gtx C) D {
+ return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
+ layout.Rigid(func(gtx C) D {
+ return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
+ layout.Flexed(.8, func(gtx C) D {
+ return t.Account.Layout(gtx)
+ }),
+ layout.Flexed(.2, func(gtx C) D {
+ return t.Amount.Layout(gtx)
+ }),
+ )
+ }),
+ layout.Rigid(t.Message.Layout),
+ )
+}
+
+type TxHeaderStyle struct {
+ Date EditorStyle
+ Payee TxEditorStyle
+ Message material.LabelStyle
+}
+
+func Header(th *Theme, date *widget.Editor, payee *appwidget.TxEditor, msg string) TxHeaderStyle {
+ t := TxHeaderStyle{
+ Payee: TxEditor(th, payee, "Payee"),
+ Date: Editor(th, date, "YYYY-MM-DD"),
+ Message: material.Body2(th.Th, msg),
+ }
+
+ if len(msg) > 0 {
+ t.Payee.Box.BorderColor = th.Danger.Base
+ t.Date.Box.BorderColor = th.Danger.Base
+ t.Message.Color = th.Danger.Base
+ }
+
+ t.Date.EditorStyle.Editor.Filter = "1234567890-"
+
+ return t
+}
+
+func (t TxHeaderStyle) Layout(gtx C) D {
+ return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
+ layout.Rigid(func(gtx C) D {
+ return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
+ layout.Flexed(.2, func(gtx C) D {
+ return t.Date.Layout(gtx)
+ }),
+ layout.Flexed(.8, func(gtx C) D {
+ return t.Payee.Layout(gtx)
+ }),
+ )
+ }),
+ layout.Rigid(t.Message.Layout),
+ )
+}
+
+type TxFormStyle struct {
+ State *appwidget.TxForm
+ th *Theme
+}
+
+func TxForm(th *Theme, state *appwidget.TxForm) TxFormStyle {
+ return TxFormStyle{
+ th: th,
+ State: state,
+ }
+}
+
+func (t TxFormStyle) Layout(gtx C) D {
+ return t.State.Layout(gtx, func(gtx C) D {
+ children := make([]layout.FlexChild, len(t.State.Rows)+1)
+ children[0] = layout.Rigid(func(gtx C) D {
+ return Header(t.th, &t.State.DateEditor, &t.State.PayeeEditor, "").Layout(gtx)
+ })
+ editorRows := children[1:]
+ for i := range editorRows {
+ row := t.State.Rows[i]
+ editorRows[i] = layout.Rigid(func(gtx C) D {
+ return Row(t.th, row).Layout(gtx)
+ })
+ }
+ return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
+ })
+}
A appwidget/tx-form.go => appwidget/tx-form.go +126 -0
@@ 0,0 1,126 @@
+package appwidget
+
+import (
+ "math/big"
+ "strconv"
+ "time"
+
+ "gioui.org/layout"
+ "gioui.org/widget"
+ "git.sr.ht/~whereswaldon/rosebud/ds"
+ "github.com/howeyc/ledger"
+ "golang.org/x/exp/constraints"
+)
+
+func max[T constraints.Ordered](a, b T) T {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+type TxRow struct {
+ Account TxEditor
+ Amount widget.Editor
+ Hint string
+ Message string
+ Invalid bool
+}
+
+func (t *TxRow) Populated() bool {
+ return t.Account.Editor.Len() > 0 || t.Amount.Len() > 0
+}
+
+func (t *TxRow) Value() ledger.Account {
+ var bal *big.Rat = nil
+ balance, err := strconv.ParseFloat(t.Amount.Text(), 64)
+ if err == nil {
+ bal = big.NewRat(0, 1).SetFloat64(balance)
+ }
+
+ return ledger.Account{
+ Name: t.Account.Editor.Text(),
+ Balance: bal,
+ }
+}
+
+type TxForm struct {
+ DateEditor widget.Editor
+ PayeeEditor TxEditor
+ Rows []*TxRow
+ Hints []string
+ events []any
+ value ledger.Transaction
+}
+
+func (t *TxForm) Events() (events []any) {
+ events, t.events = t.events, t.events[:0]
+ return events
+}
+
+func (t *TxForm) SetAccountSuggestions(suggestions []string) {
+ for i := range t.Rows {
+ t.Rows[i].Account.SetSuggestions(suggestions)
+ }
+}
+
+// balance returns whether the transaction is currently balanced. If it is
+// balanced, but has an empty amount with an inferred value, the index of
+// the empty accound and its inferred value are returned. If there is no
+// inferred value, that return value will be nil.
+func balance(tx *ledger.Transaction) (emptyIdx int, value *big.Rat, ok bool) {
+ total := big.NewRat(0, 1)
+ empty := 0
+ for i, a := range tx.AccountChanges {
+ if a.Balance == nil {
+ empty++
+ emptyIdx = i
+ } else {
+ total.Add(total, a.Balance)
+ }
+ }
+ if empty > 1 {
+ return 0, nil, false
+ }
+ if total.Sign() == 0 {
+ // Totals to zero, we're balanced.
+ return -1, nil, true
+ }
+ value = total.Neg(total)
+ return emptyIdx, value, true
+}
+
+func (t *TxForm) Layout(gtx C, w layout.Widget) D {
+ populated := 0
+ t.value.AccountChanges = t.value.AccountChanges[:0]
+ for i := range t.Rows {
+ if t.Rows[i].Populated() {
+ populated++
+ val := t.Rows[i].Value()
+ t.value.AccountChanges = append(t.value.AccountChanges, val)
+ if val.Name == "" {
+ t.Rows[i].Message = "Account name is required"
+ }
+ }
+ }
+ t.value.Payee = t.PayeeEditor.Editor.Text()
+ if t.DateEditor.Text() != "" {
+ t.value.Date, _ = time.Parse("2006-01-02", t.DateEditor.Text())
+ } else {
+ t.DateEditor.SetText(time.Now().Format("2006-01-02"))
+ }
+ emptyIdx, emptyValue, isBalanced := balance(&t.value)
+ if isBalanced && emptyValue != nil {
+ t.Rows[emptyIdx].Hint = emptyValue.FloatString(2)
+ }
+ t.Rows = ds.EnsureFilledSize(t.Rows, max(populated+1, 2))
+ dims := w(gtx)
+ for i := range t.Rows {
+ t.events = append(t.events, t.Rows[i].Account.Events()...)
+ for _, e := range t.Rows[i].Amount.Events() {
+ t.events = append(t.events, e)
+ }
+ }
+
+ return dims
+}