diff --git a/src/app/globals.css b/src/app/globals.css
index eb4e4fd..fd3e457 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -439,6 +439,14 @@ body.section-overlay-open .app-shell {
pointer-events: none;
}
+/* Flutter DraggableScrollableSheet Parity:
+ Height is scaled against available screen below system status bar,
+ stopping strictly underneath safe-top (MediaQuery.padding.top in Flutter). */
+.flutter-draggable-sheet {
+ height: calc(var(--sheet-size, 0.75) * (100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px))));
+ max-height: calc(100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px)));
+}
+
/* ── Section Overlay Slide-in Panel (Exact Flutter Najm Matching: 350ms in, 200ms out, easeInOut) ── */
.section-overlay {
position: fixed;
diff --git a/src/components/Componentes/terms-sheet.test.tsx b/src/components/Componentes/terms-sheet.test.tsx
index a008db6..545ff17 100644
--- a/src/components/Componentes/terms-sheet.test.tsx
+++ b/src/components/Componentes/terms-sheet.test.tsx
@@ -34,7 +34,7 @@ describe("TermsSheet Component (DraggableScrollableSheet Parity)", () => {
expect(dialog).toBeInTheDocument();
const section = dialog.querySelector("section")!;
- expect(section).toHaveClass("h-[calc(var(--sheet-size,0.75)*100svh)]");
+ expect(section).toHaveClass("flutter-draggable-sheet");
expect(screen.getByText("قوانین و مقررات")).toBeInTheDocument();
expect(screen.getByText("متوجه شدم")).toBeInTheDocument();
});
@@ -58,6 +58,72 @@ describe("TermsSheet Component (DraggableScrollableSheet Parity)", () => {
);
});
+ it("snaps to 1.0 when pulled up significantly and released", () => {
+ render();
+
+ const dialog = screen.getByRole("dialog");
+ const section = dialog.querySelector("section")!;
+ const content = screen.getByTestId("terms-sheet-content");
+
+ Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true });
+ Object.defineProperty(content, "clientHeight", { value: 400, configurable: true });
+ Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
+
+ // Pull up 100px from 0.75
+ fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] });
+ fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] });
+ fireEvent.touchEnd(content);
+
+ // Snaps cleanly to 1.0000
+ expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000");
+ });
+
+ it("snaps back to 0.75 when pulled up slightly and released", () => {
+ render();
+
+ const dialog = screen.getByRole("dialog");
+ const section = dialog.querySelector("section")!;
+ const content = screen.getByTestId("terms-sheet-content");
+
+ Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true });
+ Object.defineProperty(content, "clientHeight", { value: 400, configurable: true });
+ Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
+
+ // Pull up only 12px (below 30px threshold)
+ fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] });
+ fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 388 }] });
+ fireEvent.touchEnd(content);
+
+ // Snaps back to 0.7500
+ expect(section.style.getPropertyValue("--sheet-size")).toBe("0.7500");
+ });
+
+ it("snaps from 1.0 down to 0.75 when pulled down and released", () => {
+ render();
+
+ const dialog = screen.getByRole("dialog");
+ const section = dialog.querySelector("section")!;
+ const content = screen.getByTestId("terms-sheet-content");
+
+ Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true });
+ Object.defineProperty(content, "clientHeight", { value: 400, configurable: true });
+ Object.defineProperty(content, "scrollTop", { value: 0, configurable: true });
+
+ // Expand to 1.0 first
+ fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] });
+ fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] });
+ fireEvent.touchEnd(content);
+ expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000");
+
+ // Pull down 50px from 1.0
+ fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 300 }] });
+ fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 350 }] });
+ fireEvent.touchEnd(content);
+
+ // Snaps down to 0.7500
+ expect(section.style.getPropertyValue("--sheet-size")).toBe("0.7500");
+ });
+
it("grows sheet on wheel event at top of content", () => {
render();
diff --git a/src/components/Componentes/terms-sheet.tsx b/src/components/Componentes/terms-sheet.tsx
index b5b0a47..d7fa068 100644
--- a/src/components/Componentes/terms-sheet.tsx
+++ b/src/components/Componentes/terms-sheet.tsx
@@ -243,6 +243,7 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
const headerRef = useRef(null);
const contentRef = useRef(null);
const sheetSizeRef = useRef(SHEET_INITIAL_SIZE);
+ const restingStateRef = useRef(SHEET_INITIAL_SIZE);
const termsTitle = t["terms & conditions"] || "Terms & Conditions";
const gotItLabel = t["Got it"] || "Got it";
@@ -269,7 +270,9 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
isClosingRef.current = false;
setIsClosing(false);
sheetSizeRef.current = SHEET_INITIAL_SIZE;
+ restingStateRef.current = SHEET_INITIAL_SIZE;
if (sheetRef.current) {
+ sheetRef.current.style.transition = "";
sheetRef.current.style.setProperty(
"--sheet-size",
SHEET_INITIAL_SIZE.toFixed(4),
@@ -295,6 +298,7 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
// Drag-to-resize parity with Flutter DraggableScrollableSheet:
// initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0, shouldCloseOnMinExtent: true
+ // Snapping: Exactly two resting states (0.75 default and 1.0 expanded below status bar).
useEffect(() => {
if (!isOpen || isClosing) return;
@@ -310,10 +314,25 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
let startX = 0;
let startY = 0;
let lastY = 0;
+ let snapTimeout: ReturnType | null = null;
+ let wheelSnapTimeout: ReturnType | null = null;
const contentOverflows = () =>
content ? content.scrollHeight > content.clientHeight + 1 : false;
+ const snapTo = (targetSize: number) => {
+ sheetSizeRef.current = targetSize;
+ restingStateRef.current = targetSize;
+ sheet.style.transition = "height 260ms cubic-bezier(0.16, 1, 0.3, 1)";
+ sheet.style.setProperty("--sheet-size", targetSize.toFixed(4));
+ if (snapTimeout) clearTimeout(snapTimeout);
+ snapTimeout = setTimeout(() => {
+ if (sheet) {
+ sheet.style.transition = "";
+ }
+ }, 270);
+ };
+
const resize = (deltaPx: number) => {
const viewportHeight = window.innerHeight || 1;
const nextSize = Math.min(
@@ -332,6 +351,11 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
active = false;
return;
}
+ if (snapTimeout) {
+ clearTimeout(snapTimeout);
+ snapTimeout = null;
+ }
+ sheet.style.transition = "none";
active = true;
engaged = fromHeader;
isHeaderDrag = fromHeader;
@@ -385,9 +409,49 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
};
const handleTouchEnd = () => {
+ if (!active || closing) {
+ active = false;
+ engaged = false;
+ isHeaderDrag = false;
+ return;
+ }
active = false;
engaged = false;
isHeaderDrag = false;
+
+ const currentSize = sheetSizeRef.current;
+ const baseline = restingStateRef.current;
+ const totalPulled = startY - lastY; // > 0 finger pulled up, < 0 finger pulled down
+
+ // Flutter shouldCloseOnMinExtent: true when dragged near or below 60% floor
+ if (currentSize <= SHEET_MIN_SIZE + 0.01) {
+ closing = true;
+ closeSheet();
+ return;
+ }
+
+ // Snapping between only two states: 0.75 (default) and 1.0 (expanded)
+ if (baseline === SHEET_MAX_SIZE) {
+ // Dragging down from 1.0: if pulled down >= 30px or size <= 0.94, snap to 0.75
+ if (totalPulled <= -30 || currentSize <= 0.94) {
+ snapTo(SHEET_INITIAL_SIZE);
+ } else {
+ snapTo(SHEET_MAX_SIZE);
+ }
+ } else {
+ // Dragging from 0.75:
+ // If pulled down strongly, close
+ if (totalPulled <= -60) {
+ closing = true;
+ closeSheet();
+ } else if (totalPulled >= 30 || currentSize >= 0.78) {
+ // Dragged up enough to expand to 1.0
+ snapTo(SHEET_MAX_SIZE);
+ } else {
+ // Snap back to initial 0.75
+ snapTo(SHEET_INITIAL_SIZE);
+ }
+ }
};
const handleWheel = (event: WheelEvent) => {
@@ -412,6 +476,20 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
} else {
resize(delta);
}
+
+ if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout);
+ wheelSnapTimeout = setTimeout(() => {
+ if (closing) return;
+ const current = sheetSizeRef.current;
+ if (current <= SHEET_MIN_SIZE + 0.01) {
+ closing = true;
+ closeSheet();
+ } else if (current >= 0.82) {
+ snapTo(SHEET_MAX_SIZE);
+ } else {
+ snapTo(SHEET_INITIAL_SIZE);
+ }
+ }, 160);
};
const onHeaderTouchStart = (e: TouchEvent) => handleTouchStart(e, true);
@@ -436,6 +514,8 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
sheet.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
+ if (snapTimeout) clearTimeout(snapTimeout);
+ if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout);
if (header) {
header.removeEventListener("touchstart", onHeaderTouchStart);
}
@@ -477,9 +557,15 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
>