Complete UI state and error surfaces
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"image"
|
||||
@@ -28,6 +29,31 @@ import (
|
||||
|
||||
type entry = vault.Entry
|
||||
|
||||
const (
|
||||
productName = "KeePassGO"
|
||||
desktopSubtitle = "KeePass-compatible password management for desktop-first workflows"
|
||||
)
|
||||
|
||||
type bannerKind string
|
||||
|
||||
const (
|
||||
bannerNone bannerKind = ""
|
||||
bannerLoading bannerKind = "loading"
|
||||
bannerError bannerKind = "error"
|
||||
bannerStatus bannerKind = "status"
|
||||
)
|
||||
|
||||
type uiBanner struct {
|
||||
Kind bannerKind
|
||||
Message string
|
||||
}
|
||||
|
||||
type uiSurface struct {
|
||||
Title string
|
||||
Message string
|
||||
Locked bool
|
||||
}
|
||||
|
||||
type ui struct {
|
||||
mode string
|
||||
theme *material.Theme
|
||||
@@ -109,6 +135,7 @@ type ui struct {
|
||||
eyeIcon *widget.Icon
|
||||
eyeOffIcon *widget.Icon
|
||||
copyIcon *widget.Icon
|
||||
loadingMessage string
|
||||
statusMessage string
|
||||
errorMessage string
|
||||
}
|
||||
@@ -399,15 +426,109 @@ func (u *ui) changeMasterKeyAction() error {
|
||||
}
|
||||
|
||||
func (u *ui) runAction(label string, action func() error) {
|
||||
u.loadingMessage = actionLoadingLabel(label)
|
||||
if err := action(); err != nil {
|
||||
u.errorMessage = err.Error()
|
||||
u.loadingMessage = ""
|
||||
u.errorMessage = u.describeActionError(label, err)
|
||||
u.statusMessage = ""
|
||||
return
|
||||
}
|
||||
u.loadingMessage = ""
|
||||
u.errorMessage = ""
|
||||
u.statusMessage = label + " complete"
|
||||
}
|
||||
|
||||
func actionLoadingLabel(label string) string {
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" {
|
||||
return "Working..."
|
||||
}
|
||||
runes := []rune(label)
|
||||
runes[0] = []rune(strings.ToUpper(string(runes[0])))[0]
|
||||
return string(runes) + "..."
|
||||
}
|
||||
|
||||
func (u *ui) describeActionError(label string, err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) {
|
||||
return "Save conflict: the remote vault changed. Reopen it and retry the save."
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (u *ui) bannerSurface() uiBanner {
|
||||
switch {
|
||||
case strings.TrimSpace(u.loadingMessage) != "":
|
||||
return uiBanner{Kind: bannerLoading, Message: strings.TrimSpace(u.loadingMessage)}
|
||||
case strings.TrimSpace(u.errorMessage) != "":
|
||||
return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.errorMessage)}
|
||||
case strings.TrimSpace(u.statusMessage) != "":
|
||||
return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.statusMessage)}
|
||||
default:
|
||||
return uiBanner{}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) sessionSurface() uiSurface {
|
||||
if u.state.Session == nil {
|
||||
return uiSurface{}
|
||||
}
|
||||
if _, err := u.state.Session.Current(); errors.Is(err, session.ErrLocked) {
|
||||
return uiSurface{
|
||||
Title: "Vault locked",
|
||||
Message: "Enter a master password, choose a key file, or provide both to unlock the vault.",
|
||||
Locked: true,
|
||||
}
|
||||
}
|
||||
return uiSurface{}
|
||||
}
|
||||
|
||||
func (u *ui) listEmptyMessage() string {
|
||||
if surface := u.sessionSurface(); surface.Locked {
|
||||
return "Unlock the vault to browse entries and groups."
|
||||
}
|
||||
query := strings.TrimSpace(u.search.Text())
|
||||
if query != "" {
|
||||
switch u.state.Section {
|
||||
case appstate.SectionTemplates:
|
||||
return fmt.Sprintf("No templates match %q. Clear or refine the search.", query)
|
||||
case appstate.SectionRecycleBin:
|
||||
return fmt.Sprintf("No recycle-bin entries match %q. Clear or refine the search.", query)
|
||||
default:
|
||||
return fmt.Sprintf("No entries match %q. Clear or refine the search.", query)
|
||||
}
|
||||
}
|
||||
switch u.state.Section {
|
||||
case appstate.SectionTemplates:
|
||||
return "No templates yet. Save a reusable entry as a template."
|
||||
case appstate.SectionRecycleBin:
|
||||
return "Recycle Bin is empty."
|
||||
default:
|
||||
return "Create or open a vault, then add an entry to get started."
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) detailPlaceholderMessage() string {
|
||||
if surface := u.sessionSurface(); surface.Locked {
|
||||
return "Unlock the vault to inspect entries, attachments, and history."
|
||||
}
|
||||
if strings.TrimSpace(u.entryTitle.Text()) != "" || strings.TrimSpace(u.entryUsername.Text()) != "" ||
|
||||
strings.TrimSpace(u.entryPassword.Text()) != "" || strings.TrimSpace(u.entryURL.Text()) != "" ||
|
||||
strings.TrimSpace(u.entryNotes.Text()) != "" || strings.TrimSpace(u.entryFields.Text()) != "" {
|
||||
return "Complete the form to create a new item or update the current selection."
|
||||
}
|
||||
switch u.state.Section {
|
||||
case appstate.SectionTemplates:
|
||||
return "Select a template or start a reusable entry."
|
||||
case appstate.SectionRecycleBin:
|
||||
return "Select a recycle-bin entry to review or restore it."
|
||||
default:
|
||||
return "Select an entry or start a new one."
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) ensureNavClickables() {
|
||||
if len(u.breadcrumbs) < len(u.currentPath)+1 {
|
||||
u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1)
|
||||
@@ -533,7 +654,16 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(u.header),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.bannerSurface().Kind == bannerNone {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(u.banner),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
)
|
||||
}),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) {
|
||||
u.phoneSpan = gtx.Constraints.Max.Y
|
||||
@@ -574,7 +704,7 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(20), "Vault")
|
||||
lbl := material.Label(u.theme, unit.Sp(20), productName)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
@@ -597,11 +727,12 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(24), "Vault")
|
||||
lbl.Text = productName
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), "A single app concept for desktop and Android")
|
||||
lbl := material.Label(u.theme, unit.Sp(13), desktopSubtitle)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
@@ -660,7 +791,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if len(u.visible) == 0 {
|
||||
lbl := material.Label(u.theme, unit.Sp(16), "No entries match.")
|
||||
lbl := material.Label(u.theme, unit.Sp(16), u.listEmptyMessage())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}
|
||||
@@ -825,7 +956,18 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
|
||||
if !ok {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(16), "Edit the current form to create or update an item")
|
||||
surface := u.sessionSurface()
|
||||
title := surface.Title
|
||||
if title == "" {
|
||||
title = "Entry details"
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(18), title)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(16), u.detailPlaceholderMessage())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
@@ -910,6 +1052,32 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) banner(gtx layout.Context) layout.Dimensions {
|
||||
banner := u.bannerSurface()
|
||||
if banner.Kind == bannerNone {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
|
||||
bg := color.NRGBA{R: 232, G: 239, B: 235, A: 255}
|
||||
fg := accentColor
|
||||
switch banner.Kind {
|
||||
case bannerLoading:
|
||||
bg = color.NRGBA{R: 234, G: 232, B: 227, A: 255}
|
||||
fg = color.NRGBA{R: 92, G: 76, B: 34, A: 255}
|
||||
case bannerError:
|
||||
bg = color.NRGBA{R: 248, G: 228, B: 225, A: 255}
|
||||
fg = color.NRGBA{R: 130, G: 36, B: 25, A: 255}
|
||||
}
|
||||
|
||||
return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), banner.Message)
|
||||
lbl.Color = fg
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
|
||||
if u.state.Section == appstate.SectionRecycleBin {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin")
|
||||
@@ -1116,7 +1284,7 @@ func main() {
|
||||
go func() {
|
||||
w := new(app.Window)
|
||||
w.Option(
|
||||
app.Title("Vault Mock"),
|
||||
app.Title(productName),
|
||||
app.Size(width, height),
|
||||
)
|
||||
if err := run(w, strings.ToLower(*mode)); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user