From 75fbd340aac20877358ce203275c9fc962c1827f Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 1 Apr 2026 17:15:08 -0700 Subject: [PATCH] Refine entry detail and edit panels --- main.go | 231 +++++++++++++++--------- main_test.go | 88 +++++++++- ui_editor.go | 28 +++ ui_forms.go | 485 ++++++++++++++++++++++++++++++++------------------- 4 files changed, 566 insertions(+), 266 deletions(-) diff --git a/main.go b/main.go index fbfe78b..909b9ee 100644 --- a/main.go +++ b/main.go @@ -299,6 +299,7 @@ type ui struct { syncedPath []string selectedHistoryIndex int showPassword bool + generatedPasswordDraft bool togglePassword widget.Clickable copyAPITokenSecret widget.Clickable issueAPIToken widget.Clickable @@ -3719,11 +3720,11 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { password := u.detailPasswordValue() titleSize := unit.Sp(26) titlePad := unit.Dp(10) - sectionGap := unit.Dp(8) + sectionGap := unit.Dp(6) if u.mode == "phone" { titleSize = unit.Sp(18) - titlePad = unit.Dp(6) - sectionGap = unit.Dp(6) + titlePad = unit.Dp(4) + sectionGap = unit.Dp(4) } rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { @@ -3765,72 +3766,87 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { } return 0 }()}.Layout, - detailLine(u.theme, "Path", strings.Join(u.displayEntryPath(item.Path), " / ")), - layout.Spacer{Height: sectionGap}.Layout, - detailLine(u.theme, "Username", item.Username), - layout.Spacer{Height: sectionGap}.Layout, - u.passwordLine("Password", password), - layout.Spacer{Height: sectionGap}.Layout, - detailLine(u.theme, "URL", item.URL), - layout.Spacer{Height: sectionGap}.Layout, - detailLine(u.theme, "Tags", strings.Join(item.Tags, ", ")), - layout.Spacer{Height: unit.Dp(10)}.Layout, func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "Quick actions") - lbl.Color = mutedColor - return lbl.Layout(gtx) + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(detailLine(u.theme, "Path", strings.Join(u.displayEntryPath(item.Path), " / "))), + layout.Rigid(layout.Spacer{Height: sectionGap}.Layout), + layout.Rigid(detailLine(u.theme, "Username", item.Username)), + layout.Rigid(layout.Spacer{Height: sectionGap}.Layout), + layout.Rigid(detailLine(u.theme, "URL", item.URL)), + layout.Rigid(layout.Spacer{Height: sectionGap}.Layout), + layout.Rigid(detailLine(u.theme, "Tags", strings.Join(item.Tags, ", "))), + ) + }) }, - layout.Spacer{Height: unit.Dp(6)}.Layout, + layout.Spacer{Height: sectionGap}.Layout, func(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.copyUser, "Copy User") + lbl := material.Label(u.theme, unit.Sp(12), "PASSWORD") + lbl.Color = mutedColor + return lbl.Layout(gtx) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(u.passwordLine("Password", password)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") + if u.mode == "phone" { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyUser, "Copy Username") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") + }), + ) + } + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyUser, "Copy Username") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") + }), + ) }), ) - } - return layout.Flex{}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.copyUser, "Copy User") - return btn.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.copyPass, "Copy Password") - return btn.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.copyURL, "Copy URL") - return btn.Layout(gtx) - }), - ) + }) }, - layout.Spacer{Height: unit.Dp(12)}.Layout, + layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "Notes") - lbl.Color = mutedColor - return lbl.Layout(gtx) + return compactCard(gtx, func(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(12), "NOTES") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Body1(u.theme, item.Notes) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }) }, - layout.Spacer{Height: unit.Dp(4)}.Layout, - func(gtx layout.Context) layout.Dimensions { - lbl := material.Body1(u.theme, item.Notes) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }, - layout.Spacer{Height: unit.Dp(12)}.Layout, + layout.Spacer{Height: unit.Dp(8)}.Layout, u.attachmentSummaryPanel, - layout.Spacer{Height: unit.Dp(12)}.Layout, + layout.Spacer{Height: unit.Dp(8)}.Layout, u.historyPanel, - layout.Spacer{Height: unit.Dp(12)}.Layout, + layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { switch u.state.Section { case appstate.SectionTemplates: @@ -4105,36 +4121,89 @@ func (u *ui) historyPanel(gtx layout.Context) layout.Dimensions { func (u *ui) attachmentSummaryPanel(gtx layout.Context) layout.Dimensions { items := u.selectedAttachmentItems() - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "Attachments") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if len(items) == 0 { - lbl := material.Label(u.theme, unit.Sp(12), "No attachments on this entry.") + return compactCard(gtx, func(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(12), "ATTACHMENTS") lbl.Color = mutedColor return lbl.Layout(gtx) - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { - children := make([]layout.FlexChild, 0, len(items)*2) - for i, item := range items { - label := fmt.Sprintf("%s (%d B)", item.Name, item.Size) - children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), label) - lbl.Color = accentColor - return lbl.Layout(gtx) - })) - if i < len(items)-1 { - children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout)) - } + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), u.attachmentActionSummary()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if len(items) == 0 { + return layout.Dimensions{} } - return children - }()...) - }), - ) + return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { + children := make([]layout.FlexChild, 0, len(items)*2) + selectedName := strings.TrimSpace(u.attachmentName.Text()) + for i, item := range items { + index := i + name := item.Name + selected := selectedName == name + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + for u.attachmentClicks[index].Clicked(gtx) { + u.attachmentName.SetText(name) + } + return u.attachmentClicks[index].Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + size := gtx.Constraints.Min + if size.X == 0 { + size.X = gtx.Constraints.Max.X + } + if size.Y == 0 { + size.Y = gtx.Dp(unit.Dp(58)) + } + bg := panelColor + if selected { + bg = selectedColor + } + paint.FillShape(gtx.Ops, bg, clip.Rect{Max: size}.Op()) + if selected { + paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) + } + return layout.Dimensions{Size: size} + }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(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(12), name) + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + text := fmt.Sprintf("%d B", item.Size) + if selected { + text += " ยท selected" + } + lbl := material.Label(u.theme, unit.Sp(11), text) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }) + }), + ) + }) + })) + if i < len(items)-1 { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + } + } + return children + }()...) + }) + }), + ) + }) } func (u *ui) historyRow(gtx layout.Context, click *widget.Clickable, index int, item entry) layout.Dimensions { diff --git a/main_test.go b/main_test.go index b8ae25c..6a1cac6 100644 --- a/main_test.go +++ b/main_test.go @@ -2187,6 +2187,40 @@ func TestUIAttachmentActionsRejectDuplicateMissingAndOversizeCases(t *testing.T) } } +func TestUIAttachmentActionSummaryReflectsSelectionState(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Attachments: map[string][]byte{"token.txt": []byte("original")}, + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.state.NavigateToPath([]string{"Root", "Internet"}) + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + + if got := u.attachmentActionSummary(); !strings.Contains(got, "Select an attachment above") { + t.Fatalf("attachmentActionSummary() = %q, want prompt to select an attachment", got) + } + + u.attachmentName.SetText("token.txt") + if got := u.attachmentActionSummary(); !strings.Contains(got, "Selected attachment") { + t.Fatalf("attachmentActionSummary() = %q, want selected attachment guidance", got) + } + + u.attachmentName.SetText("missing.txt") + if got := u.attachmentActionSummary(); !strings.Contains(got, "is not on this entry yet") { + t.Fatalf("attachmentActionSummary() = %q, want missing attachment guidance", got) + } +} + func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { t.Parallel() @@ -2584,7 +2618,59 @@ func TestUIGeneratedPasswordFlowsIntoCreateEntryForm(t *testing.T) { } } -func TestUIBannerSurfacePrefersLoadingThenError(t *testing.T) { +func TestUIGeneratedPasswordDraftStateClearsOnReloadAndSave(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.state.NavigateToPath([]string{"Root", "Internet"}) + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + + if u.generatedPasswordDraft { + t.Fatal("generatedPasswordDraft = true, want false before generating") + } + + u.passwordProfile.SetText("strong") + if err := u.generatePasswordAction(); err != nil { + t.Fatalf("generatePasswordAction() error = %v", err) + } + if !u.generatedPasswordDraft { + t.Fatal("generatedPasswordDraft = false, want true after generating") + } + + u.loadSelectedEntryIntoEditor() + if u.generatedPasswordDraft { + t.Fatal("generatedPasswordDraft = true, want false after reloading entry into editor") + } + + u.passwordProfile.SetText("strong") + if err := u.generatePasswordAction(); err != nil { + t.Fatalf("generatePasswordAction() second call error = %v", err) + } + if !u.generatedPasswordDraft { + t.Fatal("generatedPasswordDraft = false, want true after generating the second time") + } + if err := u.saveEntryAction(); err != nil { + t.Fatalf("saveEntryAction() error = %v", err) + } + if u.generatedPasswordDraft { + t.Fatal("generatedPasswordDraft = true, want false after saving") + } +} + +func TestUIBannerSurfacePrefersLoadingThenErrorThenStatus(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) diff --git a/ui_editor.go b/ui_editor.go index 6428237..9083869 100644 --- a/ui_editor.go +++ b/ui_editor.go @@ -41,6 +41,7 @@ func (u *ui) attachmentInput() (string, []byte, error) { func (u *ui) loadSelectedEntryIntoEditor() { u.resetPasswordPeek() + u.clearGeneratedPasswordDraft() u.selectedHistoryIndex = -1 u.historyIndex.SetText("") @@ -175,6 +176,7 @@ func (u *ui) saveEntryAction() error { return err } u.editingEntry = false + u.clearGeneratedPasswordDraft() u.filter() return nil } @@ -229,6 +231,7 @@ func (u *ui) saveTemplateAction() error { return err } u.editingEntry = false + u.clearGeneratedPasswordDraft() u.filter() return nil } @@ -408,6 +411,7 @@ func (u *ui) generatePasswordAction() error { return err } u.entryPassword.SetText(password) + u.generatedPasswordDraft = true return nil } @@ -489,3 +493,27 @@ func marshalFields(fields map[string]string) string { } return strings.Join(lines, "\n") } + +func (u *ui) clearGeneratedPasswordDraft() { + u.generatedPasswordDraft = false +} + +func (u *ui) attachmentActionSummary() string { + items := u.selectedAttachmentItems() + if len(items) == 0 { + return "No attachments yet. Add one below to store a file with this entry." + } + + name := strings.TrimSpace(u.attachmentName.Text()) + if name == "" { + return "Select an attachment above, then replace it, export it, or remove it below." + } + + for _, item := range items { + if item.Name == name { + return fmt.Sprintf("Selected attachment %q. Replace it, export it, or remove it below.", name) + } + } + + return fmt.Sprintf("Attachment %q is not on this entry yet. Add it as new, or select an existing attachment above.", name) +} diff --git a/ui_forms.go b/ui_forms.go index 5dc449c..e098357 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -475,6 +475,42 @@ func passiveTonedButton(gtx layout.Context, th *material.Theme, label string) la return tonedButton(gtx, th, click, label) } +func sectionTitle(theme *material.Theme, title string) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(theme, unit.Sp(12), title) + lbl.Color = mutedColor + return lbl.Layout(gtx) + } +} + +func sectionCard(gtx layout.Context, theme *material.Theme, title, detail string, body layout.Widget) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(sectionTitle(theme, title)), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if strings.TrimSpace(detail) == "" { + return layout.Dimensions{} + } + lbl := material.Label(theme, unit.Sp(11), detail) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if strings.TrimSpace(detail) == "" { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) + }), + layout.Rigid(body), + ) + }) + }), + ) +} + func friendlyRecentVaultLabel(path string) string { value := strings.TrimSpace(path) if value == "" { @@ -544,29 +580,62 @@ func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions { for i, item := range items { index := i itemName := item.Name - label := fmt.Sprintf("%s (%d B)", itemName, item.Size) + selected := strings.TrimSpace(u.attachmentName.Text()) == itemName children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.attachmentClicks[index].Clicked(gtx) { u.attachmentName.SetText(itemName) } - return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { - return u.attachmentClicks[index].Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(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(13), itemName) - lbl.Color = accentColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), label) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - ) - }) - }) + return u.attachmentClicks[index].Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + size := gtx.Constraints.Min + if size.X == 0 { + size.X = gtx.Constraints.Max.X + } + if size.Y == 0 { + size.Y = gtx.Dp(unit.Dp(72)) + } + bg := panelColor + if selected { + bg = selectedColor + } + paint.FillShape(gtx.Ops, bg, clip.Rect{Max: size}.Op()) + if selected { + paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) + } + return layout.Dimensions{Size: size} + }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(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(13), itemName) + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), fmt.Sprintf("%d B", item.Size)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + text := "Select for replace, export, or remove." + if selected { + text = "Selected for replace, export, or remove." + } + lbl := material.Label(u.theme, unit.Sp(11), text) + lbl.Color = mutedColor + if selected { + lbl.Color = accentColor + } + return lbl.Layout(gtx) + }), + ) + }) + }), + ) }) })) if i < len(items)-1 { @@ -581,60 +650,59 @@ func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions { if len(u.customFieldKeys) == 0 { u.setCustomFieldRows(nil) } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "CUSTOM FIELDS") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Add key/value pairs. Changes are only saved when you save the entry.") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { - children := make([]layout.FlexChild, 0, len(u.customFieldKeys)*2) - for i := range u.customFieldKeys { - index := i - children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - for u.removeCustomFields[index].Clicked(gtx) { - u.removeCustomFieldRow(index) + return sectionCard(gtx, u.theme, "CUSTOM FIELDS", "Add key/value pairs. Changes are only saved when you save the entry.", func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { + children := make([]layout.FlexChild, 0, len(u.customFieldKeys)*2) + for i := range u.customFieldKeys { + index := i + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + for u.removeCustomFields[index].Clicked(gtx) { + u.removeCustomFieldRow(index) + } + return compactCard(gtx, func(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(11), fmt.Sprintf("Field %d", index+1)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return labeledEditor(u.theme, "Name", &u.customFieldKeys[index], false)(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return labeledEditor(u.theme, "Value", &u.customFieldValues[index], false)(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if len(u.customFieldKeys) == 1 && (strings.TrimSpace(u.customFieldKeys[index].Text()) != "" || strings.TrimSpace(u.customFieldValues[index].Text()) != "") { + return layout.Dimensions{} + } + return layout.Inset{Top: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.removeCustomFields[index], "Remove Field") + }) + }), + ) + }) + })) + if i < len(u.customFieldKeys)-1 { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) } - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(0.38, func(gtx layout.Context) layout.Dimensions { - return labeledEditor(u.theme, "Name", &u.customFieldKeys[index], false)(gtx) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Flexed(0.52, func(gtx layout.Context) layout.Dimensions { - return labeledEditor(u.theme, "Value", &u.customFieldValues[index], false)(gtx) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if len(u.customFieldKeys) == 1 && (strings.TrimSpace(u.customFieldKeys[index].Text()) != "" || strings.TrimSpace(u.customFieldValues[index].Text()) != "") { - return layout.Dimensions{} - } - return tonedButton(gtx, u.theme, &u.removeCustomFields[index], "Remove") - }), - ) - })) - if i < len(u.customFieldKeys)-1 { - children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) } + return children + }()...) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + for u.addCustomField.Clicked(gtx) { + u.appendCustomFieldRow("", "") } - return children - }()...) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - for u.addCustomField.Clicked(gtx) { - u.appendCustomFieldRow("", "") - } - return tonedButton(gtx, u.theme, &u.addCustomField, "Add Custom Field") - }), - ) + return tonedButton(gtx, u.theme, &u.addCustomField, "Add Another Field") + }), + ) + }) } func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { @@ -827,127 +895,176 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions { func (u *ui) entryEditorPanel(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(12), "BASICS") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), u.passwordProfileOptionsText()) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "NOTES") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledMultilineEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(120))), - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(u.customFieldEditorPanel), - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "HISTORY") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Generate Password only updates the form. Nothing is persisted until you save.") - lbl.Color = mutedColor - return lbl.Layout(gtx) + return sectionCard(gtx, u.theme, "BASICS", "Core entry identity and navigation fields.", func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))), + ) }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.cancelEdit, "Cancel") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.state.Section == appstate.SectionTemplates { - return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template") - } - return tonedButton(gtx, u.theme, &u.saveEntry, "Save Entry") - }), - ) + return sectionCard(gtx, u.theme, "PASSWORD", "Generate, review, and keep track of password changes before you save.", func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), u.passwordProfileOptionsText()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !u.generatedPasswordDraft { + return layout.Dimensions{} + } + return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return compactCard(gtx, func(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(12), "Generated password draft") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), "This generated password is only in the editor. Save the entry or template to persist it.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }) + }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password Draft") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.mode == "phone" { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyUser, "Copy Username") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") + }), + ) + } + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyUser, "Copy Username") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") + }), + ) + }), + ) + }) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "COPY") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyUser, "Copy User") }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") }), - ) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "ATTACHMENTS") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(u.attachmentList), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Attachment Name", &u.attachmentName, false)), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Attachment Path", &u.attachmentPath, false)), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.replaceAttachment, "Replace Attachment") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.removeAttachment, "Remove Attachment") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.exportAttachment, "Export Attachment") - }), - ) + return sectionCard(gtx, u.theme, "NOTES", "Long-form context for this entry.", func(gtx layout.Context) layout.Dimensions { + return labeledMultilineEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(108))(gtx) + }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(u.customFieldEditorPanel), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return sectionCard(gtx, u.theme, "HISTORY", "Pick a saved version index to restore into the current entry.", func(gtx layout.Context) layout.Dimensions { + return labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))(gtx) + }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return sectionCard(gtx, u.theme, "ATTACHMENTS", u.attachmentActionSummary(), func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(u.attachmentList), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Attachment Name", &u.attachmentName, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Attachment Path", &u.attachmentPath, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.mode == "phone" { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.replaceAttachment, "Replace Selected") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.exportAttachment, "Export Selected") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.removeAttachment, "Remove Selected") + }), + ) + } + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.replaceAttachment, "Replace Selected") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.exportAttachment, "Export Selected") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.removeAttachment, "Remove Selected") + }), + ) + }), + ) + }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return sectionCard(gtx, u.theme, "SAVE", "Entry changes only persist after you save.", func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.cancelEdit, "Cancel") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionTemplates { + return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template") + } + return tonedButton(gtx, u.theme, &u.saveEntry, "Save Entry") + }), + ) + }) }), ) }