Local-first remote sync and cross-platform UI parity #2
@@ -76,6 +76,17 @@ const (
|
||||
autofillNoticeSuppressed autofillNoticeMode = "suppressed"
|
||||
)
|
||||
|
||||
type listPanelTopSection string
|
||||
|
||||
const (
|
||||
listPanelTopSearch listPanelTopSection = "search"
|
||||
listPanelTopNavigation listPanelTopSection = "navigation"
|
||||
listPanelTopPath listPanelTopSection = "path"
|
||||
listPanelTopGroup listPanelTopSection = "group"
|
||||
listPanelTopGroupTools listPanelTopSection = "group_tools"
|
||||
listPanelTopPrimary listPanelTopSection = "primary"
|
||||
)
|
||||
|
||||
type bannerKind string
|
||||
|
||||
const (
|
||||
@@ -5917,6 +5928,63 @@ func (u *ui) entryRowMetrics() (unit.Dp, unit.Sp, unit.Sp, unit.Sp, unit.Sp, uni
|
||||
return inset, titleSize, metaSize, urlSize, pathSize, dividerGap
|
||||
}
|
||||
|
||||
func (u *ui) listPanelTopSections() []listPanelTopSection {
|
||||
sections := make([]listPanelTopSection, 0, 6)
|
||||
if u.state.Section != appstate.SectionAbout {
|
||||
sections = append(sections, listPanelTopSearch)
|
||||
}
|
||||
if !u.isVaultLocked() {
|
||||
sections = append(sections, listPanelTopNavigation)
|
||||
}
|
||||
if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) {
|
||||
sections = append(sections, listPanelTopPath)
|
||||
}
|
||||
if !u.isVaultLocked() && u.state.Section == appstate.SectionEntries {
|
||||
sections = append(sections, listPanelTopGroup, listPanelTopGroupTools)
|
||||
}
|
||||
if !u.isVaultLocked() {
|
||||
sections = append(sections, listPanelTopPrimary)
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
func (u *ui) listPanelSearchRow(gtx layout.Context) layout.Dimensions {
|
||||
if u.state.Section == appstate.SectionAbout {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if u.mode == "phone" {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
}
|
||||
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
|
||||
editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
|
||||
editor.Color = u.theme.Palette.Fg
|
||||
editor.HintColor = mutedColor
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) listPanelPrimaryActionRow(gtx layout.Context) layout.Dimensions {
|
||||
if u.state.Section == appstate.SectionAbout {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if u.isVaultLocked() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
switch u.state.Section {
|
||||
case appstate.SectionEntries:
|
||||
label := "Add Entry"
|
||||
if u.mode == "phone" {
|
||||
label = "+ " + label
|
||||
}
|
||||
btn := material.Button(u.theme, &u.addEntry, label)
|
||||
return btn.Layout(gtx)
|
||||
case appstate.SectionAPITokens:
|
||||
return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token")
|
||||
default:
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
||||
panel := card
|
||||
spacing := u.sectionSpacing()
|
||||
@@ -5928,58 +5996,21 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
||||
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
visibleEntries, entryClicks := u.visibleEntrySnapshot()
|
||||
rows := make([]layout.Widget, 0, 16+len(visibleEntries))
|
||||
if u.state.Section != appstate.SectionAbout {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
|
||||
editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
|
||||
editor.Color = u.theme.Palette.Fg
|
||||
editor.HintColor = mutedColor
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
||||
})
|
||||
})
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Spacer{Height: spacing}.Layout(gtx)
|
||||
})
|
||||
}
|
||||
if !u.isVaultLocked() {
|
||||
rows = append(rows, u.navigationHeader)
|
||||
if u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionAbout {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Spacer{Height: spacing}.Layout(gtx)
|
||||
})
|
||||
for _, section := range u.listPanelTopSections() {
|
||||
switch section {
|
||||
case listPanelTopSearch:
|
||||
rows = append(rows, u.listPanelSearchRow)
|
||||
case listPanelTopNavigation:
|
||||
rows = append(rows, u.navigationHeader)
|
||||
case listPanelTopPath:
|
||||
rows = append(rows, u.pathBar)
|
||||
case listPanelTopGroup:
|
||||
rows = append(rows, u.groupBar)
|
||||
case listPanelTopGroupTools:
|
||||
rows = append(rows, u.groupControlsSection)
|
||||
case listPanelTopPrimary:
|
||||
rows = append(rows, u.listPanelPrimaryActionRow)
|
||||
}
|
||||
}
|
||||
if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) {
|
||||
rows = append(rows, u.pathBar)
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Spacer{Height: spacing}.Layout(gtx)
|
||||
})
|
||||
}
|
||||
if !u.isVaultLocked() && u.state.Section == appstate.SectionEntries {
|
||||
rows = append(rows, u.groupBar)
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Spacer{Height: spacing}.Layout(gtx)
|
||||
})
|
||||
rows = append(rows, u.groupControlsSection)
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Spacer{Height: spacing}.Layout(gtx)
|
||||
})
|
||||
}
|
||||
if !u.isVaultLocked() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
switch u.state.Section {
|
||||
case appstate.SectionEntries:
|
||||
btn := material.Button(u.theme, &u.addEntry, "+ Add Entry")
|
||||
return btn.Layout(gtx)
|
||||
case appstate.SectionAPITokens:
|
||||
return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token")
|
||||
case appstate.SectionAbout:
|
||||
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
||||
default:
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
})
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Spacer{Height: spacing}.Layout(gtx)
|
||||
})
|
||||
@@ -6008,84 +6039,45 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
||||
})
|
||||
}
|
||||
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.navigationHeader(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.state.Section == appstate.SectionAbout {
|
||||
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.state.Section == appstate.SectionAbout {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: spacing}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.pathBar(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.groupBar(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.groupControlsSection(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.state.Section == appstate.SectionAbout {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if u.mode == "phone" {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
}
|
||||
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
|
||||
editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
|
||||
editor.Color = u.theme.Palette.Fg
|
||||
editor.HintColor = mutedColor
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.state.Section == appstate.SectionAbout {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if u.isVaultLocked() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
switch u.state.Section {
|
||||
case appstate.SectionEntries:
|
||||
label := "Add Entry"
|
||||
if u.mode == "phone" {
|
||||
label = "+ " + label
|
||||
children := make([]layout.FlexChild, 0, 16)
|
||||
for _, section := range u.listPanelTopSections() {
|
||||
switch section {
|
||||
case listPanelTopSearch:
|
||||
children = append(children, layout.Rigid(u.listPanelSearchRow))
|
||||
case listPanelTopNavigation:
|
||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
btn := material.Button(u.theme, &u.addEntry, label)
|
||||
return btn.Layout(gtx)
|
||||
case appstate.SectionAPITokens:
|
||||
return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token")
|
||||
default:
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
return u.navigationHeader(gtx)
|
||||
}))
|
||||
case listPanelTopPath:
|
||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.pathBar(gtx)
|
||||
}))
|
||||
case listPanelTopGroup:
|
||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.groupBar(gtx)
|
||||
}))
|
||||
case listPanelTopGroupTools:
|
||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.groupControlsSection(gtx)
|
||||
}))
|
||||
case listPanelTopPrimary:
|
||||
children = append(children, layout.Rigid(u.listPanelPrimaryActionRow))
|
||||
}
|
||||
children = append(children, layout.Rigid(layout.Spacer{Height: spacing}.Layout))
|
||||
}
|
||||
children = append(children,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if u.state.Section == appstate.SectionAPITokens {
|
||||
return u.apiTokenListPanel(gtx)
|
||||
@@ -6094,7 +6086,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
||||
return u.apiAuditListPanel(gtx)
|
||||
}
|
||||
if u.state.Section == appstate.SectionAbout {
|
||||
return layout.Dimensions{}
|
||||
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
||||
}
|
||||
if len(u.visible) == 0 {
|
||||
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
||||
@@ -6106,6 +6098,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
||||
})
|
||||
}),
|
||||
)
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,30 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIListPanelTopSectionsMatchAcrossDesktopAndPhoneForEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
desktop := newUIWithModel("desktop", vault.Model{})
|
||||
desktop.state.Section = appstate.SectionEntries
|
||||
phone := newUIWithModel("phone", vault.Model{})
|
||||
phone.state.Section = appstate.SectionEntries
|
||||
|
||||
want := []listPanelTopSection{
|
||||
listPanelTopSearch,
|
||||
listPanelTopNavigation,
|
||||
listPanelTopPath,
|
||||
listPanelTopGroup,
|
||||
listPanelTopGroupTools,
|
||||
listPanelTopPrimary,
|
||||
}
|
||||
if got := desktop.listPanelTopSections(); !slices.Equal(got, want) {
|
||||
t.Fatalf("desktop.listPanelTopSections() = %v, want %v", got, want)
|
||||
}
|
||||
if got := phone.listPanelTopSections(); !slices.Equal(got, want) {
|
||||
t.Fatalf("phone.listPanelTopSections() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUICurrentVaultSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user