diff --git a/main.go b/main.go index ad14a8e..0bdb919 100644 --- a/main.go +++ b/main.go @@ -1704,22 +1704,26 @@ func (u *ui) bannerSurface() uiBanner { Message: strings.TrimSpace(u.state.ErrorMessage), Dismissable: true, } - case strings.TrimSpace(u.state.StatusMessage) != "": - if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) { - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - return uiBanner{} - } - return uiBanner{ - Kind: bannerStatus, - Message: strings.TrimSpace(u.state.StatusMessage), - Dismissable: true, - } default: return uiBanner{} } } +func (u *ui) statusToastSurface() uiBanner { + if strings.TrimSpace(u.state.StatusMessage) == "" { + return uiBanner{} + } + if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) { + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + return uiBanner{} + } + return uiBanner{ + Kind: bannerStatus, + Message: strings.TrimSpace(u.state.StatusMessage), + } +} + func (u *ui) loadingDetailMessage() string { if !u.shouldShowLifecycleSetup() { return "" @@ -2360,6 +2364,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } return u.approvalDialog(gtx) }), + layout.Stacked(u.statusToast), ) } @@ -2546,41 +2551,49 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { return material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) 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(20), "Synchronize...") + lbl := material.Label(u.theme, unit.Sp(20), "Advanced Sync") lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), "Choose another source and whether to pull it into the current vault or push the current vault to it.") + lbl := material.Label(u.theme, unit.Sp(14), "Pick direction, choose the other vault, and then run the merge. The quick sync button keeps using the current source.") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showSyncPull, "Pull From Source") + return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showSyncPush, "Push To Source") + return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush) }), ) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showSyncLocal, "Local File") + return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV") + return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncDialogSummaryCard(gtx, u.theme, u.syncSourceMode, u.syncDirection) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.syncSourceMode == syncSourceRemote { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -2760,21 +2773,14 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { } func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { - label := "Synchronize" + label := "Sync" spacing := unit.Dp(4) if u.mode == "phone" { - label = "Sync" spacing = unit.Dp(3) } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.synchronizeVault, label) - btn.CornerRadius = unit.Dp(10) - if u.mode == "phone" { - btn.TextSize = unit.Sp(13) - btn.Inset = layout.Inset{Top: 8, Bottom: 8, Left: 12, Right: 12} - } - return btn.Layout(gtx) + return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone") }), layout.Rigid(layout.Spacer{Width: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -2791,8 +2797,8 @@ func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions") - btn.Background = accentColor - btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} + btn.Color = accentColor btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) if u.mode == "phone" { @@ -2806,7 +2812,13 @@ func (u *ui) syncMenu(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 { - return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Synchronize...") + lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?") + 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.openAdvancedSync, "Open Advanced Sync") }), ) }) @@ -2829,6 +2841,18 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { return u.navigationHeader(gtx) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin { + return layout.Dimensions{} + } + return u.recycleBinSectionNotice(gtx) + }), + layout.Rigid(layout.Spacer{Height: func() unit.Dp { + if u.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin { + return 0 + } + return spacing + }()}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { return layout.Dimensions{} @@ -3073,7 +3097,11 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item }), ) } - return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions { + bg := panelColor + if u.state.Section == appstate.SectionRecycleBin { + bg = color.NRGBA{R: 249, G: 242, B: 236, A: 255} + } + return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions { return row(gtx) }) }) @@ -3246,13 +3274,26 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions { title := item.Title if u.state.Section == appstate.SectionRecycleBin { - title = "Deleted: " + title + title = "Recycle Bin Entry" } lbl := material.Label(u.theme, titleSize, title) lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: titlePad}.Layout, + func(gtx layout.Context) layout.Dimensions { + if u.state.Section != appstate.SectionRecycleBin { + lbl := material.Label(u.theme, unit.Sp(16), item.Title) + return lbl.Layout(gtx) + } + return recycleDetailTitle(gtx, u.theme, item.Title) + }, + layout.Spacer{Height: func() unit.Dp { + if u.state.Section == appstate.SectionRecycleBin { + return unit.Dp(10) + } + return 0 + }()}.Layout, func(gtx layout.Context) layout.Dimensions { if u.state.Section != appstate.SectionRecycleBin { return layout.Dimensions{} @@ -3432,6 +3473,32 @@ func (u *ui) banner(gtx layout.Context) layout.Dimensions { }) } +func (u *ui) statusToast(gtx layout.Context) layout.Dimensions { + status := u.statusToastSurface() + if status.Kind == bannerNone { + return layout.Dimensions{} + } + max := gtx.Constraints.Max + gtx.Constraints.Min = max + return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y)} + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.End}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Dimensions{} + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return statusToastCard(gtx, u.theme, status.Message) + }), + ) + }), + ) + }) +} + func (u *ui) historyPanel(gtx layout.Context) layout.Dimensions { history := u.visibleHistory() u.ensureHistoryClickables() @@ -3610,9 +3677,7 @@ func (u *ui) historyRow(gtx layout.Context, click *widget.Clickable, index int, func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionRecycleBin { - lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin / Deleted entries") - lbl.Color = mutedColor - return lbl.Layout(gtx) + return recyclePathCard(gtx, u.theme, "Recycle Bin", "Deleted entries stay here until you restore them.") } u.syncCurrentPath() @@ -3885,6 +3950,124 @@ func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable return btn.Layout(gtx) } +func syncPrimaryButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, compact bool) layout.Dimensions { + btn := material.Button(th, click, label) + btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} + btn.Color = accentColor + btn.CornerRadius = unit.Dp(10) + btn.TextSize = unit.Sp(14) + btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 12, Right: 12} + if compact { + btn.TextSize = unit.Sp(13) + btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 10, Right: 10} + } + return btn.Layout(gtx) +} + +func syncChoiceButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, active bool) layout.Dimensions { + btn := material.Button(th, click, label) + btn.CornerRadius = unit.Dp(10) + btn.TextSize = unit.Sp(14) + btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 11, Right: 11} + if active { + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + } else { + btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} + btn.Color = accentColor + } + return btn.Layout(gtx) +} + +func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(th, unit.Sp(12), strings.ToUpper(text)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + } +} + +func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSourceMode, direction syncDirection) layout.Dimensions { + sourceLabel := "another local vault file" + if source == syncSourceRemote { + sourceLabel = "another WebDAV-backed vault" + } + action := "Pull changes from" + if direction == syncDirectionPush { + action = "Push the current vault into" + } + return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), 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(th, unit.Sp(12), "SYNC PLAN") + 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(th, unit.Sp(14), action+" "+sourceLabel+".") + lbl.Color = th.Palette.Fg + return lbl.Layout(gtx) + }), + ) + }) + }) +} + +func recyclePathCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions { + return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), 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(th, unit.Sp(12), strings.ToUpper(title)) + lbl.Color = color.NRGBA{R: 144, G: 74, B: 49, A: 255} + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(th, unit.Sp(13), body) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }) + }) +} + +func (u *ui) recycleBinSectionNotice(gtx layout.Context) layout.Dimensions { + return recyclePathCard(gtx, u.theme, "Recycle Bin", "Deleted entries are separated from normal browsing so you can review or restore them safely.") +} + +func recycleDetailTitle(gtx layout.Context, th *material.Theme, title string) layout.Dimensions { + return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), 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(th, unit.Sp(12), "DELETED ENTRY") + lbl.Color = color.NRGBA{R: 144, G: 74, B: 49, A: 255} + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(th, unit.Sp(16), title) + return lbl.Layout(gtx) + }), + ) + }) + }) +} + +func statusToastCard(gtx layout.Context, th *material.Theme, message string) layout.Dimensions { + return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 27, G: 58, B: 47, A: 235}), func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(th, unit.Sp(13), message) + lbl.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + return lbl.Layout(gtx) + }) + }) +} + func sectionTabButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, active bool) layout.Dimensions { btn := material.Button(th, click, label) btn.CornerRadius = unit.Dp(10) diff --git a/main_test.go b/main_test.go index 54f8083..a16d668 100644 --- a/main_test.go +++ b/main_test.go @@ -2385,7 +2385,7 @@ func TestUIGeneratedPasswordFlowsIntoCreateEntryForm(t *testing.T) { } } -func TestUIBannerSurfacePrefersLoadingThenErrorThenStatus(t *testing.T) { +func TestUIBannerSurfacePrefersLoadingThenError(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) @@ -2402,12 +2402,15 @@ func TestUIBannerSurfacePrefersLoadingThenErrorThenStatus(t *testing.T) { u.state.ErrorMessage = "" u.state.StatusMessage = "save complete" - if got := u.bannerSurface(); got.Kind != bannerStatus || got.Message != "save complete" { - t.Fatalf("bannerSurface() with status = %#v, want status banner", got) + if got := u.bannerSurface(); got.Kind != bannerNone { + t.Fatalf("bannerSurface() with status = %#v, want no status banner", got) + } + if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "save complete" { + t.Fatalf("statusToastSurface() with status = %#v, want status toast", got) } } -func TestUIStatusBannerExpiresAfterTimeout(t *testing.T) { +func TestUIStatusToastExpiresAfterTimeout(t *testing.T) { t.Parallel() now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC) @@ -2419,13 +2422,13 @@ func TestUIStatusBannerExpiresAfterTimeout(t *testing.T) { } u.statusExpiresAt = now.Add(statusBannerDuration) - if got := u.bannerSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" { - t.Fatalf("bannerSurface() before expiry = %#v, want visible status banner", got) + if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" { + t.Fatalf("statusToastSurface() before expiry = %#v, want visible status toast", got) } now = now.Add(statusBannerDuration + time.Millisecond) - if got := u.bannerSurface(); got.Kind != bannerNone { - t.Fatalf("bannerSurface() after expiry = %#v, want no banner", got) + if got := u.statusToastSurface(); got.Kind != bannerNone { + t.Fatalf("statusToastSurface() after expiry = %#v, want no toast", got) } if got := u.state.StatusMessage; got != "" { t.Fatalf("state.StatusMessage after expiry = %q, want empty", got)