Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -4,6 +4,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
|
|||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { UnistylesRuntime } from "react-native-unistyles";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import {
|
||||||
|
getRightSidebarAnimationTargets,
|
||||||
|
shouldSyncSidebarAnimation,
|
||||||
|
} from "@/utils/sidebar-animation-state";
|
||||||
|
|
||||||
const ANIMATION_DURATION = 220;
|
const ANIMATION_DURATION = 220;
|
||||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||||
@@ -31,55 +35,59 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
|
|
||||||
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
||||||
const translateX = useSharedValue(isOpen ? 0 : windowWidth);
|
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
|
const translateX = useSharedValue(initialTargets.translateX);
|
||||||
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
const prevIsOpen = useRef(isOpen);
|
const prevIsOpen = useRef(isOpen);
|
||||||
|
const prevWindowWidth = useRef(windowWidth);
|
||||||
|
|
||||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if this is initial render or if we're mid-gesture
|
const didStateChange = shouldSyncSidebarAnimation({
|
||||||
if (prevIsOpen.current === isOpen) {
|
previousIsOpen: prevIsOpen.current,
|
||||||
return;
|
nextIsOpen: isOpen,
|
||||||
}
|
previousWindowWidth: prevWindowWidth.current,
|
||||||
|
nextWindowWidth: windowWidth,
|
||||||
|
});
|
||||||
const previousIsOpen = prevIsOpen.current;
|
const previousIsOpen = prevIsOpen.current;
|
||||||
prevIsOpen.current = isOpen;
|
prevIsOpen.current = isOpen;
|
||||||
|
prevWindowWidth.current = windowWidth;
|
||||||
|
|
||||||
|
if (!didStateChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||||
if (isGesturing.value) {
|
if (isGesturing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOpen) {
|
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
translateX.value = withTiming(0, {
|
|
||||||
|
if (previousIsOpen !== isOpen) {
|
||||||
|
translateX.value = withTiming(targets.translateX, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
backdropOpacity.value = withTiming(1, {
|
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
translateX.value = withTiming(windowWidth, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
backdropOpacity.value = withTiming(0, {
|
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
translateX.value = targets.translateX;
|
||||||
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
}, [
|
}, [
|
||||||
isOpen,
|
isOpen,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
mobileView,
|
|
||||||
desktopFileExplorerOpen,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const animateToOpen = () => {
|
const animateToOpen = () => {
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
|
|||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { UnistylesRuntime } from "react-native-unistyles";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import {
|
||||||
|
getLeftSidebarAnimationTargets,
|
||||||
|
shouldSyncSidebarAnimation,
|
||||||
|
} from "@/utils/sidebar-animation-state";
|
||||||
|
|
||||||
const ANIMATION_DURATION = 220;
|
const ANIMATION_DURATION = 220;
|
||||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||||
@@ -38,46 +42,53 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||||
|
|
||||||
// Initialize based on current state
|
// Initialize based on current state
|
||||||
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
|
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
|
const translateX = useSharedValue(initialTargets.translateX);
|
||||||
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
const prevIsOpen = useRef(isOpen);
|
const prevIsOpen = useRef(isOpen);
|
||||||
|
const prevWindowWidth = useRef(windowWidth);
|
||||||
|
|
||||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if this is initial render or if we're mid-gesture
|
const didStateChange = shouldSyncSidebarAnimation({
|
||||||
if (prevIsOpen.current === isOpen) {
|
previousIsOpen: prevIsOpen.current,
|
||||||
|
nextIsOpen: isOpen,
|
||||||
|
previousWindowWidth: prevWindowWidth.current,
|
||||||
|
nextWindowWidth: windowWidth,
|
||||||
|
});
|
||||||
|
const previousIsOpen = prevIsOpen.current;
|
||||||
|
prevIsOpen.current = isOpen;
|
||||||
|
prevWindowWidth.current = windowWidth;
|
||||||
|
|
||||||
|
if (!didStateChange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
prevIsOpen.current = isOpen;
|
|
||||||
|
|
||||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||||
if (isGesturing.value) {
|
if (isGesturing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOpen) {
|
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
translateX.value = withTiming(0, {
|
|
||||||
|
if (previousIsOpen !== isOpen) {
|
||||||
|
translateX.value = withTiming(targets.translateX, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
backdropOpacity.value = withTiming(1, {
|
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
translateX.value = withTiming(-windowWidth, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
backdropOpacity.value = withTiming(0, {
|
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
translateX.value = targets.translateX;
|
||||||
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
||||||
|
|
||||||
const animateToOpen = useCallback(() => {
|
const animateToOpen = useCallback(() => {
|
||||||
|
|||||||
@@ -896,12 +896,6 @@ export default function SettingsScreen() {
|
|||||||
const handleThemeChange = useCallback(
|
const handleThemeChange = useCallback(
|
||||||
(newTheme: AppSettings["theme"]) => {
|
(newTheme: AppSettings["theme"]) => {
|
||||||
void updateSettings({ theme: newTheme });
|
void updateSettings({ theme: newTheme });
|
||||||
if (newTheme === "auto") {
|
|
||||||
UnistylesRuntime.setAdaptiveThemes(true);
|
|
||||||
} else {
|
|
||||||
UnistylesRuntime.setAdaptiveThemes(false);
|
|
||||||
UnistylesRuntime.setTheme(newTheme);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[updateSettings],
|
[updateSettings],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getLeftSidebarAnimationTargets,
|
||||||
|
getRightSidebarAnimationTargets,
|
||||||
|
shouldSyncSidebarAnimation,
|
||||||
|
} from "./sidebar-animation-state";
|
||||||
|
|
||||||
|
describe("sidebar-animation-state", () => {
|
||||||
|
it("requests a sync when the open state changes", () => {
|
||||||
|
expect(
|
||||||
|
shouldSyncSidebarAnimation({
|
||||||
|
previousIsOpen: false,
|
||||||
|
nextIsOpen: true,
|
||||||
|
previousWindowWidth: 390,
|
||||||
|
nextWindowWidth: 390,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests a sync when the viewport width changes", () => {
|
||||||
|
expect(
|
||||||
|
shouldSyncSidebarAnimation({
|
||||||
|
previousIsOpen: false,
|
||||||
|
nextIsOpen: false,
|
||||||
|
previousWindowWidth: 390,
|
||||||
|
nextWindowWidth: 430,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the left sidebar fully off-screen when closed", () => {
|
||||||
|
expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
|
||||||
|
translateX: -430,
|
||||||
|
backdropOpacity: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the right sidebar fully off-screen when closed", () => {
|
||||||
|
expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
|
||||||
|
translateX: 430,
|
||||||
|
backdropOpacity: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
interface SidebarAnimationSyncInput {
|
||||||
|
previousIsOpen: boolean;
|
||||||
|
nextIsOpen: boolean;
|
||||||
|
previousWindowWidth: number;
|
||||||
|
nextWindowWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarAnimationTargetInput {
|
||||||
|
isOpen: boolean;
|
||||||
|
windowWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarAnimationTargets {
|
||||||
|
translateX: number;
|
||||||
|
backdropOpacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
|
||||||
|
return (
|
||||||
|
input.previousIsOpen !== input.nextIsOpen ||
|
||||||
|
input.previousWindowWidth !== input.nextWindowWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLeftSidebarAnimationTargets(
|
||||||
|
input: SidebarAnimationTargetInput,
|
||||||
|
): SidebarAnimationTargets {
|
||||||
|
return {
|
||||||
|
translateX: input.isOpen ? 0 : -input.windowWidth,
|
||||||
|
backdropOpacity: input.isOpen ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRightSidebarAnimationTargets(
|
||||||
|
input: SidebarAnimationTargetInput,
|
||||||
|
): SidebarAnimationTargets {
|
||||||
|
return {
|
||||||
|
translateX: input.isOpen ? 0 : input.windowWidth,
|
||||||
|
backdropOpacity: input.isOpen ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user