1364 lines
52 KiB
Go
1364 lines
52 KiB
Go
package appui
|
|
|
|
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/internal/appstate"
|
|
)
|
|
|
|
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
|
|
panel := card
|
|
if u.usesCompactViewport() {
|
|
panel = compactCard
|
|
}
|
|
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
rows := []layout.Widget{
|
|
u.lifecycleBranding,
|
|
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
|
u.lifecycleControls,
|
|
}
|
|
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
|
|
return rows[i](gtx)
|
|
})
|
|
})
|
|
}
|
|
|
|
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|
busy := u.lifecycleBusy()
|
|
showLocalChooser := u.showLocalVaultChooser()
|
|
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. Remote sync attaches to that local vault after it opens."
|
|
lbl := material.Label(u.theme, unit.Sp(14), message)
|
|
lbl.Color = accentColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return u.lifecycleVaultChooserSection(gtx, busy, showLocalChooser, selectedLocalPath)
|
|
}),
|
|
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(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return u.lifecycleControlsFooter(gtx, busy, selectedLocalPath)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) lifecycleVaultChooserSection(gtx layout.Context, busy, showLocalChooser bool, selectedLocalPath string) layout.Dimensions {
|
|
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 {
|
|
return u.lifecycleImportSharedVaultButton(gtx, busy, showLocalChooser)
|
|
}),
|
|
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 {
|
|
return u.lifecycleVaultPathSelector(gtx, busy, selectedLocalPath)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) lifecycleImportSharedVaultButton(gtx layout.Context, busy, showLocalChooser bool) layout.Dimensions {
|
|
if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
|
|
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.importSharedVault, "Import Shared Vault")
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) lifecycleVaultPathSelector(gtx layout.Context, busy bool, selectedLocalPath string) 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{}
|
|
}
|
|
}
|
|
|
|
func (u *ui) lifecycleControlsFooter(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
|
|
if u.shouldPrioritizeLifecyclePrimaryActions() {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }),
|
|
)
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) lifecycleAdvancedSection(gtx layout.Context, busy bool) layout.Dimensions {
|
|
if busy {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(u.lifecycleAdvancedDisclosure),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
layout.Rigid(u.lifecycleAdvancedCard),
|
|
)
|
|
}
|
|
|
|
func (u *ui) lifecycleAdvancedCard(gtx layout.Context) layout.Dimensions {
|
|
if u.lifecycleAdvancedHidden || 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")
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (u *ui) lifecyclePrimaryActionsSection(gtx layout.Context, busy bool) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
label := "Open Vault"
|
|
if busy {
|
|
return passiveTonedButton(gtx, u.theme, "Opening Vault...")
|
|
}
|
|
return tonedButton(gtx, u.theme, &u.openVault, label)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel())
|
|
}),
|
|
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)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) lifecycleSelectedVaultSection(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
|
|
if busy || selectedLocalPath == "" {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) shouldPrioritizeLifecyclePrimaryActions() bool {
|
|
return u.usesCompactViewport()
|
|
}
|
|
|
|
func (u *ui) selectedRemoteCardHeading() string {
|
|
if u.selectedRemoteUsesLocalCache() {
|
|
return "CACHED VAULT"
|
|
}
|
|
return "SELECTED CONNECTION"
|
|
}
|
|
|
|
func (u *ui) selectedRemoteCardPrimaryText() string {
|
|
record := u.currentRemoteRecord()
|
|
if u.selectedRemoteUsesLocalCache() {
|
|
path := strings.TrimSpace(u.vaultPath.Text())
|
|
if label := friendlyRecentVaultLabel(path); label != "" {
|
|
return label
|
|
}
|
|
}
|
|
return friendlyRecentRemoteLabel(record)
|
|
}
|
|
|
|
func (u *ui) selectedRemoteCardDetailLines() []string {
|
|
record := u.currentRemoteRecord()
|
|
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
|
|
lines := make([]string, 0, 3)
|
|
if u.selectedRemoteUsesLocalCache() {
|
|
if dir := compactPathDirectorySummary(strings.TrimSpace(u.vaultPath.Text())); dir != "" {
|
|
lines = append(lines, dir)
|
|
}
|
|
lines = append(lines, "Sync target: "+friendlyRecentRemoteLabel(record))
|
|
} else {
|
|
lines = append(lines, "Path: "+strings.TrimSpace(record.Path))
|
|
lines = append(lines, "Server: "+strings.TrimSpace(record.BaseURL))
|
|
}
|
|
if len(lastGroup) > 0 {
|
|
lines = append(lines, "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
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(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(11), u.selectedLocalVaultRemoteSyncSummary(path))
|
|
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) selectedLocalVaultRemoteSyncSummary(path string) string {
|
|
if record, ok := u.boundRecentRemoteForLocalVault(path); ok {
|
|
summary := "Saved remote sync target: " + friendlyRecentRemoteLabel(record)
|
|
if normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) == appstate.SyncModeAutomaticOnOpenSave {
|
|
return summary + " · Syncs automatically on open and save."
|
|
}
|
|
return summary + " · Sync manually when you choose Use Remote Sync."
|
|
}
|
|
return "Open this vault to set up a WebDAV sync target for it."
|
|
}
|
|
|
|
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 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 (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.usesCompactViewport() {
|
|
size = unit.Sp(11)
|
|
}
|
|
lbl := material.Label(u.theme, size, label)
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
if u.usesCompactViewport() {
|
|
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.usesCompactViewport() {
|
|
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.usesCompactViewport() {
|
|
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 localVaultPathHelpForRuntime(goos string) string {
|
|
if supportsDesktopFilePicker(goos) || supportsSharedVaultImport(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 localVaultPathHelp() string {
|
|
return localVaultPathHelpForRuntime(runtime.GOOS)
|
|
}
|
|
|
|
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) || supportsSharedVaultImport(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)
|
|
})
|
|
}),
|
|
)
|
|
}
|
|
}
|