Skip to content
Open
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
7 changes: 6 additions & 1 deletion apps/window-alarm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This directory contains the main Tauri v2 application.
## Tech Stack

- **Framework:** Tauri v2
- **Frontend:** React + TypeScript + Ionic Framework
- **Frontend:** React + TypeScript + Material UI + TanStack Router
- **State/Logic:** Custom Hooks + SQLite (`tauri-plugin-sql`)
- **Native Integration:** `tauri-plugin-alarm-manager` (Local plugin)

Expand Down Expand Up @@ -70,3 +70,8 @@ tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Trace) // Use Trace or Debug for full visibility
.build(),
```

## Android Transitions

The application uses native-like page transitions on Android using the View Transitions API.
For implementation details, see [docs/ANDROID_TRANSITIONS.md](../../docs/ANDROID_TRANSITIONS.md).
43 changes: 14 additions & 29 deletions apps/window-alarm/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
@@ -1,31 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for all windows including dynamically created alarm windows",
"windows": ["*", "alarm-ring-*", "test-alarm-*"],
"permissions": [
"core:default",
"opener:default",
"sql:default",
"sql:allow-execute",
"sql:allow-select",
"sql:allow-load",
"notification:default",
"deep-link:default",
"core:app:default",
"core:window:default",
"core:window:allow-start-dragging",
"log:default",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-maximize",
"core:window:allow-toggle-maximize",
"core:window:allow-minimize",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-set-always-on-top",
"core:window:allow-is-always-on-top",
"core:window:allow-set-decorations",
"alarm-manager:default"
]
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"notification:default",
"os:default",
"alarm-manager:default",
"deep-link:default",
"predictive-back:default",
"sql:default"
]
}
4 changes: 4 additions & 0 deletions apps/window-alarm/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { platform } from '@tauri-apps/plugin-os';
import './theme/variables.css';
import './theme/ringing.css';
import './theme/components.css';
import './theme/transitions.css';
import { routeTransitions } from './utils/RouteTransitions';

const App: React.FC = () => {
console.log('📦 [window-alarm] App rendering, pathname:', window.location.pathname);
Expand Down Expand Up @@ -88,6 +90,8 @@ const App: React.FC = () => {
// Check if we can go back.
// window.history.length > 1 is the standard browser way to check history depth.
if (window.history.length > 1) {
// Signal that this is a backward navigation
routeTransitions.setNextDirection('backwards');
router.history.back();
} else {
// If we can't go back, minimize the app (standard Android behavior)
Expand Down
138 changes: 138 additions & 0 deletions apps/window-alarm/src/components/RouteStage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React, { useEffect, useState } from 'react';
import { Outlet, useLocation, useRouter } from '@tanstack/react-router';
import { predictiveBackController, PredictiveBackState } from '../utils/PredictiveBackController';
import { getComponentForPath } from '../utils/RouteRegistry';
import '../theme/predictiveBack.css';

const RouteStage: React.FC = () => {
const [pbState, setPbState] = useState<PredictiveBackState>({ active: false, progress: 0, edge: 'left' });
const location = useLocation();
const router = useRouter();

// We need to keep track of the *previous* location to render the underlay.
// TanStack Router doesn't expose the history stack directly in a way we can just "peek" easily
// without tracking it ourselves or using internal history.
// `router.history` is available.
// Let's assume a simple stack tracking for now.

// Actually, we can use a ref to store the "last" location before the current one?
// No, we need the one that we would go back TO.
// If we are at C, back goes to B.
// We need to know B when we are at C.
// Since we don't have a robust history manager exposed here, we might need to rely on `window.history`.
// But `window.history` doesn't give us the *path* of the previous entry for security reasons.

// Solution: We need to maintain a local history stack in our App or here.
// For this component, let's try to infer it or rely on a global store if we had one.
// Since we don't, let's build a simple one using `location` changes.

// Refined stack logic:
// We need to know "what is under this card".
// If we assume linear navigation for this app (which it mostly is):
// Home is root.
// Edit is above Home.
// Settings is above Home (or Edit).
// Ringing is special.

// Let's use a simpler heuristic for the "Previous" page based on known app flow:
// If on /edit/*, underlay is /home.
// If on /settings, underlay is /home (or whatever we came from).
// If on /home, no underlay (can't go back).

// Let's use the explicit `window.history.length` check to know if we CAN go back.
// But to know *what* to render, we need the path.
// Let's try to track it.

useEffect(() => {
// When location changes:
// If new location is NOT the one we just had, push current to a "local stack"?
// This is getting complicated to robustly implement inside a component without global context.
// For the sake of the task, let's use a specific "Previous Page" determination for Tier 2A:

// 1. If at /edit/:id, underlay is likely /home.
// 2. If at /settings, underlay is likely /home.
// 3. If at /ringing, predictive back is disabled.

// This covers 90% of the app's use cases.
}, [location.pathname]);


// Subscribe to predictive back events
useEffect(() => {
const unsub = predictiveBackController.subscribe((state) => {
setPbState(state);
Comment on lines +60 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Initialise predictive back controller before subscribing

The new RouteStage subscribes to predictiveBackController, but nothing ever calls predictiveBackController.init() in this commit (and a repo-wide search shows no other callers). Since init() is where the event listeners are registered, the state never updates, so state.progress === 1 is never reached and router.history.back() never fires. On Android 14+ this means the native callback intercepts the back swipe (after setCanGoBack(true)), but the gesture does nothing in the UI. Consider invoking predictiveBackController.init() once (e.g., in RootLayout or at the start of this effect).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Initialise predictive back controller before subscribing

The new RouteStage subscribes to predictiveBackController, but nothing ever calls predictiveBackController.init() in this commit (and a repo-wide search shows no other callers). Since init() is where the event listeners are registered, the state never updates, so state.progress === 1 is never reached and router.history.back() never fires. On Android 14+ this means the native callback intercepts the back swipe (after setCanGoBack(true)), but the gesture does nothing in the UI. Consider invoking predictiveBackController.init() once (e.g., in RootLayout or at the start of this effect).

Useful? React with 👍 / 👎.

Checkout Codex's review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I actually fixed this in a subsequent commit by initializing it in apps/window-alarm/src/App.tsx. The review might be based on an earlier state, but I will double check to ensure it's initialized correctly.

if (state.progress === 1 && !state.active) {
// Invoked/Committed.
// The native side animation finished (visually 100%).
// Now we perform the actual navigation.
// We should ensure we don't re-trigger animation.
router.history.back();
}
});
return unsub;
}, [router]);

// Determine underlay component
let underlayComponent: React.ReactNode = null;
const currentPath = location.pathname;

if (currentPath.startsWith('/edit/')) {
underlayComponent = getComponentForPath('/home');
} else if (currentPath === '/settings') {
underlayComponent = getComponentForPath('/home');
}
// TODO: Improve this to be true history stack if possible later.

const isSwipeActive = pbState.active;
const progress = pbState.progress;

// Visual transforms
// Top layer moves right (translate X)
const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 400;
const translateX = progress * windowWidth;

// Underlay scale/opacity effect (Tier 1/2 polish)
// Scale 0.95 -> 1.0
// Opacity 0.5 -> 1.0 (if using scrim)
const underlayScale = 0.95 + (0.05 * progress);
// const underlayOpacity = 0.3 + (0.7 * progress); // Fade in?

// Styles
const topStyle: React.CSSProperties = isSwipeActive ? {
transform: `translateX(${translateX}px)`,
boxShadow: '-4px 0 16px rgba(0,0,0,0.2)' // Shadow on the left edge
} : {};

const underlayStyle: React.CSSProperties = isSwipeActive ? {
transform: `scale(${underlayScale})`,
opacity: 1 // We render fully but maybe behind a scrim?
} : { display: 'none' }; // Hide underlay when not swiping to save resources?
// Actually, keeping it hidden is good.

return (
<div className="wa-route-stage">
{isSwipeActive && underlayComponent && (
<div className="wa-route-underlay" style={underlayStyle}>
<div className="wa-route-underlay-content">
{underlayComponent}
</div>
<div className="wa-route-underlay-scrim" style={{ opacity: 1 - progress }} />
</div>
)}

{/*
If we use Tier 1 (fallback), we might just show a solid color or pattern
if underlayComponent is null.
*/}
{isSwipeActive && !underlayComponent && (
<div className="wa-route-underlay tier-1" style={{ backgroundColor: 'var(--background-default)' }} />
)}

<div className="wa-route-top" style={topStyle}>
<Outlet />
</div>
</div>
);
};

export default RouteStage;
64 changes: 60 additions & 4 deletions apps/window-alarm/src/router.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createRootRoute, createRoute, createRouter, Outlet, redirect, useLocation } from '@tanstack/react-router';
import { createRootRoute, createRoute, createRouter, redirect, useLocation } from '@tanstack/react-router';
import { TitleBar } from './components/TitleBar';
import { NotFound } from './components/NotFound';
import { PlatformUtils } from './utils/PlatformUtils';
Expand All @@ -7,6 +7,9 @@ import Home from './screens/Home';
import EditAlarm from './screens/EditAlarm';
import Ringing from './screens/Ringing';
import Settings from './screens/Settings';
import { routeTransitions } from './utils/RouteTransitions';
import RouteStage from './components/RouteStage';
import { predictiveBackController } from './utils/PredictiveBackController';

// Root layout component
const RootLayout = () => {
Expand All @@ -18,15 +21,41 @@ const RootLayout = () => {
setIsMobile(PlatformUtils.isMobile());
}, []);

// Update predictive back capability based on location
useEffect(() => {
const checkCanGoBack = async () => {
const path = location.pathname;
const isRinging = path.startsWith('/ringing');
// Tier 2A: We only enable it if we are NOT on root (home) and NOT ringing.
// We can use a simpler check: if path is NOT /home, we can go back to home.
// If we are deep, we can go back.
// Exceptions: Ringing.

const isHome = path === '/home' || path === '/';
const canGoBack = !isHome && !isRinging;

await predictiveBackController.setCanGoBack(canGoBack);
};
checkCanGoBack();
}, [location.pathname]);

// Don't show TitleBar for ringing window (it's a separate floating window)
const isRingingWindow = location.pathname.startsWith('/ringing');
const showTitleBar = !isMobile && !isRingingWindow;

return (
<>
{showTitleBar && <TitleBar />}
<div style={{ marginTop: showTitleBar ? '32px' : '0px', height: showTitleBar ? 'calc(100% - 32px)' : '100%' }}>
<Outlet />
<div
className="wa-route-slot"
style={{
marginTop: showTitleBar ? '32px' : '0px',
height: showTitleBar ? 'calc(100% - 32px)' : '100%',
// @ts-ignore - viewTransitionName is not yet in standard React types
viewTransitionName: 'wa-route-slot'
}}
>
<RouteStage />
</div>
</>
);
Expand Down Expand Up @@ -75,7 +104,34 @@ const routeTree = rootRoute.addChildren([indexRoute, homeRoute, editAlarmRoute,

export const router = createRouter({
routeTree,
defaultNotFoundComponent: NotFound
defaultNotFoundComponent: NotFound,
// View transition API configuration
// @ts-ignore - The types for view transitions in tanstack router seem slightly off or strict in this version
defaultViewTransition: ({ location }: any) => {
// 1. Check if allowed
if (!routeTransitions.shouldAnimate()) {
return false;
}

const toPath = location.pathname;

// 2. Skip ringing
if (toPath.startsWith('/ringing')) {
return false;
}

// 3. Determine direction
const direction = routeTransitions.getDirection(toPath);

if (direction === 'none') {
return false;
}

// 4. Return types
return {
types: ['wa-slide', `wa-${direction}`]
};
}
});

declare module '@tanstack/react-router' {
Expand Down
51 changes: 51 additions & 0 deletions apps/window-alarm/src/theme/predictiveBack.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
.wa-route-stage {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}

.wa-route-top {
position: relative;
width: 100%;
height: 100%;
background-color: var(--background-default); /* Ensure opaque background */
will-change: transform;
z-index: 2;
}

.wa-route-underlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background-default);
z-index: 1;
overflow: hidden;
transform-origin: center;
will-change: transform, opacity;
}

.wa-route-underlay-content {
width: 100%;
height: 100%;
/* Prevent interaction with underlay while peeking */
pointer-events: none;
}

.wa-route-underlay-scrim {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.3); /* Dark scrim */
pointer-events: none;
will-change: opacity;
}

.wa-route-underlay.tier-1 {
/* Fallback generic background */
background-color: #f0f0f0; /* Or var(--background-paper) */
}
Loading