Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions android/app/src/main/java/md/zennotes/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
try {
Expand Down
158 changes: 95 additions & 63 deletions src/ui-mobile/MobileShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<button
type="button"
className={SETTINGS_VAULT_ACTION_CLASS}
onClick={openVaultManagerFromSettings}
>
Manage…
</button>
)
}

function SettingsVaultQuickSwitch(): React.JSX.Element {
const currentName = useStore((s) => s.vault?.name ?? null)
const currentRoot = useStore((s) => s.vault?.root ?? '')
Expand Down Expand Up @@ -2423,7 +2450,7 @@ function SettingsVaultQuickSwitch(): React.JSX.Element {
<div className="zn-settings-vaults-btns">
<button
type="button"
className="rounded-xl border border-paper-300/70 bg-paper-100/80 px-3.5 py-2 text-xs font-medium text-ink-800"
className={SETTINGS_VAULT_ACTION_CLASS}
disabled={busy !== null}
onClick={() =>
run('new', () => promptNewVault(currentTier === 'icloud' ? 'icloud' : 'local'))
Expand All @@ -2433,15 +2460,9 @@ function SettingsVaultQuickSwitch(): React.JSX.Element {
</button>
<button
type="button"
className="rounded-xl border border-paper-300/70 bg-paper-100/80 px-3.5 py-2 text-xs font-medium text-ink-800"
className={SETTINGS_VAULT_ACTION_CLASS}
disabled={busy !== null}
onClick={() => {
// The manager is the one canonical surface — rename, move, delete,
// remote, external folders all live there. It replaces Settings
// rather than stacking under it.
useStore.getState().setSettingsOpen(false)
window.setTimeout(() => openMobileSheet('vaults'), 30)
}}
onClick={openVaultManagerFromSettings}
>
Manage…
</button>
Expand All @@ -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<typeof ReactDOM.createRoot> | null = null
const sync = (): void => {
Expand All @@ -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(<SettingsVaultQuickSwitch />)
root.render(
access === 'quick-switch' ? <SettingsVaultQuickSwitch /> : <SettingsVaultManageButton />
)
}
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 })
Expand Down Expand Up @@ -2511,56 +2543,56 @@ 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())
}
}, [])
}

function SettingsStatusBarRow(): React.JSX.Element {
const [hidden, setHidden] = useState<boolean>(() => 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<SystemBarsMode>(() => getSystemBarsMode())
const choose = (next: SystemBarsMode): void => {
if (next === mode) return
setSystemBarsMode(next)
setMode(next)
}
return (
<div className="zn-settings-layout">
<div className="zn-settings-layout-text">
<div className="zn-settings-layout-title">Status bar</div>
<div className="zn-settings-layout-title">System bars</div>
<div className="zn-settings-layout-desc">
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.
</div>
</div>
<div className="zn-settings-layout-seg" role="radiogroup" aria-label="Status bar">
<button
type="button"
role="radio"
aria-checked={!hidden}
className={hidden ? '' : 'is-active'}
onClick={() => choose(false)}
>
Shown
</button>
<button
type="button"
role="radio"
aria-checked={hidden}
className={hidden ? 'is-active' : ''}
onClick={() => choose(true)}
>
Hidden
</button>
<div className="zn-settings-layout-seg" role="radiogroup" aria-label="System bars">
{SYSTEM_BARS_CHOICES.map((choice) => (
<button
key={choice.mode}
type="button"
role="radio"
aria-checked={mode === choice.mode}
className={mode === choice.mode ? 'is-active' : ''}
onClick={() => choose(choice.mode)}
>
{choice.label}
</button>
))}
</div>
</div>
)
Expand Down Expand Up @@ -2736,7 +2768,7 @@ function useLayoutSettingsRow(): void {
root.render(
<>
<SettingsLayoutRow />
<SettingsStatusBarRow />
<SettingsSystemBarsRow />
{isPhoneWidth() && <SettingsGesturesRow />}
</>
)
Expand Down
55 changes: 41 additions & 14 deletions src/ui-mobile/fullscreen.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
export async function applySystemBarsPreference(): Promise<void> {
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.
}
}
12 changes: 12 additions & 0 deletions src/ui-mobile/mobile.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading