diff --git a/android/app/build.gradle b/android/app/build.gradle index 8ea70e8..8278711 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId "md.zennotes" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 15 - versionName "1.1.13" + versionCode 16 + versionName "1.1.14" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/java/md/zennotes/MainActivity.java b/android/app/src/main/java/md/zennotes/MainActivity.java index 401c006..af21cc4 100644 --- a/android/app/src/main/java/md/zennotes/MainActivity.java +++ b/android/app/src/main/java/md/zennotes/MainActivity.java @@ -35,10 +35,11 @@ public void onCreate(Bundle savedInstanceState) { ShareInboxPlugin.stashFromIntent(this, getIntent()); neutralizeDoubleKeyboardInset(); installCrashProofWebViewClient(); - // Fullscreen writing (#22): while the JS shell hides the status bar - // via the StatusBar plugin, a swipe from the top edge should peek it - // transiently instead of bringing it back for good. The behavior is - // inert while the bar is visible, so it is safe to set once here. + // Fullscreen writing (#22, #42): while the JS shell hides the + // status bar (and, in Immersive Mode, the navigation bar), a swipe + // from a hidden bar's edge should peek it transiently instead of + // bringing it back for good. The behavior covers all system bars, + // is inert while they are visible, and is safe to set once here. new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView()) .setSystemBarsBehavior( WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); diff --git a/package-lock.json b/package-lock.json index 4a70fd5..e9a7ec7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.10", + "version": "1.1.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.10", + "version": "1.1.14", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@capacitor/android": "^8.0.0", diff --git a/package.json b/package.json index fe59b26..6dd3020 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.13", + "version": "1.1.14", "type": "module", "description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core", "homepage": "https://zennotes.org", diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 9a8c77c..f3d58ca 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -125,7 +125,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.13' +let appVersion = '1.1.14' export async function loadNativeAppVersion(): Promise { try { diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 1f3254f..bac8456 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -66,11 +66,13 @@ import { } from './tags-empty-state' import { siblingNotesInDrawerOrder } from './note-order' import { getPinnedNotes, loadPins } from './pins' +import { vaultSettingsAccessForLayout } from './vault-settings-access' import { getLayoutMode, isPhoneViewport, setLayoutMode, type LayoutMode } from '../viewport' import { - applyStatusBarPreference, - isStatusBarHidden, - setStatusBarHidden + applySystemBarsPreference, + getSystemBarsMode, + setSystemBarsMode, + type SystemBarsMode } from './fullscreen' import { getGesturePrefs, @@ -2329,12 +2331,37 @@ function useContextMenuCleanup(): void { /** * Settings → Vault → Location grows the mobile vault features (the desktop - * switcher and remote-workspace sections are runtime-gated off there): a - * quick-switch list of every reachable vault when there is more than one, - * plus "New Vault…" and "Remote Vault…" actions. Mounted as a React island - * inside the location card (mobilizer pattern, no app-core changes). - * Switching keeps Settings open — the location card updates in place. + * switcher and remote-workspace sections are runtime-gated off there): the + * phone layout gets a quick-switch list of every reachable vault plus + * "New Vault…" and "Manage…" actions; the wider tablet layout keeps a compact + * "Manage…" entry point into the same canonical manager, which is where remote + * (self-hosted) vaults are added. Both are React islands inside the location + * card (mobilizer pattern, no app-core changes). Switching keeps Settings + * open — the location card updates in place. */ +const SETTINGS_VAULT_ACTION_CLASS = + 'shrink-0 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3.5 py-2 text-xs font-medium text-ink-800' + +/** The manager is the one canonical surface — rename, move, delete, remote, + * external folders all live there. It replaces Settings rather than stacking + * under it. */ +function openVaultManagerFromSettings(): void { + useStore.getState().setSettingsOpen(false) + window.setTimeout(() => openMobileSheet('vaults'), 30) +} + +function SettingsVaultManageButton(): React.JSX.Element { + return ( + + ) +} + function SettingsVaultQuickSwitch(): React.JSX.Element { const currentName = useStore((s) => s.vault?.name ?? null) const currentRoot = useStore((s) => s.vault?.root ?? '') @@ -2423,7 +2450,7 @@ function SettingsVaultQuickSwitch(): React.JSX.Element {
@@ -2452,7 +2473,7 @@ function SettingsVaultQuickSwitch(): React.JSX.Element { function useVaultSettingsRows(): void { useEffect(() => { - if (!isPhoneWidth()) return + const access = vaultSettingsAccessForLayout(isPhoneWidth()) let container: HTMLElement | null = null let root: ReturnType | null = null const sync = (): void => { @@ -2468,20 +2489,31 @@ function useVaultSettingsRows(): void { if (container?.parentElement === host) return if (!container) { container = document.createElement('div') - // This wrapper — not the list inside it — is the row's flex child, so - // it is what has to claim the full line (see mobile.css). - container.className = 'zn-settings-vaults-host' + // The phone wrapper — not the list inside it — is the row's flex child, + // so it is what has to claim the full line (see mobile.css). On a + // tablet, display: contents makes the compact Manage button a peer of + // the row's own Change… button. + container.className = + access === 'quick-switch' + ? 'zn-settings-vaults-host' + : 'zn-settings-vaults-manage-host' root = ReactDOM.createRoot(container) - root.render() + root.render( + access === 'quick-switch' ? : + ) + } + if (access === 'quick-switch') { + // The host is the desktop two-column row (label + "Change…" button), + // which the phone stylesheet wraps into a stack. Mount between the + // label and the row's own buttons, so the card reads location → + // picker list → actions and mobile.css can hide those buttons in + // favor of the island's own action group. + const firstBtn = host.querySelector('button') + if (firstBtn) host.insertBefore(container, firstBtn) + else host.appendChild(container) + } else { + host.appendChild(container) } - // The host is the desktop two-column row (label + "Change…" button), - // which the phone stylesheet wraps into a stack. Mount between the label - // and the row's own buttons, so the card reads location → picker list → - // actions and mobile.css can hide those buttons in favor of the - // island's own action group. - const firstBtn = host.querySelector('button') - if (firstBtn) host.insertBefore(container, firstBtn) - else host.appendChild(container) } const observer = new MutationObserver(() => sync()) observer.observe(document.body, { childList: true, subtree: true }) @@ -2511,14 +2543,14 @@ const LAYOUT_CHOICES: { mode: LayoutMode; label: string }[] = [ { mode: 'desktop', label: 'Desktop' } ] -/** Assert the status-bar preference at boot and re-assert it on resume: +/** Assert the system-bars preference at boot and re-assert it on resume: * Android brings the system bars back on some background→foreground paths, - * and re-applying a visible bar is a no-op (#22). */ + * and re-applying visible bars is a no-op (#22, #42). */ function useFullscreenChrome(): void { useEffect(() => { - void applyStatusBarPreference() + void applySystemBarsPreference() const listener = CapApp.addListener('appStateChange', ({ isActive }) => { - if (isActive) void applyStatusBarPreference() + if (isActive) void applySystemBarsPreference() }) return () => { void listener.then((handle) => handle.remove()) @@ -2526,41 +2558,41 @@ function useFullscreenChrome(): void { }, []) } -function SettingsStatusBarRow(): React.JSX.Element { - const [hidden, setHidden] = useState(() => isStatusBarHidden()) - const choose = (next: boolean): void => { - if (next === hidden) return - setStatusBarHidden(next) - setHidden(next) +const SYSTEM_BARS_CHOICES: { mode: SystemBarsMode; label: string }[] = [ + { mode: 'shown', label: 'Shown' }, + { mode: 'status-hidden', label: 'No status bar' }, + { mode: 'immersive', label: 'Immersive' } +] + +function SettingsSystemBarsRow(): React.JSX.Element { + const [mode, setMode] = useState(() => getSystemBarsMode()) + const choose = (next: SystemBarsMode): void => { + if (next === mode) return + setSystemBarsMode(next) + setMode(next) } return (
-
Status bar
+
System bars
- Hidden gives your notes the whole screen. Swipe down from the top - edge to peek at the clock and battery. + Hide the status bar for more room, or go immersive to hide the + navigation bar too. Swipe from a hidden bar’s edge to peek at it.
-
- - +
+ {SYSTEM_BARS_CHOICES.map((choice) => ( + + ))}
) @@ -2736,7 +2768,7 @@ function useLayoutSettingsRow(): void { root.render( <> - + {isPhoneWidth() && } ) diff --git a/src/ui-mobile/fullscreen.ts b/src/ui-mobile/fullscreen.ts index 2fd0653..b3f1f28 100644 --- a/src/ui-mobile/fullscreen.ts +++ b/src/ui-mobile/fullscreen.ts @@ -1,41 +1,68 @@ /** - * Fullscreen writing: hide the Android status bar while ZenNotes is open - * (#22). The bar stays reachable — MainActivity sets the transient-by-swipe - * system-bar behavior, so a swipe from the top edge peeks it (clock, battery, - * notifications) and it slides away again on its own. + * Fullscreen writing: hide Android system chrome while ZenNotes is open. + * Two tiers — hide just the status bar (#22), or Immersive Mode (#42), + * which hides the navigation bar too so notes get the entire screen. The + * bars stay reachable either way: MainActivity sets the transient-by-swipe + * system-bar behavior, so a swipe from a hidden bar's edge peeks it and it + * slides away again on its own. * * The preference is a localStorage flag mirrored to native storage by * bootstrap.ts (same mechanism as the layout override): WebView storage * alone is not trusted to survive engine updates. Applying it is idempotent * and cheap, so the shell re-asserts it on app resume — Android occasionally * restores system bars when a backgrounded activity comes forward. + * + * The status bar keeps going through the @capacitor/status-bar plugin + * (shipped behavior since 1.1.12, config in capacitor.config.ts); only the + * navigation bar goes through core's SystemBars plugin, which the StatusBar + * plugin has no reach into. Both drive the same WindowInsetsController, so + * the split is invisible to the OS. With targetSdk 36 the app is + * edge-to-edge and Capacitor's insets pipeline re-runs on every change, so + * hidden bars leave no solid background strips, and transiently revealed + * bars draw translucent over the content (the issue-#42 transparency ask). */ +import { SystemBars, SystemBarType } from '@capacitor/core' import { StatusBar } from '@capacitor/status-bar' import { HIDE_STATUS_BAR_KEY } from '../viewport' +import { + storedFromSystemBarsMode, + systemBarsModeFromStored, + type SystemBarsMode +} from './system-bars-mode' + +export type { SystemBarsMode } from './system-bars-mode' -export function isStatusBarHidden(): boolean { +export function getSystemBarsMode(): SystemBarsMode { try { - return localStorage.getItem(HIDE_STATUS_BAR_KEY) === '1' + return systemBarsModeFromStored(localStorage.getItem(HIDE_STATUS_BAR_KEY)) } catch { - return false + return 'shown' } } -export function setStatusBarHidden(hidden: boolean): void { +export function setSystemBarsMode(mode: SystemBarsMode): void { try { - if (hidden) localStorage.setItem(HIDE_STATUS_BAR_KEY, '1') - else localStorage.removeItem(HIDE_STATUS_BAR_KEY) + const stored = storedFromSystemBarsMode(mode) + if (stored === null) localStorage.removeItem(HIDE_STATUS_BAR_KEY) + else localStorage.setItem(HIDE_STATUS_BAR_KEY, stored) } catch { // Storage unavailable: the choice applies to this session only. } - void applyStatusBarPreference() + void applySystemBarsPreference() } -export async function applyStatusBarPreference(): Promise { +export async function applySystemBarsPreference(): Promise { + const mode = getSystemBarsMode() try { - if (isStatusBarHidden()) await StatusBar.hide() - else await StatusBar.show() + if (mode === 'shown') await StatusBar.show() + else await StatusBar.hide() } catch { // Plugin unavailable (plain browser dev): visible chrome is the default. } + try { + if (mode === 'immersive') await SystemBars.hide({ bar: SystemBarType.NavigationBar }) + else await SystemBars.show({ bar: SystemBarType.NavigationBar }) + } catch { + // Same fallback; core's web SystemBars stub is a no-op anyway. + } } diff --git a/src/ui-mobile/mobile.css b/src/ui-mobile/mobile.css index a287fb2..2a6ec27 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -1878,6 +1878,18 @@ min-width: 100%; } +/* In the tablet settings row, keep only the compact manager entry point. The + wrapper disappears from flex layout so its button sits beside Change… + instead of forcing the phone quick-switch card into the row. */ +.zn-settings-vaults-manage-host { + display: contents; +} + +[data-settings-search-id='vault-location']:has(> .zn-settings-vaults-manage-host) { + flex-wrap: wrap; + row-gap: 0.5rem; +} + .zn-settings-vaults { padding-top: 0.625rem; border-top: 0.5px solid rgb(var(--z-bg-4) / 0.55); diff --git a/src/ui-mobile/system-bars-mode.test.ts b/src/ui-mobile/system-bars-mode.test.ts new file mode 100644 index 0000000..2ae49f1 --- /dev/null +++ b/src/ui-mobile/system-bars-mode.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { storedFromSystemBarsMode, systemBarsModeFromStored } from './system-bars-mode.ts' + +describe('systemBarsModeFromStored', () => { + it('keeps the 1.1.12 hidden-status-bar flag meaning status-hidden', () => { + assert.equal(systemBarsModeFromStored('1'), 'status-hidden') + }) + + it('reads the immersive flag', () => { + assert.equal(systemBarsModeFromStored('immersive'), 'immersive') + }) + + it('treats absent and unknown values as the shown default', () => { + assert.equal(systemBarsModeFromStored(null), 'shown') + assert.equal(systemBarsModeFromStored(''), 'shown') + assert.equal(systemBarsModeFromStored('true'), 'shown') + }) +}) + +describe('storedFromSystemBarsMode', () => { + it('round-trips every mode', () => { + for (const mode of ['shown', 'status-hidden', 'immersive'] as const) { + assert.equal(systemBarsModeFromStored(storedFromSystemBarsMode(mode)), mode) + } + }) + + it('clears the key for the shown default', () => { + assert.equal(storedFromSystemBarsMode('shown'), null) + }) +}) diff --git a/src/ui-mobile/system-bars-mode.ts b/src/ui-mobile/system-bars-mode.ts new file mode 100644 index 0000000..dac4162 --- /dev/null +++ b/src/ui-mobile/system-bars-mode.ts @@ -0,0 +1,24 @@ +/** Settings → Appearance → System bars: how much Android chrome stays on + * screen. 'status-hidden' is the original fullscreen-writing option (#22); + * 'immersive' (#42) hides the navigation bar too. */ +export type SystemBarsMode = 'shown' | 'status-hidden' | 'immersive' + +/** + * Stored-flag mapping. '1' is the 1.1.12 hidden-status-bar value (#22) and + * must keep meaning exactly that; 'immersive' rides the same localStorage + * key so the native mirror in bootstrap.ts needs no changes and a downgrade + * degrades to the closest older behavior (unknown value = shown). Absent + * means the default visible chrome. + */ +export function systemBarsModeFromStored(raw: string | null): SystemBarsMode { + if (raw === 'immersive') return 'immersive' + if (raw === '1') return 'status-hidden' + return 'shown' +} + +/** Inverse of the above; null means "remove the key" (the shown default). */ +export function storedFromSystemBarsMode(mode: SystemBarsMode): string | null { + if (mode === 'immersive') return 'immersive' + if (mode === 'status-hidden') return '1' + return null +} diff --git a/src/ui-mobile/vault-settings-access.test.ts b/src/ui-mobile/vault-settings-access.test.ts new file mode 100644 index 0000000..444d70d --- /dev/null +++ b/src/ui-mobile/vault-settings-access.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { vaultSettingsAccessForLayout } from './vault-settings-access.ts' + +describe('vaultSettingsAccessForLayout', () => { + it('keeps the full quick-switch controls in the phone settings layout', () => { + assert.equal(vaultSettingsAccessForLayout(true), 'quick-switch') + }) + + it('keeps the vault manager reachable in the tablet settings layout', () => { + assert.equal(vaultSettingsAccessForLayout(false), 'manage-button') + }) +}) diff --git a/src/ui-mobile/vault-settings-access.ts b/src/ui-mobile/vault-settings-access.ts new file mode 100644 index 0000000..6779f53 --- /dev/null +++ b/src/ui-mobile/vault-settings-access.ts @@ -0,0 +1,7 @@ +export type VaultSettingsAccess = 'quick-switch' | 'manage-button' + +/** Phone settings have room for the full switcher; tablet settings retain the + * compact entry point into the same canonical vault manager. */ +export function vaultSettingsAccessForLayout(isPhoneLayout: boolean): VaultSettingsAccess { + return isPhoneLayout ? 'quick-switch' : 'manage-button' +} diff --git a/src/viewport.ts b/src/viewport.ts index 46a5e06..547b449 100644 --- a/src/viewport.ts +++ b/src/viewport.ts @@ -82,10 +82,12 @@ export function setLayoutMode(mode: LayoutMode): void { } } -/** localStorage key for the fullscreen (hidden status bar) preference (#22). - * Lives here so bootstrap.ts can mirror it natively without importing the - * status-bar plugin into the boot path; '1' means hidden, absent means the - * default visible chrome. */ +/** localStorage key for the fullscreen preference. Lives here so + * bootstrap.ts can mirror it natively without importing the status-bar + * plugin into the boot path; '1' means hidden status bar (#22), + * 'immersive' hides the navigation bar too (#42), absent means the + * default visible chrome. The key name predates immersive mode — kept so + * existing users' mirrored preference survives the upgrade. */ export const HIDE_STATUS_BAR_KEY = 'zn:hide-status-bar' /** localStorage key for the swipe-gesture assignments (#24); JSON, see