~whereswaldon/rosebud

340171e49eba037d5a4dee831392b05c2b05637b — Chris Waldon 1 year, 2 months ago 3d729ef
appwidget/apptheme: add editing form

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
3 files changed, 278 insertions(+), 0 deletions(-)

A appwidget/apptheme/amount-editor.go
A appwidget/apptheme/tx-form.go
A appwidget/tx-form.go
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
}