Improve group deletion and status banner UX
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/gesture"
|
||||
@@ -38,7 +39,10 @@ const (
|
||||
productName = "KeePassGO"
|
||||
)
|
||||
|
||||
const maxAttachmentBytes = 10 << 20
|
||||
const (
|
||||
maxAttachmentBytes = 10 << 20
|
||||
statusBannerDuration = 4 * time.Second
|
||||
)
|
||||
|
||||
type bannerKind string
|
||||
|
||||
@@ -138,6 +142,8 @@ type ui struct {
|
||||
createGroup widget.Clickable
|
||||
renameGroup widget.Clickable
|
||||
deleteGroup widget.Clickable
|
||||
confirmDeleteGroup widget.Clickable
|
||||
cancelDeleteGroup widget.Clickable
|
||||
togglePasswordInline widget.Clickable
|
||||
showEntries widget.Clickable
|
||||
showTemplates widget.Clickable
|
||||
@@ -173,6 +179,9 @@ type ui struct {
|
||||
recentVaultsPath string
|
||||
editingEntry bool
|
||||
recentVaults []string
|
||||
deleteGroupPath []string
|
||||
statusExpiresAt time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -253,6 +262,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
||||
lifecycleMode: "local",
|
||||
defaultSaveAsPath: paths.DefaultSaveAsPath,
|
||||
recentVaultsPath: paths.RecentVaultsPath,
|
||||
now: time.Now,
|
||||
}
|
||||
u.state.Session = sess
|
||||
u.phoneSplit.Value = 0.46
|
||||
@@ -692,21 +702,76 @@ func (u *ui) displayEntryPath(path []string) []string {
|
||||
return append([]string(nil), path[1:]...)
|
||||
}
|
||||
|
||||
func pathHasPrefix(path, prefix []string) bool {
|
||||
if len(prefix) > len(path) {
|
||||
return false
|
||||
}
|
||||
return slices.Equal(path[:len(prefix)], prefix)
|
||||
}
|
||||
|
||||
func (u *ui) currentGroupDeletionState() (bool, string) {
|
||||
u.syncCurrentPath()
|
||||
if u.state.Section != appstate.SectionEntries || len(u.displayPath()) == 0 || u.state.Session == nil {
|
||||
return false, ""
|
||||
}
|
||||
model, err := u.state.Session.Current()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
path := append([]string(nil), u.currentPath...)
|
||||
if len(model.ChildGroups(path)) > 0 {
|
||||
return false, "This group contains child groups. Move or delete them before removing the group."
|
||||
}
|
||||
for _, item := range model.Entries {
|
||||
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
|
||||
return false, "This group contains entries. Move or delete them before removing the group."
|
||||
}
|
||||
}
|
||||
for _, item := range model.Templates {
|
||||
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
|
||||
return false, "This group contains templates. Move or delete them before removing the group."
|
||||
}
|
||||
}
|
||||
return true, "Deleting this empty group will not remove any entries."
|
||||
}
|
||||
|
||||
func (u *ui) deleteGroupPendingConfirmation() bool {
|
||||
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
|
||||
}
|
||||
|
||||
func (u *ui) clearDeleteGroupConfirmation() {
|
||||
u.deleteGroupPath = nil
|
||||
}
|
||||
|
||||
func (u *ui) armDeleteCurrentGroupAction() {
|
||||
if deletable, _ := u.currentGroupDeletionState(); !deletable {
|
||||
return
|
||||
}
|
||||
u.syncCurrentPath()
|
||||
u.deleteGroupPath = append([]string(nil), u.currentPath...)
|
||||
u.state.ErrorMessage = ""
|
||||
u.state.StatusMessage = fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / "))
|
||||
u.statusExpiresAt = u.now().Add(statusBannerDuration)
|
||||
}
|
||||
|
||||
func (u *ui) runAction(label string, action func() error) {
|
||||
u.loadingMessage = actionLoadingLabel(label)
|
||||
if err := action(); err != nil {
|
||||
u.loadingMessage = ""
|
||||
u.state.ErrorMessage = u.describeActionError(label, err)
|
||||
u.state.StatusMessage = ""
|
||||
u.statusExpiresAt = time.Time{}
|
||||
return
|
||||
}
|
||||
u.loadingMessage = ""
|
||||
u.state.ErrorMessage = ""
|
||||
if suppressStatusMessage(label) {
|
||||
u.state.StatusMessage = ""
|
||||
u.statusExpiresAt = time.Time{}
|
||||
return
|
||||
}
|
||||
u.state.StatusMessage = label + " complete"
|
||||
u.statusExpiresAt = u.now().Add(statusBannerDuration)
|
||||
}
|
||||
|
||||
func suppressStatusMessage(label string) bool {
|
||||
@@ -748,6 +813,11 @@ func (u *ui) bannerSurface() uiBanner {
|
||||
case strings.TrimSpace(u.state.ErrorMessage) != "":
|
||||
return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage)}
|
||||
case strings.TrimSpace(u.state.StatusMessage) != "":
|
||||
if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) {
|
||||
u.state.StatusMessage = ""
|
||||
u.statusExpiresAt = time.Time{}
|
||||
return uiBanner{}
|
||||
}
|
||||
return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.state.StatusMessage)}
|
||||
default:
|
||||
return uiBanner{}
|
||||
@@ -816,7 +886,7 @@ func (u *ui) listEmptyMessage() string {
|
||||
}
|
||||
switch u.state.Section {
|
||||
case appstate.SectionTemplates:
|
||||
return "No templates yet. Save a reusable entry as a template."
|
||||
return "Templates are not available in this build."
|
||||
case appstate.SectionRecycleBin:
|
||||
return "Recycle Bin is empty."
|
||||
default:
|
||||
@@ -854,6 +924,7 @@ func (u *ui) setCurrentPath(path []string) {
|
||||
u.currentPath = append([]string(nil), path...)
|
||||
u.state.NavigateToPath(path)
|
||||
u.syncedPath = append([]string(nil), path...)
|
||||
u.clearDeleteGroupConfirmation()
|
||||
}
|
||||
|
||||
func (u *ui) syncCurrentPath() {
|
||||
@@ -866,6 +937,9 @@ func (u *ui) syncCurrentPath() {
|
||||
u.state.CurrentPath = append([]string(nil), u.currentPath...)
|
||||
}
|
||||
u.syncedPath = append([]string(nil), u.currentPath...)
|
||||
if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
@@ -895,12 +969,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
u.runAction("unlock vault", u.unlockAction)
|
||||
}
|
||||
for u.showEntries.Clicked(gtx) {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
u.showEntriesSection()
|
||||
}
|
||||
for u.showTemplates.Clicked(gtx) {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
u.showTemplatesSection()
|
||||
}
|
||||
for u.showRecycle.Clicked(gtx) {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
u.showRecycleBinSection()
|
||||
}
|
||||
for u.showLocalLifecycle.Clicked(gtx) {
|
||||
@@ -988,13 +1065,24 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
u.runAction("restore history", u.restoreSelectedHistoryAction)
|
||||
}
|
||||
for u.createGroup.Clicked(gtx) {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
u.runAction("create group", u.createGroupAction)
|
||||
}
|
||||
for u.renameGroup.Clicked(gtx) {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
u.runAction("rename group", u.renameGroupAction)
|
||||
}
|
||||
for u.deleteGroup.Clicked(gtx) {
|
||||
u.armDeleteCurrentGroupAction()
|
||||
}
|
||||
for u.confirmDeleteGroup.Clicked(gtx) {
|
||||
u.runAction("delete group", u.deleteCurrentGroupAction)
|
||||
u.clearDeleteGroupConfirmation()
|
||||
}
|
||||
for u.cancelDeleteGroup.Clicked(gtx) {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
u.state.StatusMessage = ""
|
||||
u.statusExpiresAt = time.Time{}
|
||||
}
|
||||
for u.togglePassword.Clicked(gtx) {
|
||||
u.showPassword = !u.showPassword
|
||||
@@ -1186,7 +1274,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
||||
}
|
||||
label := "Add Entry"
|
||||
if u.mode == "phone" {
|
||||
label = "+ Add Entry"
|
||||
label = "+ " + label
|
||||
}
|
||||
btn := material.Button(u.theme, &u.addEntry, label)
|
||||
return btn.Layout(gtx)
|
||||
@@ -1214,10 +1302,6 @@ func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showTemplates, "Templates")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user