package main import ( "fmt" "image" "image/color" "net/url" "path/filepath" "runtime" "strings" "gioui.org/layout" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "git.julianfamily.org/keepassgo/appstate" ) func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { busy := u.lifecycleBusy() showLocalChooser := u.showLocalVaultChooser() showRemoteChooser := u.showRemoteConnectionChooser() selectedLocalPath := strings.TrimSpace(u.vaultPath.Text()) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { message := "Choose a recent vault or enter a .kdbx path, then unlock it." if u.lifecycleMode == "remote" { message = "Connect to a remote vault, then unlock it with the KeePass master key." } lbl := material.Label(u.theme, unit.Sp(14), message) lbl.Color = accentColor 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 { if busy { return passiveSectionTab(gtx, u.theme, "Local Vault", u.lifecycleMode == "local") } return sectionTabButton(gtx, u.theme, &u.showLocalLifecycle, "Local Vault", u.lifecycleMode == "local") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy { return passiveSectionTab(gtx, u.theme, "Remote Vault", u.lifecycleMode == "remote") } return sectionTabButton(gtx, u.theme, &u.showRemoteLifecycle, "Remote Vault", u.lifecycleMode == "remote") }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.lifecycleMode == "remote" { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(12), "LOCATION") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if showRemoteChooser || !u.hasSelectedRemoteTarget() { return layout.Dimensions{} } return layout.Dimensions{} }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if showRemoteChooser && !busy { return u.recentRemoteList(gtx) } return layout.Dimensions{} }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device") box.Color = accentColor return box.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help") }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showRemoteChooser { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) }), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showLocalChooser { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showLocalChooser { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showLocalChooser || busy { return layout.Dimensions{} } return u.recentVaultList(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showLocalChooser { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showLocalChooser { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showLocalChooser { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { switch { case busy: return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx) case selectedLocalPath == "": return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx) default: return layout.Dimensions{} } }), ) }), 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), "UNLOCK") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.masterPasswordField(gtx, "Leave blank if this vault is protected by key file only.") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy { return labeledEditorHelp(u.theme, "Key File", keyFileHelp(), &u.keyFilePath, false)(gtx) } return keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy { return layout.Dimensions{} } return u.lifecycleAdvancedDisclosure(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy || u.lifecycleAdvancedHidden { return layout.Dimensions{} } if u.lifecycleMode == "remote" { return 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(13), "Vault settings") lbl.Color = accentColor return lbl.Layout(gtx) }), 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.lifecycleSecuritySettingsSummary()) 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.openSecuritySettings, "Open Vault Settings") }), ) }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.lifecycleMode == "remote" { label := u.remoteOpenButtonLabel() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy { return passiveTonedButton(gtx, u.theme, label) } return tonedButton(gtx, u.theme, &u.openRemote, label) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy || !u.hasSelectedRemoteTarget() { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy || !u.hasSelectedRemoteTarget() { return layout.Dimensions{} } return u.selectedRemoteConnectionCard(gtx) }), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := "Open Vault" if busy { label = "Opening Vault..." } if busy { return passiveTonedButton(gtx, u.theme, label) } return tonedButton(gtx, u.theme, &u.openVault, label) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?") 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 busy { return passiveSectionTab(gtx, u.theme, "Create New Vault", false) } return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy || selectedLocalPath == "" { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy || selectedLocalPath == "" { return layout.Dimensions{} } return u.selectedLocalVaultCard(gtx, selectedLocalPath) }), ) }), ) } func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions { record := u.currentRemoteRecord() lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path) return compactCard(gtx, 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), "SELECTED CONNECTION") 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(14), friendlyRecentRemoteLabel(record)) 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), "Path: "+strings.TrimSpace(record.Path)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{ Username: strings.TrimSpace(u.remoteUsername.Text()), Password: u.remotePassword.Text(), })) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(lastGroup) == 0 { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / ")) 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 tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Open Different Connection") }), ) }) }) } func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions { lastGroup := u.recentVaultGroup(path) return compactCard(gtx, 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), "SELECTED VAULT") 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(16), friendlyRecentVaultLabel(path)) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { dir := compactPathDirectorySummary(path) if dir == "" { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(11), dir) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(lastGroup) == 0 { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / ")) 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 tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault") }), ) }) }) } func (u *ui) lifecycleSecuritySettingsSummary() string { return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices." } func (u *ui) lifecycleAdvancedDisclosure(gtx layout.Context) layout.Dimensions { return u.toggleLifecycleAdvanced.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { icon := u.expandLessIcon if u.lifecycleAdvancedHidden { icon = u.expandMoreIcon } if icon != nil { return icon.Layout(gtx, accentColor) } lbl := material.Label(u.theme, unit.Sp(16), ">") if !u.lifecycleAdvancedHidden { lbl.Text = "v" } lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Advanced Vault Settings") lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) }) } func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions { if len(u.recentVaults) == 0 { return layout.Dimensions{} } if len(u.recentVaultClicks) < len(u.recentVaults) { u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults)) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "TAP TO SELECT") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { maxY := gtx.Dp(unit.Dp(180)) if gtx.Constraints.Max.Y > maxY { gtx.Constraints.Max.Y = maxY } if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y { gtx.Constraints.Min.Y = gtx.Constraints.Max.Y } return material.List(u.theme, &u.recentVaultListState).Layout(gtx, len(u.recentVaults), func(gtx layout.Context, i int) layout.Dimensions { path := u.recentVaults[i] label := path if friendly := friendlyRecentVaultLabel(path); friendly != "" { label = friendly } lastGroup := u.recentVaultGroup(path) selected := strings.TrimSpace(u.vaultPath.Text()) == path return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions { return u.recentVaultClicks[i].Layout(gtx, 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 { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(15), label) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { badge := "Tap to use" if selected { badge = "Selected" } lbl := material.Label(u.theme, unit.Sp(11), badge) if selected { lbl.Color = accentColor } else { 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), compactPathDirectorySummary(path)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(lastGroup) == 0 { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / ")) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) }) }) }) }) }), ) } func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions { if len(u.recentRemotes) == 0 { return layout.Dimensions{} } if len(u.recentRemoteClicks) < len(u.recentRemotes) { u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "RECENT CONNECTIONS") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { maxY := gtx.Dp(unit.Dp(180)) if gtx.Constraints.Max.Y > maxY { gtx.Constraints.Max.Y = maxY } if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y { gtx.Constraints.Min.Y = gtx.Constraints.Max.Y } return material.List(u.theme, &u.recentRemoteListState).Layout(gtx, len(u.recentRemotes), func(gtx layout.Context, i int) layout.Dimensions { record := u.recentRemotes[i] label := friendlyRecentRemoteLabel(record) selected := strings.TrimSpace(u.remoteBaseURL.Text()) == record.BaseURL && strings.TrimSpace(u.remotePath.Text()) == record.Path return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions { return u.recentRemoteClicks[i].Layout(gtx, 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(14), label) 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), "Path: "+strings.TrimSpace(record.Path)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Server: "+normalizedRemoteHost(record.BaseURL)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(record)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(record.LastGroup) == 0 { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(record.LastGroup), " / ")) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) }) }) }) }) }), ) } func recentSelectionCard(gtx layout.Context, selected bool, w layout.Widget) layout.Dimensions { if !selected { return compactCard(gtx, w) } 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.Constraints.Max.Y } paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op()) 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, w) }), ) } func passiveSectionTab(gtx layout.Context, th *material.Theme, label string, active bool) layout.Dimensions { click := new(widget.Clickable) return sectionTabButton(gtx, th, click, label, active) } func passiveTonedButton(gtx layout.Context, th *material.Theme, label string) layout.Dimensions { click := new(widget.Clickable) 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 == "" { return "" } base := filepath.Base(value) if base == "." || base == string(filepath.Separator) || base == "" { return value } return base } func friendlyRecentRemoteLabel(record recentRemoteRecord) string { baseURL := strings.TrimSpace(record.BaseURL) path := strings.TrimSpace(record.Path) if baseURL == "" && path == "" { return "" } host := normalizedRemoteHost(baseURL) name := friendlyRecentVaultLabel(path) switch { case name != "" && host != "": return name + " · " + host case name != "": return name case host != "": return host default: return path } } func normalizedRemoteHost(baseURL string) string { baseURL = strings.TrimSpace(baseURL) if parsed, err := url.Parse(baseURL); err == nil && strings.TrimSpace(parsed.Host) != "" { return strings.TrimSpace(parsed.Host) } host := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://")) return strings.TrimSuffix(host, "/") } func recentRemoteStoredAuthSummary(record recentRemoteRecord) string { username := strings.TrimSpace(record.Username) hasPassword := record.Password != "" switch { case username != "" && hasPassword: return "saved username and password" case username != "": return "saved username" case hasPassword: return "saved password" default: return "location only" } } func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions { items := u.selectedAttachmentItems() if len(items) == 0 { lbl := material.Label(u.theme, unit.Sp(13), "No attachments on this entry.") 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 { index := i itemName := item.Name 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 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 { children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) } } return children }()...) } func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions { if len(u.customFieldKeys) == 0 { u.setCustomFieldRows(nil) } 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 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 tonedButton(gtx, u.theme, &u.addCustomField, "Add Another Field") }), ) }) } func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { if u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } deletable, deleteReason := u.currentGroupDeletionState() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "GROUP MANAGEMENT") 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), "CREATE") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(labeledEditor(u.theme, "Create Group / Subgroup", &u.groupName, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.createGroup, u.createGroupLabel()) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(u.displayPath()) == 0 { return 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), "MANAGE CURRENT GROUP") 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.Label(u.theme, unit.Sp(12), strings.Join(u.displayPath(), " / ")) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Current Group") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.moveGroup, "Move Current Group") }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !deletable || u.deleteGroupPendingConfirmation() { return layout.Dimensions{} } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete Empty Group") }), ) }), ) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(u.displayPath()) == 0 { return layout.Dimensions{} } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorHelp( u.theme, "Move Current Group To", "Enter the destination parent path. Use / for the root.", &u.groupParentPath, false, )), ) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(u.displayPath()) == 0 { return layout.Dimensions{} } if u.deleteGroupPendingConfirmation() { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), fmt.Sprintf("Delete %q? This group is empty, and the deletion cannot be undone.", strings.Join(u.displayPath(), " / "))) lbl.Color = mutedColor return lbl.Layout(gtx) }) } if deletable || strings.TrimSpace(deleteReason) == "" { return layout.Dimensions{} } return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), deleteReason) lbl.Color = mutedColor return lbl.Layout(gtx) }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !u.deleteGroupPendingConfirmation() { return layout.Dimensions{} } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.confirmDeleteGroup, "Confirm Delete") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.cancelDeleteGroup, "Cancel") }), ) }), ) } func (u *ui) groupControlsSection(gtx layout.Context) layout.Dimensions { if u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } if u.groupControlsHidden { return layout.Dimensions{} } return compactCard(gtx, u.groupControls) } func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions { return u.toggleGroupControls.Layout(gtx, func(gtx layout.Context) layout.Dimensions { content := func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { icon := u.expandLessIcon if u.groupControlsHidden { icon = u.chevronRightIcon } if icon == nil { lbl := material.Label(u.theme, unit.Sp(16), ">") if !u.groupControlsHidden { lbl.Text = "v" } lbl.Color = accentColor return lbl.Layout(gtx) } return icon.Layout(gtx, accentColor) }), layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := "Group Tools" size := unit.Sp(12) if u.mode == "phone" { size = unit.Sp(11) } lbl := material.Label(u.theme, size, label) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) } if u.mode == "phone" { return content(gtx) } return compactCard(gtx, content) }) } 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 { 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, u.accessibilityPrefs, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "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 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, u.accessibilityPrefs, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "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(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return sectionCard(gtx, u.theme, "NOTES", "Long-form context for this entry.", func(gtx layout.Context) layout.Dimensions { return labeledMultilineEditorWithFocus(u.theme, u.accessibilityPrefs, "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, u.accessibilityPrefs, "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") }), ) }) }), ) } func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget { return labeledEditorWithFocus(th, defaultAccessibilityPreferences(), label, editor, sensitive, false) } func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool) layout.Widget { return labeledEditorHelpFocus(th, defaultAccessibilityPreferences(), label, help, editor, sensitive, false) } func localVaultPathHelp() string { if supportsDesktopFilePicker(runtime.GOOS) { return "Choose the existing .kdbx file to open." } return "Enter the shared-storage path to the existing .kdbx file, for example /sdcard/Download/vault.kdbx." } func keyFileHelp() string { if supportsDesktopFilePicker(runtime.GOOS) { return "Optional path to a KeePass-compatible key file." } return "Optional shared-storage path to a KeePass-compatible key file." } func localPathSelector(th *material.Theme, editor *widget.Editor, click *widget.Clickable) layout.Widget { if supportsDesktopFilePicker(runtime.GOOS) { return selectorEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, click, "Choose File", false) } return labeledEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, false) } func keyFileSelector(th *material.Theme, editor *widget.Editor, click *widget.Clickable) layout.Widget { if supportsDesktopFilePicker(runtime.GOOS) { return selectorEditorHelp(th, "Key File", keyFileHelp(), editor, click, "Choose File", false) } return labeledEditorHelp(th, "Key File", keyFileHelp(), editor, false) } func labeledEditorHelpFocus(th *material.Theme, prefs accessibilityPreferences, label, help string, editor *widget.Editor, sensitive bool, focused bool) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(labeledEditorWithFocus(th, prefs, label, editor, sensitive, focused)), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(11), help) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) } } func selectorEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, click *widget.Clickable, buttonLabel string, sensitive bool) layout.Widget { return func(gtx layout.Context) layout.Dimensions { if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(420)) { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(labeledEditor(th, label, editor, sensitive)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, th, click, buttonLabel) }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(11), help) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 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 { gtx.Constraints.Min.X = gtx.Constraints.Max.X return labeledEditor(th, label, editor, sensitive)(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, th, click, buttonLabel) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(11), help) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) } } func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions { targetLabel := "Locked vault" targetValue := "Unlock the active vault to continue." changeLabel := "Open Different Vault" if u.state.Session != nil { if strings.TrimSpace(u.remoteBaseURL.Text()) != "" || strings.TrimSpace(u.remotePath.Text()) != "" { baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) path := strings.TrimSpace(u.remotePath.Text()) targetLabel = "Remote vault" targetValue = friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}) changeLabel = "Open Different Connection" if strings.TrimSpace(targetValue) == "" { targetValue = "Remote WebDAV vault" } } else { path := strings.TrimSpace(u.vaultPath.Text()) targetLabel = "Local vault" targetValue = friendlyRecentVaultLabel(path) if strings.TrimSpace(path) != "" { targetValue = targetValue + "\n" + path } if strings.TrimSpace(targetValue) == "" { targetValue = "Local vault file" } } } targetCard := func(gtx layout.Context) layout.Dimensions { return compactCard(gtx, 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), strings.ToUpper(targetLabel)) 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.Body1(u.theme, targetValue) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !u.shouldUseLockedSinglePane() { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { if targetLabel == "Remote vault" { return tonedButton(gtx, u.theme, &u.clearRemoteSelection, changeLabel) } return tonedButton(gtx, u.theme, &u.clearVaultSelection, changeLabel) }) }), ) }) }) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.mode == "desktop" { return layout.Dimensions{} } return targetCard(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.masterPasswordField(gtx, "Used alone or together with a key file to unlock the vault.") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock") }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.mode != "desktop" { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.mode != "desktop" { return layout.Dimensions{} } return targetCard(gtx) }), ) } func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensions { icon := u.eyeIcon desc := "Show master password" mask := rune('•') if u.showPassword { icon = u.eyeOffIcon desc = "Hide master password" mask = 0 } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "MASTER PASSWORD") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.outlinedFieldState(gtx, false, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { restore := u.masterPassword.Mask u.masterPassword.Mask = mask defer func() { u.masterPassword.Mask = restore }() gtx.Constraints.Min.X = gtx.Constraints.Max.X ed := material.Editor(u.theme, &u.masterPassword, "Master Password") dims := ed.Layout(gtx) u.requestMasterPasswordFocusIfNeeded(gtx) return dims }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.IconButton(u.theme, &u.togglePassword, icon, desc) btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} btn.Color = accentColor btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) return btn.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), help) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) } func labeledEditorWithFocus( th *material.Theme, prefs accessibilityPreferences, label string, editor *widget.Editor, sensitive bool, focused bool, ) layout.Widget { return 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(th, unit.Sp(12), strings.ToUpper(label)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return outlinedFieldStateWithPrefs(gtx, prefs, focused, func(gtx layout.Context) layout.Dimensions { mask := editor.Mask if sensitive { editor.Mask = '•' } defer func() { editor.Mask = mask }() gtx.Constraints.Min.X = gtx.Constraints.Max.X ed := material.Editor(th, editor, label) return layout.UniformInset(unit.Dp(8)).Layout(gtx, ed.Layout) }) }), ) } } func labeledMultilineEditorWithFocus( th *material.Theme, prefs accessibilityPreferences, label string, editor *widget.Editor, sensitive bool, focused bool, minHeight unit.Dp, ) layout.Widget { return 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(th, unit.Sp(12), strings.ToUpper(label)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return outlinedFieldStateWithPrefs(gtx, prefs, focused, func(gtx layout.Context) layout.Dimensions { mask := editor.Mask if sensitive { editor.Mask = '•' } defer func() { editor.Mask = mask }() gtx.Constraints.Min.X = gtx.Constraints.Max.X if min := gtx.Dp(minHeight); gtx.Constraints.Min.Y < min { gtx.Constraints.Min.Y = min } ed := material.Editor(th, editor, label) return layout.UniformInset(unit.Dp(8)).Layout(gtx, ed.Layout) }) }), ) } }