From 3dd827605cf8540c528f684339acc5e20a705afa Mon Sep 17 00:00:00 2001 From: Omar Date: Sun, 24 May 2026 00:44:56 -0400 Subject: [PATCH 01/24] Refactor authentication state management and session handling to improve reliability and user experience during network instability. This update introduces a formal `AuthSessionState` machine, improves data persistence logic during logout, and enhances the landing page behavior for returning users. - **Authentication & Session Management**: - **`AuthProvider`**: Replaced simple `isLoading` boolean with a multi-state `authState` (`loading`, `authenticated`, `unauthenticated`, `unavailable`). - **Session Recovery**: Added a retry mechanism that attempts to fetch the session every 15 seconds when the service is `unavailable`. - **Session Invalidation**: Updated `logout` and unauthorized session refreshes to clear user data while explicitly preserving a new "returning browser" marker. - **Error Handling**: Added toast notifications for failed logout attempts and ensured local state remains intact if the server-side logout fails. - **Routing & UX**: - **Returning User Flow**: Introduced `tday.returning-browser` in `localStorage`. Users who have previously interacted with the app are now redirected directly to `/login` instead of the landing page. - **`AuthBootstrapScreen`**: Standardized the loading/reconnecting UI into a reusable component used across `AuthLayout`, `LandingPage`, and `ProtectedRoute`. - **Security**: Refactored `clearClientUserData` to support excluding specific keys from deletion during cleanup. - **Backend (Kotlin)**: - Updated `SessionAuthFlowTest` to verify secure cookie attributes (`HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/`) in production environments. - **Testing**: - Expanded `AuthProvider.test.tsx` with comprehensive test cases for 401/500 responses, session refreshes, and data persistence during logout. - Updated `publicRouteAuthGuard.test.tsx` to validate the new redirect logic for returning browsers and "unavailable" auth states. --- .../tday/routes/auth/SessionAuthFlowTest.kt | 9 +- .../com/ohmz/tday/security/TestAppConfig.kt | 3 +- .../src/components/Sidebar/User/UserCard.tsx | 14 +- .../components/auth/AuthBootstrapScreen.tsx | 9 + .../auth/UnauthenticatedCacheGuard.tsx | 10 - .../components/landing/OnboardingLanding.tsx | 2 - .../src/lib/security/clearClientUserData.ts | 10 +- tday-web/src/lib/security/returningBrowser.ts | 21 ++ tday-web/src/pages/AuthLayout.tsx | 22 +- tday-web/src/pages/LandingPage.tsx | 20 +- tday-web/src/pages/ProtectedRoute.tsx | 12 +- tday-web/src/providers/AuthProvider.tsx | 161 +++++++++------ tday-web/tests/unit/AuthProvider.test.tsx | 195 +++++++++++++++++- .../tests/unit/publicRouteAuthGuard.test.tsx | 70 ++++++- 14 files changed, 442 insertions(+), 116 deletions(-) create mode 100644 tday-web/src/components/auth/AuthBootstrapScreen.tsx delete mode 100644 tday-web/src/components/auth/UnauthenticatedCacheGuard.tsx create mode 100644 tday-web/src/lib/security/returningBrowser.ts diff --git a/tday-backend/src/test/kotlin/com/ohmz/tday/routes/auth/SessionAuthFlowTest.kt b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/auth/SessionAuthFlowTest.kt index 79db6bd3..4d4c1b7a 100644 --- a/tday-backend/src/test/kotlin/com/ohmz/tday/routes/auth/SessionAuthFlowTest.kt +++ b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/auth/SessionAuthFlowTest.kt @@ -76,7 +76,10 @@ private const val SESSION_TEST_ISSUED_AT = "2026-04-01T00:00:00Z" class SessionAuthFlowTest { @Test fun `credentials callback sets session cookie with configured max age`() = testApplication { - val config = testAppConfig(sessionMaxAgeSec = 2_592_000) + val config = testAppConfig( + isProduction = true, + sessionMaxAgeSec = 2_592_000, + ) val jwtService = JwtServiceImpl(config) val userService = FakeUserService( loginUser = mapOf( @@ -107,6 +110,10 @@ class SessionAuthFlowTest { assertEquals(HttpStatusCode.OK, response.status) val cookieHeader = response.requireCookieHeader(sessionCookieName(config.isProduction)) assertTrue(cookieHeader.contains("Max-Age=2592000")) + assertTrue(cookieHeader.contains("Path=/")) + assertTrue(cookieHeader.contains("HttpOnly")) + assertTrue(cookieHeader.contains("SameSite=Lax")) + assertTrue(cookieHeader.contains("Secure")) } @Test diff --git a/tday-backend/src/test/kotlin/com/ohmz/tday/security/TestAppConfig.kt b/tday-backend/src/test/kotlin/com/ohmz/tday/security/TestAppConfig.kt index 29d58269..e8d0ac86 100644 --- a/tday-backend/src/test/kotlin/com/ohmz/tday/security/TestAppConfig.kt +++ b/tday-backend/src/test/kotlin/com/ohmz/tday/security/TestAppConfig.kt @@ -4,6 +4,7 @@ import com.ohmz.tday.config.AppConfig fun testAppConfig( authSecret: String = "test-secret-that-is-at-least-32-chars-long!!", + isProduction: Boolean = false, pbkdf2Iterations: Int = 120_000, sessionMaxAgeSec: Int = 2_592_000, sessionAbsoluteMaxAgeSec: Int = 7_776_000, @@ -20,7 +21,7 @@ fun testAppConfig( port = 8080, databaseUrl = "postgresql://test:test@localhost:5432/testdb", authSecret = authSecret, - isProduction = false, + isProduction = isProduction, corsAllowedOrigins = emptyList(), pbkdf2Iterations = pbkdf2Iterations, sessionMaxAgeSec = sessionMaxAgeSec, diff --git a/tday-web/src/components/Sidebar/User/UserCard.tsx b/tday-web/src/components/Sidebar/User/UserCard.tsx index b81db848..131af8d6 100644 --- a/tday-web/src/components/Sidebar/User/UserCard.tsx +++ b/tday-web/src/components/Sidebar/User/UserCard.tsx @@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next"; import { useTheme } from "next-themes"; import { LogOut, Moon, Sun, Monitor, Settings, Shield } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useToast } from "@/hooks/use-toast"; const railButtonClass = "group flex h-10 min-h-10 w-10 shrink-0 items-center justify-center rounded-xl text-sidebar-foreground/70 transition-colors duration-200 hover:bg-sidebar-accent/70 hover:text-sidebar-foreground"; @@ -37,6 +38,7 @@ const UserCard = ({ const { t: sidebarDict } = useTranslation("sidebar"); const { setTheme, theme } = useTheme(); const router = useRouter(); + const { toast } = useToast(); const themeLabel = theme === "dark" ? "Dark" : theme === "light" ? "Light" : "System"; @@ -52,7 +54,17 @@ const UserCard = ({ .slice(0, 2) || "U"; const handleLogout = async () => { - await logout(); + try { + await logout(); + } catch (error) { + toast({ + variant: "destructive", + description: + error instanceof Error && error.message + ? error.message + : "Unable to log out. Please try again.", + }); + } }; return ( diff --git a/tday-web/src/components/auth/AuthBootstrapScreen.tsx b/tday-web/src/components/auth/AuthBootstrapScreen.tsx new file mode 100644 index 00000000..6eca33c7 --- /dev/null +++ b/tday-web/src/components/auth/AuthBootstrapScreen.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from "lucide-react"; + +export default function AuthBootstrapScreen() { + return ( +
+ +
+ ); +} diff --git a/tday-web/src/components/auth/UnauthenticatedCacheGuard.tsx b/tday-web/src/components/auth/UnauthenticatedCacheGuard.tsx deleted file mode 100644 index 8ddcef62..00000000 --- a/tday-web/src/components/auth/UnauthenticatedCacheGuard.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useEffect } from "react"; -import { clearClientUserData } from "@/lib/security/clearClientUserData"; - -export default function UnauthenticatedCacheGuard() { - useEffect(() => { - void clearClientUserData(); - }, []); - - return null; -} diff --git a/tday-web/src/components/landing/OnboardingLanding.tsx b/tday-web/src/components/landing/OnboardingLanding.tsx index 85642675..9194a62c 100644 --- a/tday-web/src/components/landing/OnboardingLanding.tsx +++ b/tday-web/src/components/landing/OnboardingLanding.tsx @@ -10,7 +10,6 @@ import { Wand2, } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import UnauthenticatedCacheGuard from "@/components/auth/UnauthenticatedCacheGuard"; type Slide = { title: string; @@ -115,7 +114,6 @@ export default function OnboardingLanding() { return (
-
diff --git a/tday-web/src/lib/security/clearClientUserData.ts b/tday-web/src/lib/security/clearClientUserData.ts index 42dac34b..ac1cfdfb 100644 --- a/tday-web/src/lib/security/clearClientUserData.ts +++ b/tday-web/src/lib/security/clearClientUserData.ts @@ -1,3 +1,7 @@ +type ClearClientUserDataOptions = { + preserveLocalStorageKeys?: string[]; +}; + async function deleteIndexedDbDatabase(name: string): Promise { await new Promise((resolve) => { try { @@ -11,7 +15,9 @@ async function deleteIndexedDbDatabase(name: string): Promise { }); } -export async function clearClientUserData(): Promise { +export async function clearClientUserData( + options: ClearClientUserDataOptions = {}, +): Promise { if (typeof window === "undefined") return; try { @@ -21,8 +27,10 @@ export async function clearClientUserData(): Promise { } try { + const preserveKeys = new Set(options.preserveLocalStorageKeys ?? []); const keys = Object.keys(window.localStorage); for (const key of keys) { + if (preserveKeys.has(key)) continue; window.localStorage.removeItem(key); } } catch { diff --git a/tday-web/src/lib/security/returningBrowser.ts b/tday-web/src/lib/security/returningBrowser.ts new file mode 100644 index 00000000..237356db --- /dev/null +++ b/tday-web/src/lib/security/returningBrowser.ts @@ -0,0 +1,21 @@ +export const RETURNING_BROWSER_STORAGE_KEY = "tday.returning-browser"; + +export function hasReturningBrowser(): boolean { + if (typeof window === "undefined") return false; + + try { + return window.localStorage.getItem(RETURNING_BROWSER_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +export function markReturningBrowser(): void { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem(RETURNING_BROWSER_STORAGE_KEY, "1"); + } catch { + // Ignore localStorage write failures in restricted browser contexts. + } +} diff --git a/tday-web/src/pages/AuthLayout.tsx b/tday-web/src/pages/AuthLayout.tsx index 4f724e2d..50c57975 100644 --- a/tday-web/src/pages/AuthLayout.tsx +++ b/tday-web/src/pages/AuthLayout.tsx @@ -1,23 +1,24 @@ +import { useEffect } from "react"; import { Navigate, Outlet, useParams } from "react-router-dom"; -import { Loader2 } from "lucide-react"; import { SonnerToaster } from "@/components/ui/sonner"; -import UnauthenticatedCacheGuard from "@/components/auth/UnauthenticatedCacheGuard"; import { useAuth } from "@/providers/AuthProvider"; import { DEFAULT_LOCALE } from "@/i18n"; +import AuthBootstrapScreen from "@/components/auth/AuthBootstrapScreen"; +import { markReturningBrowser } from "@/lib/security/returningBrowser"; export default function AuthLayout() { - const { user, isLoading } = useAuth(); + const { user, authState } = useAuth(); const { locale } = useParams(); const loc = locale || DEFAULT_LOCALE; const isApprovedUser = user?.approvalStatus === "APPROVED"; - if (isLoading) { - return ( -
- -
- ); - } + useEffect(() => { + markReturningBrowser(); + }, []); + + if (authState === "loading" || authState === "unavailable") { + return ; + } if (isApprovedUser) { return ; @@ -25,7 +26,6 @@ export default function AuthLayout() { return (
-
diff --git a/tday-web/src/pages/LandingPage.tsx b/tday-web/src/pages/LandingPage.tsx index d1f5cc38..6004c7cf 100644 --- a/tday-web/src/pages/LandingPage.tsx +++ b/tday-web/src/pages/LandingPage.tsx @@ -1,25 +1,27 @@ import { Navigate, useParams } from "react-router-dom"; -import { Loader2 } from "lucide-react"; import { useAuth } from "@/providers/AuthProvider"; import OnboardingLanding from "@/components/landing/OnboardingLanding"; import { DEFAULT_LOCALE } from "@/i18n"; +import AuthBootstrapScreen from "@/components/auth/AuthBootstrapScreen"; +import { hasReturningBrowser } from "@/lib/security/returningBrowser"; export default function LandingPage() { - const { isAuthenticated, isLoading } = useAuth(); + const { authState } = useAuth(); const { locale } = useParams(); const loc = locale || DEFAULT_LOCALE; + const isReturningBrowser = hasReturningBrowser(); - if (isLoading) { - return ( -
- -
- ); + if (authState === "loading" || authState === "unavailable") { + return ; } - if (isAuthenticated) { + if (authState === "authenticated") { return ; } + if (isReturningBrowser) { + return ; + } + return ; } diff --git a/tday-web/src/pages/ProtectedRoute.tsx b/tday-web/src/pages/ProtectedRoute.tsx index 374daf62..28f8c1f3 100644 --- a/tday-web/src/pages/ProtectedRoute.tsx +++ b/tday-web/src/pages/ProtectedRoute.tsx @@ -1,19 +1,15 @@ import { Navigate, Outlet, useParams } from "react-router-dom"; import { useAuth } from "@/providers/AuthProvider"; -import { Loader2 } from "lucide-react"; import { DEFAULT_LOCALE } from "@/i18n"; +import AuthBootstrapScreen from "@/components/auth/AuthBootstrapScreen"; export default function ProtectedRoute() { - const { user, isLoading, isAuthenticated } = useAuth(); + const { user, authState, isAuthenticated } = useAuth(); const { locale } = useParams(); const loc = locale || DEFAULT_LOCALE; - if (isLoading) { - return ( -
- -
- ); + if (authState === "loading" || authState === "unavailable") { + return ; } if (!isAuthenticated) { diff --git a/tday-web/src/providers/AuthProvider.tsx b/tday-web/src/providers/AuthProvider.tsx index 36aa7db6..dd539fd2 100644 --- a/tday-web/src/providers/AuthProvider.tsx +++ b/tday-web/src/providers/AuthProvider.tsx @@ -4,11 +4,25 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { api, ApiError } from "@/lib/api-client"; +import { clearClientUserData } from "@/lib/security/clearClientUserData"; +import { + markReturningBrowser, + RETURNING_BROWSER_STORAGE_KEY, +} from "@/lib/security/returningBrowser"; + +const AUTH_SESSION_RETRY_DELAY_MS = 15_000; + +export type AuthSessionState = + | "loading" + | "authenticated" + | "unauthenticated" + | "unavailable"; export type AuthUser = { id: string; @@ -21,6 +35,7 @@ export type AuthUser = { type AuthContextValue = { user: AuthUser | null; + authState: AuthSessionState; isLoading: boolean; isAuthenticated: boolean; login: (email: string, credentialPayload: Record) => Promise<{ ok: boolean; code?: string; message?: string }>; @@ -32,24 +47,73 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [authState, setAuthState] = useState("loading"); const queryClient = useQueryClient(); + const authStateRef = useRef("loading"); + const userRef = useRef(null); + + const applySessionState = useCallback((nextAuthState: AuthSessionState, nextUser: AuthUser | null) => { + authStateRef.current = nextAuthState; + userRef.current = nextUser; + setAuthState(nextAuthState); + setUser(nextUser); + }, []); + + const setSessionAvailability = useCallback((nextAuthState: AuthSessionState) => { + authStateRef.current = nextAuthState; + setAuthState(nextAuthState); + }, []); const fetchSession = useCallback(async () => { try { const data = await api.GET({ url: "/api/auth/session" }); - setUser(data?.user ?? null); - } catch { - setUser(null); - } finally { - setIsLoading(false); + const nextUser = data?.user ?? null; + + if (nextUser) { + markReturningBrowser(); + applySessionState("authenticated", nextUser); + return; + } + + applySessionState("unauthenticated", null); + } catch (error) { + const apiError = error instanceof ApiError ? error : null; + + if (apiError?.status === 401) { + const hadAuthenticatedSession = + authStateRef.current === "authenticated" || userRef.current !== null; + + if (hadAuthenticatedSession) { + queryClient.clear(); + await clearClientUserData({ + preserveLocalStorageKeys: [RETURNING_BROWSER_STORAGE_KEY], + }); + } + + applySessionState("unauthenticated", null); + return; + } + + setSessionAvailability("unavailable"); } - }, []); + }, [applySessionState, queryClient, setSessionAvailability]); useEffect(() => { - fetchSession(); + void fetchSession(); }, [fetchSession]); + useEffect(() => { + if (authState !== "unavailable") return; + + const retryTimer = window.setTimeout(() => { + void fetchSession(); + }, AUTH_SESSION_RETRY_DELAY_MS); + + return () => { + window.clearTimeout(retryTimer); + }; + }, [authState, fetchSession]); + const login = useCallback( async (email: string, credentialPayload: Record) => { try { @@ -58,7 +122,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, ...credentialPayload }), }); + markReturningBrowser(); await fetchSession(); + + if (authStateRef.current === "unauthenticated") { + return { + ok: false, + message: "Unable to establish a session. Please try again.", + }; + } + return { ok: true }; } catch (e) { const err = e instanceof ApiError ? e : null; @@ -73,25 +146,34 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); const logout = useCallback(async () => { - queryClient.clear(); - await clearClientUserData(); + markReturningBrowser(); try { await api.POST({ url: "/api/auth/logout", body: "{}" }); - } catch {} - setUser(null); + } catch (error) { + const message = error instanceof ApiError + ? error.message + : "Unable to log out. Please try again."; + throw new Error(message); + } + queryClient.clear(); + await clearClientUserData({ + preserveLocalStorageKeys: [RETURNING_BROWSER_STORAGE_KEY], + }); + applySessionState("unauthenticated", null); window.location.replace(window.location.origin); - }, [queryClient]); + }, [applySessionState, queryClient]); const value = useMemo( () => ({ user, - isLoading, - isAuthenticated: user !== null, + authState, + isLoading: authState === "loading", + isAuthenticated: authState === "authenticated", login, logout, refreshSession: fetchSession, }), - [user, isLoading, login, logout, fetchSession], + [user, authState, login, logout, fetchSession], ); return {children}; @@ -102,50 +184,3 @@ export function useAuth(): AuthContextValue { if (!ctx) throw new Error("useAuth must be used within AuthProvider"); return ctx; } - -async function deleteIndexedDbDatabase(name: string): Promise { - await new Promise((resolve) => { - try { - const request = indexedDB.deleteDatabase(name); - request.onsuccess = () => resolve(); - request.onerror = () => resolve(); - request.onblocked = () => resolve(); - } catch { - resolve(); - } - }); -} - -async function clearClientUserData(): Promise { - if (typeof window === "undefined") return; - - try { - window.sessionStorage.clear(); - } catch {} - - try { - const keys = Object.keys(window.localStorage); - for (const key of keys) { - window.localStorage.removeItem(key); - } - } catch {} - - try { - if ("caches" in window) { - const cacheKeys = await window.caches.keys(); - await Promise.all(cacheKeys.map((cacheKey) => window.caches.delete(cacheKey))); - } - } catch {} - - try { - if (typeof indexedDB === "undefined") return; - if (typeof indexedDB.databases !== "function") return; - const databases = await indexedDB.databases(); - await Promise.all( - databases - .map((database) => database.name?.trim()) - .filter((name): name is string => Boolean(name)) - .map((name) => deleteIndexedDbDatabase(name)), - ); - } catch {} -} diff --git a/tday-web/tests/unit/AuthProvider.test.tsx b/tday-web/tests/unit/AuthProvider.test.tsx index ae32994e..d09f4ced 100644 --- a/tday-web/tests/unit/AuthProvider.test.tsx +++ b/tday-web/tests/unit/AuthProvider.test.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom import type { ReactNode } from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; import { AuthProvider, useAuth } from "@/providers/AuthProvider"; +import { RETURNING_BROWSER_STORAGE_KEY } from "@/lib/security/returningBrowser"; function createWrapper() { const queryClient = new QueryClient(); @@ -20,10 +21,21 @@ function createWrapper() { describe("AuthProvider", () => { afterEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + function mockResponse(status: number, body: unknown, contentType = "application/json") { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": contentType, + }, + }); + } + it("loads the session on mount and exposes the authenticated user", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response( @@ -61,7 +73,188 @@ describe("AuthProvider", () => { cache: "no-store", credentials: "same-origin", })); + expect(result.current.authState).toBe("authenticated"); expect(result.current.isAuthenticated).toBe(true); expect(result.current.user?.email).toBe("taylor@example.com"); + expect(window.localStorage.getItem(RETURNING_BROWSER_STORAGE_KEY)).toBe("1"); + }); + + it("treats a 401 session response as unauthenticated", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + mockResponse(401, { + message: "Not authenticated", + }), + ), + ); + + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authState).toBe("unauthenticated"); + }); + + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.user).toBeNull(); + }); + + it("treats transient session failures as unavailable without logging the user out", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + mockResponse(500, { + message: "Server unavailable", + }), + ), + ); + + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authState).toBe("unavailable"); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.user).toBeNull(); + }); + + it("preserves the returning-browser marker when a prior session is invalidated", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + mockResponse(200, { + user: { + id: "user-1", + name: "Taylor", + email: "taylor@example.com", + role: "USER", + approvalStatus: "APPROVED", + timeZone: "UTC", + }, + }), + ) + .mockResolvedValueOnce( + mockResponse(401, { + message: "Not authenticated", + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authState).toBe("authenticated"); + }); + + window.localStorage.setItem("menu-state", "open"); + window.sessionStorage.setItem("draft", "cached"); + + await act(async () => { + await result.current.refreshSession(); + }); + + await waitFor(() => { + expect(result.current.authState).toBe("unauthenticated"); + }); + + expect(window.localStorage.getItem(RETURNING_BROWSER_STORAGE_KEY)).toBe("1"); + expect(window.localStorage.getItem("menu-state")).toBeNull(); + expect(window.sessionStorage.getItem("draft")).toBeNull(); + }); + + it("preserves the returning-browser marker on logout while clearing other browser data", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + mockResponse(200, { + user: { + id: "user-1", + name: "Taylor", + email: "taylor@example.com", + role: "USER", + approvalStatus: "APPROVED", + timeZone: "UTC", + }, + }), + ) + .mockResolvedValueOnce( + mockResponse(200, { + message: "logged_out", + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authState).toBe("authenticated"); + }); + + window.localStorage.setItem("menu-state", "open"); + window.sessionStorage.setItem("draft", "cached"); + + await act(async () => { + await result.current.logout(); + }); + + expect(window.localStorage.getItem(RETURNING_BROWSER_STORAGE_KEY)).toBe("1"); + expect(window.localStorage.getItem("menu-state")).toBeNull(); + expect(window.sessionStorage.getItem("draft")).toBeNull(); + expect(result.current.authState).toBe("unauthenticated"); + }); + + it("does not clear auth state locally when the logout request fails", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + mockResponse(200, { + user: { + id: "user-1", + name: "Taylor", + email: "taylor@example.com", + role: "USER", + approvalStatus: "APPROVED", + timeZone: "UTC", + }, + }), + ) + .mockResolvedValueOnce( + mockResponse(500, { + message: "Logout failed", + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authState).toBe("authenticated"); + }); + + window.localStorage.setItem("menu-state", "open"); + window.sessionStorage.setItem("draft", "cached"); + + await expect(result.current.logout()).rejects.toThrow("Logout failed"); + + expect(result.current.authState).toBe("authenticated"); + expect(result.current.user?.email).toBe("taylor@example.com"); + expect(window.localStorage.getItem(RETURNING_BROWSER_STORAGE_KEY)).toBe("1"); + expect(window.localStorage.getItem("menu-state")).toBe("open"); + expect(window.sessionStorage.getItem("draft")).toBe("cached"); }); }); diff --git a/tday-web/tests/unit/publicRouteAuthGuard.test.tsx b/tday-web/tests/unit/publicRouteAuthGuard.test.tsx index 8833f931..07017970 100644 --- a/tday-web/tests/unit/publicRouteAuthGuard.test.tsx +++ b/tday-web/tests/unit/publicRouteAuthGuard.test.tsx @@ -5,7 +5,9 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import AuthLayout from "@/pages/AuthLayout"; import LandingPage from "@/pages/LandingPage"; +import ProtectedRoute from "@/pages/ProtectedRoute"; import { useAuth } from "@/providers/AuthProvider"; +import { RETURNING_BROWSER_STORAGE_KEY } from "@/lib/security/returningBrowser"; vi.mock("@/providers/AuthProvider", () => ({ useAuth: vi.fn(), @@ -17,12 +19,6 @@ vi.mock("@/components/landing/OnboardingLanding", () => ({ }, })); -vi.mock("@/components/auth/UnauthenticatedCacheGuard", () => ({ - default: function UnauthenticatedCacheGuard() { - return null; - }, -})); - vi.mock("@/components/ui/sonner", () => ({ SonnerToaster: function SonnerToaster() { return null; @@ -36,6 +32,7 @@ const useAuthMock = vi.mocked(useAuth); function createAuthState(overrides: Partial = {}): AuthState { return { user: null, + authState: "unauthenticated", isLoading: false, isAuthenticated: false, login: vi.fn(), @@ -51,6 +48,7 @@ function renderAuthLayout(initialEntry = "/en/login") { }> Login Screen
} /> + Register Screen
} /> Home Screen
} /> @@ -63,20 +61,35 @@ function renderLandingPage(initialEntry = "/en") { } /> + Login Screen
} /> Home Screen
} /> , ); } +function renderProtectedRoute(initialEntry = "/en/app/tday") { + return render( + + + }> + Home Screen} /> + + Login Screen} /> + + , + ); +} + describe("public auth route guards", () => { afterEach(() => { cleanup(); + window.localStorage.clear(); vi.clearAllMocks(); }); it("keeps auth pages in a loading state while the session is resolving", () => { - useAuthMock.mockReturnValue(createAuthState({ isLoading: true })); + useAuthMock.mockReturnValue(createAuthState({ authState: "loading", isLoading: true })); const { container } = renderAuthLayout(); @@ -87,6 +100,7 @@ describe("public auth route guards", () => { it("redirects approved authenticated users away from login", async () => { useAuthMock.mockReturnValue( createAuthState({ + authState: "authenticated", user: { id: "user-1", name: "Taylor", @@ -108,7 +122,7 @@ describe("public auth route guards", () => { }); it("keeps the landing page in a loading state while the session is resolving", () => { - useAuthMock.mockReturnValue(createAuthState({ isLoading: true })); + useAuthMock.mockReturnValue(createAuthState({ authState: "loading", isLoading: true })); const { container } = renderLandingPage(); @@ -119,6 +133,7 @@ describe("public auth route guards", () => { it("redirects authenticated users from landing to today", async () => { useAuthMock.mockReturnValue( createAuthState({ + authState: "authenticated", user: { id: "user-1", name: "Taylor", @@ -138,4 +153,43 @@ describe("public auth route guards", () => { }); expect(screen.queryByText("Landing Screen")).toBeNull(); }); + + it("keeps the landing page in a reconnecting state while auth is unavailable", () => { + useAuthMock.mockReturnValue(createAuthState({ authState: "unavailable" })); + + const { container } = renderLandingPage(); + + expect(screen.queryByText("Landing Screen")).toBeNull(); + expect(container.querySelector("svg.animate-spin")).not.toBeNull(); + }); + + it("shows onboarding for a first-time unauthenticated browser", () => { + useAuthMock.mockReturnValue(createAuthState()); + + renderLandingPage(); + + expect(screen.queryByText("Landing Screen")).not.toBeNull(); + expect(screen.queryByText("Login Screen")).toBeNull(); + }); + + it("redirects returning unauthenticated browsers to login from landing", async () => { + window.localStorage.setItem(RETURNING_BROWSER_STORAGE_KEY, "1"); + useAuthMock.mockReturnValue(createAuthState()); + + renderLandingPage(); + + await waitFor(() => { + expect(screen.queryByText("Login Screen")).not.toBeNull(); + }); + expect(screen.queryByText("Landing Screen")).toBeNull(); + }); + + it("keeps protected routes in a reconnecting state while auth is unavailable", () => { + useAuthMock.mockReturnValue(createAuthState({ authState: "unavailable" })); + + const { container } = renderProtectedRoute(); + + expect(screen.queryByText("Home Screen")).toBeNull(); + expect(container.querySelector("svg.animate-spin")).not.toBeNull(); + }); }); From 6b46009b48ef4c18c02eefbc7183d0817aaa765b Mon Sep 17 00:00:00 2001 From: Omar Date: Sun, 24 May 2026 01:35:22 -0400 Subject: [PATCH 02/24] chore: bump app version to 1.44.0 --- tday-web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tday-web/package.json b/tday-web/package.json index a11ad2e0..06d72e1a 100644 --- a/tday-web/package.json +++ b/tday-web/package.json @@ -1,6 +1,6 @@ { "name": "tday-web", - "version": "1.23.0", + "version": "1.44.0", "private": true, "type": "module", "scripts": { From 1ee5de70c98c497f2efa778971c4be8825f7f339 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Sun, 24 May 2026 01:35:54 -0400 Subject: [PATCH 03/24] feat: enhance responsiveness of OnboardingWizardOverlay - Replace `Box` with `BoxWithConstraints` to implement responsive width logic - Introduce layout constants for card width, padding, and breakpoints - Adjust card width dynamically based on screen size (wide vs. narrow layouts) - Apply `WIZARD_CARD_CONTENT_PADDING` and `WIZARD_WATERMARK_SIZE` constants - Ensure the onboarding card maintains consistent edge padding on smaller screens --- .../onboarding/OnboardingWizardOverlay.kt | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt index 67ff929f..8e73ff42 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt @@ -15,13 +15,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -359,7 +360,7 @@ fun OnboardingWizardOverlay( unfocusedPlaceholderColor = colorScheme.onSurface.copy(alpha = 0.4f), ) - Box( + BoxWithConstraints( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.25f)) @@ -370,11 +371,19 @@ fun OnboardingWizardOverlay( ), contentAlignment = Alignment.Center, ) { + val targetCardWidth = if (maxWidth >= WIZARD_WIDE_LAYOUT_BREAKPOINT) { + WIZARD_WIDE_CARD_WIDTH + } else { + WIZARD_CARD_MAX_WIDTH + } + val cardWidth = minOf( + targetCardWidth, + maxWidth - (WIZARD_SCREEN_EDGE_PADDING * 2), + ) + Card( modifier = Modifier - .fillMaxWidth() - .widthIn(max = 460.dp) - .padding(horizontal = 18.dp), + .width(cardWidth), shape = RoundedCornerShape(32.dp), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), @@ -396,7 +405,7 @@ fun OnboardingWizardOverlay( drawContent() } } - .padding(18.dp), + .padding(WIZARD_CARD_CONTENT_PADDING), ) { Icon( imageVector = if (step == WizardStep.SERVER) Icons.Rounded.Language else Icons.Rounded.Lock, @@ -404,10 +413,13 @@ fun OnboardingWizardOverlay( tint = lerp(colorScheme.surface, colorScheme.primary, 0.3f).copy(alpha = 0.25f), modifier = Modifier .align(Alignment.BottomEnd) - .size(130.dp), + .size(WIZARD_WATERMARK_SIZE), ) - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { Text( text = stringResource(R.string.onboarding_title), style = MaterialTheme.typography.headlineSmall, @@ -1020,3 +1032,9 @@ private fun WizardStepChip( private const val CREDENTIAL_PROMPT_SETTLE_DELAY_MS = 600L private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") +private val WIZARD_CARD_MAX_WIDTH = 440.dp +private val WIZARD_CARD_CONTENT_PADDING = 18.dp +private val WIZARD_SCREEN_EDGE_PADDING = 20.dp +private val WIZARD_WIDE_LAYOUT_BREAKPOINT = 600.dp +private val WIZARD_WIDE_CARD_WIDTH = 360.dp +private val WIZARD_WATERMARK_SIZE = 130.dp From e6b06b2ec4e643e563e706613cb356c07180b0a6 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Sun, 24 May 2026 02:25:33 -0400 Subject: [PATCH 04/24] Refine iOS resume reload --- .../Tday/Core/Network/TdayAPIService.swift | 7 +++ .../Tday/Feature/App/AppViewModel.swift | 61 +++++++++++++++++-- .../ConnectivityClassificationTests.swift | 13 ++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift b/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift index 2918995d..960d0e90 100644 --- a/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift +++ b/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift @@ -34,6 +34,13 @@ func isLikelyConnectivityIssue(_ error: Error) -> Bool { return false } +func isSessionAuthenticationIssue(_ error: Error) -> Bool { + guard let apiError = error as? APIError else { + return false + } + return apiError.statusCode == 401 +} + func isLikelyServerUnavailableStatusCode(_ statusCode: Int) -> Bool { statusCode == 408 || statusCode == 502 || diff --git a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift index c740001d..f05cc644 100644 --- a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift +++ b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift @@ -370,8 +370,12 @@ final class AppViewModel { replayPendingMutations: true, notifyOfflineFailure: false ) + let recoveredResult = await self.recoverSessionAndRetrySyncIfNeeded( + after: result, + connectionProbeTimeoutSeconds: nil + ) await MainActor.run { - self.applySyncResult(result) + self.applySyncResult(recoveredResult, suppressAuthenticationExpired: true) } await self.rescheduleReminders() } @@ -397,8 +401,12 @@ final class AppViewModel { replayPendingMutations: true, notifyOfflineFailure: false ) + let recoveredResult = await self.recoverSessionAndRetrySyncIfNeeded( + after: result, + connectionProbeTimeoutSeconds: nil + ) await MainActor.run { - self.applySyncResult(result) + self.applySyncResult(recoveredResult, suppressAuthenticationExpired: true) } await self.rescheduleReminders() } @@ -412,13 +420,18 @@ final class AppViewModel { } } - private func applySyncResult(_ result: Result, showOfflineNotice: Bool = false) { + private func applySyncResult( + _ result: Result, + showOfflineNotice: Bool = false, + suppressAuthenticationExpired: Bool = false + ) { switch result { case .success: isOffline = false refreshPendingMutationCount() case let .failure(error): - isOffline = isLikelyConnectivityIssue(error) + isOffline = isLikelyConnectivityIssue(error) || + (suppressAuthenticationExpired && isSessionAuthenticationIssue(error)) if isOffline && showOfflineNotice { offlineNoticeID += 1 } @@ -490,13 +503,49 @@ final class AppViewModel { notifyOfflineFailure: false, connectionProbeTimeoutSeconds: SyncAndRefreshUseCase.userRefreshConnectionTimeoutSeconds ) - applySyncResult(result, showOfflineNotice: showOfflineNotice) - if case .success = result { + let recoveredResult = await recoverSessionAndRetrySyncIfNeeded( + after: result, + connectionProbeTimeoutSeconds: SyncAndRefreshUseCase.userRefreshConnectionTimeoutSeconds + ) + applySyncResult( + recoveredResult, + showOfflineNotice: showOfflineNotice, + suppressAuthenticationExpired: true + ) + if case .success = recoveredResult { startRealtime() await rescheduleReminders() } } + private func recoverSessionAndRetrySyncIfNeeded( + after result: Result, + connectionProbeTimeoutSeconds: TimeInterval? + ) async -> Result { + guard case let .failure(error) = result, isSessionAuthenticationIssue(error) else { + return result + } + + guard let restoredSession = await container.authRepository.restoreSessionForBootstrap() else { + return result + } + + user = restoredSession.user + authenticated = true + isOffline = restoredSession.usedCachedSession + + guard !restoredSession.usedCachedSession else { + return .failure(APIError(message: "Unable to refresh session while offline", statusCode: nil)) + } + + return await container.syncAndRefresh( + force: true, + replayPendingMutations: true, + notifyOfflineFailure: false, + connectionProbeTimeoutSeconds: connectionProbeTimeoutSeconds + ) + } + private func isAdmin(_ user: SessionUser?) -> Bool { user?.role?.uppercased() == "ADMIN" } diff --git a/ios-swiftUI/Tests/TdayCoreTests/ConnectivityClassificationTests.swift b/ios-swiftUI/Tests/TdayCoreTests/ConnectivityClassificationTests.swift index c03eb2fd..bf3da869 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/ConnectivityClassificationTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/ConnectivityClassificationTests.swift @@ -65,6 +65,19 @@ final class ConnectivityClassificationTests: XCTestCase { ) } + func testUnauthorizedResponsesAreRecoverableSessionIssues() { + XCTAssertTrue( + isSessionAuthenticationIssue( + APIError(message: "Unauthorized", statusCode: 401) + ) + ) + XCTAssertFalse( + isLikelyConnectivityIssue( + APIError(message: "Unauthorized", statusCode: 401) + ) + ) + } + func testGenericServerErrorsUseServerMessage() { XCTAssertEqual( userFacingMessage(for: APIError(message: "Internal Server Error", statusCode: 500)), From a087254369949a5149cb5ab765b26d1ef3011e2f Mon Sep 17 00:00:00 2001 From: Omar Date: Sun, 24 May 2026 02:44:36 -0400 Subject: [PATCH 05/24] feat: implement session recovery and improve offline error handling - Add `isSessionAuthenticationIssue` to `ApiResponseUtils` to detect 401 errors - Implement `recoverSessionAndRetrySyncIfNeeded` in `AppViewModel` to automatically attempt session restoration when a sync fails due to authentication issues - Enhance `syncAndUpdateOfflineState` to handle suppressed authentication errors and prevent flickering offline notices - Update `AppViewModel` to treat specific authentication failures as offline states when configured, avoiding intrusive error snackbars during background syncs - Ensure `RealtimeClient` reconnects after successful session recovery if the user is authenticated - Add unit tests for `isSessionAuthenticationIssue` and session recovery logic in `AppViewModelTest` --- .../compose/core/data/ApiResponseUtils.kt | 11 + .../tday/compose/feature/app/AppViewModel.kt | 128 +++++++++-- .../compose/core/data/ApiResponseUtilsTest.kt | 11 + .../compose/feature/app/AppViewModelTest.kt | 205 ++++++++++++++++++ 4 files changed, 334 insertions(+), 21 deletions(-) create mode 100644 android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt index 096c6a03..b91bfbd2 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt @@ -76,6 +76,17 @@ internal fun isLikelyConnectivityIssue(error: Throwable): Boolean { return false } +internal fun isSessionAuthenticationIssue(error: Throwable): Boolean { + var current: Throwable? = error + while (current != null) { + if (current is ApiCallException && current.statusCode == 401) { + return true + } + current = current.cause?.takeIf { it !== current } + } + return false +} + internal fun isLikelyServerUnavailableStatus(statusCode: Int): Boolean { return statusCode == 408 || statusCode == 502 || diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt index c38d42ec..5d9af45a 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt @@ -9,6 +9,7 @@ import com.ohmz.tday.compose.core.data.auth.AuthRepository import com.ohmz.tday.compose.core.data.auth.SystemCredentialServicing import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue +import com.ohmz.tday.compose.core.data.isSessionAuthenticationIssue import com.ohmz.tday.compose.core.data.server.AppVersionManager import com.ohmz.tday.compose.core.data.server.ServerConfigRepository import com.ohmz.tday.compose.core.data.server.VersionCheckResult @@ -494,14 +495,21 @@ class AppViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isManualSyncing = true) } - val result = syncManager.syncCachedData( - force = true, - replayPendingMutations = true, - notifyOfflineFailure = false, + val result = recoverSessionAndRetrySyncIfNeeded( + after = syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + ), connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, ) val syncError = result.exceptionOrNull() - val isOffline = syncError != null && isLikelyConnectivityIssue(syncError) + val isOffline = syncError != null && + shouldTreatSyncFailureAsOffline( + error = syncError, + suppressAuthenticationExpired = true, + ) _uiState.update { it.copy( isManualSyncing = false, @@ -512,7 +520,15 @@ class AppViewModel @Inject constructor( }.getOrDefault(it.pendingMutationCount), ) } - syncError?.let(::classifyAndShowError) + if (syncError == null && !realtimeClient.isConnected && _uiState.value.authenticated) { + realtimeClient.connect() + } + syncError?.let { + classifyAndShowError( + error = it, + suppressAuthenticationExpired = true, + ) + } launch(Dispatchers.Default) { runCatching { reminderScheduler.rescheduleAll() } } } } @@ -526,6 +542,7 @@ class AppViewModel @Inject constructor( replayPending = true, markOfflineOnConnectivityFailure = false, connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + suppressAuthenticationExpired = true, ) val syncError = result.exceptionOrNull() if (syncError == null || !isLikelyConnectivityIssue(syncError)) return@launch @@ -537,6 +554,7 @@ class AppViewModel @Inject constructor( replayPending = true, showOfflineNotice = true, connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + suppressAuthenticationExpired = true, ) } } @@ -594,7 +612,10 @@ class AppViewModel @Inject constructor( val delayMs = if (hasPending) PENDING_RESYNC_INTERVAL_MS else RESYNC_INTERVAL_MS delay(delayMs) - syncAndUpdateOfflineState(replayPending = hasPending) + syncAndUpdateOfflineState( + replayPending = hasPending, + suppressAuthenticationExpired = true, + ) } } } @@ -604,25 +625,35 @@ class AppViewModel @Inject constructor( showOfflineNotice: Boolean = false, connectionProbeTimeoutMs: Long? = null, markOfflineOnConnectivityFailure: Boolean = true, + suppressAuthenticationExpired: Boolean = false, ): Result { - val result = syncManager.syncCachedData( - force = true, - replayPendingMutations = replayPending, - notifyOfflineFailure = false, + val result = recoverSessionAndRetrySyncIfNeeded( + after = syncManager.syncCachedData( + force = true, + replayPendingMutations = replayPending, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = connectionProbeTimeoutMs, + ), connectionProbeTimeoutMs = connectionProbeTimeoutMs, ) val syncError = result.exceptionOrNull() _uiState.update { - val isOffline = syncError != null && isLikelyConnectivityIssue(syncError) - val shouldMarkOffline = isOffline && markOfflineOnConnectivityFailure + val isOffline = syncError != null && + shouldTreatSyncFailureAsOffline( + error = syncError, + suppressAuthenticationExpired = suppressAuthenticationExpired, + ) + val shouldDeferOfflineState = syncError != null && + isLikelyConnectivityIssue(syncError) && + !markOfflineOnConnectivityFailure it.copy( isOffline = when { syncError == null -> false - shouldMarkOffline -> true - isOffline -> it.isOffline + shouldDeferOfflineState -> it.isOffline + isOffline -> true else -> false }, - offlineNoticeId = if (shouldMarkOffline && showOfflineNotice) { + offlineNoticeId = if (isOffline && showOfflineNotice && !shouldDeferOfflineState) { it.offlineNoticeId + 1L } else { it.offlineNoticeId @@ -633,7 +664,10 @@ class AppViewModel @Inject constructor( ) } if (syncError != null) { - classifyAndShowError(syncError) + classifyAndShowError( + error = syncError, + suppressAuthenticationExpired = suppressAuthenticationExpired, + ) } else if (!realtimeClient.isConnected && _uiState.value.authenticated) { realtimeClient.connect() } @@ -641,8 +675,52 @@ class AppViewModel @Inject constructor( return result } - private fun classifyAndShowError(error: Throwable) { - if (isLikelyConnectivityIssue(error)) return + private suspend fun recoverSessionAndRetrySyncIfNeeded( + after: Result, + connectionProbeTimeoutMs: Long?, + ): Result { + val error = after.exceptionOrNull() ?: return after + if (!isSessionAuthenticationIssue(error)) return after + + val restoredSession = authRepository.restoreSessionForBootstrap() ?: return after + _uiState.update { + it.copy( + authenticated = true, + requiresServerSetup = false, + requiresLogin = false, + serverUrl = serverConfigRepository.getServerUrl(), + user = restoredSession.user, + error = null, + pendingApprovalMessage = null, + isOffline = restoredSession.usedCachedSession, + ) + } + + if (restoredSession.usedCachedSession) { + return after + } + + return syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = connectionProbeTimeoutMs, + ) + } + + private fun shouldTreatSyncFailureAsOffline( + error: Throwable, + suppressAuthenticationExpired: Boolean, + ): Boolean { + return isLikelyConnectivityIssue(error) || + (suppressAuthenticationExpired && isSessionAuthenticationIssue(error)) + } + + private fun classifyAndShowError( + error: Throwable, + suppressAuthenticationExpired: Boolean = false, + ) { + if (shouldTreatSyncFailureAsOffline(error, suppressAuthenticationExpired)) return snackbarManager.showError(error.userFacingMessage()) { if (error !is ApiCallException || error.statusCode != 401) syncNow() } @@ -657,14 +735,20 @@ class AppViewModel @Inject constructor( when (event) { is RealtimeEvent.Connected -> { if (_uiState.value.isOffline) { - syncAndUpdateOfflineState(replayPending = true) + syncAndUpdateOfflineState( + replayPending = true, + suppressAuthenticationExpired = true, + ) } } is RealtimeEvent.TodoChanged, is RealtimeEvent.ListChanged, is RealtimeEvent.CompletedChanged, -> { - syncAndUpdateOfflineState(replayPending = false) + syncAndUpdateOfflineState( + replayPending = false, + suppressAuthenticationExpired = true, + ) } is RealtimeEvent.Disconnected -> { delay(REALTIME_RECONNECT_DELAY_MS) @@ -686,6 +770,7 @@ class AppViewModel @Inject constructor( syncAndUpdateOfflineState( replayPending = true, connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + suppressAuthenticationExpired = true, ) if (!realtimeClient.isConnected) { realtimeClient.connect() @@ -703,6 +788,7 @@ class AppViewModel @Inject constructor( replayPending = true, showOfflineNotice = true, connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + suppressAuthenticationExpired = true, ) } } diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/ApiResponseUtilsTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/ApiResponseUtilsTest.kt index 17fb79b3..e538c30a 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/ApiResponseUtilsTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/ApiResponseUtilsTest.kt @@ -34,4 +34,15 @@ class ApiResponseUtilsTest { ), ) } + + @Test + fun `unauthorized responses are treated as session auth issues not connectivity`() { + val error = ApiCallException( + statusCode = 401, + message = "Unauthorized", + ) + + assertTrue(isSessionAuthenticationIssue(error)) + assertFalse(isLikelyConnectivityIssue(error)) + } } diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt new file mode 100644 index 00000000..dd38252d --- /dev/null +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt @@ -0,0 +1,205 @@ +package com.ohmz.tday.compose.feature.app + +import app.cash.turbine.test +import com.ohmz.tday.compose.core.data.ApiCallException +import com.ohmz.tday.compose.core.data.OfflineSyncState +import com.ohmz.tday.compose.core.data.ThemePreferenceStore +import com.ohmz.tday.compose.core.data.auth.AuthRepository +import com.ohmz.tday.compose.core.data.auth.SystemCredentialServicing +import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager +import com.ohmz.tday.compose.core.data.server.AppVersionManager +import com.ohmz.tday.compose.core.data.server.ServerConfigRepository +import com.ohmz.tday.compose.core.data.settings.SettingsRepository +import com.ohmz.tday.compose.core.data.sync.SyncManager +import com.ohmz.tday.compose.core.model.SessionUser +import com.ohmz.tday.compose.core.network.ConnectivityObserver +import com.ohmz.tday.compose.core.network.RealtimeClient +import com.ohmz.tday.compose.core.network.RealtimeEvent +import com.ohmz.tday.compose.core.notification.ReminderOption +import com.ohmz.tday.compose.core.notification.ReminderPreferenceStore +import com.ohmz.tday.compose.core.notification.TaskReminderScheduler +import com.ohmz.tday.compose.core.ui.SnackbarManager +import com.ohmz.tday.compose.feature.auth.MainDispatcherRule +import com.ohmz.tday.compose.ui.theme.AppThemeMode +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AppViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val authRepository = mockk() + private val serverConfigRepository = mockk() + private val syncManager = mockk() + private val settingsRepository = mockk() + private val cacheManager = mockk() + private val themePreferenceStore = mockk() + private val reminderScheduler = mockk() + private val reminderPreferenceStore = mockk() + private val realtimeClient = mockk() + private val connectivityObserver = mockk() + private val appVersionManager = mockk() + private val systemCredentialService = mockk() + private val snackbarManager = SnackbarManager() + + private val versionState = MutableStateFlow( + AppVersionManager.VersionState(isLoadingReleases = false), + ) + private val realtimeEvents = MutableSharedFlow() + private val offlineSyncFailures = MutableSharedFlow() + private val offlineSyncSuccesses = MutableSharedFlow() + private val restoredUser = SessionUser( + id = "user-1", + name = "Taylor", + email = "user@example.com", + role = "USER", + ) + + @Before + fun setUp() { + every { themePreferenceStore.getThemeMode() } returns AppThemeMode.SYSTEM + every { reminderPreferenceStore.getDefaultReminder() } returns ReminderOption.DEFAULT + every { appVersionManager.state } returns versionState + coEvery { appVersionManager.refreshServerCompatibility() } returns Unit + every { serverConfigRepository.hasServerConfigured() } returns true + every { serverConfigRepository.getServerUrl() } returns "https://tday.example.com" + every { cacheManager.loadOfflineState() } returns OfflineSyncState() + every { syncManager.offlineSyncFailures } returns offlineSyncFailures + every { syncManager.offlineSyncSuccesses } returns offlineSyncSuccesses + every { syncManager.hasPendingMutations() } returns false + every { realtimeClient.events } returns realtimeEvents + every { realtimeClient.isConnected } returns false + every { realtimeClient.connect() } returns Unit + every { realtimeClient.disconnect() } returns Unit + every { connectivityObserver.connectivityChanges } returns emptyFlow() + every { settingsRepository.isAiSummaryEnabledSnapshot() } returns true + coEvery { authRepository.syncTimezone() } returns Unit + every { reminderScheduler.rescheduleAll() } returns Unit + every { reminderScheduler.cancelAll() } returns Unit + } + + @Test + fun `foreground reconnect retries sync after restoring session`() = runTest { + val restoredSession = AuthRepository.RestoredSession( + user = restoredUser, + usedCachedSession = false, + ) + coEvery { authRepository.restoreSessionForBootstrap() } returnsMany listOf( + restoredSession, + restoredSession, + ) + coEvery { + syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = null, + ) + } returns Result.success(Unit) + coEvery { + syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + ) + } returnsMany listOf( + Result.failure(ApiCallException(statusCode = 401, message = "Unauthorized")), + Result.success(Unit), + ) + + val viewModel = makeViewModel() + runCurrent() + + snackbarManager.events.test { + viewModel.reconnectAfterForeground() + runCurrent() + runCurrent() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + assertTrue(viewModel.uiState.value.authenticated) + assertFalse(viewModel.uiState.value.isOffline) + assertEquals(restoredUser, viewModel.uiState.value.user) + + viewModel.logout() + runCurrent() + } + + @Test + fun `foreground reconnect marks app offline when session cannot be restored`() = runTest { + val restoredSession = AuthRepository.RestoredSession( + user = restoredUser, + usedCachedSession = false, + ) + coEvery { authRepository.restoreSessionForBootstrap() } returnsMany listOf( + restoredSession, + null, + ) + coEvery { + syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = null, + ) + } returns Result.success(Unit) + coEvery { + syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + ) + } returns Result.failure(ApiCallException(statusCode = 401, message = "Unauthorized")) + + val viewModel = makeViewModel() + runCurrent() + + snackbarManager.events.test { + viewModel.reconnectAfterForeground() + runCurrent() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + assertTrue(viewModel.uiState.value.authenticated) + assertTrue(viewModel.uiState.value.isOffline) + assertEquals(restoredUser, viewModel.uiState.value.user) + + viewModel.logout() + runCurrent() + } + + private fun makeViewModel(): AppViewModel = + AppViewModel( + authRepository = authRepository, + serverConfigRepository = serverConfigRepository, + syncManager = syncManager, + settingsRepository = settingsRepository, + cacheManager = cacheManager, + themePreferenceStore = themePreferenceStore, + reminderScheduler = reminderScheduler, + reminderPreferenceStore = reminderPreferenceStore, + snackbarManager = snackbarManager, + realtimeClient = realtimeClient, + connectivityObserver = connectivityObserver, + appVersionManager = appVersionManager, + systemCredentialService = systemCredentialService, + ) +} From 10185476b3c50090ab8278a743ada2eb96f2d1b9 Mon Sep 17 00:00:00 2001 From: Omar Date: Sun, 24 May 2026 18:39:23 -0400 Subject: [PATCH 06/24] feat: add today's tasks list and task actions to Home screen Display today's todos directly on the Home screen for both iOS and Android, enabling quick management without navigating to a sub-screen. - Add `todayTodos` state and fetch logic to `HomeViewModel` on both platforms - Implement `HomeTodayCard` to display the current date and today's task count - Add `HomeTodayTaskRow` with support for completion, deletion, and editing - Implement horizontal swipe gestures for task actions (Edit/Delete) in the today's task list - Reorganize the Home category grid to accommodate the new today's card layout - Integrate task editing flows via `CreateTaskSheet` (iOS) and `CreateTaskBottomSheet` (Android) directly from the Home screen - Update UI metrics, including tile heights, corner radii, and padding for a more compact dashboard view --- .../java/com/ohmz/tday/compose/TdayApp.kt | 11 + .../tday/compose/feature/home/HomeScreen.kt | 455 ++++++++++++++++-- .../compose/feature/home/HomeViewModel.kt | 58 ++- .../Tday/Feature/Home/HomeScreen.swift | 346 +++++++++++-- .../Tday/Feature/Home/HomeViewModel.swift | 35 ++ 5 files changed, 830 insertions(+), 75 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt index 45cb247c..3cd82846 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt @@ -344,6 +344,14 @@ fun TdayApp( iconKey = iconKey, ) }, + onCompleteTask = { todo -> homeViewModel.completeTodo(todo) }, + onDeleteTask = { todo -> homeViewModel.deleteTodo(todo) }, + onUpdateTask = { todo, payload -> + homeViewModel.updateTask( + todo, + payload + ) + }, ) } else { HomeScreen( @@ -362,6 +370,9 @@ fun TdayApp( onCreateTask = { _ -> }, onParseTaskTitleNlp = { _, _ -> null }, onCreateList = { _, _, _ -> }, + onCompleteTask = {}, + onDeleteTask = {}, + onUpdateTask = { _, _ -> }, ) } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index 33e05781..a73a1fcb 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -18,9 +19,12 @@ import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource @@ -45,8 +49,11 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState @@ -74,12 +81,14 @@ import androidx.compose.material.icons.rounded.CardGiftcard import androidx.compose.material.icons.rounded.ChangeHistory import androidx.compose.material.icons.rounded.ChatBubbleOutline import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.ChildCare import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.Computer import androidx.compose.material.icons.rounded.ContentCut +import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Description import androidx.compose.material.icons.rounded.DesktopWindows import androidx.compose.material.icons.rounded.DirectionsBoat @@ -110,6 +119,7 @@ import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.Payments import androidx.compose.material.icons.rounded.Pets import androidx.compose.material.icons.rounded.PriorityHigh +import androidx.compose.material.icons.rounded.RadioButtonUnchecked import androidx.compose.material.icons.rounded.Restaurant import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.School @@ -142,6 +152,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -188,8 +199,11 @@ import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat import com.ohmz.tday.compose.R import com.ohmz.tday.compose.core.model.CreateTaskPayload +import com.ohmz.tday.compose.core.model.ListSummary +import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter +import com.ohmz.tday.compose.core.ui.TaskSwipeActionButton import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet import com.ohmz.tday.compose.ui.component.TdayPullToRefreshBox import com.ohmz.tday.compose.ui.theme.TdayDimens @@ -197,6 +211,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.time.Instant import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale @OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) @@ -217,6 +233,9 @@ fun HomeScreen( onCreateTask: (payload: CreateTaskPayload) -> Unit, onParseTaskTitleNlp: suspend (title: String, referenceDueEpochMs: Long) -> TodoTitleNlpResponse?, onCreateList: (name: String, color: String?, iconKey: String?) -> Unit, + onCompleteTask: (todo: com.ohmz.tday.compose.core.model.TodoItem) -> Unit, + onDeleteTask: (todo: com.ohmz.tday.compose.core.model.TodoItem) -> Unit, + onUpdateTask: (todo: com.ohmz.tday.compose.core.model.TodoItem, payload: CreateTaskPayload) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val focusManager = LocalFocusManager.current @@ -239,6 +258,10 @@ fun HomeScreen( var searchResultsBounds by remember { mutableStateOf(null) } var rootInRoot by remember { mutableStateOf(Offset.Zero) } var showCreateTask by rememberSaveable { mutableStateOf(false) } + var editTargetTodoId by rememberSaveable { mutableStateOf(null) } + val editTargetTodo = remember(editTargetTodoId, uiState.todayTodos) { + editTargetTodoId?.let { id -> uiState.todayTodos.firstOrNull { it.id == id } } + } var listName by rememberSaveable { mutableStateOf("") } var listColor by rememberSaveable { mutableStateOf(DEFAULT_LIST_COLOR) } var listIconKey by rememberSaveable { mutableStateOf(DEFAULT_LIST_ICON_KEY) } @@ -488,19 +511,38 @@ fun HomeScreen( }, ) } + item { + HomeTodayCard( + count = uiState.summary.todayCount, + onClick = { + closeSearch() + onOpenToday() + }, + ) + } + + items( + uiState.todayTodos, + key = { it.id }, + ) { todo -> + HomeTodayTaskRow( + todo = todo, + lists = uiState.summary.lists, + onComplete = { onCompleteTask(todo) }, + onDelete = { onDeleteTask(todo) }, + onEdit = { editTargetTodoId = todo.id }, + ) + } + item { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { CategoryGrid( - todayCount = uiState.summary.todayCount, overdueCount = overdueCount, scheduledCount = uiState.summary.scheduledCount, allCount = uiState.summary.allCount, priorityCount = uiState.summary.priorityCount, completedCount = uiState.summary.completedCount, - onOpenToday = { - closeSearch() - onOpenToday() - }, + calendarCount = uiState.summary.scheduledCount, onOpenOverdue = { closeSearch() onOpenOverdue() @@ -521,21 +563,11 @@ fun HomeScreen( closeSearch() onOpenCompleted() }, - ) - - CategoryCard( - modifier = Modifier.fillMaxWidth(), - color = calendarTileColor(colorScheme), - icon = Icons.Rounded.CalendarToday, - backgroundGrid = true, - title = "Calendar", - count = uiState.summary.scheduledCount, - onClick = { + onOpenCalendar = { closeSearch() onOpenCalendar() }, ) - } } @@ -708,6 +740,20 @@ fun HomeScreen( ) } + editTargetTodo?.let { todo -> + CreateTaskBottomSheet( + lists = uiState.summary.lists, + editingTask = todo, + onParseTaskTitleNlp = onParseTaskTitleNlp, + onDismiss = { editTargetTodoId = null }, + onCreateTask = { _ -> }, + onUpdateTask = { target, payload -> + onUpdateTask(target, payload) + editTargetTodoId = null + }, + ) + } + if (showCreateList) { CreateListBottomSheet( listName = listName, @@ -1476,35 +1522,367 @@ private fun PressableIconButton( } } +private val HOME_TODAY_DUE_FORMATTER: DateTimeFormatter = + DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()) +private val HOME_TODAY_DATE_FORMATTER: DateTimeFormatter = + DateTimeFormatter.ofPattern("EEE, MMM d").withZone(ZoneId.systemDefault()) + +@Composable +private fun HomeTodayCard( + count: Int, + onClick: () -> Unit, +) { + val view = LocalView.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val animatedScale by animateFloatAsState( + targetValue = if (isPressed) 0.97f else 1f, + label = "todayCardScale" + ) + val animatedOffsetY by animateDpAsState( + targetValue = if (isPressed) 2.dp else 0.dp, + label = "todayCardOffsetY" + ) + val animatedElevation by animateDpAsState( + targetValue = if (isPressed) 2.dp else 9.dp, + label = "todayCardElevation" + ) + val dateLabel = remember { HOME_TODAY_DATE_FORMATTER.format(Instant.now()) } + val color = Color(0xFF6EA8E1) + + Card( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {} + .offset(y = animatedOffsetY) + .graphicsLayer { scaleX = animatedScale; scaleY = animatedScale }, + onClick = { + performGentleHaptic(view) + onClick() + }, + interactionSource = interactionSource, + colors = CardDefaults.cardColors(containerColor = color), + elevation = CardDefaults.cardElevation( + defaultElevation = animatedElevation, + pressedElevation = animatedElevation + ), + shape = RoundedCornerShape(26.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .drawWithCache { + val glow = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.22f), + Color.White.copy(alpha = 0.08f), + Color.Transparent + ), + center = Offset(size.width * 0.22f, size.height * 0.2f), + radius = size.width * 0.72f, + ) + val pearl = Brush.radialGradient( + colors = listOf(Color.White.copy(alpha = 0.10f), Color.Transparent), + center = Offset(size.width * 0.9f, size.height * 0.75f), + radius = size.width * 0.55f, + ) + onDrawWithContent { drawRect(glow); drawRect(pearl); drawContent() } + }, + ) { + Box(modifier = Modifier.matchParentSize()) { + Icon( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = 22.dp, y = 12.dp) + .size(124.dp), + imageVector = Icons.Rounded.WbSunny, + contentDescription = null, + tint = lerp(color, Color.White, 0.28f).copy(alpha = 0.4f), + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + Icons.Rounded.WbSunny, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(26.dp) + ) + Text( + text = dateLabel, + style = MaterialTheme.typography.titleLarge, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } + Text( + text = count.toString(), + style = MaterialTheme.typography.headlineMedium, + color = Color.White, + fontWeight = FontWeight.Black, + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HomeTodayTaskRow( + todo: TodoItem, + lists: List, + onComplete: () -> Unit, + onDelete: () -> Unit, + onEdit: () -> Unit, +) { + val density = LocalDensity.current + val colorScheme = MaterialTheme.colorScheme + val view = LocalView.current + val coroutineScope = rememberCoroutineScope() + val actionRevealPx = with(density) { 176.dp.toPx() } + val swipeHintOffsetPx = with(density) { 42.dp.toPx() }.coerceAtMost(actionRevealPx * 0.24f) + val maxElasticDragPx = actionRevealPx * 1.14f + var targetOffsetX by remember(todo.id) { mutableFloatStateOf(0f) } + var swipeHinting by remember(todo.id) { mutableStateOf(false) } + var localCompleted by remember(todo.id) { mutableStateOf(false) } + var pendingCompletion by remember(todo.id) { mutableStateOf(false) } + val animatedOffsetX by animateFloatAsState( + targetValue = targetOffsetX, + animationSpec = spring(stiffness = androidx.compose.animation.core.Spring.StiffnessLow), + label = "homeTodaySwipeOffset", + ) + val actionRevealProgress = (-animatedOffsetX / actionRevealPx).coerceIn(0f, 1f) + val dueText = HOME_TODAY_DUE_FORMATTER.format(todo.due) + val rowShape = RoundedCornerShape(16.dp) + val foregroundColor = colorScheme.background + val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } + val listIndicatorColor = homeTodayListAccentColor(listMeta?.color) + val isOverdue = !todo.completed && todo.due.isBefore(Instant.now()) + val subtitleColor = + if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.8f + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(58.dp), + ) { + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 2.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TaskSwipeActionButton( + icon = Icons.Rounded.Edit, + contentDescription = "Edit", + label = "Edit", + tint = Color.White, + background = Color(0xFF4C7DDE), + revealProgress = actionRevealProgress, + revealDelay = 0.62f, + onClick = { + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + onEdit() + targetOffsetX = 0f + }, + ) + TaskSwipeActionButton( + icon = Icons.Rounded.Delete, + contentDescription = "Delete", + label = "Delete", + tint = Color.White, + background = Color(0xFFFF453A), + revealProgress = actionRevealProgress, + revealDelay = 0.04f, + onClick = { + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + onDelete() + targetOffsetX = 0f + }, + ) + } + + Card( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { translationX = animatedOffsetX } + .draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + targetOffsetX = (targetOffsetX + delta).coerceIn(-maxElasticDragPx, 0f) + }, + onDragStopped = { velocity -> + targetOffsetX = + if (velocity < -1450f || targetOffsetX < -(actionRevealPx * 0.32f)) { + -actionRevealPx + } else { + 0f + } + }, + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (targetOffsetX != 0f) { + targetOffsetX = 0f + } else if (!swipeHinting && !pendingCompletion) { + swipeHinting = true + coroutineScope.launch { + targetOffsetX = -swipeHintOffsetPx + delay(150) + targetOffsetX = 0f + delay(360) + swipeHinting = false + } + } + }, + shape = rowShape, + colors = CardDefaults.cardColors(containerColor = foregroundColor), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 4.dp, vertical = 2.dp) + .semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .sizeIn(minWidth = 48.dp, minHeight = 48.dp) + .wrapContentSize(Alignment.Center) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = true, radius = 24.dp), + enabled = !pendingCompletion, + ) { + if (!pendingCompletion) { + localCompleted = true + pendingCompletion = true + coroutineScope.launch { + delay(180) + onComplete() + } + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (localCompleted) Icons.Rounded.CheckCircle else Icons.Rounded.RadioButtonUnchecked, + contentDescription = if (localCompleted) "Completed" else "Mark complete", + tint = if (localCompleted) Color(0xFF6FBF86) else colorScheme.onSurfaceVariant.copy( + alpha = 0.78f + ), + modifier = Modifier.size(24.dp), + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = todo.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "Due $dueText", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = subtitleColor, + ) + } + + if (listMeta != null) { + Icon( + imageVector = homeTodayListIcon(listMeta.iconKey), + contentDescription = null, + tint = listIndicatorColor, + modifier = Modifier + .size(18.dp) + .padding(end = 0.dp), + ) + Spacer(Modifier.width(12.dp)) + } + } + } + } +} + +private fun homeTodayListAccentColor(colorKey: String?): Color { + return when (colorKey) { + "RED" -> Color(0xFFE65E52) + "ORANGE" -> Color(0xFFF29F38) + "YELLOW" -> Color(0xFFF3D04A) + "LIME" -> Color(0xFF8ACF56) + "BLUE" -> Color(0xFF5C9FE7) + "PURPLE" -> Color(0xFF8D6CE2) + "PINK" -> Color(0xFFDF6DAA) + "TEAL" -> Color(0xFF4EB5B0) + "CORAL" -> Color(0xFFE3876D) + "GOLD" -> Color(0xFFCFAB57) + "DEEP_BLUE" -> Color(0xFF4B73D6) + "ROSE" -> Color(0xFFD9799A) + else -> Color(0xFF5C9FE7) + } +} + +private fun homeTodayListIcon(iconKey: String?): androidx.compose.ui.graphics.vector.ImageVector { + return when (iconKey) { + "WORK" -> Icons.Rounded.Work + "SCHOOL" -> Icons.Rounded.School + "HOME" -> Icons.Rounded.Home + "SHOPPING" -> Icons.Rounded.ShoppingCart + "HEALTH" -> Icons.Rounded.Favorite + "FITNESS" -> Icons.Rounded.FitnessCenter + "TRAVEL" -> Icons.Rounded.Flight + "FOOD" -> Icons.Rounded.Restaurant + "FINANCE" -> Icons.Rounded.Payments + "MUSIC" -> Icons.Rounded.MusicNote + else -> Icons.AutoMirrored.Rounded.List + } +} + @Composable private fun CategoryGrid( - todayCount: Int, overdueCount: Int, scheduledCount: Int, allCount: Int, priorityCount: Int, completedCount: Int, - onOpenToday: () -> Unit, + calendarCount: Int, onOpenOverdue: () -> Unit, onOpenScheduled: () -> Unit, onOpenAll: () -> Unit, onOpenPriority: () -> Unit, onOpenCompleted: () -> Unit, + onOpenCalendar: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val completedColor = completedTileColor(colorScheme) Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - CategoryCard( - modifier = Modifier.weight(1f), - color = Color(0xFF6EA8E1), - icon = Icons.Rounded.WbSunny, - backgroundWatermark = Icons.Rounded.WbSunny, - title = stringResource(R.string.home_category_today), - count = todayCount, - onClick = onOpenToday, - ) CategoryCard( modifier = Modifier.weight(1f), color = Color(0xFFDA7661), @@ -1514,8 +1892,6 @@ private fun CategoryGrid( count = overdueCount, onClick = onOpenOverdue, ) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), color = Color(0xFFDDB37D), @@ -1525,6 +1901,8 @@ private fun CategoryGrid( count = scheduledCount, onClick = onOpenScheduled, ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), color = Color(0xFFD48A8C), @@ -1534,8 +1912,6 @@ private fun CategoryGrid( count = priorityCount, onClick = onOpenPriority, ) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), color = Color(0xFF4E4E50), @@ -1545,6 +1921,8 @@ private fun CategoryGrid( count = allCount, onClick = onOpenAll, ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), color = completedColor, @@ -1554,6 +1932,15 @@ private fun CategoryGrid( count = completedCount, onClick = onOpenCompleted, ) + CategoryCard( + modifier = Modifier.weight(1f), + color = calendarTileColor(colorScheme), + icon = Icons.Rounded.CalendarToday, + backgroundGrid = true, + title = "Calendar", + count = calendarCount, + onClick = onOpenCalendar, + ) } } } @@ -1745,8 +2132,8 @@ private fun CategoryCard( Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -1757,7 +2144,7 @@ private fun CategoryCard( icon, contentDescription = null, tint = Color.White, - modifier = Modifier.size(28.dp), + modifier = Modifier.size(26.dp), ) if (count != null) { Text( diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt index 06ddd02f..4085ebac 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt @@ -35,6 +35,7 @@ data class HomeUiState( lists = emptyList(), ), val searchableTodos: List = emptyList(), + val todayTodos: List = emptyList(), val errorMessage: String? = null, ) @@ -54,6 +55,7 @@ class HomeViewModel @Inject constructor( isLoading = false, summary = todoRepository.fetchDashboardSummarySnapshot(), searchableTodos = todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL), + todayTodos = todoRepository.fetchTodosSnapshot(mode = TodoListMode.TODAY), errorMessage = null, ) }.getOrElse { HomeUiState() }, @@ -75,13 +77,18 @@ class HomeViewModel @Inject constructor( fun refreshFromCache() { runCatching { - todoRepository.fetchDashboardSummarySnapshot() to todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL) - }.onSuccess { (summary, todos) -> + Triple( + todoRepository.fetchDashboardSummarySnapshot(), + todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL), + todoRepository.fetchTodosSnapshot(mode = TodoListMode.TODAY), + ) + }.onSuccess { (summary, todos, todayTodos) -> _uiState.update { current -> current.copy( isLoading = activeLoadingRefreshes > 0, summary = if (current.summary == summary) current.summary else summary, searchableTodos = if (current.searchableTodos == todos) current.searchableTodos else todos, + todayTodos = if (current.todayTodos == todayTodos) current.todayTodos else todayTodos, errorMessage = null, ) } @@ -122,14 +129,19 @@ class HomeViewModel @Inject constructor( ) .onFailure { /* fall back to local cache */ } } - todoRepository.fetchDashboardSummary() to todoRepository.fetchTodos(mode = TodoListMode.ALL) - }.onSuccess { (summary, todos) -> + Triple( + todoRepository.fetchDashboardSummary(), + todoRepository.fetchTodos(mode = TodoListMode.ALL), + todoRepository.fetchTodos(mode = TodoListMode.TODAY), + ) + }.onSuccess { (summary, todos, todayTodos) -> _uiState.update { current -> val keepLoading = activeLoadingRefreshes > if (showLoading) 1 else 0 current.copy( isLoading = keepLoading, summary = if (current.summary == summary) current.summary else summary, searchableTodos = if (current.searchableTodos == todos) current.searchableTodos else todos, + todayTodos = if (current.todayTodos == todayTodos) current.todayTodos else todayTodos, errorMessage = null, ) } @@ -207,6 +219,44 @@ class HomeViewModel @Inject constructor( ) } + fun completeTodo(todo: TodoItem) { + _uiState.update { current -> + current.copy(todayTodos = current.todayTodos.filterNot { it.id == todo.id }) + } + viewModelScope.launch { + runCatching { todoRepository.completeTodo(todo) } + .onSuccess { refreshInternal(forceSync = false, showLoading = false) } + .onFailure { error -> + _uiState.update { it.copy(errorMessage = error.userFacingMessage("Could not complete task.")) } + refreshFromCache() + } + } + } + + fun deleteTodo(todo: TodoItem) { + _uiState.update { current -> + current.copy(todayTodos = current.todayTodos.filterNot { it.id == todo.id }) + } + viewModelScope.launch { + runCatching { todoRepository.deleteTodo(todo) } + .onSuccess { refreshInternal(forceSync = false, showLoading = false) } + .onFailure { error -> + _uiState.update { it.copy(errorMessage = error.userFacingMessage("Could not delete task.")) } + refreshFromCache() + } + } + } + + fun updateTask(todo: TodoItem, payload: CreateTaskPayload) { + viewModelScope.launch { + runCatching { todoRepository.updateTodo(todo, payload) } + .onSuccess { refreshInternal(forceSync = false, showLoading = false) } + .onFailure { error -> + _uiState.update { it.copy(errorMessage = error.userFacingMessage("Could not update task.")) } + } + } + } + val lists: List get() = _uiState.value.summary.lists } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 3b16be4d..cd8caef6 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -8,7 +8,9 @@ private enum HomeMetrics { static let compactButtonSize: CGFloat = 30 static let titleAnchorDistance: CGFloat = screenPadding + topBarButtonSize static let tileCornerRadius: CGFloat = 26 - static let tileHeight: CGFloat = 102 + static let tileHeight: CGFloat = 94 + static let tileInnerPadding: CGFloat = 12 + static let todayCardHeight: CGFloat = 70 static let listRowHeight: CGFloat = 70 static let tileWatermarkSize: CGFloat = 116 static let tileWatermarkTrailingInset: CGFloat = 22 @@ -89,6 +91,8 @@ struct HomeScreen: View { @State private var openingSearchResultID: String? @State private var showingCreateTask = false @State private var showingCreateList = false + @State private var editingTodo: TodoItem? + @State private var completingTodoIDs: Set = [] init(container: AppContainer, onNavigate: @escaping (AppRoute) -> Void) { self.onNavigate = onNavigate @@ -170,17 +174,27 @@ struct HomeScreen: View { isDisabled: searchExpanded ) + HomeTodayCard( + count: viewModel.summary.todayCount, + action: { + closeSearch() + onNavigate(.todayTodos) + } + ) + + if !viewModel.todayTodos.isEmpty { + ForEach(viewModel.todayTodos) { todo in + homeTodayTaskRow(todo) + } + } + HomeCategoryBoard( - todayCount: viewModel.summary.todayCount, overdueCount: overdueCount, scheduledCount: viewModel.summary.scheduledCount, allCount: viewModel.summary.allCount, priorityCount: viewModel.summary.priorityCount, completedCount: viewModel.summary.completedCount, - onOpenToday: { - closeSearch() - onNavigate(.todayTodos) - }, + calendarCount: viewModel.summary.scheduledCount, onOpenOverdue: { closeSearch() onNavigate(.overdueTodos) @@ -316,6 +330,21 @@ struct HomeScreen: View { } } } + .sheet(item: $editingTodo) { todo in + CreateTaskSheet( + lists: viewModel.lists, + titleText: "Edit task", + submitText: "Save", + initialPayload: CreateTaskPayload(title: todo.title, description: todo.description, priority: todo.priority, due: todo.due, rrule: todo.rrule, listId: todo.listId), + onParseTaskTitleNlp: { title, dueRef in + await viewModel.parseTaskTitleNlp(text: title, referenceDueEpochMs: dueRef) + }, + onDismiss: { editingTodo = nil }, + onSubmit: { payload in + await viewModel.updateTask(todo, payload: payload) + } + ) + } .navigationBackButtonBehavior() } @@ -328,6 +357,32 @@ struct HomeScreen: View { searchResultsFrame = .zero } + private func completeTodoWithoutReflow(_ todo: TodoItem) { + guard !completingTodoIDs.contains(todo.id) else { return } + withAnimation(.easeInOut(duration: 0.16)) { + _ = completingTodoIDs.insert(todo.id) + } + Task { + try? await Task.sleep(nanoseconds: 190_000_000) + await viewModel.complete(todo) + await MainActor.run { + _ = completingTodoIDs.remove(todo.id) + } + } + } + + @ViewBuilder + private func homeTodayTaskRow(_ todo: TodoItem) -> some View { + HomeTodayTaskRow( + todo: todo, + lists: viewModel.lists, + isCompleting: completingTodoIDs.contains(todo.id), + onComplete: { completeTodoWithoutReflow(todo) }, + onDelete: { Task { await viewModel.delete(todo) } }, + onEdit: { editingTodo = todo } + ) + } + private func openSearchResult(_ todo: TodoItem) { guard openingSearchResultID == nil else { return @@ -506,14 +561,241 @@ private struct HomeIconCircleButton: View { } } +private struct HomeTodayTaskRow: View { + let todo: TodoItem + let lists: [ListSummary] + let isCompleting: Bool + let onComplete: () -> Void + let onDelete: () -> Void + let onEdit: () -> Void + + @Environment(\.tdayColors) private var colors + + @State private var offsetX: CGFloat = 0 + @State private var isHinting = false + + private let revealWidth: CGFloat = 152 + + private var listMeta: ListSummary? { + todo.listId.flatMap { id in lists.first { $0.id == id } } + } + + private var isOverdue: Bool { !todo.completed && todo.due < Date() } + private var dueText: String { todo.due.formatted(date: .omitted, time: .shortened) } + private var subtitleColor: Color { isOverdue ? colors.error : colors.onSurfaceVariant.opacity(0.8) } + private var revealProgress: CGFloat { min(1, max(0, -offsetX / revealWidth)) } + + var body: some View { + ZStack(alignment: .trailing) { + HStack(spacing: 0) { + Spacer() + Button { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } + onEdit() + } label: { + VStack(spacing: 2) { + Image(systemName: "square.and.pencil") + .font(.system(size: 16, weight: .semibold)) + Text("Edit") + .font(.tdayRounded(size: 11, weight: .semibold)) + } + .foregroundStyle(.white) + .frame(width: 64) + .frame(maxHeight: .infinity) + .background(TaskSwipeActionTint.edit) + } + .opacity(Double(min(1, max(0, (revealProgress - 0.3) / 0.7)))) + + Button { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } + onDelete() + } label: { + VStack(spacing: 2) { + Image(systemName: "trash") + .font(.system(size: 16, weight: .semibold)) + Text("Delete") + .font(.tdayRounded(size: 11, weight: .semibold)) + } + .foregroundStyle(.white) + .frame(width: 64) + .frame(maxHeight: .infinity) + .background(TaskSwipeActionTint.delete) + } + .opacity(Double(min(1, max(0, (revealProgress - 0.02) / 0.5)))) + } + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .frame(maxWidth: .infinity) + + rowContent + .offset(x: offsetX) + .gesture( + DragGesture(minimumDistance: 6) + .onChanged { value in + guard abs(value.translation.width) > abs(value.translation.height) else { return } + let proposed = value.translation.width + if proposed < 0 { + offsetX = max(-revealWidth * 1.12, proposed) + } else { + offsetX = min(0, offsetX + proposed * 0.15) + } + } + .onEnded { value in + let velocity = value.predictedEndTranslation.width - value.translation.width + let shouldOpen = offsetX < -(revealWidth * 0.32) || velocity < -200 + withAnimation(.spring(response: 0.34, dampingFraction: 0.78)) { + offsetX = shouldOpen ? -revealWidth : 0 + } + } + ) + .onTapGesture { + if offsetX != 0 { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } + } else if !isHinting && !isCompleting { + isHinting = true + Task { @MainActor in + withAnimation(.spring(response: 0.26, dampingFraction: 0.78)) { offsetX = -28 } + try? await Task.sleep(nanoseconds: 150_000_000) + withAnimation(.spring(response: 0.38, dampingFraction: 0.68)) { offsetX = 0 } + try? await Task.sleep(nanoseconds: 340_000_000) + isHinting = false + } + } + } + } + .opacity(isCompleting ? 0 : 1) + .scaleEffect(isCompleting ? 0.985 : 1, anchor: .center) + .animation(.easeInOut(duration: 0.16), value: isCompleting) + .allowsHitTesting(!isCompleting) + } + + private var rowContent: some View { + HStack(alignment: .center, spacing: 12) { + Button(action: onComplete) { + Image(systemName: todo.completed ? "checkmark.circle.fill" : "circle") + .font(.system(size: 24, weight: .regular)) + .foregroundStyle(todo.completed ? Color.green : colors.onSurfaceVariant.opacity(0.78)) + .frame(width: 38, height: 38) + } + .buttonStyle(TdayPressButtonStyle(shadowColor: .black, pressedShadowOpacity: 0, normalShadowOpacity: 0)) + + VStack(alignment: .leading, spacing: 3) { + Text(todo.title) + .font(.tdayRounded(size: 18, weight: .bold)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + + Text("Due \(dueText)") + .font(.tdayRounded(size: 13, weight: .semibold)) + .foregroundStyle(subtitleColor) + } + + Spacer(minLength: 0) + + if let listMeta { + Image(systemName: homeTodayListSymbolName(for: listMeta.iconKey)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(todoListAccentColor(for: listMeta.color)) + .padding(.trailing, 8) + } + } + .padding(.vertical, 10) + .padding(.horizontal, 4) + .background(Color(uiColor: .systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .contentShape(Rectangle()) + } +} + +private func homeTodayListSymbolName(for key: String?) -> String { + switch key { + case "sun": return "sun.max.fill" + case "calendar": return "calendar" + case "schedule": return "clock" + case "flag": return "flag.fill" + case "check": return "checkmark" + case "smile": return "face.smiling" + case "star": return "star.fill" + case "heart": return "heart.fill" + case "book": return "book.fill" + case "music": return "music.note" + case "camera": return "camera.fill" + case "cart": return "cart.fill" + case "home": return "house.fill" + case "briefcase": return "briefcase.fill" + case "dumbbell": return "dumbbell.fill" + case "leaf": return "leaf.fill" + case "car": return "car.fill" + case "airplane": return "airplane" + case "person": return "person.fill" + case "globe": return "globe" + default: return "list.bullet" + } +} + +private struct HomeTodayCard: View { + let count: Int + let action: () -> Void + + private var dateLabel: String { + Date.now.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day()) + } + + var body: some View { + let color = Color(hex: 0x6EA8E1) + let shape = RoundedRectangle(cornerRadius: HomeMetrics.tileCornerRadius, style: .continuous) + + Button(action: action) { + ZStack { + shape.fill(color) + shape.fill( + RadialGradient( + colors: [Color.white.opacity(0.22), Color.white.opacity(0.08), .clear], + center: UnitPoint(x: 0.22, y: 0.2), + startRadius: 0, + endRadius: 200 + ) + ) + + Image(systemName: "sun.max.fill") + .font(.system(size: HomeMetrics.tileWatermarkSize, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.15)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .offset(x: 28, y: 22) + .clipped() + + HStack { + HStack(spacing: 10) { + Image(systemName: "sun.max.fill") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.white) + Text(dateLabel) + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(.white) + } + Spacer() + Text("\(count)") + .font(.tdayRounded(size: 34, weight: .black)) + .foregroundStyle(.white) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + .frame(maxWidth: .infinity) + .frame(height: HomeMetrics.todayCardHeight) + .clipShape(shape) + .contentShape(shape) + } + .buttonStyle(HomeTileButtonStyle()) + } +} + private struct HomeCategoryBoard: View { - let todayCount: Int let overdueCount: Int let scheduledCount: Int let allCount: Int let priorityCount: Int let completedCount: Int - let onOpenToday: () -> Void + let calendarCount: Int let onOpenOverdue: () -> Void let onOpenScheduled: () -> Void let onOpenAll: () -> Void @@ -524,15 +806,6 @@ private struct HomeCategoryBoard: View { var body: some View { VStack(spacing: HomeMetrics.tileGap) { HStack(spacing: HomeMetrics.tileGap) { - HomeCategoryTile( - color: Color(hex: 0x6EA8E1), - icon: "sun.max.fill", - watermark: "sun.max.fill", - title: "Today", - count: todayCount, - action: onOpenToday - ) - HomeCategoryTile( color: Color(hex: 0xDA7661), icon: "exclamationmark.circle", @@ -541,9 +814,7 @@ private struct HomeCategoryBoard: View { count: overdueCount, action: onOpenOverdue ) - } - HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( color: Color(hex: 0xDDB37D), icon: "clock", @@ -552,7 +823,9 @@ private struct HomeCategoryBoard: View { count: scheduledCount, action: onOpenScheduled ) + } + HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( color: Color(hex: 0xD48A8C), icon: "flag.fill", @@ -561,9 +834,7 @@ private struct HomeCategoryBoard: View { count: priorityCount, action: onOpenPriority ) - } - HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( color: Color(hex: 0x4E4E50), icon: "tray.fill", @@ -572,7 +843,9 @@ private struct HomeCategoryBoard: View { count: allCount, action: onOpenAll ) + } + HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( color: Color(hex: 0xA8C8B2), icon: "checkmark", @@ -581,18 +854,17 @@ private struct HomeCategoryBoard: View { count: completedCount, action: onOpenCompleted ) - } - HomeCategoryTile( - color: Color(hex: 0xC3B4DF), - icon: "calendar", - watermark: nil, - title: "Calendar", - count: scheduledCount, - backgroundGrid: true, - action: onOpenCalendar - ) - .frame(maxWidth: .infinity) + HomeCategoryTile( + color: Color(hex: 0xC3B4DF), + icon: "calendar", + watermark: nil, + title: "Calendar", + count: calendarCount, + backgroundGrid: true, + action: onOpenCalendar + ) + } } } } @@ -656,22 +928,22 @@ private struct HomeCategoryTile: View { .allowsHitTesting(false) } - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { HStack(alignment: .center) { Image(systemName: icon) - .font(.system(size: 24, weight: .bold)) + .font(.system(size: 22, weight: .bold)) .foregroundStyle(.white) Spacer() Text("\(count)") - .font(.tdayRounded(size: 28, weight: .black)) + .font(.tdayRounded(size: 26, weight: .black)) .foregroundStyle(.white) } Text(title) - .font(.tdayRounded(size: 22, weight: .bold)) + .font(.tdayRounded(size: 20, weight: .bold)) .foregroundStyle(.white) } - .padding(16) + .padding(HomeMetrics.tileInnerPadding) } .frame(maxWidth: .infinity, alignment: .topLeading) .frame(height: HomeMetrics.tileHeight) diff --git a/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift b/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift index e8fa74e4..0f074c89 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift @@ -9,8 +9,11 @@ final class HomeViewModel { var isLoading = true var summary = DashboardSummary(todayCount: 0, scheduledCount: 0, allCount: 0, priorityCount: 0, completedCount: 0, lists: []) var searchableTodos: [TodoItem] = [] + var todayTodos: [TodoItem] = [] var errorMessage: String? + var lists: [ListSummary] { summary.lists } + @ObservationIgnored nonisolated(unsafe) private var observationTask: Task? @ObservationIgnored private var activeLoadingRefreshes = 0 @@ -47,6 +50,7 @@ final class HomeViewModel { func refreshFromCache() { summary = container.todoRepository.fetchDashboardSummarySnapshot() searchableTodos = container.todoRepository.fetchTodosSnapshot(mode: .all) + todayTodos = container.todoRepository.fetchTodosSnapshot(mode: .today) isLoading = activeLoadingRefreshes > 0 errorMessage = nil } @@ -69,6 +73,37 @@ final class HomeViewModel { } } + func complete(_ todo: TodoItem) async { + todayTodos.removeAll { $0.id == todo.id } + do { + try await container.completeTodo(todo) + refreshFromCache() + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not complete task.") + refreshFromCache() + } + } + + func delete(_ todo: TodoItem) async { + todayTodos.removeAll { $0.id == todo.id } + do { + try await container.todoRepository.deleteTodo(todo) + refreshFromCache() + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not delete task.") + refreshFromCache() + } + } + + func updateTask(_ todo: TodoItem, payload: CreateTaskPayload) async { + do { + try await container.todoRepository.updateTodo(todo, payload: payload) + refreshFromCache() + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not update task.") + } + } + func parseTaskTitleNlp(text: String, referenceDueEpochMs: Int64) async -> TodoTitleNlpResponse? { await container.todoRepository.parseTodoTitleNlp(text: text, referenceDueEpochMs: referenceDueEpochMs) } From cfd91148104b776ec423d9c2f7d227729c1a8309 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Sun, 24 May 2026 19:03:17 -0400 Subject: [PATCH 07/24] refactor: unified swipe actions and improved task completion UI across platforms Standardize the custom trailing swipe action behavior across iOS screens and implement a more robust task completion animation for the home screen on both Android and iOS. - **iOS Refinement**: - Introduce `todoTrailingSwipeActions` view modifier in `SwipeActions.swift` to replace native `swipeActions`, providing consistent custom pill-style buttons (Edit/Delete) with reveal animations. - Adopt the new swipe modifier in `TodoListScreen`, `CalendarScreen`, and `CompletedScreen`. - Enhance `HomeTodayTaskRow` with a multi-phase completion state (`active`, `checked`, `fading`), adding a strikethrough animation and a delayed fade-out. - Consolidate common swipe button UI into `HomeTodaySwipeActionButton`. - **Android/Compose Enhancement**: - Update `HomeTodayTaskRow` to include a canvas-drawn strikethrough animation on the task title when completed. - Implement a phased completion flow with alpha fading and a 500ms delay before triggering the `onComplete` callback. - Improve swipe action button visuals with localized strings and standardized colors. - Wrap the "Today" task list in a single `item` block containing a `Column` to optimize rendering within the `HomeScreen` LazyColumn. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../tday/compose/feature/home/HomeScreen.kt | 355 ++++++++++-------- .../Feature/Calendar/CalendarScreen.swift | 24 +- .../Feature/Completed/CompletedScreen.swift | 19 +- .../Tday/Feature/Home/HomeScreen.swift | 289 +++++++++----- .../Tday/Feature/Todos/TodoListScreen.swift | 42 +-- .../Tday/UI/Component/SwipeActions.swift | 170 +++++++++ 6 files changed, 593 insertions(+), 306 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index a73a1fcb..a76b8c30 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt @@ -53,7 +53,6 @@ import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState @@ -163,6 +162,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -188,6 +188,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -521,17 +522,20 @@ fun HomeScreen( ) } - items( - uiState.todayTodos, - key = { it.id }, - ) { todo -> - HomeTodayTaskRow( - todo = todo, - lists = uiState.summary.lists, - onComplete = { onCompleteTask(todo) }, - onDelete = { onDeleteTask(todo) }, - onEdit = { editTargetTodoId = todo.id }, - ) + if (uiState.todayTodos.isNotEmpty()) { + item { + Column(modifier = Modifier.fillMaxWidth()) { + uiState.todayTodos.forEach { todo -> + HomeTodayTaskRow( + todo = todo, + lists = uiState.summary.lists, + onComplete = { onCompleteTask(todo) }, + onDelete = { onDeleteTask(todo) }, + onEdit = { editTargetTodoId = todo.id }, + ) + } + } + } } item { @@ -1655,15 +1659,26 @@ private fun HomeTodayTaskRow( var swipeHinting by remember(todo.id) { mutableStateOf(false) } var localCompleted by remember(todo.id) { mutableStateOf(false) } var pendingCompletion by remember(todo.id) { mutableStateOf(false) } + var completionFading by remember(todo.id) { mutableStateOf(false) } + var titleLayoutResult by remember(todo.id) { mutableStateOf(null) } val animatedOffsetX by animateFloatAsState( targetValue = targetOffsetX, animationSpec = spring(stiffness = androidx.compose.animation.core.Spring.StiffnessLow), label = "homeTodaySwipeOffset", ) + val completionAlpha by animateFloatAsState( + targetValue = if (completionFading) 0f else 1f, + animationSpec = tween(durationMillis = 220), + label = "homeTodayCompletionAlpha", + ) + val titleStrikeProgress by animateFloatAsState( + targetValue = if (localCompleted) 1f else 0f, + animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), + label = "homeTodayTitleStrikeProgress", + ) val actionRevealProgress = (-animatedOffsetX / actionRevealPx).coerceIn(0f, 1f) val dueText = HOME_TODAY_DUE_FORMATTER.format(todo.due) val rowShape = RoundedCornerShape(16.dp) - val foregroundColor = colorScheme.background val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val listIndicatorColor = homeTodayListAccentColor(listMeta?.color) val isOverdue = !todo.completed && todo.due.isBefore(Instant.now()) @@ -1671,158 +1686,204 @@ private fun HomeTodayTaskRow( if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant.copy( alpha = 0.8f ) + val subtitleText = if (isOverdue) { + stringResource(R.string.todos_due_overdue_text, dueText) + } else { + stringResource(R.string.todos_due_text, dueText) + } - Box( + Column( modifier = Modifier .fillMaxWidth() - .height(58.dp), + .graphicsLayer { alpha = completionAlpha }, ) { - Row( + Box( modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 2.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, + .fillMaxWidth() + .height(58.dp), ) { - TaskSwipeActionButton( - icon = Icons.Rounded.Edit, - contentDescription = "Edit", - label = "Edit", - tint = Color.White, - background = Color(0xFF4C7DDE), - revealProgress = actionRevealProgress, - revealDelay = 0.62f, - onClick = { - ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) - onEdit() - targetOffsetX = 0f - }, - ) - TaskSwipeActionButton( - icon = Icons.Rounded.Delete, - contentDescription = "Delete", - label = "Delete", - tint = Color.White, - background = Color(0xFFFF453A), - revealProgress = actionRevealProgress, - revealDelay = 0.04f, - onClick = { - ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) - onDelete() - targetOffsetX = 0f - }, - ) - } - - Card( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { translationX = animatedOffsetX } - .draggable( - orientation = Orientation.Horizontal, - state = rememberDraggableState { delta -> - targetOffsetX = (targetOffsetX + delta).coerceIn(-maxElasticDragPx, 0f) - }, - onDragStopped = { velocity -> - targetOffsetX = - if (velocity < -1450f || targetOffsetX < -(actionRevealPx * 0.32f)) { - -actionRevealPx - } else { - 0f - } + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 2.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TaskSwipeActionButton( + icon = Icons.Rounded.Edit, + contentDescription = stringResource(R.string.action_edit_task), + label = stringResource(R.string.action_edit), + tint = Color.White, + background = Color(0xFF4C7DDE), + revealProgress = actionRevealProgress, + revealDelay = 0.62f, + onClick = { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK + ) + onEdit() + targetOffsetX = 0f }, ) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - if (targetOffsetX != 0f) { + TaskSwipeActionButton( + icon = Icons.Rounded.Delete, + contentDescription = stringResource(R.string.action_delete_task), + label = stringResource(R.string.action_delete), + tint = Color.White, + background = Color(0xFFFF453A), + revealProgress = actionRevealProgress, + revealDelay = 0.04f, + onClick = { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK + ) + onDelete() targetOffsetX = 0f - } else if (!swipeHinting && !pendingCompletion) { - swipeHinting = true - coroutineScope.launch { - targetOffsetX = -swipeHintOffsetPx - delay(150) - targetOffsetX = 0f - delay(360) - swipeHinting = false - } - } - }, - shape = rowShape, - colors = CardDefaults.cardColors(containerColor = foregroundColor), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Row( + }, + ) + } + + Card( modifier = Modifier .fillMaxSize() - .padding(horizontal = 4.dp, vertical = 2.dp) - .semantics(mergeDescendants = true) {}, - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .sizeIn(minWidth = 48.dp, minHeight = 48.dp) - .wrapContentSize(Alignment.Center) - .clip(CircleShape) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(bounded = true, radius = 24.dp), - enabled = !pendingCompletion, - ) { - if (!pendingCompletion) { - localCompleted = true - pendingCompletion = true - coroutineScope.launch { - delay(180) - onComplete() + .graphicsLayer { translationX = animatedOffsetX } + .draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + targetOffsetX = (targetOffsetX + delta).coerceIn(-maxElasticDragPx, 0f) + }, + onDragStopped = { velocity -> + targetOffsetX = + if (velocity < -1450f || targetOffsetX < -(actionRevealPx * 0.32f)) { + -actionRevealPx + } else { + 0f } - } }, - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = if (localCompleted) Icons.Rounded.CheckCircle else Icons.Rounded.RadioButtonUnchecked, - contentDescription = if (localCompleted) "Completed" else "Mark complete", - tint = if (localCompleted) Color(0xFF6FBF86) else colorScheme.onSurfaceVariant.copy( - alpha = 0.78f - ), - modifier = Modifier.size(24.dp), ) - } - - Column( + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (targetOffsetX != 0f) { + targetOffsetX = 0f + } else if (!swipeHinting && !pendingCompletion) { + swipeHinting = true + coroutineScope.launch { + targetOffsetX = -swipeHintOffsetPx + delay(150) + targetOffsetX = 0f + delay(360) + swipeHinting = false + } + } + }, + shape = rowShape, + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Row( modifier = Modifier - .weight(1f) - .padding(start = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), + .fillMaxSize() + .padding(horizontal = 4.dp, vertical = 2.dp) + .semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = todo.title, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = "Due $dueText", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - color = subtitleColor, - ) - } + Box( + modifier = Modifier + .sizeIn(minWidth = 48.dp, minHeight = 48.dp) + .wrapContentSize(Alignment.Center) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = true, radius = 24.dp), + enabled = !pendingCompletion, + ) { + if (!pendingCompletion) { + targetOffsetX = 0f + localCompleted = true + pendingCompletion = true + coroutineScope.launch { + delay(500) + completionFading = true + delay(220) + onComplete() + } + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (localCompleted) Icons.Rounded.CheckCircle else Icons.Rounded.RadioButtonUnchecked, + contentDescription = if (localCompleted) { + stringResource(R.string.label_completed) + } else { + stringResource(R.string.label_mark_complete) + }, + tint = if (localCompleted) Color(0xFF6FBF86) else colorScheme.onSurfaceVariant.copy( + alpha = 0.78f + ), + modifier = Modifier.size(24.dp), + ) + } - if (listMeta != null) { - Icon( - imageVector = homeTodayListIcon(listMeta.iconKey), - contentDescription = null, - tint = listIndicatorColor, + Column( modifier = Modifier - .size(18.dp) - .padding(end = 0.dp), - ) - Spacer(Modifier.width(12.dp)) + .weight(1f) + .padding(start = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = todo.title, + modifier = Modifier.drawWithContent { + drawContent() + if (titleStrikeProgress > 0f) { + val lineEnd = ( + titleLayoutResult + ?.takeIf { it.lineCount > 0 } + ?.getLineRight(0) ?: size.width + ).coerceIn(0f, size.width) + val lineY = size.height * 0.56f + drawLine( + color = colorScheme.onSurface.copy(alpha = 0.65f), + start = Offset(0f, lineY), + end = Offset(lineEnd * titleStrikeProgress, lineY), + strokeWidth = TdayDimens.BorderWidthThick.toPx(), + ) + } + }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = if (localCompleted) { + colorScheme.onSurface.copy(alpha = 0.78f) + } else { + colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + onTextLayout = { titleLayoutResult = it }, + ) + Text( + text = subtitleText, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = subtitleColor, + ) + } + + if (listMeta != null) { + Icon( + imageVector = homeTodayListIcon(listMeta.iconKey), + contentDescription = null, + tint = listIndicatorColor, + modifier = Modifier + .size(18.dp) + .padding(end = 0.dp), + ) + Spacer(Modifier.width(12.dp)) + } } } } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 70073b9f..5e133621 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -168,7 +168,14 @@ struct CalendarScreen: View { .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .swipeRevealHintOnTap() + .todoTrailingSwipeActions( + onEdit: { + editingTodo = todo + }, + onDelete: { + Task { await viewModel.delete(todo) } + } + ) .swipeActions(edge: .leading, allowsFullSwipe: true) { Button { Task { await viewModel.complete(todo) } @@ -177,21 +184,6 @@ struct CalendarScreen: View { } .tint(.green) } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - Task { await viewModel.delete(todo) } - } label: { - Label("Delete", systemImage: "trash") - } - .tint(TaskSwipeActionTint.delete) - - Button { - editingTodo = todo - } label: { - Label("Edit", systemImage: "square.and.pencil") - } - .tint(TaskSwipeActionTint.edit) - } TimelineRowDivider() } } diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index d1fe78bf..84b5fa4c 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -327,22 +327,13 @@ private struct CompletedTimelineRow: View { .animation(.easeInOut(duration: 0.22), value: isFading) .transition(.opacity.combined(with: .scale(scale: 0.985))) .allowsHitTesting(!isRestoring) - .swipeRevealHintOnTap(enabled: !isRestoring) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { + .todoTrailingSwipeActions( + enabled: !isRestoring, + onEdit: onEdit, + onDelete: { Task { await onDelete() } - } label: { - Label("Delete", systemImage: "trash") - } - .tint(TaskSwipeActionTint.delete) - - Button { - onEdit() - } label: { - Label("Edit", systemImage: "square.and.pencil") } - .tint(TaskSwipeActionTint.edit) - } + ) } private func startRestore() { diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index cd8caef6..6079e53b 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -92,7 +92,6 @@ struct HomeScreen: View { @State private var showingCreateTask = false @State private var showingCreateList = false @State private var editingTodo: TodoItem? - @State private var completingTodoIDs: Set = [] init(container: AppContainer, onNavigate: @escaping (AppRoute) -> Void) { self.onNavigate = onNavigate @@ -183,8 +182,10 @@ struct HomeScreen: View { ) if !viewModel.todayTodos.isEmpty { - ForEach(viewModel.todayTodos) { todo in - homeTodayTaskRow(todo) + VStack(spacing: 0) { + ForEach(viewModel.todayTodos) { todo in + homeTodayTaskRow(todo) + } } } @@ -357,27 +358,12 @@ struct HomeScreen: View { searchResultsFrame = .zero } - private func completeTodoWithoutReflow(_ todo: TodoItem) { - guard !completingTodoIDs.contains(todo.id) else { return } - withAnimation(.easeInOut(duration: 0.16)) { - _ = completingTodoIDs.insert(todo.id) - } - Task { - try? await Task.sleep(nanoseconds: 190_000_000) - await viewModel.complete(todo) - await MainActor.run { - _ = completingTodoIDs.remove(todo.id) - } - } - } - @ViewBuilder private func homeTodayTaskRow(_ todo: TodoItem) -> some View { HomeTodayTaskRow( todo: todo, lists: viewModel.lists, - isCompleting: completingTodoIDs.contains(todo.id), - onComplete: { completeTodoWithoutReflow(todo) }, + onComplete: { await viewModel.complete(todo) }, onDelete: { Task { await viewModel.delete(todo) } }, onEdit: { editingTodo = todo } ) @@ -561,11 +547,16 @@ private struct HomeIconCircleButton: View { } } +private enum HomeTodayTaskCompletionPhase { + case active + case checked + case fading +} + private struct HomeTodayTaskRow: View { let todo: TodoItem let lists: [ListSummary] - let isCompleting: Bool - let onComplete: () -> Void + let onComplete: () async -> Void let onDelete: () -> Void let onEdit: () -> Void @@ -573,6 +564,7 @@ private struct HomeTodayTaskRow: View { @State private var offsetX: CGFloat = 0 @State private var isHinting = false + @State private var completionPhase = HomeTodayTaskCompletionPhase.active private let revealWidth: CGFloat = 152 @@ -582,109 +574,108 @@ private struct HomeTodayTaskRow: View { private var isOverdue: Bool { !todo.completed && todo.due < Date() } private var dueText: String { todo.due.formatted(date: .omitted, time: .shortened) } + private var subtitleText: String { isOverdue ? "Overdue, \(dueText)" : "Due \(dueText)" } private var subtitleColor: Color { isOverdue ? colors.error : colors.onSurfaceVariant.opacity(0.8) } private var revealProgress: CGFloat { min(1, max(0, -offsetX / revealWidth)) } + private var isCompleting: Bool { completionPhase != .active } + private var isFading: Bool { completionPhase == .fading } + private var titleColor: Color { + isCompleting ? colors.onSurface.opacity(0.78) : colors.onSurface + } var body: some View { - ZStack(alignment: .trailing) { - HStack(spacing: 0) { - Spacer() - Button { - withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } - onEdit() - } label: { - VStack(spacing: 2) { - Image(systemName: "square.and.pencil") - .font(.system(size: 16, weight: .semibold)) - Text("Edit") - .font(.tdayRounded(size: 11, weight: .semibold)) + VStack(spacing: 0) { + ZStack(alignment: .trailing) { + HStack(spacing: 16) { + Spacer() + HomeTodaySwipeActionButton( + title: "Edit", + systemImage: "square.and.pencil", + tint: TaskSwipeActionTint.edit, + revealProgress: revealProgress, + revealDelay: 0.62 + ) { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } + onEdit() } - .foregroundStyle(.white) - .frame(width: 64) - .frame(maxHeight: .infinity) - .background(TaskSwipeActionTint.edit) - } - .opacity(Double(min(1, max(0, (revealProgress - 0.3) / 0.7)))) - Button { - withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } - onDelete() - } label: { - VStack(spacing: 2) { - Image(systemName: "trash") - .font(.system(size: 16, weight: .semibold)) - Text("Delete") - .font(.tdayRounded(size: 11, weight: .semibold)) + HomeTodaySwipeActionButton( + title: "Delete", + systemImage: "trash", + tint: TaskSwipeActionTint.delete, + revealProgress: revealProgress, + revealDelay: 0.04 + ) { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } + onDelete() } - .foregroundStyle(.white) - .frame(width: 64) - .frame(maxHeight: .infinity) - .background(TaskSwipeActionTint.delete) } - .opacity(Double(min(1, max(0, (revealProgress - 0.02) / 0.5)))) - } - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .frame(maxWidth: .infinity) - - rowContent - .offset(x: offsetX) - .gesture( - DragGesture(minimumDistance: 6) - .onChanged { value in - guard abs(value.translation.width) > abs(value.translation.height) else { return } - let proposed = value.translation.width - if proposed < 0 { - offsetX = max(-revealWidth * 1.12, proposed) - } else { - offsetX = min(0, offsetX + proposed * 0.15) + .padding(.trailing, 2) + .frame(maxWidth: .infinity) + + rowContent + .offset(x: offsetX) + .gesture( + DragGesture(minimumDistance: 6) + .onChanged { value in + guard abs(value.translation.width) > abs(value.translation.height) else { return } + let proposed = value.translation.width + if proposed < 0 { + offsetX = max(-revealWidth * 1.12, proposed) + } else { + offsetX = min(0, offsetX + proposed * 0.15) + } } - } - .onEnded { value in - let velocity = value.predictedEndTranslation.width - value.translation.width - let shouldOpen = offsetX < -(revealWidth * 0.32) || velocity < -200 - withAnimation(.spring(response: 0.34, dampingFraction: 0.78)) { - offsetX = shouldOpen ? -revealWidth : 0 + .onEnded { value in + let velocity = value.predictedEndTranslation.width - value.translation.width + let shouldOpen = offsetX < -(revealWidth * 0.32) || velocity < -200 + withAnimation(.spring(response: 0.34, dampingFraction: 0.78)) { + offsetX = shouldOpen ? -revealWidth : 0 + } + } + ) + .onTapGesture { + if offsetX != 0 { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } + } else if !isHinting && !isCompleting { + isHinting = true + Task { @MainActor in + withAnimation(.spring(response: 0.26, dampingFraction: 0.78)) { offsetX = -28 } + try? await Task.sleep(nanoseconds: 150_000_000) + withAnimation(.spring(response: 0.38, dampingFraction: 0.68)) { offsetX = 0 } + try? await Task.sleep(nanoseconds: 340_000_000) + isHinting = false } - } - ) - .onTapGesture { - if offsetX != 0 { - withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } - } else if !isHinting && !isCompleting { - isHinting = true - Task { @MainActor in - withAnimation(.spring(response: 0.26, dampingFraction: 0.78)) { offsetX = -28 } - try? await Task.sleep(nanoseconds: 150_000_000) - withAnimation(.spring(response: 0.38, dampingFraction: 0.68)) { offsetX = 0 } - try? await Task.sleep(nanoseconds: 340_000_000) - isHinting = false } } - } + } } - .opacity(isCompleting ? 0 : 1) - .scaleEffect(isCompleting ? 0.985 : 1, anchor: .center) - .animation(.easeInOut(duration: 0.16), value: isCompleting) + .opacity(isFading ? 0 : 1) + .scaleEffect(isFading ? 0.985 : 1, anchor: .center) + .animation(.easeInOut(duration: 0.22), value: isFading) .allowsHitTesting(!isCompleting) } private var rowContent: some View { HStack(alignment: .center, spacing: 12) { - Button(action: onComplete) { - Image(systemName: todo.completed ? "checkmark.circle.fill" : "circle") + Button(action: startCompletion) { + Image(systemName: isCompleting || todo.completed ? "checkmark.circle.fill" : "circle") .font(.system(size: 24, weight: .regular)) - .foregroundStyle(todo.completed ? Color.green : colors.onSurfaceVariant.opacity(0.78)) + .foregroundStyle(isCompleting || todo.completed ? Color.green : colors.onSurfaceVariant.opacity(0.78)) .frame(width: 38, height: 38) } .buttonStyle(TdayPressButtonStyle(shadowColor: .black, pressedShadowOpacity: 0, normalShadowOpacity: 0)) + .disabled(isCompleting) VStack(alignment: .leading, spacing: 3) { - Text(todo.title) - .font(.tdayRounded(size: 18, weight: .bold)) - .foregroundStyle(colors.onSurface) - .lineLimit(1) + HomeTodayTaskTitle( + text: todo.title, + isCompleted: isCompleting, + titleColor: titleColor, + strikeColor: colors.onSurface.opacity(0.65) + ) - Text("Due \(dueText)") + Text(subtitleText) .font(.tdayRounded(size: 13, weight: .semibold)) .foregroundStyle(subtitleColor) } @@ -700,10 +691,106 @@ private struct HomeTodayTaskRow: View { } .padding(.vertical, 10) .padding(.horizontal, 4) - .background(Color(uiColor: .systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .contentShape(Rectangle()) } + + private func startCompletion() { + guard completionPhase == .active else { return } + + withAnimation(.easeInOut(duration: 0.18)) { + offsetX = 0 + completionPhase = .checked + } + + Task { @MainActor in + try? await Task.sleep(nanoseconds: 500_000_000) + withAnimation(.easeInOut(duration: 0.22)) { + completionPhase = .fading + } + try? await Task.sleep(nanoseconds: 220_000_000) + await onComplete() + if completionPhase == .fading { + withAnimation(.easeInOut(duration: 0.16)) { + completionPhase = .active + } + } + } + } +} + +private struct HomeTodayTaskTitle: View { + let text: String + let isCompleted: Bool + let titleColor: Color + let strikeColor: Color + + private var strikeProgress: CGFloat { + isCompleted ? 1 : 0 + } + + var body: some View { + Text(text) + .font(.tdayRounded(size: 18, weight: .bold)) + .foregroundStyle(titleColor) + .lineLimit(1) + .overlay { + GeometryReader { proxy in + Rectangle() + .fill(strikeColor) + .frame(width: proxy.size.width * strikeProgress, height: 1.4) + .position( + x: (proxy.size.width * strikeProgress) / 2, + y: proxy.size.height * 0.55 + ) + } + .allowsHitTesting(false) + } + .animation(.easeInOut(duration: 0.32), value: isCompleted) + } +} + +private struct HomeTodaySwipeActionButton: View { + let title: String + let systemImage: String + let tint: Color + let revealProgress: CGFloat + let revealDelay: CGFloat + let action: () -> Void + + private var easedReveal: CGFloat { + let normalized = max(0, min(1, (revealProgress - revealDelay) / (1 - revealDelay))) + return normalized * normalized * (3 - (2 * normalized)) + } + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + ZStack { + RoundedRectangle(cornerRadius: 17, style: .continuous) + .fill(tint) + Image(systemName: systemImage) + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(.white) + } + .frame(width: 56, height: 34) + + Text(title) + .font(.tdayRounded(size: 12, weight: .bold)) + .foregroundStyle(Color(uiColor: .secondaryLabel).opacity(0.82)) + .lineLimit(1) + } + .frame(minWidth: 60) + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0, + normalShadowOpacity: 0 + ) + ) + .opacity(Double(easedReveal)) + .scaleEffect(0.38 + (0.62 * easedReveal)) + } } private func homeTodayListSymbolName(for key: String?) -> String { diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 4bf10fca..fbb0a05c 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -730,22 +730,15 @@ struct TodoListScreen: View { .animation(.easeInOut(duration: 0.16), value: isCompleting) .opacity(draggedTodo?.id == todo.id && activeDropSectionId != nil ? 0.55 : 1) .allowsHitTesting(!isCompleting) - .swipeRevealHintOnTap(enabled: !isCompleting) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - Task { await viewModel.delete(todo) } - } label: { - Label("Delete", systemImage: "trash") - } - .tint(TaskSwipeActionTint.delete) - - Button { + .todoTrailingSwipeActions( + enabled: !isCompleting, + onEdit: { editingTodo = todo - } label: { - Label("Edit", systemImage: "square.and.pencil") + }, + onDelete: { + Task { await viewModel.delete(todo) } } - .tint(TaskSwipeActionTint.edit) - } + ) .swipeActions(edge: .leading, allowsFullSwipe: true) { Button { completeTodoWithoutReflow(todo) @@ -850,22 +843,15 @@ struct TodoListScreen: View { .allowsHitTesting(!isCompleting) .transition(.opacity.combined(with: .scale(scale: 0.985))) .modifier(TimelineTaskFlashHighlight(active: flashHighlight)) - .swipeRevealHintOnTap(enabled: !isCompleting) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - Task { await viewModel.delete(todo) } - } label: { - Label("Delete", systemImage: "trash") - } - .tint(TaskSwipeActionTint.delete) - - Button { + .todoTrailingSwipeActions( + enabled: !isCompleting, + onEdit: { editingTodo = todo - } label: { - Label("Edit", systemImage: "square.and.pencil") + }, + onDelete: { + Task { await viewModel.delete(todo) } } - .tint(TaskSwipeActionTint.edit) - } + ) .onDrop( of: [UTType.plainText.identifier], delegate: ScheduledTodoDropDelegate( diff --git a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift index aa3eca12..9a8e5752 100644 --- a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift +++ b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift @@ -29,6 +29,176 @@ extension View { func swipeRevealHintOnTap(enabled: Bool = true) -> some View { modifier(SwipeRevealHintModifier(enabled: enabled)) } + + func todoTrailingSwipeActions( + enabled: Bool = true, + onEdit: @escaping () -> Void, + onDelete: @escaping () -> Void + ) -> some View { + modifier( + TodoTrailingSwipeActionsModifier( + enabled: enabled, + onEdit: onEdit, + onDelete: onDelete + ) + ) + } +} + +private struct TodoTrailingSwipeActionsModifier: ViewModifier { + let enabled: Bool + let onEdit: () -> Void + let onDelete: () -> Void + + @State private var offsetX: CGFloat = 0 + @State private var isHinting = false + @State private var dragStartOffsetX: CGFloat? + @State private var isHorizontalDragging = false + + private let revealWidth: CGFloat = 152 + + private var revealProgress: CGFloat { + min(1, max(0, -offsetX / revealWidth)) + } + + func body(content: Content) -> some View { + ZStack(alignment: .trailing) { + HStack(spacing: 16) { + Spacer() + TodoSwipePillActionButton( + title: "Edit", + systemImage: "square.and.pencil", + tint: TaskSwipeActionTint.edit, + revealProgress: revealProgress, + revealDelay: 0.62 + ) { + closeActions() + onEdit() + } + + TodoSwipePillActionButton( + title: "Delete", + systemImage: "trash", + tint: TaskSwipeActionTint.delete, + revealProgress: revealProgress, + revealDelay: 0.04 + ) { + closeActions() + onDelete() + } + } + .padding(.trailing, 2) + .frame(maxWidth: .infinity) + + content + .offset(x: offsetX) + .contentShape(Rectangle()) + .simultaneousGesture( + DragGesture(minimumDistance: 6) + .onChanged { value in + guard enabled else { return } + guard abs(value.translation.width) > abs(value.translation.height) else { return } + if !isHorizontalDragging { + dragStartOffsetX = offsetX + isHorizontalDragging = true + } + let proposed = (dragStartOffsetX ?? offsetX) + value.translation.width + if proposed < 0 { + offsetX = max(-revealWidth * 1.12, min(0, proposed)) + } else { + offsetX = 0 + } + } + .onEnded { value in + defer { + dragStartOffsetX = nil + isHorizontalDragging = false + } + guard enabled, isHorizontalDragging else { return } + let velocity = value.predictedEndTranslation.width - value.translation.width + let shouldOpen = offsetX < -(revealWidth * 0.32) || velocity < -200 + withAnimation(.spring(response: 0.34, dampingFraction: 0.78)) { + offsetX = shouldOpen ? -revealWidth : 0 + } + } + ) + .onTapGesture { + guard enabled else { return } + if offsetX != 0 { + closeActions() + } else { + revealHint() + } + } + } + } + + private func closeActions() { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { + offsetX = 0 + } + } + + private func revealHint() { + guard !isHinting else { return } + + isHinting = true + Task { @MainActor in + withAnimation(.spring(response: 0.26, dampingFraction: 0.78)) { + offsetX = -28 + } + try? await Task.sleep(nanoseconds: 150_000_000) + withAnimation(.spring(response: 0.38, dampingFraction: 0.68)) { + offsetX = 0 + } + try? await Task.sleep(nanoseconds: 340_000_000) + isHinting = false + } + } +} + +private struct TodoSwipePillActionButton: View { + let title: String + let systemImage: String + let tint: Color + let revealProgress: CGFloat + let revealDelay: CGFloat + let action: () -> Void + + private var easedReveal: CGFloat { + let normalized = max(0, min(1, (revealProgress - revealDelay) / (1 - revealDelay))) + return normalized * normalized * (3 - (2 * normalized)) + } + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + ZStack { + RoundedRectangle(cornerRadius: 17, style: .continuous) + .fill(tint) + Image(systemName: systemImage) + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(.white) + } + .frame(width: 56, height: 34) + + Text(title) + .font(.tdayRounded(size: 12, weight: .bold)) + .foregroundStyle(Color(uiColor: .secondaryLabel).opacity(0.82)) + .lineLimit(1) + } + .frame(minWidth: 60) + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0, + normalShadowOpacity: 0 + ) + ) + .opacity(Double(easedReveal)) + .scaleEffect(0.38 + (0.62 * easedReveal)) + } } private struct SwipeRevealHintModifier: ViewModifier { From 9e4947edfb1ae3a4b62186c856e8448485162b71 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Sun, 24 May 2026 19:24:16 -0400 Subject: [PATCH 08/24] refactor: conditional date dividers in todo and completed lists Implement logic to display date dividers only when consecutive items fall on different local days. This replaces static dividers and improves visual grouping across Android (Compose) and iOS (SwiftUI) platforms. - **Intelligent Divider Logic**: Introduce `shouldShowDateDivider` to check if the subsequent visible item (even across collapsed sections) belongs to a different calendar day. - **Android (Compose)**: - Update `CompletedScreen`, `TodoListScreen`, `HomeScreen`, and `CalendarScreen` to pass `showDateDivider` to row components. - Refactor `SwipeTaskRow` and `CalendarTodoRow` to conditionally render the bottom `Spacer` divider. - **iOS (SwiftUI)**: - Update `TodoListScreen`, `CompletedScreen`, `CalendarScreen`, and `HomeScreen` (search results) to wrap `TimelineRowDivider` in conditional checks. - Adjust `resultsHeight` calculation in Home search to account for the dynamic number of dividers. - **Cross-Platform Consistency**: Ensure date comparisons use the appropriate system time zone or `Calendar` instance to determine day boundaries. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../feature/calendar/CalendarScreen.kt | 32 ++++++++--- .../feature/completed/CompletedScreen.kt | 41 ++++++++++++++ .../tday/compose/feature/home/HomeScreen.kt | 17 +++++- .../compose/feature/todos/TodoListScreen.kt | 44 +++++++++++++++ .../Feature/Calendar/CalendarScreen.swift | 14 ++++- .../Feature/Completed/CompletedScreen.swift | 51 ++++++++++++++++-- .../Tday/Feature/Home/HomeScreen.swift | 18 +++++-- .../Tday/Feature/Todos/TodoListScreen.swift | 54 ++++++++++++++++--- 8 files changed, 246 insertions(+), 25 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index 41b75751..419d605e 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -166,6 +166,16 @@ private val CalendarPeriodWeekDayCellHeight = 72.dp private val CalendarPeriodPageHorizontalGutter = 2.dp private val CalendarPeriodCardBottomPadding = 18.dp +private fun shouldShowDateDivider( + afterItemIndex: Int, + items: List, + zoneId: ZoneId, +): Boolean { + val currentTodo = items.getOrNull(afterItemIndex) ?: return false + val nextTodo = items.getOrNull(afterItemIndex + 1) ?: return false + return LocalDate.ofInstant(currentTodo.due, zoneId) != LocalDate.ofInstant(nextTodo.due, zoneId) +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun CalendarScreen( @@ -466,11 +476,16 @@ fun CalendarScreen( if (selectedDatePendingTasks.isNotEmpty()) { item { Column(modifier = Modifier.fillMaxWidth()) { - selectedDatePendingTasks.forEach { todo -> + selectedDatePendingTasks.forEachIndexed { index, todo -> key(todo.id) { CalendarTodoRow( todo = todo, lists = uiState.lists, + showDateDivider = shouldShowDateDivider( + afterItemIndex = index, + items = selectedDatePendingTasks, + zoneId = zoneId, + ), onComplete = { onCompleteTask(todo) }, onInfo = { editTargetId = todo.id }, onDelete = { onDelete(todo) }, @@ -1610,6 +1625,7 @@ private fun CalendarTodoRow( modifier: Modifier = Modifier, todo: TodoItem, lists: List, + showDateDivider: Boolean, onComplete: () -> Unit, onInfo: () -> Unit, onDelete: () -> Unit, @@ -1819,12 +1835,14 @@ private fun CalendarTodoRow( } } } - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(colorScheme.outlineVariant.copy(alpha = 0.55f)), - ) + if (showDateDivider) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colorScheme.outlineVariant.copy(alpha = 0.55f)), + ) + } } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt index ea180596..71bb5c6b 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt @@ -107,6 +107,7 @@ import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet import com.ohmz.tday.compose.ui.theme.TdayDimens import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -288,6 +289,12 @@ fun CompletedScreen( .padding(top = 4.dp), item = completed, lists = uiState.lists, + showDateDivider = shouldShowDateDivider( + afterItemIndex = itemIndex, + inSectionIndex = sectionIndex, + sections = timelineSections, + collapsedSectionKeys = collapsedSectionKeys, + ), onInfo = { editTargetId = completed.id }, onDelete = { onDelete(completed) }, onUncomplete = { onUncomplete(completed) }, @@ -542,6 +549,7 @@ private fun CompletedSwipeRow( modifier: Modifier = Modifier, item: CompletedItem, lists: List, + showDateDivider: Boolean, onInfo: () -> Unit, onDelete: () -> Unit, onUncomplete: () -> Unit, @@ -801,12 +809,14 @@ private fun CompletedSwipeRow( } } } + if (showDateDivider) { Spacer( modifier = Modifier .fillMaxWidth() .height(1.dp) .background(colorScheme.outlineVariant.copy(alpha = 0.58f)), ) + } } } @@ -956,6 +966,37 @@ private data class CompletedSection( val items: List, ) +private fun shouldShowDateDivider( + afterItemIndex: Int, + inSectionIndex: Int, + sections: List, + collapsedSectionKeys: Set, + zoneId: ZoneId = ZoneId.systemDefault(), +): Boolean { + val section = sections.getOrNull(inSectionIndex) ?: return false + val currentItem = section.items.getOrNull(afterItemIndex) ?: return false + val nextItemInSection = section.items.getOrNull(afterItemIndex + 1) + if (nextItemInSection != null) { + return !currentItem.completedDate() + .isSameLocalDayAs(nextItemInSection.completedDate(), zoneId) + } + + val nextVisibleItem = sections + .asSequence() + .drop(inSectionIndex + 1) + .filter { it.key !in collapsedSectionKeys } + .flatMap { it.items.asSequence() } + .firstOrNull() + ?: return false + + return !currentItem.completedDate().isSameLocalDayAs(nextVisibleItem.completedDate(), zoneId) +} + +private fun CompletedItem.completedDate() = completedAt ?: due + +private fun Instant.isSameLocalDayAs(other: Instant, zoneId: ZoneId): Boolean = + LocalDate.ofInstant(this, zoneId) == LocalDate.ofInstant(other, zoneId) + private fun buildCompletedTimelineSections( items: List, zoneId: ZoneId = ZoneId.systemDefault(), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index a76b8c30..efdd3c28 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt @@ -211,6 +211,7 @@ import com.ohmz.tday.compose.ui.theme.TdayDimens import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.time.Instant +import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -708,7 +709,11 @@ fun HomeScreen( ) } } - if (index < searchResults.lastIndex) { + if (shouldShowDateDivider( + afterItemIndex = index, + items = searchResults, + ) + ) { Spacer( modifier = Modifier .fillMaxWidth() @@ -781,6 +786,16 @@ fun HomeScreen( } } +private fun shouldShowDateDivider( + afterItemIndex: Int, + items: List, + zoneId: ZoneId = ZoneId.systemDefault(), +): Boolean { + val currentTodo = items.getOrNull(afterItemIndex) ?: return false + val nextTodo = items.getOrNull(afterItemIndex + 1) ?: return false + return LocalDate.ofInstant(currentTodo.due, zoneId) != LocalDate.ofInstant(nextTodo.due, zoneId) +} + @Composable private fun CreateListBottomSheet( listName: String, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index 97843d5e..78b1889f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -738,6 +738,12 @@ fun TodoListScreen( useMinimalStyle = usesTodayStyle, flashHighlight = flashTodoId == todo.id || flashTodoId == todo.canonicalId, showEarlierDateTimeSubtitle = showEarlierDateTimeSubtitle, + showDateDivider = shouldShowDateDivider( + afterItemIndex = itemIndex, + inSectionIndex = sectionIndex, + sections = timelineSections, + collapsedSectionKeys = collapsedSectionKeys, + ), onComplete = { onComplete(todo) }, onDelete = { onDelete(todo) }, onInfo = { @@ -1696,6 +1702,7 @@ private fun TimelineTaskRow( useMinimalStyle: Boolean, flashHighlight: Boolean, showEarlierDateTimeSubtitle: Boolean, + showDateDivider: Boolean, onComplete: () -> Unit, onDelete: () -> Unit, onInfo: () -> Unit, @@ -1715,6 +1722,7 @@ private fun TimelineTaskRow( onInfo = onInfo, showDuePrefix = true, showDueDateInSubtitle = showEarlierDateTimeSubtitle, + showDateDivider = showDateDivider, ) } else if ( useMinimalStyle && @@ -1736,6 +1744,7 @@ private fun TimelineTaskRow( onInfo = onInfo, showDuePrefix = true, showDueDateInSubtitle = showEarlierDateTimeSubtitle, + showDateDivider = showDateDivider, dragEnabled = onDragTodoStart != null, dragging = draggedTodo?.id == todo.id, onDragStart = { onDragTodoStart?.invoke() }, @@ -1804,6 +1813,34 @@ private data class TodoSection( val targetDate: LocalDate? = null, ) +private fun shouldShowDateDivider( + afterItemIndex: Int, + inSectionIndex: Int, + sections: List, + collapsedSectionKeys: Set, + zoneId: ZoneId = ZoneId.systemDefault(), +): Boolean { + val section = sections.getOrNull(inSectionIndex) ?: return false + val currentTodo = section.items.getOrNull(afterItemIndex) ?: return false + val nextTodoInSection = section.items.getOrNull(afterItemIndex + 1) + if (nextTodoInSection != null) { + return !currentTodo.due.isSameLocalDayAs(nextTodoInSection.due, zoneId) + } + + val nextVisibleTodo = sections + .asSequence() + .drop(inSectionIndex + 1) + .filter { it.key !in collapsedSectionKeys } + .flatMap { it.items.asSequence() } + .firstOrNull() + ?: return false + + return !currentTodo.due.isSameLocalDayAs(nextVisibleTodo.due, zoneId) +} + +private fun Instant.isSameLocalDayAs(other: Instant, zoneId: ZoneId): Boolean = + LocalDate.ofInstant(this, zoneId) == LocalDate.ofInstant(other, zoneId) + private enum class TodaySectionSlot { MORNING, AFTERNOON, TONIGHT, } @@ -2267,6 +2304,7 @@ private fun AllTaskSwipeRow( onInfo: () -> Unit, showDuePrefix: Boolean, showDueDateInSubtitle: Boolean = false, + showDateDivider: Boolean, ) { SwipeTaskRow( todo = todo, @@ -2280,6 +2318,7 @@ private fun AllTaskSwipeRow( showDueText = true, showDuePrefix = showDuePrefix, showDueDateInSubtitle = showDueDateInSubtitle, + showDateDivider = showDateDivider, useDelayedFadeCompletion = false, ) } @@ -2295,6 +2334,7 @@ private fun TodayTaskSwipeRow( onInfo: () -> Unit, showDuePrefix: Boolean, showDueDateInSubtitle: Boolean = false, + showDateDivider: Boolean, dragEnabled: Boolean = false, dragging: Boolean = false, onDragStart: (() -> Unit)? = null, @@ -2311,6 +2351,7 @@ private fun TodayTaskSwipeRow( showDueText = true, showDuePrefix = showDuePrefix, showDueDateInSubtitle = showDueDateInSubtitle, + showDateDivider = showDateDivider, useDelayedFadeCompletion = mode != TodoListMode.TODAY, dragEnabled = dragEnabled, dragging = dragging, @@ -2332,6 +2373,7 @@ private fun SwipeTaskRow( showDueText: Boolean, showDuePrefix: Boolean, showDueDateInSubtitle: Boolean = false, + showDateDivider: Boolean = false, useDelayedFadeCompletion: Boolean = false, useFadeOnCompletion: Boolean = false, dragEnabled: Boolean = false, @@ -2675,12 +2717,14 @@ private fun SwipeTaskRow( } } } + if (showDateDivider) { Spacer( modifier = Modifier .fillMaxWidth() .height(1.dp) .background(colorScheme.outlineVariant.copy(alpha = 0.58f)), ) + } } } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 5e133621..b7ea9250 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -160,7 +160,7 @@ struct CalendarScreen: View { Section { if !pendingItems.isEmpty { - ForEach(pendingItems) { todo in + ForEach(Array(pendingItems.enumerated()), id: \.element.id) { index, todo in CalendarPendingTaskRow( todo: todo, onComplete: { Task { await viewModel.complete(todo) } } @@ -184,7 +184,9 @@ struct CalendarScreen: View { } .tint(.green) } - TimelineRowDivider() + if shouldShowDateDivider(after: index, in: pendingItems) { + TimelineRowDivider() + } } } } header: { @@ -271,6 +273,14 @@ struct CalendarScreen: View { Calendar.current.isDate(date, inSameDayAs: selectedDate) } + private func shouldShowDateDivider(after index: Int, in items: [TodoItem]) -> Bool { + guard items.indices.contains(index), + items.indices.contains(index + 1) else { + return false + } + return !Calendar.current.isDate(items[index].due, inSameDayAs: items[index + 1].due) + } + @ViewBuilder private var calendarModeCard: some View { switch displayMode { diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index 84b5fa4c..5629014a 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -107,7 +107,12 @@ struct CompletedScreen: View { } ForEach(Array(groupedItems.enumerated()), id: \.element.id) { index, section in - completedTimelineSection(section, isFirstSection: index == 0) + completedTimelineSection( + section, + sectionIndex: index, + sections: groupedItems, + isFirstSection: index == 0 + ) } Color.clear @@ -145,19 +150,26 @@ struct CompletedScreen: View { } @ViewBuilder - private func completedTimelineSection(_ section: TimelineSection, isFirstSection: Bool) -> some View { + private func completedTimelineSection( + _ section: TimelineSection, + sectionIndex: Int, + sections: [TimelineSection], + isFirstSection: Bool + ) -> some View { let isCollapsed = collapsedSectionIDs.contains(section.id) Section { if !isCollapsed { - ForEach(Array(section.items.enumerated()), id: \.element.id) { _, item in + ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, item in completedTimelineRow(item) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .transition(completedRowTransition()) - TimelineRowDivider() - .transition(completedRowTransition()) + if shouldShowDateDivider(after: itemIndex, inSectionAt: sectionIndex, sections: sections) { + TimelineRowDivider() + .transition(completedRowTransition()) + } } } } header: { @@ -194,6 +206,35 @@ struct CompletedScreen: View { } } + private func shouldShowDateDivider( + after itemIndex: Int, + inSectionAt sectionIndex: Int, + sections: [TimelineSection] + ) -> Bool { + guard sections.indices.contains(sectionIndex), + sections[sectionIndex].items.indices.contains(itemIndex) else { + return false + } + + let currentItem = sections[sectionIndex].items[itemIndex] + let currentDate = currentItem.completedAt ?? currentItem.due + let nextItemInSection = sections[sectionIndex].items.dropFirst(itemIndex + 1).first + if let nextItemInSection { + let nextDate = nextItemInSection.completedAt ?? nextItemInSection.due + return !Calendar.current.isDate(currentDate, inSameDayAs: nextDate) + } + + let nextVisibleItem = sections.dropFirst(sectionIndex + 1) + .first { !collapsedSectionIDs.contains($0.id) && !$0.items.isEmpty }? + .items.first + + guard let nextVisibleItem else { + return false + } + let nextDate = nextVisibleItem.completedAt ?? nextVisibleItem.due + return !Calendar.current.isDate(currentDate, inSameDayAs: nextDate) + } + private func completedRowTransition() -> AnyTransition { let insertion = AnyTransition.opacity .combined(with: .move(edge: .top)) diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 6079e53b..8f0af7bf 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -1194,10 +1194,13 @@ private struct HomeSearchResultsOverlay: View { private let resultVerticalPadding: CGFloat = 8 private let resultSeparatorHeight: CGFloat = 1 + private var resultSeparatorCount: Int { + todos.indices.filter { shouldShowDateDivider(after: $0) }.count + } + private var resultsHeight: CGFloat { - let separatorCount = max(todos.count - 1, 0) let contentHeight = (CGFloat(todos.count) * resultRowHeight) + - (CGFloat(separatorCount) * resultSeparatorHeight) + + (CGFloat(resultSeparatorCount) * resultSeparatorHeight) + resultVerticalPadding return min(contentHeight, maxResultsHeight) } @@ -1255,7 +1258,7 @@ private struct HomeSearchResultsOverlay: View { .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) - if index < todos.count - 1 { + if shouldShowDateDivider(after: index) { Rectangle() .fill(colors.onSurface.opacity(0.08)) .frame(height: 1) @@ -1276,6 +1279,14 @@ private struct HomeSearchResultsOverlay: View { ) .shadow(color: Color.black.opacity(0.14), radius: 10, x: 0, y: 8) } + + private func shouldShowDateDivider(after index: Int) -> Bool { + guard todos.indices.contains(index), + todos.indices.contains(index + 1) else { + return false + } + return !Calendar.current.isDate(todos[index].due, inSameDayAs: todos[index + 1].due) + } } private struct HomeTileButtonStyle: ButtonStyle { @@ -1851,6 +1862,7 @@ private extension Color { ) ) } + } private extension CGFloat { diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index fbb0a05c..9259423e 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -598,12 +598,14 @@ struct TodoListScreen: View { ForEach(Array(groupedSections.enumerated()), id: \.element.id) { index, section in Section { if !section.items.isEmpty { - ForEach(Array(section.items.enumerated()), id: \.element.id) { _, todo in + ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) .listRowSeparator(.hidden) - TimelineRowDivider() + if shouldShowDateDivider(after: itemIndex, inSectionAt: index, sections: groupedSections) { + TimelineRowDivider() + } } } } header: { @@ -662,7 +664,12 @@ struct TodoListScreen: View { } ForEach(Array(groupedSections.enumerated()), id: \.element.id) { index, section in - minimalTimelineSection(section, isFirstSection: index == 0) + minimalTimelineSection( + section, + sectionIndex: index, + sections: groupedSections, + isFirstSection: index == 0 + ) } Color.clear @@ -895,21 +902,28 @@ struct TodoListScreen: View { } @ViewBuilder - private func minimalTimelineSection(_ section: TodoTimelineSection, isFirstSection: Bool) -> some View { + private func minimalTimelineSection( + _ section: TodoTimelineSection, + sectionIndex: Int, + sections: [TodoTimelineSection], + isFirstSection: Bool + ) -> some View { let canCollapseSection = canCollapseTimelineSection(section) let isCollapsed = canCollapseSection && collapsedSectionIDs.contains(section.id) Section { if !isCollapsed { - ForEach(Array(section.items.enumerated()), id: \.element.id) { _, todo in + ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section, flashHighlight: shouldFlashTodo(todo)) .id(timelineTodoScrollID(todo.id)) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) .listRowSeparator(.hidden) .transition(timelineRowTransition()) - TimelineRowDivider() - .transition(timelineRowTransition()) + if shouldShowDateDivider(after: itemIndex, inSectionAt: sectionIndex, sections: sections) { + TimelineRowDivider() + .transition(timelineRowTransition()) + } } } } header: { @@ -965,6 +979,32 @@ struct TodoListScreen: View { } } + private func shouldShowDateDivider( + after itemIndex: Int, + inSectionAt sectionIndex: Int, + sections: [TodoTimelineSection] + ) -> Bool { + guard sections.indices.contains(sectionIndex), + sections[sectionIndex].items.indices.contains(itemIndex) else { + return false + } + + let currentTodo = sections[sectionIndex].items[itemIndex] + let nextTodoInSection = sections[sectionIndex].items.dropFirst(itemIndex + 1).first + if let nextTodoInSection { + return !Calendar.current.isDate(currentTodo.due, inSameDayAs: nextTodoInSection.due) + } + + let nextVisibleTodo = sections.dropFirst(sectionIndex + 1) + .first { !isTimelineSectionCollapsed($0) && !$0.items.isEmpty }? + .items.first + + guard let nextVisibleTodo else { + return false + } + return !Calendar.current.isDate(currentTodo.due, inSameDayAs: nextVisibleTodo.due) + } + private func timelineRowTransition() -> AnyTransition { let insertion = AnyTransition.opacity .combined(with: .move(edge: .top)) From 716ef49bbf3c913064c3ecd997d80a39fc065689 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Sun, 24 May 2026 19:37:16 -0400 Subject: [PATCH 09/24] fix(ui): improve swipe action layering and hit testing Refactor the swipe-to-reveal action logic in `HomeScreen` and `SwipeActions` to improve interaction reliability. These changes ensure that action buttons are layered correctly and do not intercept touches prematurely. - **Reorder ZStack Layers**: Move the action button `HStack` after the row content in the `ZStack`. This ensures the buttons are rendered on top of the content when revealed, preventing the base row from blocking button interactions. - **Enhanced Hit Testing**: Add `.allowsHitTesting(easedReveal > 0.8)` to `HomeTodaySwipeActionButton` and `TodoSwipePillActionButton`. This prevents accidental triggers by only enabling button interactions when they are nearly fully revealed. - **Consistent Implementation**: Applied these layering and hit-test improvements across both the home screen today tasks and the generic `SwipeActions` component. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../Tday/Feature/Home/HomeScreen.swift | 55 ++++++++++--------- .../Tday/UI/Component/SwipeActions.swift | 55 ++++++++++--------- 2 files changed, 56 insertions(+), 54 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 8f0af7bf..def86925 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -586,33 +586,6 @@ private struct HomeTodayTaskRow: View { var body: some View { VStack(spacing: 0) { ZStack(alignment: .trailing) { - HStack(spacing: 16) { - Spacer() - HomeTodaySwipeActionButton( - title: "Edit", - systemImage: "square.and.pencil", - tint: TaskSwipeActionTint.edit, - revealProgress: revealProgress, - revealDelay: 0.62 - ) { - withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } - onEdit() - } - - HomeTodaySwipeActionButton( - title: "Delete", - systemImage: "trash", - tint: TaskSwipeActionTint.delete, - revealProgress: revealProgress, - revealDelay: 0.04 - ) { - withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } - onDelete() - } - } - .padding(.trailing, 2) - .frame(maxWidth: .infinity) - rowContent .offset(x: offsetX) .gesture( @@ -648,6 +621,33 @@ private struct HomeTodayTaskRow: View { } } } + + HStack(spacing: 16) { + Spacer() + HomeTodaySwipeActionButton( + title: "Edit", + systemImage: "square.and.pencil", + tint: TaskSwipeActionTint.edit, + revealProgress: revealProgress, + revealDelay: 0.62 + ) { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } + onEdit() + } + + HomeTodaySwipeActionButton( + title: "Delete", + systemImage: "trash", + tint: TaskSwipeActionTint.delete, + revealProgress: revealProgress, + revealDelay: 0.04 + ) { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } + onDelete() + } + } + .padding(.trailing, 2) + .frame(maxWidth: .infinity) } } .opacity(isFading ? 0 : 1) @@ -790,6 +790,7 @@ private struct HomeTodaySwipeActionButton: View { ) .opacity(Double(easedReveal)) .scaleEffect(0.38 + (0.62 * easedReveal)) + .allowsHitTesting(easedReveal > 0.8) } } diff --git a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift index 9a8e5752..b5b10be6 100644 --- a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift +++ b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift @@ -63,33 +63,6 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { func body(content: Content) -> some View { ZStack(alignment: .trailing) { - HStack(spacing: 16) { - Spacer() - TodoSwipePillActionButton( - title: "Edit", - systemImage: "square.and.pencil", - tint: TaskSwipeActionTint.edit, - revealProgress: revealProgress, - revealDelay: 0.62 - ) { - closeActions() - onEdit() - } - - TodoSwipePillActionButton( - title: "Delete", - systemImage: "trash", - tint: TaskSwipeActionTint.delete, - revealProgress: revealProgress, - revealDelay: 0.04 - ) { - closeActions() - onDelete() - } - } - .padding(.trailing, 2) - .frame(maxWidth: .infinity) - content .offset(x: offsetX) .contentShape(Rectangle()) @@ -130,6 +103,33 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { revealHint() } } + + HStack(spacing: 16) { + Spacer() + TodoSwipePillActionButton( + title: "Edit", + systemImage: "square.and.pencil", + tint: TaskSwipeActionTint.edit, + revealProgress: revealProgress, + revealDelay: 0.62 + ) { + closeActions() + onEdit() + } + + TodoSwipePillActionButton( + title: "Delete", + systemImage: "trash", + tint: TaskSwipeActionTint.delete, + revealProgress: revealProgress, + revealDelay: 0.04 + ) { + closeActions() + onDelete() + } + } + .padding(.trailing, 2) + .frame(maxWidth: .infinity) } } @@ -198,6 +198,7 @@ private struct TodoSwipePillActionButton: View { ) .opacity(Double(easedReveal)) .scaleEffect(0.38 + (0.62 * easedReveal)) + .allowsHitTesting(easedReveal > 0.8) } } From 519d6165ed54c8754e1a165ea9ca2cd8452fec42 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Sun, 24 May 2026 19:45:14 -0400 Subject: [PATCH 10/24] style(ui): refine home screen typography and text styles Update text components in `HomeScreen.kt` to use the custom `TdayFontFamily` and adjust font weights and sizes for improved visual hierarchy. - **Date Label**: Update to `ExtraBold` weight at `22.sp` with defined line height. - **Task Count**: Upgrade style to `headlineLarge` with `34.sp` font size and `40.sp` line height. - **Task Title**: Change style to `titleMedium` using `ExtraBold` weight at `18.sp`. - **Subtitle**: Adjust weight to `Bold` and set font size to `13.sp`. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../tday/compose/feature/home/HomeScreen.kt | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index efdd3c28..6ae59078 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt @@ -193,6 +193,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.zIndex @@ -208,6 +209,7 @@ import com.ohmz.tday.compose.core.ui.TaskSwipeActionButton import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet import com.ohmz.tday.compose.ui.component.TdayPullToRefreshBox import com.ohmz.tday.compose.ui.theme.TdayDimens +import com.ohmz.tday.compose.ui.theme.TdayFontFamily import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.time.Instant @@ -1640,14 +1642,20 @@ private fun HomeTodayCard( text = dateLabel, style = MaterialTheme.typography.titleLarge, color = Color.White, - fontWeight = FontWeight.Bold, + fontFamily = TdayFontFamily, + fontSize = 22.sp, + fontWeight = FontWeight.ExtraBold, + lineHeight = 28.sp, ) } Text( text = count.toString(), - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.headlineLarge, color = Color.White, + fontFamily = TdayFontFamily, + fontSize = 34.sp, fontWeight = FontWeight.Black, + lineHeight = 40.sp, ) } } @@ -1869,8 +1877,11 @@ private fun HomeTodayTaskRow( ) } }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + fontFamily = TdayFontFamily, + fontSize = 18.sp, + fontWeight = FontWeight.ExtraBold, + lineHeight = 22.sp, color = if (localCompleted) { colorScheme.onSurface.copy(alpha = 0.78f) } else { @@ -1883,7 +1894,10 @@ private fun HomeTodayTaskRow( Text( text = subtitleText, style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, + fontFamily = TdayFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + lineHeight = 18.sp, color = subtitleColor, ) } From 59f36ef70798830ae80efb91100f9f7e0c9aff9d Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Sun, 24 May 2026 19:55:32 -0400 Subject: [PATCH 11/24] refactor: standardize list icon and color helper functions Consolidate and rename list-related utility functions across Android and iOS home screens to improve consistency and reduce code duplication. - **Android (Compose)**: - Remove local `homeTodayListAccentColor` and `homeTodayListIcon` helper functions from `HomeScreen.kt`. - Replace them with centralized calls to `listColorAccent` and `listIconForKey`. - **iOS (SwiftUI)**: - Rename `homeTodayListSymbolName` to `homeListSymbolName`. - Rename `todoListAccentColor` to `homeListAccentColor`. - Remove the redundant local implementation of `homeTodayListSymbolName` in `HomeScreen.swift`. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../tday/compose/feature/home/HomeScreen.kt | 38 +------------------ .../Tday/Feature/Home/HomeScreen.swift | 30 +-------------- 2 files changed, 4 insertions(+), 64 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index 6ae59078..c39c1455 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt @@ -1703,7 +1703,7 @@ private fun HomeTodayTaskRow( val dueText = HOME_TODAY_DUE_FORMATTER.format(todo.due) val rowShape = RoundedCornerShape(16.dp) val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } - val listIndicatorColor = homeTodayListAccentColor(listMeta?.color) + val listIndicatorColor = listColorAccent(listMeta?.color) val isOverdue = !todo.completed && todo.due.isBefore(Instant.now()) val subtitleColor = if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant.copy( @@ -1904,7 +1904,7 @@ private fun HomeTodayTaskRow( if (listMeta != null) { Icon( - imageVector = homeTodayListIcon(listMeta.iconKey), + imageVector = listIconForKey(listMeta.iconKey), contentDescription = null, tint = listIndicatorColor, modifier = Modifier @@ -1919,40 +1919,6 @@ private fun HomeTodayTaskRow( } } -private fun homeTodayListAccentColor(colorKey: String?): Color { - return when (colorKey) { - "RED" -> Color(0xFFE65E52) - "ORANGE" -> Color(0xFFF29F38) - "YELLOW" -> Color(0xFFF3D04A) - "LIME" -> Color(0xFF8ACF56) - "BLUE" -> Color(0xFF5C9FE7) - "PURPLE" -> Color(0xFF8D6CE2) - "PINK" -> Color(0xFFDF6DAA) - "TEAL" -> Color(0xFF4EB5B0) - "CORAL" -> Color(0xFFE3876D) - "GOLD" -> Color(0xFFCFAB57) - "DEEP_BLUE" -> Color(0xFF4B73D6) - "ROSE" -> Color(0xFFD9799A) - else -> Color(0xFF5C9FE7) - } -} - -private fun homeTodayListIcon(iconKey: String?): androidx.compose.ui.graphics.vector.ImageVector { - return when (iconKey) { - "WORK" -> Icons.Rounded.Work - "SCHOOL" -> Icons.Rounded.School - "HOME" -> Icons.Rounded.Home - "SHOPPING" -> Icons.Rounded.ShoppingCart - "HEALTH" -> Icons.Rounded.Favorite - "FITNESS" -> Icons.Rounded.FitnessCenter - "TRAVEL" -> Icons.Rounded.Flight - "FOOD" -> Icons.Rounded.Restaurant - "FINANCE" -> Icons.Rounded.Payments - "MUSIC" -> Icons.Rounded.MusicNote - else -> Icons.AutoMirrored.Rounded.List - } -} - @Composable private fun CategoryGrid( overdueCount: Int, diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index def86925..e8a4836e 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -683,9 +683,9 @@ private struct HomeTodayTaskRow: View { Spacer(minLength: 0) if let listMeta { - Image(systemName: homeTodayListSymbolName(for: listMeta.iconKey)) + Image(systemName: homeListSymbolName(for: listMeta.iconKey)) .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(todoListAccentColor(for: listMeta.color)) + .foregroundStyle(homeListAccentColor(for: listMeta.color)) .padding(.trailing, 8) } } @@ -794,32 +794,6 @@ private struct HomeTodaySwipeActionButton: View { } } -private func homeTodayListSymbolName(for key: String?) -> String { - switch key { - case "sun": return "sun.max.fill" - case "calendar": return "calendar" - case "schedule": return "clock" - case "flag": return "flag.fill" - case "check": return "checkmark" - case "smile": return "face.smiling" - case "star": return "star.fill" - case "heart": return "heart.fill" - case "book": return "book.fill" - case "music": return "music.note" - case "camera": return "camera.fill" - case "cart": return "cart.fill" - case "home": return "house.fill" - case "briefcase": return "briefcase.fill" - case "dumbbell": return "dumbbell.fill" - case "leaf": return "leaf.fill" - case "car": return "car.fill" - case "airplane": return "airplane" - case "person": return "person.fill" - case "globe": return "globe" - default: return "list.bullet" - } -} - private struct HomeTodayCard: View { let count: Int let action: () -> Void From 5fc539cbf8bf629935929e79f49ea75e82294d3c Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Sun, 24 May 2026 21:41:22 -0400 Subject: [PATCH 12/24] feat: implement task drag-and-drop rescheduling for Android and iOS Introduce cross-platform support for rescheduling tasks via drag-and-drop in both the todo list and calendar views, including specialized handling for recurring task series. - **Rescheduling Logic**: - Add `TaskRescheduleScope` to distinguish between updating a single `OCCURRENCE` or the entire `SERIES`. - Implement `movedDuePreservingTime` to ensure tasks maintain their original local time when moved to a new date. - Introduce `timelineRescheduleTargetDate` to resolve drop target dates from various UI section keys (day, month, etc.). - **Android (Compose)**: - Enable `dragAndDropSource` and `dragAndDropTarget` on task rows and list sections. - Add an `AlertDialog` to prompt users when moving recurring tasks. - Integrate haptic feedback during drag-and-drop interactions. - **iOS (SwiftUI)**: - Implement `DropDelegate` and `onDrag` modifiers for `TodoListScreen` and `CalendarScreen`. - Add `confirmationDialog` for recurring task rescheduling choices. - Update `CalendarMonthGrid` and `CalendarWeekCard` to support visual drop targets and task relocation. - **Core Improvements**: - Refactor `TodoListViewModel` and `CalendarViewModel` to support scoped task updates. - Add unit tests for date parsing and rescheduling target resolution in `CacheMappersDateParsingTests.swift`. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../java/com/ohmz/tday/compose/TdayApp.kt | 2 + .../tday/compose/core/model/DomainModels.kt | 83 ++++++ .../feature/calendar/CalendarScreen.kt | 246 +++++++++++++++++- .../feature/calendar/CalendarViewModel.kt | 30 ++- .../compose/feature/todos/TodoListScreen.kt | 111 ++++++-- .../feature/todos/TodoListViewModel.kt | 32 ++- .../app/src/main/res/values/strings.xml | 4 + .../Tday/Core/Model/DomainModels.swift | 126 +++++++++ .../Feature/Calendar/CalendarScreen.swift | 220 +++++++++++++++- .../Feature/Calendar/CalendarViewModel.swift | 13 + .../Tday/Feature/Todos/TodoListScreen.swift | 137 ++++++++-- .../Feature/Todos/TodoListViewModel.swift | 23 +- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 + .../CacheMappersDateParsingTests.swift | 37 +++ 14 files changed, 976 insertions(+), 92 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt index 3cd82846..7fdf5373 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt @@ -595,6 +595,7 @@ fun TdayApp( onParseTaskTitleNlp = viewModel::parseTaskTitleNlp, onCompleteTask = viewModel::complete, onUpdateTask = viewModel::updateTask, + onMoveTask = viewModel::moveTask, onDelete = { todo -> viewModel.delete(todo) { showTaskDeletedToast() @@ -839,6 +840,7 @@ private fun TodosRoute( onAddTask = viewModel::addTask, onParseTaskTitleNlp = viewModel::parseTaskTitleNlp, onUpdateTask = viewModel::updateTask, + onMoveTask = viewModel::moveTask, onComplete = viewModel::toggleComplete, onDelete = { todo -> viewModel.delete(todo) { diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt index 63f622b0..d55eb27c 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt @@ -1,6 +1,10 @@ package com.ohmz.tday.compose.core.model import java.time.Instant +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId +import java.time.ZonedDateTime enum class TodoListMode { TODAY, @@ -11,6 +15,11 @@ enum class TodoListMode { LIST, } +enum class TaskRescheduleScope { + OCCURRENCE, + SERIES, +} + data class CreateTaskPayload( val title: String, val description: String? = null, @@ -41,6 +50,80 @@ data class TodoItem( get() = instanceDate?.toEpochMilli() } +fun TodoListMode.supportsTaskReschedule(): Boolean { + return when (this) { + TodoListMode.SCHEDULED, + TodoListMode.ALL, + TodoListMode.PRIORITY, + TodoListMode.LIST, + -> true + + TodoListMode.TODAY, + TodoListMode.OVERDUE, + -> false + } +} + +fun TodoItem.repositoryTargetForReschedule(scope: TaskRescheduleScope): TodoItem { + return when (scope) { + TaskRescheduleScope.OCCURRENCE -> this + TaskRescheduleScope.SERIES -> copy(id = canonicalId, instanceDate = null) + } +} + +fun movedDuePreservingTime( + due: Instant, + targetDate: LocalDate, + zoneId: ZoneId = ZoneId.systemDefault(), +): Instant { + val dueTime = due.atZone(zoneId).toLocalTime() + return ZonedDateTime.of(targetDate, dueTime, zoneId).toInstant() +} + +fun createMovedTaskPayload( + todo: TodoItem, + targetDate: LocalDate, + zoneId: ZoneId = ZoneId.systemDefault(), +): CreateTaskPayload { + return CreateTaskPayload( + title = todo.title, + description = todo.description, + priority = todo.priority, + due = movedDuePreservingTime(todo.due, targetDate, zoneId), + rrule = todo.rrule, + listId = todo.listId, + ) +} + +fun timelineRescheduleTargetDate( + sectionKey: String, + today: LocalDate = LocalDate.now(), +): LocalDate? { + val currentMonth = YearMonth.from(today) + if (sectionKey.startsWith("day-")) { + val date = runCatching { LocalDate.parse(sectionKey.removePrefix("day-")) }.getOrNull() + ?: return null + return date.takeIf { YearMonth.from(it) >= currentMonth } + } + + if (sectionKey.startsWith("rest-")) { + val month = runCatching { YearMonth.parse(sectionKey.removePrefix("rest-")) }.getOrNull() + ?: return null + val horizonStart = today.plusDays(7) + return horizonStart.takeIf { + month == currentMonth && YearMonth.from(it) == month + } + } + + if (sectionKey.startsWith("month-")) { + val month = runCatching { YearMonth.parse(sectionKey.removePrefix("month-")) }.getOrNull() + ?: return null + return month.takeIf { it >= currentMonth }?.atDay(1) + } + + return null +} + data class ListSummary( val id: String, val name: String, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index 419d605e..b9f950bd 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -1,5 +1,7 @@ package com.ohmz.tday.compose.feature.calendar +import android.content.ClipData +import android.view.View import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColorAsState @@ -18,8 +20,11 @@ import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.draganddrop.dragAndDropSource +import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.interaction.MutableInteractionSource @@ -69,6 +74,7 @@ import androidx.compose.material.icons.rounded.Restaurant import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.WbSunny import androidx.compose.material.icons.rounded.Work +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -78,6 +84,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.ripple import androidx.compose.runtime.Composable @@ -93,6 +100,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.DragAndDropTransferData +import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset @@ -120,6 +131,7 @@ import com.ohmz.tday.compose.R import com.ohmz.tday.compose.core.model.CompletedItem import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary +import com.ohmz.tday.compose.core.model.TaskRescheduleScope import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.ui.snapTitleCollapsePx @@ -176,6 +188,11 @@ private fun shouldShowDateDivider( return LocalDate.ofInstant(currentTodo.due, zoneId) != LocalDate.ofInstant(nextTodo.due, zoneId) } +private data class CalendarTaskRescheduleDrop( + val todo: TodoItem, + val targetDate: LocalDate, +) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun CalendarScreen( @@ -186,9 +203,11 @@ fun CalendarScreen( onParseTaskTitleNlp: suspend (title: String, referenceDueEpochMs: Long) -> TodoTitleNlpResponse?, onCompleteTask: (TodoItem) -> Unit, onUpdateTask: (TodoItem, CreateTaskPayload) -> Unit, + onMoveTask: (todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) -> Unit, onDelete: (TodoItem) -> Unit, ) { val zoneId = remember { ZoneId.systemDefault() } + val view = LocalView.current val today = remember { LocalDate.now(zoneId) } val minNavigableMonth = remember(zoneId) { YearMonth.now(zoneId) } val listState = rememberLazyListState() @@ -299,11 +318,22 @@ fun CalendarScreen( var editTargetId by rememberSaveable { mutableStateOf(null) } var showCreateTaskSheet by rememberSaveable { mutableStateOf(false) } var createDueEpochMs by rememberSaveable { mutableStateOf(null) } + var draggedCalendarTodoId by rememberSaveable { mutableStateOf(null) } + var activeDropDateIso by remember { mutableStateOf(null) } + var pendingRescheduleDrop by remember { mutableStateOf(null) } val editTarget = remember(editTargetId, uiState.items) { editTargetId?.let { targetId -> uiState.items.firstOrNull { it.id == targetId } } } + val draggedCalendarTodo = remember(draggedCalendarTodoId, uiState.items) { + draggedCalendarTodoId?.let { targetId -> + uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } + } + } + val activeDropDate = remember(activeDropDateIso) { + activeDropDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + } fun openCreateTaskSheetForSelectedDate() { val currentDate = LocalDate.now(zoneId) val prefillDue = if (selectedDate == currentDate) { @@ -315,6 +345,19 @@ fun CalendarScreen( createDueEpochMs = prefillDue.toInstant().toEpochMilli() showCreateTaskSheet = true } + fun requestTaskReschedule(todo: TodoItem, targetDate: LocalDate) { + draggedCalendarTodoId = null + activeDropDateIso = null + val currentDate = LocalDate.ofInstant(todo.due, zoneId) + if (currentDate == targetDate) return + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + if (todo.isRecurring) { + pendingRescheduleDrop = CalendarTaskRescheduleDrop(todo = todo, targetDate = targetDate) + } else { + onMoveTask(todo, targetDate, TaskRescheduleScope.OCCURRENCE) + selectDate(targetDate) + } + } LaunchedEffect(listState.isScrollInProgress, monthTitleSnapThresholdPx) { if (listState.isScrollInProgress) return@LaunchedEffect if (listState.firstVisibleItemIndex != 0) return@LaunchedEffect @@ -419,6 +462,9 @@ fun CalendarScreen( selectedDate = selectedDate, today = today, tasksByDate = tasksByDate, + draggedTodo = draggedCalendarTodo, + activeDropDate = activeDropDate, + canSelectDate = ::canNavigateTo, todayJumpRequest = todayJumpRequest, onTodayJumpHandled = ::clearTodayJumpRequest, onPrevMonth = { @@ -430,12 +476,18 @@ fun CalendarScreen( visibleMonthIso = visibleMonth.plusMonths(1).toString() }, onSelectDate = ::selectDate, + onDropDateChanged = { date -> + activeDropDateIso = date?.toString() + }, + onMoveTaskToDate = ::requestTaskReschedule, ) CalendarViewMode.WEEK -> CalendarWeekCard( selectedDate = selectedDate, today = today, tasksByDate = tasksByDate, + draggedTodo = draggedCalendarTodo, + activeDropDate = activeDropDate, canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), canSelectDate = ::canNavigateTo, todayJumpRequest = todayJumpRequest, @@ -443,18 +495,29 @@ fun CalendarScreen( onPrevWeek = { selectDate(selectedDate.minusWeeks(1)) }, onNextWeek = { selectDate(selectedDate.plusWeeks(1)) }, onSelectDate = ::selectDate, + onDropDateChanged = { date -> + activeDropDateIso = date?.toString() + }, + onMoveTaskToDate = ::requestTaskReschedule, ) CalendarViewMode.DAY -> CalendarDayCard( selectedDate = selectedDate, today = today, tasksByDate = tasksByDate, + draggedTodo = draggedCalendarTodo, + activeDropDate = activeDropDate, canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), + canSelectDate = ::canNavigateTo, todayJumpRequest = todayJumpRequest, onTodayJumpHandled = ::clearTodayJumpRequest, onPrevDay = { selectDate(selectedDate.minusDays(1)) }, onNextDay = { selectDate(selectedDate.plusDays(1)) }, onSelectDate = ::selectDate, + onDropDateChanged = { date -> + activeDropDateIso = date?.toString() + }, + onMoveTaskToDate = ::requestTaskReschedule, ) } } @@ -489,6 +552,11 @@ fun CalendarScreen( onComplete = { onCompleteTask(todo) }, onInfo = { editTargetId = todo.id }, onDelete = { onDelete(todo) }, + dragging = draggedCalendarTodo?.id == todo.id, + onDragStart = { + activeDropDateIso = null + draggedCalendarTodoId = todo.id + }, ) } } @@ -528,6 +596,44 @@ fun CalendarScreen( ) } + pendingRescheduleDrop?.let { drop -> + AlertDialog( + onDismissRequest = { pendingRescheduleDrop = null }, + title = { + Text( + text = stringResource(R.string.todos_reschedule_recurring_title), + fontWeight = FontWeight.ExtraBold, + ) + }, + text = { + Text(text = stringResource(R.string.todos_reschedule_recurring_message)) + }, + dismissButton = { + TextButton(onClick = { pendingRescheduleDrop = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + confirmButton = { + Row { + TextButton(onClick = { + pendingRescheduleDrop = null + onMoveTask(drop.todo, drop.targetDate, TaskRescheduleScope.OCCURRENCE) + selectDate(drop.targetDate) + }) { + Text(stringResource(R.string.todos_reschedule_this_occurrence)) + } + TextButton(onClick = { + pendingRescheduleDrop = null + onMoveTask(drop.todo, drop.targetDate, TaskRescheduleScope.SERIES) + selectDate(drop.targetDate) + }) { + Text(stringResource(R.string.todos_reschedule_entire_series)) + } + } + }, + ) + } + editTarget?.let { todo -> CreateTaskBottomSheet( lists = uiState.lists, @@ -601,6 +707,8 @@ private fun CalendarWeekCard( selectedDate: LocalDate, today: LocalDate, tasksByDate: Map>, + draggedTodo: TodoItem?, + activeDropDate: LocalDate?, canGoPrevWeek: Boolean, canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, @@ -608,6 +716,8 @@ private fun CalendarWeekCard( onPrevWeek: () -> Unit, onNextWeek: () -> Unit, onSelectDate: (LocalDate) -> Unit, + onDropDateChanged: (LocalDate?) -> Unit, + onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val weekStart = remember(selectedDate) { startOfWeek(selectedDate) } @@ -773,7 +883,11 @@ private fun CalendarWeekCard( isSelected = isSelected, isToday = isToday, isEnabled = isEnabled, + isDropTarget = activeDropDate == day, + draggedTodo = draggedTodo.takeIf { isEnabled }, onClick = { onSelectDate(day) }, + onDropDateChanged = onDropDateChanged, + onMoveTaskToDate = onMoveTaskToDate, modifier = Modifier.weight(1f), ) } @@ -790,21 +904,28 @@ private fun CalendarWeekDayCell( isSelected: Boolean, isToday: Boolean, isEnabled: Boolean, + isDropTarget: Boolean, + draggedTodo: TodoItem?, onClick: () -> Unit, + onDropDateChanged: (LocalDate?) -> Unit, + onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, modifier: Modifier = Modifier, ) { val colorScheme = MaterialTheme.colorScheme val containerColor = when { + isDropTarget -> CalendarAccentPurple.copy(alpha = 0.34f) isSelected -> CalendarAccentPurple.copy(alpha = 0.24f) isToday -> CalendarTodayBlue.copy(alpha = 0.16f) else -> colorScheme.background } val borderColor = when { + isDropTarget -> CalendarAccentPurple isSelected -> CalendarAccentPurple.copy(alpha = 0.95f) isToday -> CalendarTodayBlue.copy(alpha = 0.74f) else -> Color.Transparent } val borderWidth = when { + isDropTarget -> 2.dp isSelected -> 1.6.dp isToday -> 1.4.dp else -> 0.dp @@ -819,6 +940,13 @@ private fun CalendarWeekDayCell( modifier = modifier .height(CalendarPeriodCardPageHeight) .minimumInteractiveComponentSize() + .calendarDateDropTarget( + date = date, + draggedTodo = draggedTodo, + enabled = isEnabled, + onDropDateChanged = onDropDateChanged, + onMoveTaskToDate = onMoveTaskToDate, + ) .graphicsLayer { alpha = if (isEnabled) 1f else 0.48f }, contentAlignment = Alignment.Center, ) { @@ -871,17 +999,58 @@ private fun CalendarWeekDayCell( } } +@OptIn(ExperimentalFoundationApi::class) +private fun Modifier.calendarDateDropTarget( + date: LocalDate, + draggedTodo: TodoItem?, + enabled: Boolean, + onDropDateChanged: (LocalDate?) -> Unit, + onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, +): Modifier { + if (!enabled || draggedTodo == null) return this + + return dragAndDropTarget( + shouldStartDragAndDrop = { event -> + event.mimeTypes().any { mimeType -> mimeType.startsWith("text/") } + }, + target = object : DragAndDropTarget { + override fun onEntered(event: DragAndDropEvent) { + onDropDateChanged(date) + } + + override fun onExited(event: DragAndDropEvent) { + onDropDateChanged(null) + } + + override fun onDrop(event: DragAndDropEvent): Boolean { + onDropDateChanged(null) + onMoveTaskToDate(draggedTodo, date) + return true + } + + override fun onEnded(event: DragAndDropEvent) { + onDropDateChanged(null) + } + }, + ) +} + @Composable private fun CalendarDayCard( selectedDate: LocalDate, today: LocalDate, tasksByDate: Map>, + draggedTodo: TodoItem?, + activeDropDate: LocalDate?, canGoPrevDay: Boolean, + canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, onTodayJumpHandled: (Int) -> Unit, onPrevDay: () -> Unit, onNextDay: () -> Unit, onSelectDate: (LocalDate) -> Unit, + onDropDateChanged: (LocalDate?) -> Unit, + onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val coroutineScope = rememberCoroutineScope() @@ -1028,8 +1197,26 @@ private fun CalendarDayCard( .height(CalendarPeriodCardPageHeight), ) { displayDate -> val taskCount = tasksByDate[displayDate]?.size ?: 0 + val isEnabled = canSelectDate(displayDate) Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .calendarDateDropTarget( + date = displayDate, + draggedTodo = draggedTodo.takeIf { isEnabled }, + enabled = isEnabled, + onDropDateChanged = onDropDateChanged, + onMoveTaskToDate = onMoveTaskToDate, + ) + .clip(RoundedCornerShape(16.dp)) + .background( + if (activeDropDate == displayDate) { + CalendarAccentPurple.copy(alpha = 0.12f) + } else { + Color.Transparent + }, + ) + .padding(horizontal = 6.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { Text( @@ -1243,11 +1430,16 @@ private fun CalendarMonthCard( selectedDate: LocalDate, today: LocalDate, tasksByDate: Map>, + draggedTodo: TodoItem?, + activeDropDate: LocalDate?, + canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, onTodayJumpHandled: (Int) -> Unit, onPrevMonth: () -> Unit, onNextMonth: () -> Unit, onSelectDate: (LocalDate) -> Unit, + onDropDateChanged: (LocalDate?) -> Unit, + onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val coroutineScope = rememberCoroutineScope() @@ -1427,12 +1619,18 @@ private fun CalendarMonthCard( ) { week.forEach { cell -> val taskCount = tasksByDate[cell.date]?.size ?: 0 + val isEnabled = canSelectDate(cell.date) CalendarDayCell( cell = cell, taskCount = taskCount, isSelected = cell.date == selectedDate, isToday = cell.date == today, + isEnabled = isEnabled, + isDropTarget = activeDropDate == cell.date, + draggedTodo = draggedTodo.takeIf { isEnabled }, onClick = { onSelectDate(cell.date) }, + onDropDateChanged = onDropDateChanged, + onMoveTaskToDate = onMoveTaskToDate, modifier = Modifier.weight(1f), ) } @@ -1500,16 +1698,22 @@ private fun CalendarDayCell( taskCount: Int, isSelected: Boolean, isToday: Boolean, + isEnabled: Boolean, + isDropTarget: Boolean, + draggedTodo: TodoItem?, onClick: () -> Unit, + onDropDateChanged: (LocalDate?) -> Unit, + onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, modifier: Modifier = Modifier, ) { val colorScheme = MaterialTheme.colorScheme val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val targetCellBackground = when { + isDropTarget -> CalendarAccentPurple.copy(alpha = 0.34f) isSelected -> CalendarAccentPurple.copy(alpha = if (isPressed) 0.32f else 0.24f) isToday -> CalendarTodayBlue.copy(alpha = if (isPressed) 0.24f else 0.16f) - isPressed && cell.isCurrentMonth -> colorScheme.onSurfaceVariant.copy(alpha = 0.12f) + isPressed && isEnabled -> colorScheme.onSurfaceVariant.copy(alpha = 0.12f) else -> Color.Transparent } val cellBackground by animateColorAsState( @@ -1517,9 +1721,10 @@ private fun CalendarDayCell( label = "calendarMonthDateCellBackground", ) val targetCellBorderColor = when { + isDropTarget -> CalendarAccentPurple isSelected -> CalendarAccentPurple.copy(alpha = 0.95f) isToday -> CalendarTodayBlue.copy(alpha = 0.74f) - isPressed && cell.isCurrentMonth -> colorScheme.onSurfaceVariant.copy(alpha = 0.34f) + isPressed && isEnabled -> colorScheme.onSurfaceVariant.copy(alpha = 0.34f) else -> Color.Transparent } val cellBorderColor by animateColorAsState( @@ -1527,9 +1732,10 @@ private fun CalendarDayCell( label = "calendarMonthDateCellBorder", ) val targetCellBorderWidth = when { + isDropTarget -> 2.dp isSelected -> 1.6.dp isToday -> 1.4.dp - isPressed && cell.isCurrentMonth -> 1.2.dp + isPressed && isEnabled -> 1.2.dp else -> 0.dp } val cellBorderWidth by animateDpAsState( @@ -1553,8 +1759,15 @@ private fun CalendarDayCell( .fillMaxWidth() .height(CalendarMonthDayCellHeight) .graphicsLayer { alpha = if (cell.isCurrentMonth) 1f else 0.45f } + .calendarDateDropTarget( + date = cell.date, + draggedTodo = draggedTodo, + enabled = isEnabled, + onDropDateChanged = onDropDateChanged, + onMoveTaskToDate = onMoveTaskToDate, + ) .clickable( - enabled = cell.isCurrentMonth, + enabled = isEnabled, interactionSource = interactionSource, indication = null, onClick = onClick, @@ -1595,7 +1808,7 @@ private fun CalendarDayCell( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { - if (taskCount > 0 && cell.isCurrentMonth) { + if (taskCount > 0 && isEnabled) { Box( modifier = Modifier .size(CalendarMonthTaskDotSize) @@ -1620,6 +1833,7 @@ private fun CalendarDayCell( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun CalendarTodoRow( modifier: Modifier = Modifier, @@ -1629,6 +1843,8 @@ private fun CalendarTodoRow( onComplete: () -> Unit, onInfo: () -> Unit, onDelete: () -> Unit, + dragging: Boolean, + onDragStart: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -1660,6 +1876,7 @@ private fun CalendarTodoRow( Column( modifier = modifier .fillMaxWidth() + .graphicsLayer { alpha = if (dragging) 0.55f else 1f } .semantics(mergeDescendants = true) { }, verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -1709,6 +1926,23 @@ private fun CalendarTodoRow( modifier = Modifier .fillMaxSize() .graphicsLayer { translationX = animatedOffsetX } + .dragAndDropSource { + detectTapGestures( + onLongPress = { + onDragStart() + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + startTransfer( + DragAndDropTransferData( + clipData = ClipData.newPlainText("todo-id", todo.id), + flags = View.DRAG_FLAG_GLOBAL, + ), + ) + }, + ) + } .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt index 282a9bc8..e95c0190 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt @@ -10,9 +10,12 @@ import com.ohmz.tday.compose.core.data.todo.TodoRepository import com.ohmz.tday.compose.core.model.CompletedItem import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary +import com.ohmz.tday.compose.core.model.TaskRescheduleScope import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoListMode import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse +import com.ohmz.tday.compose.core.model.createMovedTaskPayload +import com.ohmz.tday.compose.core.model.repositoryTargetForReschedule import com.ohmz.tday.compose.core.notification.TaskReminderScheduler import com.ohmz.tday.compose.core.ui.userFacingMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -22,6 +25,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.LocalDate import javax.inject.Inject data class CalendarUiState( @@ -231,6 +235,26 @@ class CalendarViewModel @Inject constructor( } fun updateTask(todo: TodoItem, payload: CreateTaskPayload) { + updateTaskInternal( + visibleTodo = todo, + repositoryTodo = todo, + payload = payload, + ) + } + + fun moveTask(todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) { + updateTaskInternal( + visibleTodo = todo, + repositoryTodo = todo.repositoryTargetForReschedule(scope), + payload = createMovedTaskPayload(todo, targetDate), + ) + } + + private fun updateTaskInternal( + visibleTodo: TodoItem, + repositoryTodo: TodoItem, + payload: CreateTaskPayload, + ) { val normalizedTitle = payload.title.trim() if (normalizedTitle.isBlank()) return @@ -244,7 +268,7 @@ class CalendarViewModel @Inject constructor( val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } val previousState = _uiState.value - val optimisticTodo = todo.copy( + val optimisticTodo = visibleTodo.copy( title = normalizedTitle, description = normalizedDescription, priority = normalizedPriority, @@ -256,7 +280,7 @@ class CalendarViewModel @Inject constructor( _uiState.update { current -> current.copy( items = current.items.map { item -> - if (item.id == todo.id) optimisticTodo else item + if (item.id == visibleTodo.id) optimisticTodo else item }, errorMessage = null, ) @@ -265,7 +289,7 @@ class CalendarViewModel @Inject constructor( viewModelScope.launch { runCatching { todoRepository.updateTodo( - todo = todo, + todo = repositoryTodo, payload = CreateTaskPayload( title = normalizedTitle, description = normalizedDescription, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index 78b1889f..910f379b 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -198,10 +198,13 @@ import androidx.core.view.ViewCompat import com.ohmz.tday.compose.R import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary +import com.ohmz.tday.compose.core.model.TaskRescheduleScope import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoListMode import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter +import com.ohmz.tday.compose.core.model.supportsTaskReschedule +import com.ohmz.tday.compose.core.model.timelineRescheduleTargetDate import com.ohmz.tday.compose.core.ui.EmptyTaskBackgroundMessage import com.ohmz.tday.compose.core.ui.EmptyTaskWatermark import com.ohmz.tday.compose.core.ui.TaskSwipeActionButton @@ -234,11 +237,14 @@ fun TodoListScreen( onAddTask: (payload: CreateTaskPayload) -> Unit, onParseTaskTitleNlp: suspend (title: String, referenceDueEpochMs: Long) -> TodoTitleNlpResponse?, onUpdateTask: (todo: TodoItem, payload: CreateTaskPayload) -> Unit, + onMoveTask: (todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) -> Unit, onComplete: (todo: TodoItem) -> Unit, onDelete: (todo: TodoItem) -> Unit, onUpdateListSettings: (listId: String, name: String, color: String?, iconKey: String?) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme + val view = LocalView.current + val zoneId = remember { ZoneId.systemDefault() } val selectedList = uiState.lists.firstOrNull { it.id == uiState.listId } val selectedListColorKey = selectedList?.color val usesTodayStyle = @@ -385,6 +391,7 @@ fun TodoListScreen( var editTargetTodoId by rememberSaveable { mutableStateOf(null) } var draggedScheduledTodoId by rememberSaveable(uiState.mode) { mutableStateOf(null) } var activeDropSectionKey by remember(uiState.mode) { mutableStateOf(null) } + var pendingRescheduleDrop by remember(uiState.mode) { mutableStateOf(null) } var showListSettingsSheet by rememberSaveable { mutableStateOf(false) } var showSummarySheet by rememberSaveable(uiState.mode) { mutableStateOf(false) } var listSettingsTargetId by rememberSaveable { mutableStateOf(null) } @@ -402,6 +409,20 @@ fun TodoListScreen( uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } } } + val canRescheduleTasks = uiState.mode.supportsTaskReschedule() + val requestTaskReschedule: (TodoItem, LocalDate) -> Unit = { todo, targetDate -> + draggedScheduledTodoId = null + activeDropSectionKey = null + val currentDate = LocalDate.ofInstant(todo.due, zoneId) + if (currentDate != targetDate) { + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + if (todo.isRecurring) { + pendingRescheduleDrop = TaskRescheduleDrop(todo = todo, targetDate = targetDate) + } else { + onMoveTask(todo, targetDate, TaskRescheduleScope.OCCURRENCE) + } + } + } val canSummarizeCurrentMode = uiState.mode != TodoListMode.LIST && uiState.mode != TodoListMode.OVERDUE && @@ -601,7 +622,7 @@ fun TodoListScreen( val sectionCanCollapse = sectionModeCanCollapse && sectionHasTasks val isCollapsed = sectionCanCollapse && collapsedSectionKeys.contains(section.key) - val sectionDraggedTodo = if (uiState.mode == TodoListMode.SCHEDULED) { + val sectionDraggedTodo = if (canRescheduleTasks) { draggedScheduledTodo } else { null @@ -614,7 +635,7 @@ fun TodoListScreen( } } val onSectionDragEnd: (() -> Unit)? = - if (uiState.mode == TodoListMode.SCHEDULED) { + if (canRescheduleTasks) { { draggedScheduledTodoId = null activeDropSectionKey = null @@ -623,12 +644,8 @@ fun TodoListScreen( null } val onMoveTaskToSectionDate: ((TodoItem, LocalDate) -> Unit)? = - if (uiState.mode == TodoListMode.SCHEDULED) { - { todo, targetDate -> - draggedScheduledTodoId = null - activeDropSectionKey = null - onUpdateTask(todo, createMovedTaskPayload(todo, targetDate)) - } + if (canRescheduleTasks) { + requestTaskReschedule } else { null } @@ -750,7 +767,7 @@ fun TodoListScreen( editTargetTodoId = todo.id }, draggedTodo = sectionDraggedTodo, - onDragTodoStart = if (uiState.mode == TodoListMode.SCHEDULED) { + onDragTodoStart = if (canRescheduleTasks) { { activeDropSectionKey = null draggedScheduledTodoId = todo.id @@ -870,6 +887,42 @@ fun TodoListScreen( ) } + pendingRescheduleDrop?.let { drop -> + AlertDialog( + onDismissRequest = { pendingRescheduleDrop = null }, + title = { + Text( + text = stringResource(R.string.todos_reschedule_recurring_title), + fontWeight = FontWeight.ExtraBold, + ) + }, + text = { + Text(text = stringResource(R.string.todos_reschedule_recurring_message)) + }, + dismissButton = { + TextButton(onClick = { pendingRescheduleDrop = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + confirmButton = { + Row { + TextButton(onClick = { + pendingRescheduleDrop = null + onMoveTask(drop.todo, drop.targetDate, TaskRescheduleScope.OCCURRENCE) + }) { + Text(stringResource(R.string.todos_reschedule_this_occurrence)) + } + TextButton(onClick = { + pendingRescheduleDrop = null + onMoveTask(drop.todo, drop.targetDate, TaskRescheduleScope.SERIES) + }) { + Text(stringResource(R.string.todos_reschedule_entire_series)) + } + } + }, + ) + } + editTargetTodo?.let { todo -> CreateTaskBottomSheet( lists = uiState.lists, @@ -1723,6 +1776,9 @@ private fun TimelineTaskRow( showDuePrefix = true, showDueDateInSubtitle = showEarlierDateTimeSubtitle, showDateDivider = showDateDivider, + dragEnabled = onDragTodoStart != null, + dragging = draggedTodo?.id == todo.id, + onDragStart = { onDragTodoStart?.invoke() }, ) } else if ( useMinimalStyle && @@ -1813,6 +1869,11 @@ private data class TodoSection( val targetDate: LocalDate? = null, ) +private data class TaskRescheduleDrop( + val todo: TodoItem, + val targetDate: LocalDate, +) + private fun shouldShowDateDivider( afterItemIndex: Int, inSectionIndex: Int, @@ -1997,7 +2058,7 @@ private fun buildScheduledSections( date = date, zoneId = zoneId, ), - targetDate = date, + targetDate = timelineRescheduleTargetDate("day-$date", today), ) } @@ -2062,6 +2123,7 @@ private fun buildScheduledSections( date = currentMonth.atEndOfMonth(), zoneId = zoneId, ), + targetDate = timelineRescheduleTargetDate("rest-$currentMonth", today), ) val futureMonthsWithData = @@ -2086,6 +2148,7 @@ private fun buildScheduledSections( date = targetMonth.atDay(1), zoneId = zoneId, ), + targetDate = timelineRescheduleTargetDate("month-$targetMonth", today), ) targetMonth = targetMonth.plusMonths(1) } @@ -2183,24 +2246,6 @@ private fun quickAddDefaultsForTodaySection( return ZonedDateTime.of(today, time, zoneId).toInstant().toEpochMilli() } -private fun createMovedTaskPayload( - todo: TodoItem, - targetDate: LocalDate, - zoneId: ZoneId = ZoneId.systemDefault(), -): CreateTaskPayload { - val dueTime = todo.due.atZone(zoneId).toLocalTime() - val movedDue = ZonedDateTime.of(targetDate, dueTime, zoneId).toInstant() - - return CreateTaskPayload( - title = todo.title, - description = todo.description, - priority = todo.priority, - due = movedDue, - rrule = todo.rrule, - listId = todo.listId, - ) -} - private suspend fun LazyListState.animateSearchResultScrollToItem( targetIndex: Int, targetKey: String, @@ -2305,6 +2350,9 @@ private fun AllTaskSwipeRow( showDuePrefix: Boolean, showDueDateInSubtitle: Boolean = false, showDateDivider: Boolean, + dragEnabled: Boolean = false, + dragging: Boolean = false, + onDragStart: (() -> Unit)? = null, ) { SwipeTaskRow( todo = todo, @@ -2320,6 +2368,9 @@ private fun AllTaskSwipeRow( showDueDateInSubtitle = showDueDateInSubtitle, showDateDivider = showDateDivider, useDelayedFadeCompletion = false, + dragEnabled = dragEnabled, + dragging = dragging, + onDragStart = onDragStart, ) } @@ -2544,6 +2595,10 @@ private fun SwipeTaskRow( detectTapGestures( onLongPress = { onDragStart?.invoke() + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) startTransfer( DragAndDropTransferData( clipData = ClipData.newPlainText( diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt index 12630189..264d8fb8 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt @@ -11,10 +11,13 @@ import com.ohmz.tday.compose.core.data.sync.SyncManager import com.ohmz.tday.compose.core.data.todo.TodoRepository import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary +import com.ohmz.tday.compose.core.model.TaskRescheduleScope import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoListMode import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter +import com.ohmz.tday.compose.core.model.createMovedTaskPayload +import com.ohmz.tday.compose.core.model.repositoryTargetForReschedule import com.ohmz.tday.compose.core.notification.TaskReminderScheduler import com.ohmz.tday.compose.core.ui.userFacingMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -24,6 +27,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.LocalDate import javax.inject.Inject data class TodoListUiState( @@ -291,6 +295,26 @@ class TodoListViewModel @Inject constructor( } fun updateTask(todo: TodoItem, payload: CreateTaskPayload) { + updateTaskInternal( + visibleTodo = todo, + repositoryTodo = todo, + payload = payload, + ) + } + + fun moveTask(todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) { + updateTaskInternal( + visibleTodo = todo, + repositoryTodo = todo.repositoryTargetForReschedule(scope), + payload = createMovedTaskPayload(todo, targetDate), + ) + } + + private fun updateTaskInternal( + visibleTodo: TodoItem, + repositoryTodo: TodoItem, + payload: CreateTaskPayload, + ) { val normalizedTitle = payload.title.trim() if (normalizedTitle.isBlank()) return @@ -306,7 +330,7 @@ class TodoListViewModel @Inject constructor( val previousState = _uiState.value val mode = previousState.mode val currentListId = previousState.listId - val updatedTodo = todo.copy( + val updatedTodo = visibleTodo.copy( title = normalizedTitle, description = normalizedDescription, priority = normalizedPriority, @@ -317,11 +341,11 @@ class TodoListViewModel @Inject constructor( _uiState.update { current -> val optimisticItems = current.items - .map { item -> if (item.id == todo.id) updatedTodo else item } + .map { item -> if (item.id == visibleTodo.id) updatedTodo else item } .filterNot { item -> current.mode == TodoListMode.LIST && !current.listId.isNullOrBlank() && - item.id == todo.id && + item.id == visibleTodo.id && item.listId != current.listId } current.copy(items = optimisticItems, errorMessage = null) @@ -330,7 +354,7 @@ class TodoListViewModel @Inject constructor( viewModelScope.launch { runCatching { todoRepository.updateTodo( - todo = todo, + todo = repositoryTodo, payload = CreateTaskPayload( title = normalizedTitle, description = normalizedDescription, diff --git a/android-compose/app/src/main/res/values/strings.xml b/android-compose/app/src/main/res/values/strings.xml index 3e429983..019351a9 100644 --- a/android-compose/app/src/main/res/values/strings.xml +++ b/android-compose/app/src/main/res/values/strings.xml @@ -112,6 +112,10 @@ Today Tomorrow Rest of %1$s + Move repeating task? + Choose whether to move only this task occurrence or the entire repeating series. + This occurrence + Entire series Summary Close summary Creating your task summary… diff --git a/ios-swiftUI/Tday/Core/Model/DomainModels.swift b/ios-swiftUI/Tday/Core/Model/DomainModels.swift index 4a147944..c50b1cdb 100644 --- a/ios-swiftUI/Tday/Core/Model/DomainModels.swift +++ b/ios-swiftUI/Tday/Core/Model/DomainModels.swift @@ -44,6 +44,11 @@ enum TodoListMode: String, Codable, CaseIterable, Hashable { } } +enum TaskRescheduleScope: String, Codable, Hashable { + case occurrence + case series +} + struct CreateTaskPayload: Equatable, Hashable, Codable { let title: String let description: String? @@ -80,6 +85,127 @@ struct TodoItem: Identifiable, Equatable, Hashable, Codable { } } +extension TodoListMode { + var supportsTaskReschedule: Bool { + switch self { + case .scheduled, .all, .priority, .list: + return true + case .today, .overdue: + return false + } + } +} + +extension TodoItem { + func repositoryTargetForReschedule(scope: TaskRescheduleScope) -> TodoItem { + switch scope { + case .occurrence: + return self + case .series: + return TodoItem( + id: canonicalId, + canonicalId: canonicalId, + title: title, + description: description, + priority: priority, + due: due, + rrule: rrule, + instanceDate: nil, + pinned: pinned, + completed: completed, + listId: listId, + updatedAt: updatedAt + ) + } + } +} + +func movedDuePreservingTime( + due: Date, + targetDay: Date, + calendar: Calendar = .current +) -> Date? { + let dueComponents = calendar.dateComponents([.hour, .minute, .second, .nanosecond], from: due) + var targetComponents = calendar.dateComponents([.year, .month, .day], from: targetDay) + targetComponents.timeZone = calendar.timeZone + targetComponents.hour = dueComponents.hour + targetComponents.minute = dueComponents.minute + targetComponents.second = dueComponents.second + targetComponents.nanosecond = dueComponents.nanosecond + return calendar.date(from: targetComponents) +} + +func movedTaskPayload( + todo: TodoItem, + targetDay: Date, + calendar: Calendar = .current +) -> CreateTaskPayload? { + guard let movedDue = movedDuePreservingTime(due: todo.due, targetDay: targetDay, calendar: calendar) else { + return nil + } + return CreateTaskPayload( + title: todo.title, + description: todo.description, + priority: todo.priority, + due: movedDue, + rrule: todo.rrule, + listId: todo.listId + ) +} + +func timelineRescheduleTargetDate( + sectionId: String, + today: Date = Date(), + calendar: Calendar = .current +) -> Date? { + let startOfToday = calendar.startOfDay(for: today) + let currentMonthStart = rescheduleMonthStart(for: startOfToday, calendar: calendar) + + if sectionId.hasPrefix("scheduled-") || sectionId.hasPrefix("priority-") { + guard let suffix = sectionId.split(separator: "-").last, + let interval = TimeInterval(String(suffix)) else { + return nil + } + let date = calendar.startOfDay(for: Date(timeIntervalSince1970: interval)) + return rescheduleMonthStart(for: date, calendar: calendar) >= currentMonthStart ? date : nil + } + + if sectionId.hasPrefix("rest-") { + guard let monthIndexValue = Int(sectionId.replacingOccurrences(of: "rest-", with: "")) else { + return nil + } + let horizonStart = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 7, to: startOfToday) ?? startOfToday) + return rescheduleMonthIndex(for: horizonStart, calendar: calendar) == monthIndexValue && + rescheduleMonthStart(for: horizonStart, calendar: calendar) == currentMonthStart ? horizonStart : nil + } + + if sectionId.hasPrefix("month-") { + guard let monthIndexValue = Int(sectionId.replacingOccurrences(of: "month-", with: "")) else { + return nil + } + let currentMonthIndex = rescheduleMonthIndex(for: startOfToday, calendar: calendar) + guard monthIndexValue >= currentMonthIndex else { + return nil + } + let year = (monthIndexValue - 1) / 12 + let month = ((monthIndexValue - 1) % 12) + 1 + return calendar.date(from: DateComponents(year: year, month: month, day: 1)) + } + + return nil +} + +private func rescheduleMonthStart(for date: Date, calendar: Calendar) -> Date { + let components = calendar.dateComponents([.year, .month], from: date) + return calendar.date(from: components).map(calendar.startOfDay) ?? calendar.startOfDay(for: date) +} + +private func rescheduleMonthIndex(for date: Date, calendar: Calendar) -> Int { + let year = calendar.component(.year, from: date) + let month = calendar.component(.month, from: date) + return year * 12 + month +} + struct ListSummary: Identifiable, Equatable, Hashable, Codable { let id: String let name: String diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index b7ea9250..b725d98f 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import UIKit +import UniformTypeIdentifiers private enum CalendarTitleHandoff { static let collapseDistance: CGFloat = 180 @@ -46,6 +47,11 @@ private struct CalendarTodayJumpRequest: Equatable { let targetDate: Date } +private struct CalendarTaskRescheduleDrop: Equatable { + let todo: TodoItem + let targetDate: Date +} + struct CalendarScreen: View { @State private var viewModel: CalendarViewModel @Environment(\.tdayColors) private var colors @@ -60,6 +66,9 @@ struct CalendarScreen: View { @State private var calendarTitleCollapseOffset: CGFloat = 0 @State private var todayJumpRequestID = 0 @State private var todayJumpRequest: CalendarTodayJumpRequest? + @State private var draggedTodo: TodoItem? + @State private var activeDropDate: Date? + @State private var pendingRescheduleDrop: CalendarTaskRescheduleDrop? init(container: AppContainer) { _viewModel = State(initialValue: CalendarViewModel(container: container)) @@ -165,6 +174,12 @@ struct CalendarScreen: View { todo: todo, onComplete: { Task { await viewModel.complete(todo) } } ) + .opacity(draggedTodo?.id == todo.id && activeDropDate != nil ? 0.55 : 1) + .onDrag { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + draggedTodo = todo + return NSItemProvider(object: todo.id as NSString) + } .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -252,6 +267,30 @@ struct CalendarScreen: View { } ) } + .confirmationDialog( + "Move repeating task?", + isPresented: Binding( + get: { pendingRescheduleDrop != nil }, + set: { isPresented in + if !isPresented { + pendingRescheduleDrop = nil + } + } + ), + titleVisibility: .visible + ) { + Button("This occurrence") { + commitPendingReschedule(scope: .occurrence) + } + Button("Entire series") { + commitPendingReschedule(scope: .series) + } + Button("Cancel", role: .cancel) { + pendingRescheduleDrop = nil + } + } message: { + Text("Choose whether to move only this task occurrence or the entire repeating series.") + } } private var calendarTopInset: some View { @@ -290,12 +329,16 @@ struct CalendarScreen: View { selectedDate: selectedDate, tasksByDay: pendingItemsByDay, accentColor: calendarAccentColor, + draggedTodo: draggedTodo, + activeDropDate: activeDropDate, canGoPreviousMonth: canGoPreviousMonth, minimumNavigableMonth: minimumNavigableMonth, todayJumpRequest: todayJumpRequest, onPreviousMonth: { navigateMonth(by: -1) }, onNextMonth: { navigateMonth(by: 1) }, - onSelectDate: { selectDate($0) } + onSelectDate: { selectDate($0) }, + onDropDateChange: { activeDropDate = $0 }, + onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) } ) case .week: CalendarWeekCard( @@ -303,12 +346,16 @@ struct CalendarScreen: View { today: Date(), tasksByDay: pendingItemsByDay, accentColor: calendarAccentColor, + draggedTodo: draggedTodo, + activeDropDate: activeDropDate, canGoPreviousWeek: canGoPreviousWeek, canSelectDate: { canNavigate(to: $0) }, todayJumpRequest: todayJumpRequest, onPreviousWeek: { navigateDay(by: -7) }, onNextWeek: { navigateDay(by: 7) }, - onSelectDate: { selectDate($0) } + onSelectDate: { selectDate($0) }, + onDropDateChange: { activeDropDate = $0 }, + onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) } ) case .day: CalendarDayCard( @@ -316,12 +363,16 @@ struct CalendarScreen: View { today: Date(), tasksByDay: pendingItemsByDay, accentColor: calendarAccentColor, + draggedTodo: draggedTodo, + activeDropDate: activeDropDate, canGoPreviousDay: canGoPreviousDay, canSelectDate: { canNavigate(to: $0) }, todayJumpRequest: todayJumpRequest, onPreviousDay: { navigateDay(by: -1) }, onNextDay: { navigateDay(by: 1) }, - onSelectDate: { selectDate($0) } + onSelectDate: { selectDate($0) }, + onDropDateChange: { activeDropDate = $0 }, + onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) } ) } } @@ -355,6 +406,40 @@ struct CalendarScreen: View { todayJumpRequestID += 1 todayJumpRequest = CalendarTodayJumpRequest(id: todayJumpRequestID, targetDate: Date()) } + + private func requestReschedule(_ todo: TodoItem, to targetDate: Date) { + draggedTodo = nil + activeDropDate = nil + let targetDay = Calendar.current.startOfDay(for: targetDate) + guard !Calendar.current.isDate(todo.due, inSameDayAs: targetDay) else { + return + } + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if todo.isRecurring { + pendingRescheduleDrop = CalendarTaskRescheduleDrop(todo: todo, targetDate: targetDay) + } else { + Task { + await viewModel.moveTask(todo, toDay: targetDay, scope: .occurrence) + await MainActor.run { + selectDate(targetDay) + } + } + } + } + + private func commitPendingReschedule(scope: TaskRescheduleScope) { + guard let drop = pendingRescheduleDrop else { + return + } + pendingRescheduleDrop = nil + Task { + await viewModel.moveTask(drop.todo, toDay: drop.targetDate, scope: scope) + await MainActor.run { + selectDate(drop.targetDate) + } + } + } } private struct CalendarViewModeTabs: View { @@ -386,12 +471,16 @@ private struct CalendarMonthGrid: View { let selectedDate: Date let tasksByDay: [Date: [TodoItem]] let accentColor: Color + let draggedTodo: TodoItem? + let activeDropDate: Date? let canGoPreviousMonth: Bool let minimumNavigableMonth: Date let todayJumpRequest: CalendarTodayJumpRequest? let onPreviousMonth: () -> Void let onNextMonth: () -> Void let onSelectDate: (Date) -> Void + let onDropDateChange: (Date?) -> Void + let onMoveTaskToDate: (TodoItem, Date) -> Void @Environment(\.tdayColors) private var colors @State private var pageSelection = calendarNativePagerCenterIndex @@ -493,14 +582,23 @@ private struct CalendarMonthGrid: View { day: day, isSelected: Calendar.current.isDate(day.date, inSameDayAs: selectedDate), isToday: Calendar.current.isDateInToday(day.date), + isEnabled: canSelectDate(day.date), + isDropTarget: activeDropDate.map { Calendar.current.isDate($0, inSameDayAs: day.date) } ?? false, taskCount: dayTasks.count, accentColor: accentColor, - onSelectDate: onSelectDate + draggedTodo: draggedTodo, + onSelectDate: onSelectDate, + onDropDateChange: onDropDateChange, + onMoveTaskToDate: onMoveTaskToDate ) } } } + private func canSelectDate(_ date: Date) -> Bool { + calendarMonthStart(for: date) >= minimumNavigableMonth + } + private func monthPages(previousMonth: Date?, displayMonth: Date, nextMonth: Date?) -> [CalendarPagerPage] { var pages: [CalendarPagerPage] = [] @@ -604,12 +702,16 @@ private struct CalendarWeekCard: View { let today: Date let tasksByDay: [Date: [TodoItem]] let accentColor: Color + let draggedTodo: TodoItem? + let activeDropDate: Date? let canGoPreviousWeek: Bool let canSelectDate: (Date) -> Bool let todayJumpRequest: CalendarTodayJumpRequest? let onPreviousWeek: () -> Void let onNextWeek: () -> Void let onSelectDate: (Date) -> Void + let onDropDateChange: (Date?) -> Void + let onMoveTaskToDate: (TodoItem, Date) -> Void @Environment(\.tdayColors) private var colors @State private var pageSelection = calendarNativePagerCenterIndex @@ -696,7 +798,11 @@ private struct CalendarWeekCard: View { isToday: Calendar.current.isDate(date, inSameDayAs: today), isEnabled: isEnabled, accentColor: accentColor, - onSelect: { onSelectDate(date) } + isDropTarget: activeDropDate.map { Calendar.current.isDate($0, inSameDayAs: date) } ?? false, + draggedTodo: draggedTodo, + onSelect: { onSelectDate(date) }, + onDropDateChange: onDropDateChange, + onMoveTaskToDate: onMoveTaskToDate ) } } @@ -791,7 +897,11 @@ private struct CalendarWeekDayCell: View { let isToday: Bool let isEnabled: Bool let accentColor: Color + let isDropTarget: Bool + let draggedTodo: TodoItem? let onSelect: () -> Void + let onDropDateChange: (Date?) -> Void + let onMoveTaskToDate: (TodoItem, Date) -> Void @Environment(\.tdayColors) private var colors @@ -825,6 +935,15 @@ private struct CalendarWeekDayCell: View { } .buttonStyle(.plain) .disabled(!isEnabled) + .onDrop( + of: [UTType.plainText.identifier], + delegate: CalendarDateDropDelegate( + date: date, + draggedTodo: isEnabled ? draggedTodo : nil, + onMove: onMoveTaskToDate, + onDateChange: onDropDateChange + ) + ) .opacity(isEnabled ? 1 : 0.48) } @@ -839,6 +958,9 @@ private struct CalendarWeekDayCell: View { } private var cellBackground: Color { + if isDropTarget { + return accentColor.opacity(0.34) + } if isSelected { return accentColor.opacity(0.24) } @@ -849,6 +971,9 @@ private struct CalendarWeekDayCell: View { } private var cellBorderColor: Color { + if isDropTarget { + return accentColor + } if isSelected { return accentColor.opacity(0.95) } @@ -859,6 +984,9 @@ private struct CalendarWeekDayCell: View { } private var cellBorderWidth: CGFloat { + if isDropTarget { + return 2 + } if isSelected { return 1.6 } @@ -889,17 +1017,55 @@ private struct CalendarWeekDayCell: View { } } +private struct CalendarDateDropDelegate: DropDelegate { + let date: Date + let draggedTodo: TodoItem? + let onMove: (TodoItem, Date) -> Void + let onDateChange: (Date?) -> Void + + func validateDrop(info: DropInfo) -> Bool { + draggedTodo != nil + } + + func dropEntered(info: DropInfo) { + onDateChange(Calendar.current.startOfDay(for: date)) + } + + func dropExited(info: DropInfo) { + onDateChange(nil) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + onDateChange(nil) + } + guard let draggedTodo else { + return false + } + onMove(draggedTodo, Calendar.current.startOfDay(for: date)) + return true + } +} + private struct CalendarDayCard: View { let selectedDate: Date let today: Date let tasksByDay: [Date: [TodoItem]] let accentColor: Color + let draggedTodo: TodoItem? + let activeDropDate: Date? let canGoPreviousDay: Bool let canSelectDate: (Date) -> Bool let todayJumpRequest: CalendarTodayJumpRequest? let onPreviousDay: () -> Void let onNextDay: () -> Void let onSelectDate: (Date) -> Void + let onDropDateChange: (Date?) -> Void + let onMoveTaskToDate: (TodoItem, Date) -> Void @Environment(\.tdayColors) private var colors @State private var pageSelection = calendarNativePagerCenterIndex @@ -984,7 +1150,9 @@ private struct CalendarDayCard: View { } private func daySummary(for date: Date) -> some View { - VStack(alignment: .leading, spacing: 14) { + let isEnabled = canSelectDate(date) + let isDropTarget = activeDropDate.map { Calendar.current.isDate($0, inSameDayAs: date) } ?? false + return VStack(alignment: .leading, spacing: 14) { Text(dateTitle(for: date)) .font(.tdayRounded(size: 25, weight: .heavy)) .foregroundStyle(Calendar.current.isDate(date, inSameDayAs: today) ? accentColor : colors.onSurface) @@ -993,7 +1161,22 @@ private struct CalendarDayCard: View { .font(.tdayRounded(size: 18, weight: .heavy)) .foregroundStyle(colors.onSurfaceVariant) } + .padding(.horizontal, 6) + .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) + .background( + isDropTarget ? accentColor.opacity(0.12) : .clear, + in: RoundedRectangle(cornerRadius: 16, style: .continuous) + ) + .onDrop( + of: [UTType.plainText.identifier], + delegate: CalendarDateDropDelegate( + date: date, + draggedTodo: isEnabled ? draggedTodo : nil, + onMove: onMoveTaskToDate, + onDateChange: onDropDateChange + ) + ) } private func resetPageSelection() { @@ -1099,9 +1282,14 @@ private struct CalendarMonthDayCell: View { let day: CalendarMonthDay let isSelected: Bool let isToday: Bool + let isEnabled: Bool + let isDropTarget: Bool let taskCount: Int let accentColor: Color + let draggedTodo: TodoItem? let onSelectDate: (Date) -> Void + let onDropDateChange: (Date?) -> Void + let onMoveTaskToDate: (TodoItem, Date) -> Void @Environment(\.tdayColors) private var colors @@ -1153,7 +1341,16 @@ private struct CalendarMonthDayCell: View { .frame(height: CalendarMonthGridMetrics.dayCellHeight) } .buttonStyle(.plain) - .disabled(!day.isCurrentMonth) + .disabled(!isEnabled) + .onDrop( + of: [UTType.plainText.identifier], + delegate: CalendarDateDropDelegate( + date: day.date, + draggedTodo: isEnabled ? draggedTodo : nil, + onMove: onMoveTaskToDate, + onDateChange: onDropDateChange + ) + ) .opacity(day.isCurrentMonth ? 1 : 0.45) } @@ -1172,6 +1369,9 @@ private struct CalendarMonthDayCell: View { } private var cellBackground: Color { + if isDropTarget { + return accentColor.opacity(0.34) + } if isSelected { return accentColor.opacity(0.24) } @@ -1182,6 +1382,9 @@ private struct CalendarMonthDayCell: View { } private var cellBorderColor: Color { + if isDropTarget { + return accentColor + } if isSelected { return accentColor.opacity(0.95) } @@ -1192,6 +1395,9 @@ private struct CalendarMonthDayCell: View { } private var cellBorderWidth: CGFloat { + if isDropTarget { + return 2 + } if isSelected { return 1.6 } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift index d809ad8a..0778e079 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift @@ -74,6 +74,19 @@ final class CalendarViewModel { } } + func moveTask(_ todo: TodoItem, toDay targetDay: Date, scope: TaskRescheduleScope) async { + let calendar = Calendar.current + guard !calendar.isDate(todo.due, inSameDayAs: targetDay), + let payload = movedTaskPayload(todo: todo, targetDay: targetDay, calendar: calendar) else { + return + } + + await updateTask( + todo.repositoryTargetForReschedule(scope: scope), + payload: payload + ) + } + func delete(_ todo: TodoItem) async { do { try await container.todoRepository.deleteTodo(todo) diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 9259423e..dfd45262 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit import UniformTypeIdentifiers enum TodoTimelineMetrics { @@ -150,6 +151,7 @@ struct TodoListScreen: View { @State private var showingListSettings = false @State private var draggedTodo: TodoItem? @State private var activeDropSectionId: String? + @State private var pendingRescheduleDrop: TodoRescheduleDrop? @State private var collapsedSectionIDs: Set @State private var timelineScrollOffset: CGFloat = 0 @State private var completingTodoIDs: Set = [] @@ -287,6 +289,30 @@ struct TodoListScreen: View { .sheet(isPresented: $showingListSettings) { listSettingsSheetContent } + .confirmationDialog( + "Move repeating task?", + isPresented: Binding( + get: { pendingRescheduleDrop != nil }, + set: { isPresented in + if !isPresented { + pendingRescheduleDrop = nil + } + } + ), + titleVisibility: .visible + ) { + Button("This occurrence") { + commitPendingReschedule(scope: .occurrence) + } + Button("Entire series") { + commitPendingReschedule(scope: .series) + } + Button("Cancel", role: .cancel) { + pendingRescheduleDrop = nil + } + } message: { + Text("Choose whether to move only this task occurrence or the entire repeating series.") + } } @ToolbarContentBuilder @@ -431,6 +457,31 @@ struct TodoListScreen: View { } } + private func requestReschedule(_ todo: TodoItem, to targetDate: Date) { + activeDropSectionId = nil + draggedTodo = nil + guard !Calendar.current.isDate(todo.due, inSameDayAs: targetDate) else { + return + } + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if todo.isRecurring { + pendingRescheduleDrop = TodoRescheduleDrop(todo: todo, targetDate: targetDate) + } else { + Task { await viewModel.moveTask(todo, toDay: targetDate, scope: .occurrence) } + } + } + + private func commitPendingReschedule(scope: TaskRescheduleScope) { + guard let drop = pendingRescheduleDrop else { + return + } + pendingRescheduleDrop = nil + Task { + await viewModel.moveTask(drop.todo, toDay: drop.targetDate, scope: scope) + } + } + private func matchesHighlightedTodo(_ todo: TodoItem, id: String) -> Bool { todo.id == id || todo.canonicalId == id } @@ -545,7 +596,7 @@ struct TodoListScreen: View { todoRow(todo, in: section) .listRowBackground(todo.id == highlightedTodoId ? colors.surfaceVariant : colors.surface) } - if viewModel.mode == .scheduled, !section.items.isEmpty { + if viewModel.mode.supportsTaskReschedule, !section.items.isEmpty { Color.clear .frame(height: 8) .listRowInsets(EdgeInsets()) @@ -555,9 +606,7 @@ struct TodoListScreen: View { section: section, draggedTodo: draggedTodo, onMove: { todo, targetDate in - activeDropSectionId = nil - draggedTodo = nil - Task { await viewModel.moveTask(todo, toDay: targetDate) } + requestReschedule(todo, to: targetDate) }, onSectionChange: { sectionId in activeDropSectionId = sectionId @@ -569,6 +618,19 @@ struct TodoListScreen: View { Text(section.title) .foregroundStyle(activeDropSectionId == section.id ? colors.primary : colors.onSurfaceVariant) .timelinePinnedSectionHeaderBackground() + .onDrop( + of: [UTType.plainText.identifier], + delegate: ScheduledTodoDropDelegate( + section: section, + draggedTodo: draggedTodo, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + onSectionChange: { sectionId in + activeDropSectionId = sectionId + } + ) + ) } } } @@ -763,9 +825,7 @@ struct TodoListScreen: View { section: section, draggedTodo: draggedTodo, onMove: { droppedTodo, targetDate in - activeDropSectionId = nil - draggedTodo = nil - Task { await viewModel.moveTask(droppedTodo, toDay: targetDate) } + requestReschedule(droppedTodo, to: targetDate) }, onSectionChange: { sectionId in activeDropSectionId = sectionId @@ -774,7 +834,7 @@ struct TodoListScreen: View { ) .modifier( ScheduledDragModifier( - enabled: viewModel.mode == .scheduled, + enabled: viewModel.mode.supportsTaskReschedule, todo: todo, onDragStart: { draggedTodo = todo @@ -865,9 +925,7 @@ struct TodoListScreen: View { section: section, draggedTodo: draggedTodo, onMove: { droppedTodo, targetDate in - activeDropSectionId = nil - draggedTodo = nil - Task { await viewModel.moveTask(droppedTodo, toDay: targetDate) } + requestReschedule(droppedTodo, to: targetDate) }, onSectionChange: { sectionId in activeDropSectionId = sectionId @@ -876,7 +934,7 @@ struct TodoListScreen: View { ) .modifier( ScheduledDragModifier( - enabled: viewModel.mode == .scheduled, + enabled: viewModel.mode.supportsTaskReschedule, todo: todo, onDragStart: { draggedTodo = todo @@ -939,6 +997,19 @@ struct TodoListScreen: View { .id(timelineSectionScrollID(section.id)) .padding(.top, isFirstSection ? 0 : 8) .timelinePinnedSectionHeaderBackground() + .onDrop( + of: [UTType.plainText.identifier], + delegate: ScheduledTodoDropDelegate( + section: section, + draggedTodo: draggedTodo, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + onSectionChange: { sectionId in + activeDropSectionId = sectionId + } + ) + ) .listRowInsets( EdgeInsets( top: 0, @@ -1595,6 +1666,11 @@ private struct TodoTimelineSection: Identifiable, Hashable { let targetDate: Date? } +private struct TodoRescheduleDrop: Equatable { + let todo: TodoItem + let targetDate: Date +} + private struct ScheduledDragModifier: ViewModifier { let enabled: Bool let todo: TodoItem @@ -1604,6 +1680,7 @@ private struct ScheduledDragModifier: ViewModifier { func body(content: Content) -> some View { if enabled { content.onDrag { + UIImpactFeedbackGenerator(style: .light).impactOccurred() onDragStart() return NSItemProvider(object: todo.id as NSString) } @@ -1710,14 +1787,17 @@ private func buildSections(items: [TodoItem], mode: TodoListMode) -> [TodoTimeli calendar.startOfDay(for: item.due) } return grouped.keys.sorted().map { date in - TodoTimelineSection( - id: "scheduled-\(date.timeIntervalSince1970)", - title: scheduledSectionTitle(for: date, calendar: calendar), - items: grouped[date]?.sorted(by: todoTimelineSortPrecedes) ?? [], - isCollapsible: false, - targetDate: date - ) - } + TodoTimelineSection( + id: "scheduled-\(date.timeIntervalSince1970)", + title: scheduledSectionTitle(for: date, calendar: calendar), + items: grouped[date]?.sorted(by: todoTimelineSortPrecedes) ?? [], + isCollapsible: false, + targetDate: timelineRescheduleTargetDate( + sectionId: "scheduled-\(date.timeIntervalSince1970)", + calendar: calendar + ) + ) + } case .all: return buildFutureTimelineSections(items: items, calendar: calendar, placesEarlierBeforeToday: true) case .priority, .list: @@ -1751,12 +1831,13 @@ private func buildFutureTimelineSections( let horizonStart = calendar.date(byAdding: .day, value: 7, to: today) ?? today func daySection(for date: Date, title: String) -> TodoTimelineSection { - TodoTimelineSection( - id: "priority-\(date.timeIntervalSince1970)", + let sectionId = "priority-\(date.timeIntervalSince1970)" + return TodoTimelineSection( + id: sectionId, title: title, items: groupedByDate[date] ?? [], isCollapsible: false, - targetDate: nil + targetDate: timelineRescheduleTargetDate(sectionId: sectionId, calendar: calendar) ) } @@ -1811,7 +1892,10 @@ private func buildFutureTimelineSections( title: "Rest of \(monthTitle(for: currentMonthStart, currentYear: currentYear, calendar: calendar))", items: restOfCurrentMonthItems, isCollapsible: false, - targetDate: nil + targetDate: timelineRescheduleTargetDate( + sectionId: "rest-\(currentMonthIndex)", + calendar: calendar + ) ) ) } @@ -1844,7 +1928,10 @@ private func buildFutureTimelineSections( title: monthTitle(for: monthStart, currentYear: currentYear, calendar: calendar), items: monthItems, isCollapsible: false, - targetDate: nil + targetDate: timelineRescheduleTargetDate( + sectionId: "month-\(targetMonthIndex)", + calendar: calendar + ) ) ) diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift index 5c70dd6e..c7ba1604 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift @@ -105,34 +105,19 @@ final class TodoListViewModel { } } - func moveTask(_ todo: TodoItem, toDay targetDay: Date) async { + func moveTask(_ todo: TodoItem, toDay targetDay: Date, scope: TaskRescheduleScope) async { let calendar = Calendar.current guard !calendar.isDate(todo.due, inSameDayAs: targetDay) else { return } - let dueTimeComponents = calendar.dateComponents([.hour, .minute, .second, .nanosecond], from: todo.due) - var targetComponents = calendar.dateComponents([.year, .month, .day], from: targetDay) - targetComponents.timeZone = calendar.timeZone - targetComponents.hour = dueTimeComponents.hour - targetComponents.minute = dueTimeComponents.minute - targetComponents.second = dueTimeComponents.second - targetComponents.nanosecond = dueTimeComponents.nanosecond - - guard let movedDue = calendar.date(from: targetComponents) else { + guard let payload = movedTaskPayload(todo: todo, targetDay: targetDay, calendar: calendar) else { return } await updateTask( - todo, - payload: CreateTaskPayload( - title: todo.title, - description: todo.description, - priority: todo.priority, - due: movedDue, - rrule: todo.rrule, - listId: todo.listId - ) + todo.repositoryTargetForReschedule(scope: scope), + payload: payload ) } diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index de22cf6f..1f300118 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 3667E1D45490DE558553F39F /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC35A6A50C3BFA68468FEDF9 /* OfflineBanner.swift */; }; 384B88FF643D87A6157C76C2 /* Nunito.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D9D2A99D6C098B63352D4FB8 /* Nunito.ttf */; }; 3E0BE8F327DB1A2EE5B101C4 /* ReminderPreferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3407352DB3FED037D2A26BF /* ReminderPreferenceStore.swift */; }; + 434143484D41505045525300 /* CacheMappersDateParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434143484D41505045525301 /* CacheMappersDateParsingTests.swift */; }; 494E748270A233BECED5A359 /* TodayTasksWidgetSnapshotStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC92D6152EADEDF915AE116B /* TodayTasksWidgetSnapshotStoreTests.swift */; }; 4A7B9C0D1E2F3456789ABC01 /* CompletedSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */; }; 4D2A9B7E2CE3424C9D111001 /* ConnectivityClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */; }; @@ -117,6 +118,7 @@ 37A754219844F42A2230F08B /* SwiftDataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDataModels.swift; sourceTree = ""; }; 39ADD9E07BF8B32C3FC7B170 /* TdayTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TdayTheme.swift; sourceTree = ""; }; 42749601AFF38EAE17BD3213 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; + 434143484D41505045525301 /* CacheMappersDateParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheMappersDateParsingTests.swift; sourceTree = ""; }; 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedSyncMergeTests.swift; sourceTree = ""; }; 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityClassificationTests.swift; sourceTree = ""; }; 4E4F544946444545504C3032 /* NotificationDeepLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDeepLinkRouter.swift; sourceTree = ""; }; @@ -223,6 +225,7 @@ isa = PBXGroup; children = ( 629820F1BA29236313992076 /* ApiModelContractTests.swift */, + 434143484D41505045525301 /* CacheMappersDateParsingTests.swift */, 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */, 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */, 4F52544D434C49454E543032 /* RealtimeClientTests.swift */, @@ -668,6 +671,7 @@ buildActionMask = 2147483647; files = ( B4995200636F408E4296FF0D /* ApiModelContractTests.swift in Sources */, + 434143484D41505045525300 /* CacheMappersDateParsingTests.swift in Sources */, 4A7B9C0D1E2F3456789ABC01 /* CompletedSyncMergeTests.swift in Sources */, 4D2A9B7E2CE3424C9D111001 /* ConnectivityClassificationTests.swift in Sources */, 4F52544D434C49454E543031 /* RealtimeClientTests.swift in Sources */, diff --git a/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift b/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift index 21543493..8161d3c7 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift @@ -19,4 +19,41 @@ final class CacheMappersDateParsingTests: XCTestCase { XCTAssertEqual(components.hour, 21) XCTAssertEqual(components.minute, 0) } + + func testMovedDuePreservingTimeKeepsOriginalLocalTime() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Toronto")! + let due = Date(timeIntervalSince1970: 1_778_870_730) + let target = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 6, day: 3))) + + let moved = try XCTUnwrap(movedDuePreservingTime(due: due, targetDay: target, calendar: calendar)) + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: moved) + + XCTAssertEqual(components.year, 2026) + XCTAssertEqual(components.month, 6) + XCTAssertEqual(components.day, 3) + XCTAssertEqual(components.hour, 14) + XCTAssertEqual(components.minute, 45) + XCTAssertEqual(components.second, 30) + } + + func testTimelineRescheduleTargetDateResolvesSectionTargets() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Toronto")! + let today = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 5, day: 24))) + + let dayTarget = try XCTUnwrap(timelineRescheduleTargetDate(sectionId: "priority-1779854400.0", today: today, calendar: calendar)) + XCTAssertEqual(calendar.component(.day, from: dayTarget), 27) + + let restTarget = try XCTUnwrap(timelineRescheduleTargetDate(sectionId: "rest-24317", today: today, calendar: calendar)) + XCTAssertEqual(calendar.component(.day, from: restTarget), 31) + + let monthTarget = try XCTUnwrap(timelineRescheduleTargetDate(sectionId: "month-24319", today: today, calendar: calendar)) + XCTAssertEqual(calendar.component(.year, from: monthTarget), 2026) + XCTAssertEqual(calendar.component(.month, from: monthTarget), 7) + XCTAssertEqual(calendar.component(.day, from: monthTarget), 1) + + XCTAssertNil(timelineRescheduleTargetDate(sectionId: "earlier", today: today, calendar: calendar)) + XCTAssertNil(timelineRescheduleTargetDate(sectionId: "month-24316", today: today, calendar: calendar)) + } } From 9ab6da9802000b37c05b11ab556b2ebdd7d44640 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Sun, 24 May 2026 22:10:50 -0400 Subject: [PATCH 13/24] feat: enhance drag-and-drop rescheduling across iOS and Android Improved the cross-platform task rescheduling experience by enabling drop support for external content (via task IDs) and refining the visual feedback for drop targets. - **Cross-Platform Drag-and-Drop Improvements**: - Implemented `resolveTodoForDrop` to lookup tasks by ID or canonical ID when dropped from external sources or providers. - Updated drop delegates and targets to handle `itemProviders` (iOS) and `ClipData` (Android) for task ID extraction. - Expanded supported drag content types to include `plainText` and `text`. - **UI & Feedback**: - Standardized drop target highlighting to use `error` (accent) colors for better visibility during drag operations. - Applied drop target coloring to section headers, calendar dates, and month/week navigation labels. - Added state-driven text coloring and background tints to calendar cells when acting as active drop targets. - **Android Specifics**: - Refactored drag source detection to use `detectDragGesturesAfterLongPress` for more reliable gesture handling. - Added unit tests for `TaskReschedule` logic, covering date movement and timeline target resolution. - **iOS Specifics**: - Refined `ScheduledTodoDropDelegate` to validate drops based on both the dragged item presence and content type conformity. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../feature/calendar/CalendarScreen.kt | 81 ++++++++++--- .../compose/feature/todos/TodoListScreen.kt | 36 +++++- .../compose/core/model/TaskRescheduleTest.kt | 67 +++++++++++ .../Feature/Calendar/CalendarScreen.swift | 112 ++++++++++++++---- .../Tday/Feature/Todos/TodoListScreen.swift | 53 +++++++-- 5 files changed, 296 insertions(+), 53 deletions(-) create mode 100644 android-compose/app/src/test/java/com/ohmz/tday/compose/core/model/TaskRescheduleTest.kt diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index b9f950bd..c500e220 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.draganddrop.dragAndDropSource import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.interaction.MutableInteractionSource @@ -104,6 +104,7 @@ import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.draganddrop.mimeTypes +import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset @@ -331,6 +332,9 @@ fun CalendarScreen( uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } } } + val resolveTodoForDrop: (String) -> TodoItem? = { targetId -> + uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } + } val activeDropDate = remember(activeDropDateIso) { activeDropDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } } @@ -480,6 +484,7 @@ fun CalendarScreen( activeDropDateIso = date?.toString() }, onMoveTaskToDate = ::requestTaskReschedule, + resolveTodo = resolveTodoForDrop, ) CalendarViewMode.WEEK -> CalendarWeekCard( @@ -499,6 +504,7 @@ fun CalendarScreen( activeDropDateIso = date?.toString() }, onMoveTaskToDate = ::requestTaskReschedule, + resolveTodo = resolveTodoForDrop, ) CalendarViewMode.DAY -> CalendarDayCard( @@ -518,6 +524,7 @@ fun CalendarScreen( activeDropDateIso = date?.toString() }, onMoveTaskToDate = ::requestTaskReschedule, + resolveTodo = resolveTodoForDrop, ) } } @@ -718,6 +725,7 @@ private fun CalendarWeekCard( onSelectDate: (LocalDate) -> Unit, onDropDateChanged: (LocalDate?) -> Unit, onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, + resolveTodo: (String) -> TodoItem?, ) { val colorScheme = MaterialTheme.colorScheme val weekStart = remember(selectedDate) { startOfWeek(selectedDate) } @@ -843,7 +851,15 @@ private fun CalendarWeekCard( fontSize = CalendarPeriodHeaderTitleSize, ), fontWeight = FontWeight.ExtraBold, - color = colorScheme.onSurface, + color = if ( + activeDropDate != null && + activeDropDate >= weekStart && + activeDropDate <= weekStart.plusDays(6) + ) { + colorScheme.error + } else { + colorScheme.onSurface + }, ) } MiniCalendarNavButton( @@ -888,6 +904,7 @@ private fun CalendarWeekCard( onClick = { onSelectDate(day) }, onDropDateChanged = onDropDateChanged, onMoveTaskToDate = onMoveTaskToDate, + resolveTodo = resolveTodo, modifier = Modifier.weight(1f), ) } @@ -909,17 +926,18 @@ private fun CalendarWeekDayCell( onClick: () -> Unit, onDropDateChanged: (LocalDate?) -> Unit, onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, + resolveTodo: (String) -> TodoItem?, modifier: Modifier = Modifier, ) { val colorScheme = MaterialTheme.colorScheme val containerColor = when { - isDropTarget -> CalendarAccentPurple.copy(alpha = 0.34f) + isDropTarget -> colorScheme.error.copy(alpha = 0.20f) isSelected -> CalendarAccentPurple.copy(alpha = 0.24f) isToday -> CalendarTodayBlue.copy(alpha = 0.16f) else -> colorScheme.background } val borderColor = when { - isDropTarget -> CalendarAccentPurple + isDropTarget -> colorScheme.error isSelected -> CalendarAccentPurple.copy(alpha = 0.95f) isToday -> CalendarTodayBlue.copy(alpha = 0.74f) else -> Color.Transparent @@ -931,6 +949,7 @@ private fun CalendarWeekDayCell( else -> 0.dp } val stateTint = when { + isDropTarget -> colorScheme.error isSelected -> CalendarAccentPurple isToday -> CalendarTodayBlue else -> CalendarAccentPurple @@ -946,6 +965,7 @@ private fun CalendarWeekDayCell( enabled = isEnabled, onDropDateChanged = onDropDateChanged, onMoveTaskToDate = onMoveTaskToDate, + resolveTodo = resolveTodo, ) .graphicsLayer { alpha = if (isEnabled) 1f else 0.48f }, contentAlignment = Alignment.Center, @@ -979,7 +999,7 @@ private fun CalendarWeekDayCell( Text( text = date.dayOfMonth.toString(), style = MaterialTheme.typography.titleMedium, - color = if (isSelected || isToday) stateTint else colorScheme.onSurface, + color = if (isDropTarget || isSelected || isToday) stateTint else colorScheme.onSurface, fontWeight = FontWeight.ExtraBold, ) Text( @@ -1006,8 +1026,9 @@ private fun Modifier.calendarDateDropTarget( enabled: Boolean, onDropDateChanged: (LocalDate?) -> Unit, onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, + resolveTodo: (String) -> TodoItem?, ): Modifier { - if (!enabled || draggedTodo == null) return this + if (!enabled) return this return dragAndDropTarget( shouldStartDragAndDrop = { event -> @@ -1023,8 +1044,9 @@ private fun Modifier.calendarDateDropTarget( } override fun onDrop(event: DragAndDropEvent): Boolean { + val todo = draggedTodo ?: event.todoIdText()?.let(resolveTodo) ?: return false onDropDateChanged(null) - onMoveTaskToDate(draggedTodo, date) + onMoveTaskToDate(todo, date) return true } @@ -1035,6 +1057,17 @@ private fun Modifier.calendarDateDropTarget( ) } +private fun DragAndDropEvent.todoIdText(): String? { + val clipData = toAndroidDragEvent().clipData ?: return null + for (index in 0 until clipData.itemCount) { + val text = clipData.getItemAt(index).text?.toString()?.trim() + if (!text.isNullOrBlank()) { + return text + } + } + return null +} + @Composable private fun CalendarDayCard( selectedDate: LocalDate, @@ -1051,6 +1084,7 @@ private fun CalendarDayCard( onSelectDate: (LocalDate) -> Unit, onDropDateChanged: (LocalDate?) -> Unit, onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, + resolveTodo: (String) -> TodoItem?, ) { val colorScheme = MaterialTheme.colorScheme val coroutineScope = rememberCoroutineScope() @@ -1207,11 +1241,12 @@ private fun CalendarDayCard( enabled = isEnabled, onDropDateChanged = onDropDateChanged, onMoveTaskToDate = onMoveTaskToDate, + resolveTodo = resolveTodo, ) .clip(RoundedCornerShape(16.dp)) .background( if (activeDropDate == displayDate) { - CalendarAccentPurple.copy(alpha = 0.12f) + colorScheme.error.copy(alpha = 0.12f) } else { Color.Transparent }, @@ -1224,7 +1259,11 @@ private fun CalendarDayCard( style = MaterialTheme.typography.headlineSmall.copy( fontSize = CalendarDaySummaryTitleSize, ), - color = if (displayDate == today) CalendarAccentPurple else colorScheme.onSurface, + color = when { + activeDropDate == displayDate -> colorScheme.error + displayDate == today -> CalendarAccentPurple + else -> colorScheme.onSurface + }, fontWeight = FontWeight.ExtraBold, ) Text( @@ -1440,6 +1479,7 @@ private fun CalendarMonthCard( onSelectDate: (LocalDate) -> Unit, onDropDateChanged: (LocalDate?) -> Unit, onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, + resolveTodo: (String) -> TodoItem?, ) { val colorScheme = MaterialTheme.colorScheme val coroutineScope = rememberCoroutineScope() @@ -1569,7 +1609,11 @@ private fun CalendarMonthCard( fontSize = CalendarMonthHeaderTitleSize, ), fontWeight = FontWeight.ExtraBold, - color = colorScheme.onSurface, + color = if (activeDropDate?.let { YearMonth.from(it) } == visibleMonth) { + colorScheme.error + } else { + colorScheme.onSurface + }, ) } MiniCalendarNavButton( @@ -1631,6 +1675,7 @@ private fun CalendarMonthCard( onClick = { onSelectDate(cell.date) }, onDropDateChanged = onDropDateChanged, onMoveTaskToDate = onMoveTaskToDate, + resolveTodo = resolveTodo, modifier = Modifier.weight(1f), ) } @@ -1704,13 +1749,14 @@ private fun CalendarDayCell( onClick: () -> Unit, onDropDateChanged: (LocalDate?) -> Unit, onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, + resolveTodo: (String) -> TodoItem?, modifier: Modifier = Modifier, ) { val colorScheme = MaterialTheme.colorScheme val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val targetCellBackground = when { - isDropTarget -> CalendarAccentPurple.copy(alpha = 0.34f) + isDropTarget -> colorScheme.error.copy(alpha = 0.20f) isSelected -> CalendarAccentPurple.copy(alpha = if (isPressed) 0.32f else 0.24f) isToday -> CalendarTodayBlue.copy(alpha = if (isPressed) 0.24f else 0.16f) isPressed && isEnabled -> colorScheme.onSurfaceVariant.copy(alpha = 0.12f) @@ -1721,7 +1767,7 @@ private fun CalendarDayCell( label = "calendarMonthDateCellBackground", ) val targetCellBorderColor = when { - isDropTarget -> CalendarAccentPurple + isDropTarget -> colorScheme.error isSelected -> CalendarAccentPurple.copy(alpha = 0.95f) isToday -> CalendarTodayBlue.copy(alpha = 0.74f) isPressed && isEnabled -> colorScheme.onSurfaceVariant.copy(alpha = 0.34f) @@ -1743,13 +1789,14 @@ private fun CalendarDayCell( label = "calendarMonthDateCellBorderWidth", ) val stateTint = when { + isDropTarget -> colorScheme.error isSelected -> CalendarAccentPurple isToday -> CalendarTodayBlue else -> CalendarAccentPurple } val cellShape = RoundedCornerShape(16.dp) val dayTextColor = when { - isSelected || isToday -> stateTint + isDropTarget || isSelected || isToday -> stateTint cell.isCurrentMonth -> colorScheme.onSurface else -> colorScheme.onSurfaceVariant.copy(alpha = 0.45f) } @@ -1765,6 +1812,7 @@ private fun CalendarDayCell( enabled = isEnabled, onDropDateChanged = onDropDateChanged, onMoveTaskToDate = onMoveTaskToDate, + resolveTodo = resolveTodo, ) .clickable( enabled = isEnabled, @@ -1927,8 +1975,8 @@ private fun CalendarTodoRow( .fillMaxSize() .graphicsLayer { translationX = animatedOffsetX } .dragAndDropSource { - detectTapGestures( - onLongPress = { + detectDragGesturesAfterLongPress( + onDragStart = { onDragStart() ViewCompat.performHapticFeedback( view, @@ -1941,6 +1989,9 @@ private fun CalendarTodoRow( ), ) }, + onDrag = { change, _ -> + change.consume() + }, ) } .draggable( diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index 910f379b..684f108f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -19,7 +19,7 @@ import androidx.compose.foundation.draganddrop.dragAndDropSource import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.horizontalScroll @@ -165,6 +165,7 @@ import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.draganddrop.mimeTypes +import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush @@ -409,6 +410,9 @@ fun TodoListScreen( uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } } } + val resolveTodoForDrop: (String) -> TodoItem? = { targetId -> + uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } + } val canRescheduleTasks = uiState.mode.supportsTaskReschedule() val requestTaskReschedule: (TodoItem, LocalDate) -> Unit = { todo, targetDate -> draggedScheduledTodoId = null @@ -670,6 +674,7 @@ fun TodoListScreen( .timelineSectionDropTarget( section = section, draggedTodo = sectionDraggedTodo, + resolveTodo = resolveTodoForDrop, onDropTargetChanged = onSectionDropTargetChanged, onDragTodoEnd = onSectionDragEnd, onMoveTaskToDate = onMoveTaskToSectionDate, @@ -738,6 +743,7 @@ fun TodoListScreen( .timelineSectionDropTarget( section = section, draggedTodo = sectionDraggedTodo, + resolveTodo = resolveTodoForDrop, onDropTargetChanged = onSectionDropTargetChanged, onDragTodoEnd = onSectionDragEnd, onMoveTaskToDate = onMoveTaskToSectionDate, @@ -1667,6 +1673,8 @@ private fun TimelineSectionHeader( } val headerTextColor = if (isHeaderPressed) { androidx.compose.ui.graphics.lerp(baseHeaderColor, colorScheme.onSurface, 0.16f) + } else if (isDropTarget) { + colorScheme.error } else { baseHeaderColor } @@ -1705,7 +1713,7 @@ private fun TimelineSectionHeader( .clip(RoundedCornerShape(18.dp)) .background( if (isDropTarget) { - colorScheme.primary.copy(alpha = 0.1f) + colorScheme.error.copy(alpha = 0.1f) } else { Color.Transparent }, @@ -1825,11 +1833,12 @@ private fun TimelineTaskRow( private fun Modifier.timelineSectionDropTarget( section: TodoSection, draggedTodo: TodoItem?, + resolveTodo: (String) -> TodoItem?, onDropTargetChanged: (Boolean) -> Unit, onDragTodoEnd: (() -> Unit)?, onMoveTaskToDate: ((TodoItem, LocalDate) -> Unit)?, ): Modifier { - if (section.targetDate == null || draggedTodo == null || onMoveTaskToDate == null) { + if (section.targetDate == null || onMoveTaskToDate == null) { return this } @@ -1848,8 +1857,9 @@ private fun Modifier.timelineSectionDropTarget( override fun onDrop(event: DragAndDropEvent): Boolean { val targetDate = section.targetDate ?: return false + val todo = draggedTodo ?: event.todoIdText()?.let(resolveTodo) ?: return false onDropTargetChanged(false) - onMoveTaskToDate(draggedTodo, targetDate) + onMoveTaskToDate(todo, targetDate) return true } @@ -1861,6 +1871,17 @@ private fun Modifier.timelineSectionDropTarget( ) } +private fun DragAndDropEvent.todoIdText(): String? { + val clipData = toAndroidDragEvent().clipData ?: return null + for (index in 0 until clipData.itemCount) { + val text = clipData.getItemAt(index).text?.toString()?.trim() + if (!text.isNullOrBlank()) { + return text + } + } + return null +} + private data class TodoSection( val key: String, val title: String, @@ -2592,8 +2613,8 @@ private fun SwipeTaskRow( .then( if (dragEnabled) { Modifier.dragAndDropSource { - detectTapGestures( - onLongPress = { + detectDragGesturesAfterLongPress( + onDragStart = { onDragStart?.invoke() ViewCompat.performHapticFeedback( view, @@ -2609,6 +2630,9 @@ private fun SwipeTaskRow( ), ) }, + onDrag = { change, _ -> + change.consume() + }, ) } } else { diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/model/TaskRescheduleTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/model/TaskRescheduleTest.kt new file mode 100644 index 00000000..5ce346f7 --- /dev/null +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/model/TaskRescheduleTest.kt @@ -0,0 +1,67 @@ +package com.ohmz.tday.compose.core.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +class TaskRescheduleTest { + private val zoneId = ZoneId.of("America/Toronto") + + @Test + fun `movedDuePreservingTime keeps original local time`() { + val due = Instant.parse("2026-05-15T18:45:30Z") + val moved = movedDuePreservingTime( + due = due, + targetDate = LocalDate.parse("2026-06-03"), + zoneId = zoneId, + ) + + val movedLocal = moved.atZone(zoneId) + assertEquals(LocalDate.parse("2026-06-03"), movedLocal.toLocalDate()) + assertEquals(14, movedLocal.hour) + assertEquals(45, movedLocal.minute) + assertEquals(30, movedLocal.second) + } + + @Test + fun `timelineRescheduleTargetDate resolves exact day sections`() { + val today = LocalDate.parse("2026-05-24") + + assertEquals( + LocalDate.parse("2026-05-27"), + timelineRescheduleTargetDate("day-2026-05-27", today), + ) + } + + @Test + fun `timelineRescheduleTargetDate resolves current month rest to horizon start`() { + val today = LocalDate.parse("2026-05-24") + + assertEquals( + LocalDate.parse("2026-05-31"), + timelineRescheduleTargetDate("rest-2026-05", today), + ) + } + + @Test + fun `timelineRescheduleTargetDate resolves future month buckets to first day`() { + val today = LocalDate.parse("2026-05-24") + + assertEquals( + LocalDate.parse("2026-07-01"), + timelineRescheduleTargetDate("month-2026-07", today), + ) + } + + @Test + fun `timelineRescheduleTargetDate rejects earlier and past month targets`() { + val today = LocalDate.parse("2026-05-24") + + assertNull(timelineRescheduleTargetDate("earlier", today)) + assertNull(timelineRescheduleTargetDate("day-2026-04-30", today)) + assertNull(timelineRescheduleTargetDate("month-2026-04", today)) + } +} diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index b725d98f..c892f9ef 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -2,6 +2,8 @@ import SwiftUI import UIKit import UniformTypeIdentifiers +private let calendarTaskDragContentTypes = [UTType.plainText.identifier, UTType.text.identifier] + private enum CalendarTitleHandoff { static let collapseDistance: CGFloat = 180 static let expandedTitleHeight: CGFloat = 56 @@ -338,7 +340,8 @@ struct CalendarScreen: View { onNextMonth: { navigateMonth(by: 1) }, onSelectDate: { selectDate($0) }, onDropDateChange: { activeDropDate = $0 }, - onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) } + onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) }, + resolveTodo: resolveTodoForDrop ) case .week: CalendarWeekCard( @@ -355,7 +358,8 @@ struct CalendarScreen: View { onNextWeek: { navigateDay(by: 7) }, onSelectDate: { selectDate($0) }, onDropDateChange: { activeDropDate = $0 }, - onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) } + onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) }, + resolveTodo: resolveTodoForDrop ) case .day: CalendarDayCard( @@ -372,7 +376,8 @@ struct CalendarScreen: View { onNextDay: { navigateDay(by: 1) }, onSelectDate: { selectDate($0) }, onDropDateChange: { activeDropDate = $0 }, - onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) } + onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) }, + resolveTodo: resolveTodoForDrop ) } } @@ -428,6 +433,10 @@ struct CalendarScreen: View { } } + private func resolveTodoForDrop(id: String) -> TodoItem? { + viewModel.items.first { $0.id == id || $0.canonicalId == id } + } + private func commitPendingReschedule(scope: TaskRescheduleScope) { guard let drop = pendingRescheduleDrop else { return @@ -481,6 +490,7 @@ private struct CalendarMonthGrid: View { let onSelectDate: (Date) -> Void let onDropDateChange: (Date?) -> Void let onMoveTaskToDate: (TodoItem, Date) -> Void + let resolveTodo: (String) -> TodoItem? @Environment(\.tdayColors) private var colors @State private var pageSelection = calendarNativePagerCenterIndex @@ -517,6 +527,8 @@ private struct CalendarMonthGrid: View { let isPagingAtRest = pageSelection == calendarNativePagerCenterIndex let isPreviousEnabled = canGoPrevious && isPagingAtRest let isNextEnabled = isPagingAtRest + let isMonthDropTarget = activeDropDate + .map { calendarMonthStart(for: $0) == calendarMonthStart(for: displayMonth) } ?? false return VStack(spacing: CalendarPeriodCardMetrics.contentSpacing) { HStack { @@ -531,7 +543,7 @@ private struct CalendarMonthGrid: View { Text(monthTitle(for: displayMonth)) .font(.tdayRounded(size: 21, weight: .heavy)) - .foregroundStyle(colors.onSurface) + .foregroundStyle(isMonthDropTarget ? colors.error : colors.onSurface) Spacer(minLength: 0) @@ -589,7 +601,8 @@ private struct CalendarMonthGrid: View { draggedTodo: draggedTodo, onSelectDate: onSelectDate, onDropDateChange: onDropDateChange, - onMoveTaskToDate: onMoveTaskToDate + onMoveTaskToDate: onMoveTaskToDate, + resolveTodo: resolveTodo ) } } @@ -712,6 +725,7 @@ private struct CalendarWeekCard: View { let onSelectDate: (Date) -> Void let onDropDateChange: (Date?) -> Void let onMoveTaskToDate: (TodoItem, Date) -> Void + let resolveTodo: (String) -> TodoItem? @Environment(\.tdayColors) private var colors @State private var pageSelection = calendarNativePagerCenterIndex @@ -735,6 +749,11 @@ private struct CalendarWeekCard: View { let previousPageWeekDate = jumpDirection == .previous ? pendingTodayJump?.targetDate : previousWeekDate let nextPageWeekDate = jumpDirection == .next ? pendingTodayJump?.targetDate : nextWeekDate let isPagingAtRest = pageSelection == calendarNativePagerCenterIndex + let weekEnd = Calendar.current.date(byAdding: .day, value: 6, to: weekStart) ?? weekStart + let isWeekDropTarget = activeDropDate.map { activeDate in + let activeDay = Calendar.current.startOfDay(for: activeDate) + return activeDay >= weekStart && activeDay <= weekEnd + } ?? false return VStack(spacing: CalendarPeriodCardMetrics.contentSpacing) { HStack { @@ -749,7 +768,7 @@ private struct CalendarWeekCard: View { Text(calendarWeekRangeText(from: weekStart)) .font(.tdayRounded(size: 21, weight: .heavy)) - .foregroundStyle(colors.onSurface) + .foregroundStyle(isWeekDropTarget ? colors.error : colors.onSurface) .lineLimit(1) .minimumScaleFactor(0.82) @@ -802,7 +821,8 @@ private struct CalendarWeekCard: View { draggedTodo: draggedTodo, onSelect: { onSelectDate(date) }, onDropDateChange: onDropDateChange, - onMoveTaskToDate: onMoveTaskToDate + onMoveTaskToDate: onMoveTaskToDate, + resolveTodo: resolveTodo ) } } @@ -902,6 +922,7 @@ private struct CalendarWeekDayCell: View { let onSelect: () -> Void let onDropDateChange: (Date?) -> Void let onMoveTaskToDate: (TodoItem, Date) -> Void + let resolveTodo: (String) -> TodoItem? @Environment(\.tdayColors) private var colors @@ -936,10 +957,12 @@ private struct CalendarWeekDayCell: View { .buttonStyle(.plain) .disabled(!isEnabled) .onDrop( - of: [UTType.plainText.identifier], + of: calendarTaskDragContentTypes, delegate: CalendarDateDropDelegate( date: date, - draggedTodo: isEnabled ? draggedTodo : nil, + canDrop: isEnabled, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, onMove: onMoveTaskToDate, onDateChange: onDropDateChange ) @@ -959,7 +982,7 @@ private struct CalendarWeekDayCell: View { private var cellBackground: Color { if isDropTarget { - return accentColor.opacity(0.34) + return colors.error.opacity(0.20) } if isSelected { return accentColor.opacity(0.24) @@ -972,7 +995,7 @@ private struct CalendarWeekDayCell: View { private var cellBorderColor: Color { if isDropTarget { - return accentColor + return colors.error } if isSelected { return accentColor.opacity(0.95) @@ -997,6 +1020,9 @@ private struct CalendarWeekDayCell: View { } private var dayTextColor: Color { + if isDropTarget { + return colors.error + } if isSelected { return accentColor } @@ -1007,6 +1033,9 @@ private struct CalendarWeekDayCell: View { } private var stateTint: Color { + if isDropTarget { + return colors.error + } if isSelected { return accentColor } @@ -1019,16 +1048,20 @@ private struct CalendarWeekDayCell: View { private struct CalendarDateDropDelegate: DropDelegate { let date: Date + let canDrop: Bool let draggedTodo: TodoItem? + let resolveTodo: (String) -> TodoItem? let onMove: (TodoItem, Date) -> Void let onDateChange: (Date?) -> Void func validateDrop(info: DropInfo) -> Bool { - draggedTodo != nil + canDrop && info.hasItemsConforming(to: calendarTaskDragContentTypes) } func dropEntered(info: DropInfo) { - onDateChange(Calendar.current.startOfDay(for: date)) + if validateDrop(info: info) { + onDateChange(Calendar.current.startOfDay(for: date)) + } } func dropExited(info: DropInfo) { @@ -1044,11 +1077,31 @@ private struct CalendarDateDropDelegate: DropDelegate { onDateChange(nil) } guard let draggedTodo else { - return false + return performProviderDrop(info: info) } onMove(draggedTodo, Calendar.current.startOfDay(for: date)) return true } + + private func performProviderDrop(info: DropInfo) -> Bool { + guard canDrop, + let provider = info.itemProviders(for: calendarTaskDragContentTypes).first else { + return false + } + let targetDate = Calendar.current.startOfDay(for: date) + provider.loadObject(ofClass: NSString.self) { object, _ in + guard let rawId = object as? NSString else { + return + } + let todoId = rawId as String + DispatchQueue.main.async { + if let todo = resolveTodo(todoId) { + onMove(todo, targetDate) + } + } + } + return true + } } private struct CalendarDayCard: View { @@ -1066,6 +1119,7 @@ private struct CalendarDayCard: View { let onSelectDate: (Date) -> Void let onDropDateChange: (Date?) -> Void let onMoveTaskToDate: (TodoItem, Date) -> Void + let resolveTodo: (String) -> TodoItem? @Environment(\.tdayColors) private var colors @State private var pageSelection = calendarNativePagerCenterIndex @@ -1155,7 +1209,10 @@ private struct CalendarDayCard: View { return VStack(alignment: .leading, spacing: 14) { Text(dateTitle(for: date)) .font(.tdayRounded(size: 25, weight: .heavy)) - .foregroundStyle(Calendar.current.isDate(date, inSameDayAs: today) ? accentColor : colors.onSurface) + .foregroundStyle( + isDropTarget ? colors.error : + (Calendar.current.isDate(date, inSameDayAs: today) ? accentColor : colors.onSurface) + ) Text(taskCountText(for: date)) .font(.tdayRounded(size: 18, weight: .heavy)) @@ -1165,14 +1222,16 @@ private struct CalendarDayCard: View { .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) .background( - isDropTarget ? accentColor.opacity(0.12) : .clear, + isDropTarget ? colors.error.opacity(0.12) : .clear, in: RoundedRectangle(cornerRadius: 16, style: .continuous) ) .onDrop( - of: [UTType.plainText.identifier], + of: calendarTaskDragContentTypes, delegate: CalendarDateDropDelegate( date: date, - draggedTodo: isEnabled ? draggedTodo : nil, + canDrop: isEnabled, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, onMove: onMoveTaskToDate, onDateChange: onDropDateChange ) @@ -1290,6 +1349,7 @@ private struct CalendarMonthDayCell: View { let onSelectDate: (Date) -> Void let onDropDateChange: (Date?) -> Void let onMoveTaskToDate: (TodoItem, Date) -> Void + let resolveTodo: (String) -> TodoItem? @Environment(\.tdayColors) private var colors @@ -1343,10 +1403,12 @@ private struct CalendarMonthDayCell: View { .buttonStyle(.plain) .disabled(!isEnabled) .onDrop( - of: [UTType.plainText.identifier], + of: calendarTaskDragContentTypes, delegate: CalendarDateDropDelegate( date: day.date, - draggedTodo: isEnabled ? draggedTodo : nil, + canDrop: isEnabled, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, onMove: onMoveTaskToDate, onDateChange: onDropDateChange ) @@ -1359,6 +1421,9 @@ private struct CalendarMonthDayCell: View { } private var dayTextColor: Color { + if isDropTarget { + return colors.error + } if !day.isCurrentMonth { return colors.onSurfaceVariant.opacity(0.48) } @@ -1370,7 +1435,7 @@ private struct CalendarMonthDayCell: View { private var cellBackground: Color { if isDropTarget { - return accentColor.opacity(0.34) + return colors.error.opacity(0.20) } if isSelected { return accentColor.opacity(0.24) @@ -1383,7 +1448,7 @@ private struct CalendarMonthDayCell: View { private var cellBorderColor: Color { if isDropTarget { - return accentColor + return colors.error } if isSelected { return accentColor.opacity(0.95) @@ -1408,6 +1473,9 @@ private struct CalendarMonthDayCell: View { } private var stateTint: Color { + if isDropTarget { + return colors.error + } if isSelected { return accentColor } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index dfd45262..c1c1de08 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -2,6 +2,8 @@ import SwiftUI import UIKit import UniformTypeIdentifiers +private let todoDragContentTypes = [UTType.plainText.identifier, UTType.text.identifier] + enum TodoTimelineMetrics { static let horizontalPadding: CGFloat = 18 static let heroTitleSize: CGFloat = 32 @@ -472,6 +474,10 @@ struct TodoListScreen: View { } } + private func resolveTodoForDrop(id: String) -> TodoItem? { + viewModel.items.first { $0.id == id || $0.canonicalId == id } + } + private func commitPendingReschedule(scope: TaskRescheduleScope) { guard let drop = pendingRescheduleDrop else { return @@ -601,10 +607,11 @@ struct TodoListScreen: View { .frame(height: 8) .listRowInsets(EdgeInsets()) .onDrop( - of: [UTType.plainText.identifier], + of: todoDragContentTypes, delegate: ScheduledTodoDropDelegate( section: section, draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, @@ -616,13 +623,14 @@ struct TodoListScreen: View { } } header: { Text(section.title) - .foregroundStyle(activeDropSectionId == section.id ? colors.primary : colors.onSurfaceVariant) + .foregroundStyle(activeDropSectionId == section.id ? colors.error : colors.onSurfaceVariant) .timelinePinnedSectionHeaderBackground() .onDrop( - of: [UTType.plainText.identifier], + of: todoDragContentTypes, delegate: ScheduledTodoDropDelegate( section: section, draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, @@ -820,10 +828,11 @@ struct TodoListScreen: View { return rowContent .transition(.opacity.combined(with: .scale(scale: 0.985))) .onDrop( - of: [UTType.plainText.identifier], + of: todoDragContentTypes, delegate: ScheduledTodoDropDelegate( section: section, draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, onMove: { droppedTodo, targetDate in requestReschedule(droppedTodo, to: targetDate) }, @@ -920,10 +929,11 @@ struct TodoListScreen: View { } ) .onDrop( - of: [UTType.plainText.identifier], + of: todoDragContentTypes, delegate: ScheduledTodoDropDelegate( section: section, draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, onMove: { droppedTodo, targetDate in requestReschedule(droppedTodo, to: targetDate) }, @@ -998,10 +1008,11 @@ struct TodoListScreen: View { .padding(.top, isFirstSection ? 0 : 8) .timelinePinnedSectionHeaderBackground() .onDrop( - of: [UTType.plainText.identifier], + of: todoDragContentTypes, delegate: ScheduledTodoDropDelegate( section: section, draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, @@ -1485,7 +1496,7 @@ struct TimelineSectionHeader: View { HStack(spacing: 8) { Text(title) .font(.tdayRounded(size: TodoTimelineMetrics.sectionTitleSize, weight: .bold)) - .foregroundStyle(isActiveDropTarget ? colors.primary : colors.onSurfaceVariant.opacity(0.78)) + .foregroundStyle(isActiveDropTarget ? colors.error : colors.onSurfaceVariant.opacity(0.78)) .textCase(nil) if isCollapsible { @@ -1693,15 +1704,18 @@ private struct ScheduledDragModifier: ViewModifier { private struct ScheduledTodoDropDelegate: DropDelegate { let section: TodoTimelineSection let draggedTodo: TodoItem? + let resolveTodo: (String) -> TodoItem? let onMove: (TodoItem, Date) -> Void let onSectionChange: (String?) -> Void func validateDrop(info: DropInfo) -> Bool { - draggedTodo != nil && section.targetDate != nil + section.targetDate != nil && info.hasItemsConforming(to: todoDragContentTypes) } func dropEntered(info: DropInfo) { - onSectionChange(section.id) + if validateDrop(info: info) { + onSectionChange(section.id) + } } func dropExited(info: DropInfo) { @@ -1717,11 +1731,30 @@ private struct ScheduledTodoDropDelegate: DropDelegate { onSectionChange(nil) } guard let todo = draggedTodo, let targetDate = section.targetDate else { - return false + return performProviderDrop(info: info) } onMove(todo, targetDate) return true } + + private func performProviderDrop(info: DropInfo) -> Bool { + guard let targetDate = section.targetDate, + let provider = info.itemProviders(for: todoDragContentTypes).first else { + return false + } + provider.loadObject(ofClass: NSString.self) { object, _ in + guard let rawId = object as? NSString else { + return + } + let todoId = rawId as String + DispatchQueue.main.async { + if let todo = resolveTodo(todoId) { + onMove(todo, targetDate) + } + } + } + return true + } } private func buildSections(items: [TodoItem], mode: TodoListMode) -> [TodoTimelineSection] { From cce4f8ba8f7636445cd66bc982cbe70bf19c66de Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 00:31:14 -0400 Subject: [PATCH 14/24] feat: add "Earlier" drop target and visual placeholders for task rescheduling Implement a dedicated drop target for the "Earlier" section and introduce visual placeholders during drag-and-drop operations to improve the task rescheduling experience on both Android and iOS. - **Rescheduling Logic**: - Update `timelineRescheduleTargetDate` to resolve the "Earlier" section key to "yesterday" relative to the current date. - Enable the "Earlier" section to appear as an empty drop target when a task is being dragged, even if it contains no items. - Add logic to prevent redundant reschedule requests using a drop signature check. - **UI & Animation (Android/Compose)**: - Introduce `TimelineDropPlaceholder` with animated height and border styles to indicate active drop zones. - Refactor `buildTimelineSections` to support optional empty sections during drag operations. - Ensure smooth transitions for placeholders using `animateItem` and `animateDpAsState`. - **UI & Drag-and-Drop (iOS/SwiftUI)**: - Introduce `TodoDropPlaceholder` and `calendarTaskDropTarget` modifier to standardize drop behavior across List and Calendar screens. - Implement `TodoTaskDragSession` and `CalendarTaskDragSession` singletons to track dragged items across view updates reliably. - Enhance `TodoListScreen` to show a dashed placeholder in sections when a task is dragged over them. - **Testing**: - Add unit tests for "Earlier" section date resolution in `TaskRescheduleTest.kt` (Android) and `CacheMappersDateParsingTests.swift` (iOS). Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../tday/compose/core/model/DomainModels.kt | 4 + .../compose/feature/todos/TodoListScreen.kt | 107 ++++++- .../compose/core/model/TaskRescheduleTest.kt | 4 +- .../Tday/Core/Model/DomainModels.swift | 4 + .../Feature/Calendar/CalendarScreen.swift | 118 ++++++-- .../Tday/Feature/Todos/TodoListScreen.swift | 268 +++++++++++++----- .../CacheMappersDateParsingTests.swift | 3 +- 7 files changed, 398 insertions(+), 110 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt index d55eb27c..507212f5 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt @@ -100,6 +100,10 @@ fun timelineRescheduleTargetDate( today: LocalDate = LocalDate.now(), ): LocalDate? { val currentMonth = YearMonth.from(today) + if (sectionKey == "earlier") { + return today.minusDays(1) + } + if (sectionKey.startsWith("day-")) { val date = runCatching { LocalDate.parse(sectionKey.removePrefix("day-")) }.getOrNull() ?: return null diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index 684f108f..f2673281 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -268,10 +268,13 @@ fun TodoListScreen( uiState.mode == TodoListMode.TODAY && !uiState.hasHydratedSnapshot && uiState.items.isEmpty() - val timelineSections = remember(uiState.mode, uiState.items) { + var draggedScheduledTodoId by rememberSaveable(uiState.mode) { mutableStateOf(null) } + val canRescheduleTasks = uiState.mode.supportsTaskReschedule() + val timelineSections = remember(uiState.mode, uiState.items, draggedScheduledTodoId) { buildTimelineSections( mode = uiState.mode, items = uiState.items, + includeEmptyEarlierTarget = canRescheduleTasks && draggedScheduledTodoId != null, ) } var timelineAnimationsReady by remember(uiState.mode, uiState.listId) { @@ -390,7 +393,6 @@ fun TodoListScreen( var flashTodoId by remember(uiState.mode) { mutableStateOf(null) } var quickAddDueEpochMs by rememberSaveable { mutableStateOf(null) } var editTargetTodoId by rememberSaveable { mutableStateOf(null) } - var draggedScheduledTodoId by rememberSaveable(uiState.mode) { mutableStateOf(null) } var activeDropSectionKey by remember(uiState.mode) { mutableStateOf(null) } var pendingRescheduleDrop by remember(uiState.mode) { mutableStateOf(null) } var showListSettingsSheet by rememberSaveable { mutableStateOf(false) } @@ -413,7 +415,6 @@ fun TodoListScreen( val resolveTodoForDrop: (String) -> TodoItem? = { targetId -> uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } } - val canRescheduleTasks = uiState.mode.supportsTaskReschedule() val requestTaskReschedule: (TodoItem, LocalDate) -> Unit = { todo, targetDate -> draggedScheduledTodoId = null activeDropSectionKey = null @@ -712,6 +713,51 @@ fun TodoListScreen( ) } + if (canRescheduleTasks && draggedScheduledTodoId != null && section.targetDate != null) { + item( + key = "timeline-drop-placeholder-${section.key}", + contentType = "timeline-drop-placeholder", + ) { + var placeholderModifier: Modifier = Modifier + if (timelineAnimationsEnabled) { + placeholderModifier = placeholderModifier.animateItem( + fadeInSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ), + placementSpec = tween( + durationMillis = 260, + easing = FastOutSlowInEasing, + ), + fadeOutSpec = tween( + durationMillis = 120, + easing = FastOutSlowInEasing, + ), + ) + } + TimelineDropPlaceholder( + modifier = placeholderModifier + .timelineSectionDropTarget( + section = section, + draggedTodo = sectionDraggedTodo, + resolveTodo = resolveTodoForDrop, + onDropTargetChanged = onSectionDropTargetChanged, + onDragTodoEnd = onSectionDragEnd, + onMoveTaskToDate = onMoveTaskToSectionDate, + ) + .padding( + bottom = if (isCollapsed || section.items.isEmpty()) { + timelineItemSpacing + } else { + 8.dp + }, + ), + active = activeDropSectionKey == section.key, + useMinimalStyle = usesTodayStyle, + ) + } + } + if (!isCollapsed && section.items.isNotEmpty()) { val showEarlierDateTimeSubtitle = section.key == "earlier" && @@ -1754,6 +1800,48 @@ private fun TimelineSectionHeader( } } +@Composable +private fun TimelineDropPlaceholder( + modifier: Modifier = Modifier, + active: Boolean, + useMinimalStyle: Boolean, +) { + val colorScheme = MaterialTheme.colorScheme + val placeholderHeight by animateDpAsState( + targetValue = if (active) { + if (useMinimalStyle) 66.dp else 72.dp + } else { + if (useMinimalStyle) 46.dp else 52.dp + }, + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + label = "timelineDropPlaceholderHeight", + ) + Box( + modifier = modifier + .fillMaxWidth() + .height(placeholderHeight) + .clip(RoundedCornerShape(18.dp)) + .background( + if (active) { + colorScheme.error.copy(alpha = 0.10f) + } else { + colorScheme.surfaceVariant.copy(alpha = 0.16f) + }, + ) + .border( + BorderStroke( + width = if (active) 1.5.dp else 1.dp, + color = if (active) { + colorScheme.error.copy(alpha = 0.64f) + } else { + colorScheme.onSurfaceVariant.copy(alpha = 0.16f) + }, + ), + RoundedCornerShape(18.dp), + ), + ) +} + @Composable private fun TimelineTaskRow( modifier: Modifier = Modifier, @@ -1930,6 +2018,7 @@ private enum class TodaySectionSlot { private fun buildTimelineSections( mode: TodoListMode, items: List, + includeEmptyEarlierTarget: Boolean = false, ): List { val zoneId = ZoneId.systemDefault() return when (mode) { @@ -1946,6 +2035,7 @@ private fun buildTimelineSections( zoneId = zoneId, futureOnly = false, placesEarlierBeforeToday = true, + includeEmptyEarlierTarget = includeEmptyEarlierTarget, ) TodoListMode.PRIORITY, TodoListMode.LIST -> buildScheduledSections( @@ -1953,6 +2043,7 @@ private fun buildTimelineSections( zoneId = zoneId, futureOnly = false, placesEarlierBeforeToday = false, + includeEmptyEarlierTarget = includeEmptyEarlierTarget, ) } } @@ -2057,6 +2148,7 @@ private fun buildScheduledSections( zoneId: ZoneId, futureOnly: Boolean, placesEarlierBeforeToday: Boolean = true, + includeEmptyEarlierTarget: Boolean = false, ): List { val now = Instant.now() val sorted = items.asSequence().filter { todo -> @@ -2100,16 +2192,19 @@ private fun buildScheduledSections( val earlierSection = if (!futureOnly) { val earlierItems = groupedByDate.asSequence().filter { (date, _) -> date < today } .flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due }.toList() - earlierItems.takeIf { it.isNotEmpty() }?.let { + if (earlierItems.isNotEmpty() || includeEmptyEarlierTarget) { TodoSection( key = "earlier", title = "Earlier", - items = it, + items = earlierItems, quickAddDefaults = quickAddDefaultsForDate( - date = today, + date = today.minusDays(1), zoneId = zoneId, ), + targetDate = timelineRescheduleTargetDate("earlier", today), ) + } else { + null } } else { null diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/model/TaskRescheduleTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/model/TaskRescheduleTest.kt index 5ce346f7..8a50a420 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/model/TaskRescheduleTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/model/TaskRescheduleTest.kt @@ -57,10 +57,10 @@ class TaskRescheduleTest { } @Test - fun `timelineRescheduleTargetDate rejects earlier and past month targets`() { + fun `timelineRescheduleTargetDate resolves earlier to yesterday and rejects past month targets`() { val today = LocalDate.parse("2026-05-24") - assertNull(timelineRescheduleTargetDate("earlier", today)) + assertEquals(LocalDate.parse("2026-05-23"), timelineRescheduleTargetDate("earlier", today)) assertNull(timelineRescheduleTargetDate("day-2026-04-30", today)) assertNull(timelineRescheduleTargetDate("month-2026-04", today)) } diff --git a/ios-swiftUI/Tday/Core/Model/DomainModels.swift b/ios-swiftUI/Tday/Core/Model/DomainModels.swift index c50b1cdb..b8098422 100644 --- a/ios-swiftUI/Tday/Core/Model/DomainModels.swift +++ b/ios-swiftUI/Tday/Core/Model/DomainModels.swift @@ -161,6 +161,10 @@ func timelineRescheduleTargetDate( let startOfToday = calendar.startOfDay(for: today) let currentMonthStart = rescheduleMonthStart(for: startOfToday, calendar: calendar) + if sectionId == "earlier" { + return calendar.date(byAdding: .day, value: -1, to: startOfToday) + } + if sectionId.hasPrefix("scheduled-") || sectionId.hasPrefix("priority-") { guard let suffix = sectionId.split(separator: "-").last, let interval = TimeInterval(String(suffix)) else { diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index c892f9ef..c13103c1 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -4,6 +4,14 @@ import UniformTypeIdentifiers private let calendarTaskDragContentTypes = [UTType.plainText.identifier, UTType.text.identifier] +private final class CalendarTaskDragSession { + static let shared = CalendarTaskDragSession() + var todo: TodoItem? + var handledDropSignature: String? + + private init() {} +} + private enum CalendarTitleHandoff { static let collapseDistance: CGFloat = 180 static let expandedTitleHeight: CGFloat = 56 @@ -180,6 +188,8 @@ struct CalendarScreen: View { .onDrag { UIImpactFeedbackGenerator(style: .light).impactOccurred() draggedTodo = todo + CalendarTaskDragSession.shared.todo = todo + CalendarTaskDragSession.shared.handledDropSignature = nil return NSItemProvider(object: todo.id as NSString) } .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) @@ -415,7 +425,13 @@ struct CalendarScreen: View { private func requestReschedule(_ todo: TodoItem, to targetDate: Date) { draggedTodo = nil activeDropDate = nil + CalendarTaskDragSession.shared.todo = nil let targetDay = Calendar.current.startOfDay(for: targetDate) + let dropSignature = "\(todo.id)|\(targetDay.timeIntervalSince1970)" + guard CalendarTaskDragSession.shared.handledDropSignature != dropSignature else { + return + } + CalendarTaskDragSession.shared.handledDropSignature = dropSignature guard !Calendar.current.isDate(todo.due, inSameDayAs: targetDay) else { return } @@ -956,16 +972,13 @@ private struct CalendarWeekDayCell: View { } .buttonStyle(.plain) .disabled(!isEnabled) - .onDrop( - of: calendarTaskDragContentTypes, - delegate: CalendarDateDropDelegate( - date: date, - canDrop: isEnabled, - draggedTodo: draggedTodo, - resolveTodo: resolveTodo, - onMove: onMoveTaskToDate, - onDateChange: onDropDateChange - ) + .calendarTaskDropTarget( + date: date, + canDrop: isEnabled, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, + onMove: onMoveTaskToDate, + onDateChange: onDropDateChange ) .opacity(isEnabled ? 1 : 0.48) } @@ -1076,7 +1089,7 @@ private struct CalendarDateDropDelegate: DropDelegate { defer { onDateChange(nil) } - guard let draggedTodo else { + guard let draggedTodo = draggedTodo ?? CalendarTaskDragSession.shared.todo else { return performProviderDrop(info: info) } onMove(draggedTodo, Calendar.current.startOfDay(for: date)) @@ -1104,6 +1117,55 @@ private struct CalendarDateDropDelegate: DropDelegate { } } +private extension View { + func calendarTaskDropTarget( + date: Date, + canDrop: Bool, + draggedTodo: TodoItem?, + resolveTodo: @escaping (String) -> TodoItem?, + onMove: @escaping (TodoItem, Date) -> Void, + onDateChange: @escaping (Date?) -> Void + ) -> some View { + self + .onDrop( + of: calendarTaskDragContentTypes, + delegate: CalendarDateDropDelegate( + date: date, + canDrop: canDrop, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, + onMove: onMove, + onDateChange: onDateChange + ) + ) + .dropDestination(for: String.self) { ids, _ in + guard canDrop else { + onDateChange(nil) + return false + } + let targetDate = Calendar.current.startOfDay(for: date) + let todo = draggedTodo + ?? CalendarTaskDragSession.shared.todo + ?? ids.compactMap(resolveTodo).first + guard let todo else { + onDateChange(nil) + return false + } + onDateChange(nil) + onMove(todo, targetDate) + return true + } isTargeted: { active in + guard canDrop else { + if !active { + onDateChange(nil) + } + return + } + onDateChange(active ? Calendar.current.startOfDay(for: date) : nil) + } + } +} + private struct CalendarDayCard: View { let selectedDate: Date let today: Date @@ -1225,16 +1287,13 @@ private struct CalendarDayCard: View { isDropTarget ? colors.error.opacity(0.12) : .clear, in: RoundedRectangle(cornerRadius: 16, style: .continuous) ) - .onDrop( - of: calendarTaskDragContentTypes, - delegate: CalendarDateDropDelegate( - date: date, - canDrop: isEnabled, - draggedTodo: draggedTodo, - resolveTodo: resolveTodo, - onMove: onMoveTaskToDate, - onDateChange: onDropDateChange - ) + .calendarTaskDropTarget( + date: date, + canDrop: isEnabled, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, + onMove: onMoveTaskToDate, + onDateChange: onDropDateChange ) } @@ -1402,16 +1461,13 @@ private struct CalendarMonthDayCell: View { } .buttonStyle(.plain) .disabled(!isEnabled) - .onDrop( - of: calendarTaskDragContentTypes, - delegate: CalendarDateDropDelegate( - date: day.date, - canDrop: isEnabled, - draggedTodo: draggedTodo, - resolveTodo: resolveTodo, - onMove: onMoveTaskToDate, - onDateChange: onDropDateChange - ) + .calendarTaskDropTarget( + date: day.date, + canDrop: isEnabled, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, + onMove: onMoveTaskToDate, + onDateChange: onDropDateChange ) .opacity(day.isCurrentMonth ? 1 : 0.45) } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index c1c1de08..ee78bb47 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -4,6 +4,14 @@ import UniformTypeIdentifiers private let todoDragContentTypes = [UTType.plainText.identifier, UTType.text.identifier] +private final class TodoTaskDragSession { + static let shared = TodoTaskDragSession() + var todo: TodoItem? + var handledDropSignature: String? + + private init() {} +} + enum TodoTimelineMetrics { static let horizontalPadding: CGFloat = 18 static let heroTitleSize: CGFloat = 32 @@ -167,7 +175,11 @@ struct TodoListScreen: View { } private var groupedSections: [TodoTimelineSection] { - buildSections(items: viewModel.items, mode: viewModel.mode) + buildSections( + items: viewModel.items, + mode: viewModel.mode, + includeEmptyEarlierTarget: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + ) } private var isTodayMode: Bool { @@ -454,6 +466,7 @@ struct TodoListScreen: View { private func handleItemsChanged() { activeDropSectionId = nil draggedTodo = nil + TodoTaskDragSession.shared.todo = nil if viewModel.mode == .all, highlightedTodoId != nil { collapsedSectionIDs = [] } @@ -462,6 +475,13 @@ struct TodoListScreen: View { private func requestReschedule(_ todo: TodoItem, to targetDate: Date) { activeDropSectionId = nil draggedTodo = nil + TodoTaskDragSession.shared.todo = nil + let targetDay = Calendar.current.startOfDay(for: targetDate) + let dropSignature = "\(todo.id)|\(targetDay.timeIntervalSince1970)" + guard TodoTaskDragSession.shared.handledDropSignature != dropSignature else { + return + } + TodoTaskDragSession.shared.handledDropSignature = dropSignature guard !Calendar.current.isDate(todo.due, inSameDayAs: targetDate) else { return } @@ -602,32 +622,29 @@ struct TodoListScreen: View { todoRow(todo, in: section) .listRowBackground(todo.id == highlightedTodoId ? colors.surfaceVariant : colors.surface) } + if viewModel.mode.supportsTaskReschedule, + draggedTodo != nil, + section.targetDate != nil { + TodoDropPlaceholder(isActive: activeDropSectionId == section.id) + .listRowInsets(EdgeInsets(top: 4, leading: 20, bottom: 6, trailing: 20)) + .listRowBackground(colors.surface) + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + onSectionChange: { sectionId in + activeDropSectionId = sectionId + } + ) + } if viewModel.mode.supportsTaskReschedule, !section.items.isEmpty { Color.clear .frame(height: 8) .listRowInsets(EdgeInsets()) - .onDrop( - of: todoDragContentTypes, - delegate: ScheduledTodoDropDelegate( - section: section, - draggedTodo: draggedTodo, - resolveTodo: resolveTodoForDrop, - onMove: { todo, targetDate in - requestReschedule(todo, to: targetDate) - }, - onSectionChange: { sectionId in - activeDropSectionId = sectionId - } - ) - ) - } - } header: { - Text(section.title) - .foregroundStyle(activeDropSectionId == section.id ? colors.error : colors.onSurfaceVariant) - .timelinePinnedSectionHeaderBackground() - .onDrop( - of: todoDragContentTypes, - delegate: ScheduledTodoDropDelegate( + .scheduledTodoDropTarget( section: section, draggedTodo: draggedTodo, resolveTodo: resolveTodoForDrop, @@ -638,6 +655,21 @@ struct TodoListScreen: View { activeDropSectionId = sectionId } ) + } + } header: { + Text(section.title) + .foregroundStyle(activeDropSectionId == section.id ? colors.error : colors.onSurfaceVariant) + .timelinePinnedSectionHeaderBackground() + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + onSectionChange: { sectionId in + activeDropSectionId = sectionId + } ) } } @@ -827,19 +859,16 @@ struct TodoListScreen: View { return rowContent .transition(.opacity.combined(with: .scale(scale: 0.985))) - .onDrop( - of: todoDragContentTypes, - delegate: ScheduledTodoDropDelegate( - section: section, - draggedTodo: draggedTodo, - resolveTodo: resolveTodoForDrop, - onMove: { droppedTodo, targetDate in - requestReschedule(droppedTodo, to: targetDate) - }, - onSectionChange: { sectionId in - activeDropSectionId = sectionId - } - ) + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { droppedTodo, targetDate in + requestReschedule(droppedTodo, to: targetDate) + }, + onSectionChange: { sectionId in + activeDropSectionId = sectionId + } ) .modifier( ScheduledDragModifier( @@ -928,19 +957,16 @@ struct TodoListScreen: View { Task { await viewModel.delete(todo) } } ) - .onDrop( - of: todoDragContentTypes, - delegate: ScheduledTodoDropDelegate( - section: section, - draggedTodo: draggedTodo, - resolveTodo: resolveTodoForDrop, - onMove: { droppedTodo, targetDate in - requestReschedule(droppedTodo, to: targetDate) - }, - onSectionChange: { sectionId in - activeDropSectionId = sectionId - } - ) + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { droppedTodo, targetDate in + requestReschedule(droppedTodo, to: targetDate) + }, + onSectionChange: { sectionId in + activeDropSectionId = sectionId + } ) .modifier( ScheduledDragModifier( @@ -980,6 +1006,26 @@ struct TodoListScreen: View { let isCollapsed = canCollapseSection && collapsedSectionIDs.contains(section.id) Section { + if viewModel.mode.supportsTaskReschedule, + draggedTodo != nil, + section.targetDate != nil { + TodoDropPlaceholder(isActive: activeDropSectionId == section.id) + .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 8, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowBackground(colors.background) + .listRowSeparator(.hidden) + .transition(timelineRowTransition()) + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + onSectionChange: { sectionId in + activeDropSectionId = sectionId + } + ) + } if !isCollapsed { ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section, flashHighlight: shouldFlashTodo(todo)) @@ -1007,19 +1053,16 @@ struct TodoListScreen: View { .id(timelineSectionScrollID(section.id)) .padding(.top, isFirstSection ? 0 : 8) .timelinePinnedSectionHeaderBackground() - .onDrop( - of: todoDragContentTypes, - delegate: ScheduledTodoDropDelegate( - section: section, - draggedTodo: draggedTodo, - resolveTodo: resolveTodoForDrop, - onMove: { todo, targetDate in - requestReschedule(todo, to: targetDate) - }, - onSectionChange: { sectionId in - activeDropSectionId = sectionId - } - ) + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + onSectionChange: { sectionId in + activeDropSectionId = sectionId + } ) .listRowInsets( EdgeInsets( @@ -1524,6 +1567,27 @@ struct TimelineSectionHeader: View { } } +private struct TodoDropPlaceholder: View { + let isActive: Bool + + @Environment(\.tdayColors) private var colors + + var body: some View { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isActive ? colors.error.opacity(0.10) : colors.surfaceVariant.opacity(0.18)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke( + isActive ? colors.error.opacity(0.64) : colors.onSurfaceVariant.opacity(0.18), + style: StrokeStyle(lineWidth: isActive ? 1.5 : 1, dash: [7, 7]) + ) + ) + .frame(height: isActive ? 70 : 52) + .animation(.easeInOut(duration: 0.18), value: isActive) + .accessibilityHidden(true) + } +} + private struct TimelineSectionHeaderButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label @@ -1693,6 +1757,8 @@ private struct ScheduledDragModifier: ViewModifier { content.onDrag { UIImpactFeedbackGenerator(style: .light).impactOccurred() onDragStart() + TodoTaskDragSession.shared.todo = todo + TodoTaskDragSession.shared.handledDropSignature = nil return NSItemProvider(object: todo.id as NSString) } } else { @@ -1730,7 +1796,8 @@ private struct ScheduledTodoDropDelegate: DropDelegate { defer { onSectionChange(nil) } - guard let todo = draggedTodo, let targetDate = section.targetDate else { + guard let todo = draggedTodo ?? TodoTaskDragSession.shared.todo, + let targetDate = section.targetDate else { return performProviderDrop(info: info) } onMove(todo, targetDate) @@ -1757,7 +1824,57 @@ private struct ScheduledTodoDropDelegate: DropDelegate { } } -private func buildSections(items: [TodoItem], mode: TodoListMode) -> [TodoTimelineSection] { +private extension View { + func scheduledTodoDropTarget( + section: TodoTimelineSection, + draggedTodo: TodoItem?, + resolveTodo: @escaping (String) -> TodoItem?, + onMove: @escaping (TodoItem, Date) -> Void, + onSectionChange: @escaping (String?) -> Void + ) -> some View { + self + .onDrop( + of: todoDragContentTypes, + delegate: ScheduledTodoDropDelegate( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, + onMove: onMove, + onSectionChange: onSectionChange + ) + ) + .dropDestination(for: String.self) { ids, _ in + guard let targetDate = section.targetDate else { + onSectionChange(nil) + return false + } + let todo = draggedTodo + ?? TodoTaskDragSession.shared.todo + ?? ids.compactMap(resolveTodo).first + guard let todo else { + onSectionChange(nil) + return false + } + onSectionChange(nil) + onMove(todo, targetDate) + return true + } isTargeted: { active in + guard section.targetDate != nil else { + if !active { + onSectionChange(nil) + } + return + } + onSectionChange(active ? section.id : nil) + } + } +} + +private func buildSections( + items: [TodoItem], + mode: TodoListMode, + includeEmptyEarlierTarget: Bool = false +) -> [TodoTimelineSection] { let calendar = Calendar.current switch mode { case .today: @@ -1832,9 +1949,19 @@ private func buildSections(items: [TodoItem], mode: TodoListMode) -> [TodoTimeli ) } case .all: - return buildFutureTimelineSections(items: items, calendar: calendar, placesEarlierBeforeToday: true) + return buildFutureTimelineSections( + items: items, + calendar: calendar, + placesEarlierBeforeToday: true, + includeEmptyEarlierTarget: includeEmptyEarlierTarget + ) case .priority, .list: - return buildFutureTimelineSections(items: items, calendar: calendar, placesEarlierBeforeToday: false) + return buildFutureTimelineSections( + items: items, + calendar: calendar, + placesEarlierBeforeToday: false, + includeEmptyEarlierTarget: includeEmptyEarlierTarget + ) } } @@ -1851,7 +1978,8 @@ private func scheduledSectionTitle(for date: Date, calendar: Calendar) -> String private func buildFutureTimelineSections( items: [TodoItem], calendar: Calendar, - placesEarlierBeforeToday: Bool + placesEarlierBeforeToday: Bool, + includeEmptyEarlierTarget: Bool ) -> [TodoTimelineSection] { let now = Date() let today = calendar.startOfDay(for: now) @@ -1882,13 +2010,13 @@ private func buildFutureTimelineSections( .flatMap { groupedByDate[$0] ?? [] } let earlierSection: TodoTimelineSection? - if !earlierItems.isEmpty { + if !earlierItems.isEmpty || includeEmptyEarlierTarget { earlierSection = TodoTimelineSection( id: "earlier", title: "Earlier", items: earlierItems, - isCollapsible: true, - targetDate: nil + isCollapsible: !earlierItems.isEmpty, + targetDate: timelineRescheduleTargetDate(sectionId: "earlier", today: today, calendar: calendar) ) } else { earlierSection = nil diff --git a/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift b/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift index 8161d3c7..b3580277 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift @@ -53,7 +53,8 @@ final class CacheMappersDateParsingTests: XCTestCase { XCTAssertEqual(calendar.component(.month, from: monthTarget), 7) XCTAssertEqual(calendar.component(.day, from: monthTarget), 1) - XCTAssertNil(timelineRescheduleTargetDate(sectionId: "earlier", today: today, calendar: calendar)) + let earlierTarget = try XCTUnwrap(timelineRescheduleTargetDate(sectionId: "earlier", today: today, calendar: calendar)) + XCTAssertEqual(calendar.component(.day, from: earlierTarget), 23) XCTAssertNil(timelineRescheduleTargetDate(sectionId: "month-24316", today: today, calendar: calendar)) } } From 27e187a8affbb4492e0adf774befa0c0ffb8d240 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 02:18:40 -0400 Subject: [PATCH 15/24] feat: implement custom in-app drag and drop for task rescheduling Replace native platform drag-and-drop with a custom implementation for both iOS (SwiftUI) and Android (Compose) to improve control and responsiveness during task rescheduling. - **Cross-Platform Changes**: - Introduce manual drag gesture handling using `LongPressGesture` (iOS) and `detectDragGesturesAfterLongPress` (Android). - Implement a custom drag preview overlay that follows the user's touch. - Track drop target boundaries globally using coordinate space names (iOS) and a shared state map of `Rect` bounds (Android). - **iOS Implementation**: - Add `TodoInAppDrag` and `TodoDropTargetFrame` models for state tracking. - Use `PreferencePicker` to bubble up component frames to the `TodoListScreen` for hit testing. - Update `TodoRow` and section headers to register their frames as potential drop targets. - **Android Implementation**: - Replace `dragAndDropSource`/`dragAndDropTarget` with a manual pointer input and `onGloballyPositioned` logic. - Add `TimelineInAppDrag` state to track the active todo and its position relative to the root container. - Refactor `SwipeTaskRow` to support custom drag lifecycle callbacks (`onDragStart`, `onDragMove`, `onDragEnd`, `onDragCancel`). Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../compose/feature/todos/TodoListScreen.kt | 394 ++++++++++++------ .../Tday/Feature/Todos/TodoListScreen.swift | 270 +++++++++++- 2 files changed, 534 insertions(+), 130 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index f2673281..16a11844 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -1,7 +1,5 @@ package com.ohmz.tday.compose.feature.todos -import android.content.ClipData -import android.view.View import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing @@ -15,8 +13,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.draganddrop.dragAndDropSource -import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress @@ -150,9 +146,11 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -161,13 +159,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draganddrop.DragAndDropEvent -import androidx.compose.ui.draganddrop.DragAndDropTarget -import androidx.compose.ui.draganddrop.DragAndDropTransferData -import androidx.compose.ui.draganddrop.mimeTypes -import androidx.compose.ui.draganddrop.toAndroidDragEvent +import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -181,6 +176,9 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView @@ -191,9 +189,11 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp +import androidx.compose.ui.zIndex import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat import com.ohmz.tday.compose.R @@ -394,6 +394,10 @@ fun TodoListScreen( var quickAddDueEpochMs by rememberSaveable { mutableStateOf(null) } var editTargetTodoId by rememberSaveable { mutableStateOf(null) } var activeDropSectionKey by remember(uiState.mode) { mutableStateOf(null) } + var activeTimelineDrag by remember(uiState.mode) { mutableStateOf(null) } + var timelineDragContainerOrigin by remember(uiState.mode) { mutableStateOf(Offset.Zero) } + val timelineDropTargetBounds = + remember(uiState.mode) { mutableStateMapOf() } var pendingRescheduleDrop by remember(uiState.mode) { mutableStateOf(null) } var showListSettingsSheet by rememberSaveable { mutableStateOf(false) } var showSummarySheet by rememberSaveable(uiState.mode) { mutableStateOf(false) } @@ -412,12 +416,11 @@ fun TodoListScreen( uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } } } - val resolveTodoForDrop: (String) -> TodoItem? = { targetId -> - uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } - } val requestTaskReschedule: (TodoItem, LocalDate) -> Unit = { todo, targetDate -> draggedScheduledTodoId = null activeDropSectionKey = null + activeTimelineDrag = null + timelineDropTargetBounds.clear() val currentDate = LocalDate.ofInstant(todo.due, zoneId) if (currentDate != targetDate) { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) @@ -494,6 +497,42 @@ fun TodoListScreen( collapsedSectionKeys = collapsedSectionKeys + "earlier" } } + LaunchedEffect(draggedScheduledTodoId) { + if (draggedScheduledTodoId == null) { + timelineDropTargetBounds.clear() + } + } + + fun updateActiveTimelineDropTarget(position: Offset) { + activeDropSectionKey = timelineDropTargetBounds.values + .asSequence() + .filter { target -> target.bounds.contains(position) } + .minByOrNull { target -> target.bounds.height } + ?.sectionKey + } + + fun finishTimelineDrag(position: Offset?) { + val drag = activeTimelineDrag + val targetKey = position + ?.let { dropPosition -> + timelineDropTargetBounds.values + .asSequence() + .filter { target -> target.bounds.contains(dropPosition) } + .minByOrNull { target -> target.bounds.height } + ?.sectionKey + } + ?: activeDropSectionKey + val targetDate = targetKey + ?.let { key -> timelineSections.firstOrNull { section -> section.key == key } } + ?.targetDate + activeTimelineDrag = null + draggedScheduledTodoId = null + activeDropSectionKey = null + timelineDropTargetBounds.clear() + if (drag != null && targetDate != null) { + requestTaskReschedule(drag.todo, targetDate) + } + } Scaffold( containerColor = colorScheme.background, @@ -572,7 +611,11 @@ fun TodoListScreen( }, ) { padding -> Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + timelineDragContainerOrigin = coordinates.positionInRoot() + }, ) { Box( modifier = Modifier @@ -627,33 +670,12 @@ fun TodoListScreen( val sectionCanCollapse = sectionModeCanCollapse && sectionHasTasks val isCollapsed = sectionCanCollapse && collapsedSectionKeys.contains(section.key) + val isActiveDropSection = activeDropSectionKey == section.key val sectionDraggedTodo = if (canRescheduleTasks) { draggedScheduledTodo } else { null } - val onSectionDropTargetChanged: (Boolean) -> Unit = { active -> - if (active) { - activeDropSectionKey = section.key - } else if (activeDropSectionKey == section.key) { - activeDropSectionKey = null - } - } - val onSectionDragEnd: (() -> Unit)? = - if (canRescheduleTasks) { - { - draggedScheduledTodoId = null - activeDropSectionKey = null - } - } else { - null - } - val onMoveTaskToSectionDate: ((TodoItem, LocalDate) -> Unit)? = - if (canRescheduleTasks) { - requestTaskReschedule - } else { - null - } item( key = "timeline-header-${section.key}", @@ -672,19 +694,25 @@ fun TodoListScreen( } TimelineSectionHeader( modifier = headerModifier - .timelineSectionDropTarget( + .fillMaxWidth() + .heightIn( + min = if (canRescheduleTasks && draggedScheduledTodoId != null) { + if (usesTodayStyle) 44.dp else 56.dp + } else { + 1.dp + }, + ) + .timelineInAppDropTarget( + targetId = "header-${section.key}", section = section, - draggedTodo = sectionDraggedTodo, - resolveTodo = resolveTodoForDrop, - onDropTargetChanged = onSectionDropTargetChanged, - onDragTodoEnd = onSectionDragEnd, - onMoveTaskToDate = onMoveTaskToSectionDate, + enabled = canRescheduleTasks && draggedScheduledTodoId != null, + dropTargets = timelineDropTargetBounds, ) .padding(top = if (sectionIndex == 0) 0.dp else 8.dp), section = section, useMinimalStyle = usesTodayStyle, isCollapsed = isCollapsed, - isDropTarget = activeDropSectionKey == section.key, + isDropTarget = isActiveDropSection, bottomSpacing = if (isCollapsed) { timelineItemSpacing } else { @@ -713,7 +741,7 @@ fun TodoListScreen( ) } - if (canRescheduleTasks && draggedScheduledTodoId != null && section.targetDate != null) { + if (canRescheduleTasks && isActiveDropSection && section.targetDate != null) { item( key = "timeline-drop-placeholder-${section.key}", contentType = "timeline-drop-placeholder", @@ -737,13 +765,11 @@ fun TodoListScreen( } TimelineDropPlaceholder( modifier = placeholderModifier - .timelineSectionDropTarget( + .timelineInAppDropTarget( + targetId = "placeholder-${section.key}", section = section, - draggedTodo = sectionDraggedTodo, - resolveTodo = resolveTodoForDrop, - onDropTargetChanged = onSectionDropTargetChanged, - onDragTodoEnd = onSectionDragEnd, - onMoveTaskToDate = onMoveTaskToSectionDate, + enabled = true, + dropTargets = timelineDropTargetBounds, ) .padding( bottom = if (isCollapsed || section.items.isEmpty()) { @@ -752,7 +778,7 @@ fun TodoListScreen( 8.dp }, ), - active = activeDropSectionKey == section.key, + active = true, useMinimalStyle = usesTodayStyle, ) } @@ -786,13 +812,11 @@ fun TodoListScreen( } TimelineTaskRow( modifier = rowModifier - .timelineSectionDropTarget( + .timelineInAppDropTarget( + targetId = "row-${section.key}-${todo.id}", section = section, - draggedTodo = sectionDraggedTodo, - resolveTodo = resolveTodoForDrop, - onDropTargetChanged = onSectionDropTargetChanged, - onDragTodoEnd = onSectionDragEnd, - onMoveTaskToDate = onMoveTaskToSectionDate, + enabled = canRescheduleTasks && draggedScheduledTodoId != null, + dropTargets = timelineDropTargetBounds, ) .padding( bottom = if (itemIndex == section.items.lastIndex) { @@ -820,13 +844,31 @@ fun TodoListScreen( }, draggedTodo = sectionDraggedTodo, onDragTodoStart = if (canRescheduleTasks) { - { + { position -> activeDropSectionKey = null + timelineDropTargetBounds.clear() draggedScheduledTodoId = todo.id + activeTimelineDrag = + TimelineInAppDrag(todo, position) } } else { null }, + onDragTodoMove = { position -> + activeTimelineDrag = + activeTimelineDrag?.copy(position = position) + ?: TimelineInAppDrag(todo, position) + updateActiveTimelineDropTarget(position) + }, + onDragTodoEnd = { position -> + finishTimelineDrag(position) + }, + onDragTodoCancel = { + activeTimelineDrag = null + draggedScheduledTodoId = null + activeDropSectionKey = null + timelineDropTargetBounds.clear() + }, ) } } @@ -876,6 +918,23 @@ fun TodoListScreen( message = emptyStateMessageForMode(uiState.mode), ) } + + activeTimelineDrag?.let { drag -> + TimelineTaskDragPreview( + modifier = Modifier + .offset { + val localPosition = drag.position - timelineDragContainerOrigin + IntOffset( + x = (localPosition.x - with(density) { 130.dp.toPx() }).roundToInt(), + y = (localPosition.y - with(density) { 34.dp.toPx() }).roundToInt(), + ) + } + .zIndex(20f), + todo = drag.todo, + lists = uiState.lists, + mode = uiState.mode, + ) + } } } @@ -1842,6 +1901,78 @@ private fun TimelineDropPlaceholder( ) } +@Composable +private fun TimelineTaskDragPreview( + modifier: Modifier = Modifier, + todo: TodoItem, + lists: List, + mode: TodoListMode, +) { + val colorScheme = MaterialTheme.colorScheme + val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } + val showListIndicator = listMeta != null && mode != TodoListMode.LIST + Card( + modifier = modifier + .sizeIn(minWidth = 220.dp, maxWidth = 280.dp) + .graphicsLayer { + shadowElevation = 18f + alpha = 0.96f + }, + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = colorScheme.surface), + border = BorderStroke(1.dp, colorScheme.outlineVariant.copy(alpha = 0.55f)), + elevation = CardDefaults.cardElevation(defaultElevation = 10.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.RadioButtonUnchecked, + contentDescription = null, + tint = colorScheme.onSurfaceVariant.copy(alpha = 0.76f), + modifier = Modifier.size(22.dp), + ) + Column( + modifier = Modifier.weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = todo.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.ExtraBold, + color = colorScheme.onSurface, + maxLines = 1, + ) + Text( + text = TODO_DUE_TIME_FORMATTER.format(todo.due), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + if (showListIndicator) { + Icon( + imageVector = listIconForKey(listMeta?.iconKey), + contentDescription = null, + tint = listAccentColor(listMeta?.color), + modifier = Modifier.size(18.dp), + ) + } + if (isHighPriority(todo.priority)) { + Icon( + imageVector = Icons.Rounded.Flag, + contentDescription = null, + tint = priorityColor(todo.priority), + modifier = Modifier.size(18.dp), + ) + } + } + } +} + @Composable private fun TimelineTaskRow( modifier: Modifier = Modifier, @@ -1856,7 +1987,10 @@ private fun TimelineTaskRow( onDelete: () -> Unit, onInfo: () -> Unit, draggedTodo: TodoItem? = null, - onDragTodoStart: (() -> Unit)? = null, + onDragTodoStart: ((Offset) -> Unit)? = null, + onDragTodoMove: (Offset) -> Unit = {}, + onDragTodoEnd: (Offset?) -> Unit = {}, + onDragTodoCancel: () -> Unit = {}, ) { Box( modifier = modifier.fillMaxWidth(), @@ -1874,7 +2008,10 @@ private fun TimelineTaskRow( showDateDivider = showDateDivider, dragEnabled = onDragTodoStart != null, dragging = draggedTodo?.id == todo.id, - onDragStart = { onDragTodoStart?.invoke() }, + onDragStart = { position -> onDragTodoStart?.invoke(position) }, + onDragMove = onDragTodoMove, + onDragEnd = onDragTodoEnd, + onDragCancel = onDragTodoCancel, ) } else if ( useMinimalStyle && @@ -1899,7 +2036,10 @@ private fun TimelineTaskRow( showDateDivider = showDateDivider, dragEnabled = onDragTodoStart != null, dragging = draggedTodo?.id == todo.id, - onDragStart = { onDragTodoStart?.invoke() }, + onDragStart = { position -> onDragTodoStart?.invoke(position) }, + onDragMove = onDragTodoMove, + onDragEnd = onDragTodoEnd, + onDragCancel = onDragTodoCancel, ) } else if (useMinimalStyle) { TodayTodoRow( @@ -1917,59 +2057,48 @@ private fun TimelineTaskRow( } } -@OptIn(ExperimentalFoundationApi::class) -private fun Modifier.timelineSectionDropTarget( +private fun Modifier.timelineInAppDropTarget( + targetId: String, section: TodoSection, - draggedTodo: TodoItem?, - resolveTodo: (String) -> TodoItem?, - onDropTargetChanged: (Boolean) -> Unit, - onDragTodoEnd: (() -> Unit)?, - onMoveTaskToDate: ((TodoItem, LocalDate) -> Unit)?, + enabled: Boolean, + dropTargets: MutableMap, ): Modifier { - if (section.targetDate == null || onMoveTaskToDate == null) { + if (!enabled || section.targetDate == null) { return this } - return dragAndDropTarget( - shouldStartDragAndDrop = { event -> - event.mimeTypes().any { mimeType -> mimeType.startsWith("text/") } - }, - target = object : DragAndDropTarget { - override fun onEntered(event: DragAndDropEvent) { - onDropTargetChanged(true) - } - - override fun onExited(event: DragAndDropEvent) { - onDropTargetChanged(false) - } - - override fun onDrop(event: DragAndDropEvent): Boolean { - val targetDate = section.targetDate ?: return false - val todo = draggedTodo ?: event.todoIdText()?.let(resolveTodo) ?: return false - onDropTargetChanged(false) - onMoveTaskToDate(todo, targetDate) - return true - } - - override fun onEnded(event: DragAndDropEvent) { - onDropTargetChanged(false) - onDragTodoEnd?.invoke() + return composed { + DisposableEffect(targetId) { + onDispose { + dropTargets.remove(targetId) } - }, - ) -} - -private fun DragAndDropEvent.todoIdText(): String? { - val clipData = toAndroidDragEvent().clipData ?: return null - for (index in 0 until clipData.itemCount) { - val text = clipData.getItemAt(index).text?.toString()?.trim() - if (!text.isNullOrBlank()) { - return text + } + onGloballyPositioned { coordinates -> + val position = coordinates.positionInRoot() + val size = coordinates.size + dropTargets[targetId] = TimelineDropTargetBounds( + sectionKey = section.key, + bounds = Rect( + left = position.x, + top = position.y, + right = position.x + size.width, + bottom = position.y + size.height, + ), + ) } } - return null } +private data class TimelineDropTargetBounds( + val sectionKey: String, + val bounds: Rect, +) + +private data class TimelineInAppDrag( + val todo: TodoItem, + val position: Offset, +) + private data class TodoSection( val key: String, val title: String, @@ -2468,7 +2597,10 @@ private fun AllTaskSwipeRow( showDateDivider: Boolean, dragEnabled: Boolean = false, dragging: Boolean = false, - onDragStart: (() -> Unit)? = null, + onDragStart: ((Offset) -> Unit)? = null, + onDragMove: (Offset) -> Unit = {}, + onDragEnd: (Offset?) -> Unit = {}, + onDragCancel: () -> Unit = {}, ) { SwipeTaskRow( todo = todo, @@ -2487,6 +2619,9 @@ private fun AllTaskSwipeRow( dragEnabled = dragEnabled, dragging = dragging, onDragStart = onDragStart, + onDragMove = onDragMove, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, ) } @@ -2504,7 +2639,10 @@ private fun TodayTaskSwipeRow( showDateDivider: Boolean, dragEnabled: Boolean = false, dragging: Boolean = false, - onDragStart: (() -> Unit)? = null, + onDragStart: ((Offset) -> Unit)? = null, + onDragMove: (Offset) -> Unit = {}, + onDragEnd: (Offset?) -> Unit = {}, + onDragCancel: () -> Unit = {}, ) { SwipeTaskRow( todo = todo, @@ -2523,6 +2661,9 @@ private fun TodayTaskSwipeRow( dragEnabled = dragEnabled, dragging = dragging, onDragStart = onDragStart, + onDragMove = onDragMove, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, ) } @@ -2545,7 +2686,10 @@ private fun SwipeTaskRow( useFadeOnCompletion: Boolean = false, dragEnabled: Boolean = false, dragging: Boolean = false, - onDragStart: (() -> Unit)? = null, + onDragStart: ((Offset) -> Unit)? = null, + onDragMove: (Offset) -> Unit = {}, + onDragEnd: (Offset?) -> Unit = {}, + onDragCancel: () -> Unit = {}, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -2559,6 +2703,8 @@ private fun SwipeTaskRow( var localCompleted by remember(todo.id) { mutableStateOf(false) } var pendingCompletion by remember(todo.id) { mutableStateOf(false) } var completionFading by remember(todo.id) { mutableStateOf(false) } + var rowOriginInRoot by remember(todo.id) { mutableStateOf(Offset.Zero) } + var dragPointerPosition by remember(todo.id) { mutableStateOf(null) } val highlightAnim = remember(todo.id) { Animatable(0f) } val visuallyCompleted = localCompleted || (keepCompletedInline && todo.completed) val animatedOffsetX by animateFloatAsState( @@ -2704,29 +2850,39 @@ private fun SwipeTaskRow( Card( modifier = Modifier .fillMaxSize() + .onGloballyPositioned { coordinates -> + rowOriginInRoot = coordinates.positionInRoot() + } .graphicsLayer { translationX = animatedOffsetX } .then( if (dragEnabled) { - Modifier.dragAndDropSource { + Modifier.pointerInput(todo.id, dragEnabled) { detectDragGesturesAfterLongPress( - onDragStart = { - onDragStart?.invoke() + onDragStart = { localOffset -> + targetOffsetX = 0f + val startPosition = rowOriginInRoot + localOffset + dragPointerPosition = startPosition + onDragStart?.invoke(startPosition) + onDragMove(startPosition) ViewCompat.performHapticFeedback( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) - startTransfer( - DragAndDropTransferData( - clipData = ClipData.newPlainText( - "todo-id", - todo.id - ), - flags = View.DRAG_FLAG_GLOBAL, - ), - ) }, - onDrag = { change, _ -> + onDrag = { change, dragAmount -> change.consume() + val nextPosition = (dragPointerPosition + ?: rowOriginInRoot) + dragAmount + dragPointerPosition = nextPosition + onDragMove(nextPosition) + }, + onDragEnd = { + onDragEnd(dragPointerPosition) + dragPointerPosition = null + }, + onDragCancel = { + dragPointerPosition = null + onDragCancel() }, ) } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index ee78bb47..f46cba87 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -3,6 +3,7 @@ import UIKit import UniformTypeIdentifiers private let todoDragContentTypes = [UTType.plainText.identifier, UTType.text.identifier] +private let todoTimelineDragCoordinateSpace = "todoTimelineDragCoordinateSpace" private final class TodoTaskDragSession { static let shared = TodoTaskDragSession() @@ -12,6 +13,24 @@ private final class TodoTaskDragSession { private init() {} } +private struct TodoInAppDrag: Equatable { + let todo: TodoItem + var location: CGPoint +} + +private struct TodoDropTargetFrame: Equatable { + let sectionID: String + let frame: CGRect +} + +private struct TodoDropTargetFramePreferenceKey: PreferenceKey { + static var defaultValue: [String: TodoDropTargetFrame] = [:] + + static func reduce(value: inout [String: TodoDropTargetFrame], nextValue: () -> [String: TodoDropTargetFrame]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + enum TodoTimelineMetrics { static let horizontalPadding: CGFloat = 18 static let heroTitleSize: CGFloat = 32 @@ -160,7 +179,9 @@ struct TodoListScreen: View { @State private var showingSummary = false @State private var showingListSettings = false @State private var draggedTodo: TodoItem? + @State private var inAppDrag: TodoInAppDrag? @State private var activeDropSectionId: String? + @State private var dropTargetFrames: [String: TodoDropTargetFrame] = [:] @State private var pendingRescheduleDrop: TodoRescheduleDrop? @State private var collapsedSectionIDs: Set @State private var timelineScrollOffset: CGFloat = 0 @@ -178,7 +199,7 @@ struct TodoListScreen: View { buildSections( items: viewModel.items, mode: viewModel.mode, - includeEmptyEarlierTarget: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + includeEmptyEarlierTarget: viewModel.mode.supportsTaskReschedule && (draggedTodo != nil || inAppDrag != nil) ) } @@ -255,7 +276,11 @@ struct TodoListScreen: View { var body: some View { modeContent + .coordinateSpace(name: todoTimelineDragCoordinateSpace) .background(colors.background) + .onPreferenceChange(TodoDropTargetFramePreferenceKey.self) { frames in + dropTargetFrames = frames + } .overlay { if viewModel.items.isEmpty, !viewModel.isLoading { ZStack { @@ -270,6 +295,14 @@ struct TodoListScreen: View { .allowsHitTesting(false) } } + .overlay(alignment: .topLeading) { + if let inAppDrag { + TodoDragPreview(todo: inAppDrag.todo) + .position(x: inAppDrag.location.x, y: inAppDrag.location.y - 34) + .zIndex(20) + .allowsHitTesting(false) + } + } .navigationBackButtonBehavior() .navigationTitleTypography( largeTitleColor: modeAccentColor, @@ -466,6 +499,8 @@ struct TodoListScreen: View { private func handleItemsChanged() { activeDropSectionId = nil draggedTodo = nil + inAppDrag = nil + dropTargetFrames = [:] TodoTaskDragSession.shared.todo = nil if viewModel.mode == .all, highlightedTodoId != nil { collapsedSectionIDs = [] @@ -475,6 +510,8 @@ struct TodoListScreen: View { private func requestReschedule(_ todo: TodoItem, to targetDate: Date) { activeDropSectionId = nil draggedTodo = nil + inAppDrag = nil + dropTargetFrames = [:] TodoTaskDragSession.shared.todo = nil let targetDay = Calendar.current.startOfDay(for: targetDate) let dropSignature = "\(todo.id)|\(targetDay.timeIntervalSince1970)" @@ -498,6 +535,49 @@ struct TodoListScreen: View { viewModel.items.first { $0.id == id || $0.canonicalId == id } } + private func beginInAppDrag(_ todo: TodoItem, at location: CGPoint) { + if draggedTodo?.id != todo.id { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + draggedTodo = todo + inAppDrag = TodoInAppDrag(todo: todo, location: location) + updateInAppDrag(todo, to: location) + } + + private func updateInAppDrag(_ todo: TodoItem, to location: CGPoint) { + inAppDrag = TodoInAppDrag(todo: todo, location: location) + activeDropSectionId = dropSectionID(at: location) + } + + private func finishInAppDrag(_ todo: TodoItem, at location: CGPoint?) { + let targetSectionID = location.flatMap(dropSectionID(at:)) ?? activeDropSectionId + let targetDate = targetSectionID + .flatMap { sectionID in groupedSections.first { $0.id == sectionID }?.targetDate } + activeDropSectionId = nil + draggedTodo = nil + inAppDrag = nil + dropTargetFrames = [:] + if let targetDate { + requestReschedule(todo, to: targetDate) + } + } + + private func cancelInAppDrag() { + activeDropSectionId = nil + draggedTodo = nil + inAppDrag = nil + dropTargetFrames = [:] + } + + private func dropSectionID(at location: CGPoint) -> String? { + dropTargetFrames.values + .filter { $0.frame.contains(location) } + .min { lhs, rhs in + (lhs.frame.width * lhs.frame.height) < (rhs.frame.width * rhs.frame.height) + }? + .sectionID + } + private func commitPendingReschedule(scope: TaskRescheduleScope) { guard let drop = pendingRescheduleDrop else { return @@ -620,12 +700,22 @@ struct TodoListScreen: View { Section { ForEach(section.items) { todo in todoRow(todo, in: section) + .todoInAppDropTargetFrame( + targetID: "standard-row-\(section.id)-\(todo.id)", + section: section, + enabled: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + ) .listRowBackground(todo.id == highlightedTodoId ? colors.surfaceVariant : colors.surface) } if viewModel.mode.supportsTaskReschedule, - draggedTodo != nil, + activeDropSectionId == section.id, section.targetDate != nil { TodoDropPlaceholder(isActive: activeDropSectionId == section.id) + .todoInAppDropTargetFrame( + targetID: "standard-placeholder-\(section.id)", + section: section, + enabled: true + ) .listRowInsets(EdgeInsets(top: 4, leading: 20, bottom: 6, trailing: 20)) .listRowBackground(colors.surface) .scheduledTodoDropTarget( @@ -643,6 +733,11 @@ struct TodoListScreen: View { if viewModel.mode.supportsTaskReschedule, !section.items.isEmpty { Color.clear .frame(height: 8) + .todoInAppDropTargetFrame( + targetID: "standard-spacer-\(section.id)", + section: section, + enabled: draggedTodo != nil + ) .listRowInsets(EdgeInsets()) .scheduledTodoDropTarget( section: section, @@ -659,6 +754,13 @@ struct TodoListScreen: View { } header: { Text(section.title) .foregroundStyle(activeDropSectionId == section.id ? colors.error : colors.onSurfaceVariant) + .frame(maxWidth: .infinity, minHeight: 38, alignment: .leading) + .contentShape(Rectangle()) + .todoInAppDropTargetFrame( + targetID: "standard-header-\(section.id)", + section: section, + enabled: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + ) .timelinePinnedSectionHeaderBackground() .scheduledTodoDropTarget( section: section, @@ -871,12 +973,13 @@ struct TodoListScreen: View { } ) .modifier( - ScheduledDragModifier( + TodoInAppDragModifier( enabled: viewModel.mode.supportsTaskReschedule, todo: todo, - onDragStart: { - draggedTodo = todo - } + onStart: beginInAppDrag, + onMove: updateInAppDrag, + onEnd: finishInAppDrag, + onCancel: cancelInAppDrag ) ) } @@ -969,12 +1072,13 @@ struct TodoListScreen: View { } ) .modifier( - ScheduledDragModifier( + TodoInAppDragModifier( enabled: viewModel.mode.supportsTaskReschedule, todo: todo, - onDragStart: { - draggedTodo = todo - } + onStart: beginInAppDrag, + onMove: updateInAppDrag, + onEnd: finishInAppDrag, + onCancel: cancelInAppDrag ) ) } @@ -1007,9 +1111,14 @@ struct TodoListScreen: View { Section { if viewModel.mode.supportsTaskReschedule, - draggedTodo != nil, + activeDropSectionId == section.id, section.targetDate != nil { TodoDropPlaceholder(isActive: activeDropSectionId == section.id) + .todoInAppDropTargetFrame( + targetID: "minimal-placeholder-\(section.id)", + section: section, + enabled: true + ) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 8, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) .listRowSeparator(.hidden) @@ -1030,6 +1139,11 @@ struct TodoListScreen: View { ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section, flashHighlight: shouldFlashTodo(todo)) .id(timelineTodoScrollID(todo.id)) + .todoInAppDropTargetFrame( + targetID: "minimal-row-\(section.id)-\(todo.id)", + section: section, + enabled: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + ) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) .listRowSeparator(.hidden) @@ -1052,6 +1166,13 @@ struct TodoListScreen: View { ) .id(timelineSectionScrollID(section.id)) .padding(.top, isFirstSection ? 0 : 8) + .frame(maxWidth: .infinity, minHeight: viewModel.mode.supportsTaskReschedule && draggedTodo != nil ? 44 : nil, alignment: .leading) + .contentShape(Rectangle()) + .todoInAppDropTargetFrame( + targetID: "minimal-header-\(section.id)", + section: section, + enabled: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + ) .timelinePinnedSectionHeaderBackground() .scheduledTodoDropTarget( section: section, @@ -1555,6 +1676,7 @@ struct TimelineSectionHeader: View { .padding(.horizontal, TodoTimelineMetrics.horizontalPadding) .padding(.bottom, 4) .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) if let onTap { Button(action: onTap) { @@ -1567,6 +1689,49 @@ struct TimelineSectionHeader: View { } } +private struct TodoDragPreview: View { + let todo: TodoItem + + @Environment(\.tdayColors) private var colors + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "circle") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.76)) + + VStack(alignment: .leading, spacing: 3) { + Text(todo.title) + .font(.tdayRounded(size: 16, weight: .bold)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + Text(todo.due.formatted(date: .omitted, time: .shortened)) + .font(.tdayRounded(size: 12, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant) + .lineLimit(1) + } + + Spacer(minLength: 0) + + if todo.priority.lowercased() == "high" { + Image(systemName: "flag.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(priorityColor(todo.priority)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(width: 260, alignment: .leading) + .background(colors.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(colors.onSurfaceVariant.opacity(0.14), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.18), radius: 16, x: 0, y: 8) + .opacity(0.96) + } +} + private struct TodoDropPlaceholder: View { let isActive: Bool @@ -1588,6 +1753,89 @@ private struct TodoDropPlaceholder: View { } } +private struct TodoInAppDragModifier: ViewModifier { + let enabled: Bool + let todo: TodoItem + let onStart: (TodoItem, CGPoint) -> Void + let onMove: (TodoItem, CGPoint) -> Void + let onEnd: (TodoItem, CGPoint?) -> Void + let onCancel: () -> Void + + @State private var didStart = false + + func body(content: Content) -> some View { + if enabled { + content.highPriorityGesture( + LongPressGesture(minimumDuration: 0.22) + .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .named(todoTimelineDragCoordinateSpace))) + .onChanged { value in + guard case let .second(true, drag?) = value else { + return + } + if !didStart { + didStart = true + onStart(todo, drag.location) + } else { + onMove(todo, drag.location) + } + } + .onEnded { value in + defer { + didStart = false + } + guard case let .second(true, drag?) = value else { + onCancel() + return + } + onEnd(todo, drag.location) + } + ) + } else { + content + } + } +} + +private struct TodoInAppDropTargetFrameModifier: ViewModifier { + let targetID: String + let section: TodoTimelineSection + let enabled: Bool + + func body(content: Content) -> some View { + content.background { + if enabled, section.targetDate != nil { + GeometryReader { proxy in + Color.clear.preference( + key: TodoDropTargetFramePreferenceKey.self, + value: [ + targetID: TodoDropTargetFrame( + sectionID: section.id, + frame: proxy.frame(in: .named(todoTimelineDragCoordinateSpace)) + ) + ] + ) + } + } + } + } +} + +private extension View { + func todoInAppDropTargetFrame( + targetID: String, + section: TodoTimelineSection, + enabled: Bool + ) -> some View { + modifier( + TodoInAppDropTargetFrameModifier( + targetID: targetID, + section: section, + enabled: enabled + ) + ) + } +} + private struct TimelineSectionHeaderButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label From b300124a6b756bdfcbdac667374370fbc4f88308 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 02:28:14 -0400 Subject: [PATCH 16/24] fix(todos): improve drag-and-drop coordinate handling and UI consistency Refine the drag-and-drop implementation in `TodoListScreen` to use global coordinates, ensuring more reliable hit testing and preview positioning across both iOS and Android platforms. - **iOS: Global Coordinate Transition**: - Switch from named coordinate spaces to `.global` for drag gestures and drop target frame calculations. - Introduce `TodoDragRootFramePreferenceKey` to track the screen's root global frame. - Calculate `TodoDragPreview` position relative to the root frame to maintain alignment during global drags. - **Android: UI Cleanup**: - Remove the conditional background color highlight on drop targets in `TodoListScreen.kt` to align with the intended visual design. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../compose/feature/todos/TodoListScreen.kt | 8 +---- .../Tday/Feature/Todos/TodoListScreen.swift | 30 +++++++++++++++++-- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index 16a11844..1a430c20 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -1816,13 +1816,7 @@ private fun TimelineSectionHeader( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) - .background( - if (isDropTarget) { - colorScheme.error.copy(alpha = 0.1f) - } else { - Color.Transparent - }, - ) + .background(Color.Transparent) .padding(horizontal = 4.dp) .heightIn(min = minimumHeaderHeight) .then(headerClickModifier), diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index f46cba87..b94ae03d 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -31,6 +31,14 @@ private struct TodoDropTargetFramePreferenceKey: PreferenceKey { } } +private struct TodoDragRootFramePreferenceKey: PreferenceKey { + static var defaultValue: CGRect = .zero + + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} + enum TodoTimelineMetrics { static let horizontalPadding: CGFloat = 18 static let heroTitleSize: CGFloat = 32 @@ -182,6 +190,7 @@ struct TodoListScreen: View { @State private var inAppDrag: TodoInAppDrag? @State private var activeDropSectionId: String? @State private var dropTargetFrames: [String: TodoDropTargetFrame] = [:] + @State private var dragRootGlobalFrame: CGRect = .zero @State private var pendingRescheduleDrop: TodoRescheduleDrop? @State private var collapsedSectionIDs: Set @State private var timelineScrollOffset: CGFloat = 0 @@ -281,6 +290,17 @@ struct TodoListScreen: View { .onPreferenceChange(TodoDropTargetFramePreferenceKey.self) { frames in dropTargetFrames = frames } + .background { + GeometryReader { proxy in + Color.clear.preference( + key: TodoDragRootFramePreferenceKey.self, + value: proxy.frame(in: .global) + ) + } + } + .onPreferenceChange(TodoDragRootFramePreferenceKey.self) { frame in + dragRootGlobalFrame = frame + } .overlay { if viewModel.items.isEmpty, !viewModel.isLoading { ZStack { @@ -297,8 +317,12 @@ struct TodoListScreen: View { } .overlay(alignment: .topLeading) { if let inAppDrag { + let previewLocation = CGPoint( + x: inAppDrag.location.x - dragRootGlobalFrame.minX, + y: inAppDrag.location.y - dragRootGlobalFrame.minY + ) TodoDragPreview(todo: inAppDrag.todo) - .position(x: inAppDrag.location.x, y: inAppDrag.location.y - 34) + .position(x: previewLocation.x, y: previewLocation.y - 34) .zIndex(20) .allowsHitTesting(false) } @@ -1767,7 +1791,7 @@ private struct TodoInAppDragModifier: ViewModifier { if enabled { content.highPriorityGesture( LongPressGesture(minimumDuration: 0.22) - .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .named(todoTimelineDragCoordinateSpace))) + .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .global)) .onChanged { value in guard case let .second(true, drag?) = value else { return @@ -1810,7 +1834,7 @@ private struct TodoInAppDropTargetFrameModifier: ViewModifier { value: [ targetID: TodoDropTargetFrame( sectionID: section.id, - frame: proxy.frame(in: .named(todoTimelineDragCoordinateSpace)) + frame: proxy.frame(in: .global) ) ] ) From 2835421ebc132ce5c016c8a604a5c0cc296031c9 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 02:42:36 -0400 Subject: [PATCH 17/24] refactor(todos): simplify drag preview positioning and refine drag-and-drop gestures Streamline the coordinate calculation for in-app drag previews and improve the reliability of the long-press-to-drag gesture sequence. - **Simplified Coordinate Tracking**: Remove `TodoDragRootFramePreferenceKey` and the manual global frame state. Instead, use a `GeometryReader` within the overlay to dynamically calculate the local preview position relative to the global drag location. - **Gesture Refinement**: Update `TodoInAppDragModifier` to use a `simultaneousGesture` for location tracking and a `highPriorityGesture` for the long-press trigger. This ensures the drag location is captured immediately when the long press activates, preventing "jumping" previews. - **UI Enhancements**: - Adjust `TodoDragPreview` vertical offset for better finger alignment. - Refine `placeholderStroke` logic in `TodoDropTargetPlaceholder` to switch between a solid border when active and a dashed border when inactive. - Set `allowsHitTesting(false)` on the drag preview overlay to prevent it from interfering with underlying touch events. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../Tday/Feature/Todos/TodoListScreen.swift | 113 ++++++++++-------- 1 file changed, 62 insertions(+), 51 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index b94ae03d..166d5855 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -31,14 +31,6 @@ private struct TodoDropTargetFramePreferenceKey: PreferenceKey { } } -private struct TodoDragRootFramePreferenceKey: PreferenceKey { - static var defaultValue: CGRect = .zero - - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() - } -} - enum TodoTimelineMetrics { static let horizontalPadding: CGFloat = 18 static let heroTitleSize: CGFloat = 32 @@ -190,7 +182,6 @@ struct TodoListScreen: View { @State private var inAppDrag: TodoInAppDrag? @State private var activeDropSectionId: String? @State private var dropTargetFrames: [String: TodoDropTargetFrame] = [:] - @State private var dragRootGlobalFrame: CGRect = .zero @State private var pendingRescheduleDrop: TodoRescheduleDrop? @State private var collapsedSectionIDs: Set @State private var timelineScrollOffset: CGFloat = 0 @@ -290,17 +281,6 @@ struct TodoListScreen: View { .onPreferenceChange(TodoDropTargetFramePreferenceKey.self) { frames in dropTargetFrames = frames } - .background { - GeometryReader { proxy in - Color.clear.preference( - key: TodoDragRootFramePreferenceKey.self, - value: proxy.frame(in: .global) - ) - } - } - .onPreferenceChange(TodoDragRootFramePreferenceKey.self) { frame in - dragRootGlobalFrame = frame - } .overlay { if viewModel.items.isEmpty, !viewModel.isLoading { ZStack { @@ -316,16 +296,20 @@ struct TodoListScreen: View { } } .overlay(alignment: .topLeading) { - if let inAppDrag { - let previewLocation = CGPoint( - x: inAppDrag.location.x - dragRootGlobalFrame.minX, - y: inAppDrag.location.y - dragRootGlobalFrame.minY - ) - TodoDragPreview(todo: inAppDrag.todo) - .position(x: previewLocation.x, y: previewLocation.y - 34) - .zIndex(20) - .allowsHitTesting(false) + GeometryReader { proxy in + if let inAppDrag { + let rootFrame = proxy.frame(in: .global) + let previewLocation = CGPoint( + x: inAppDrag.location.x - rootFrame.minX, + y: inAppDrag.location.y - rootFrame.minY + ) + TodoDragPreview(todo: inAppDrag.todo) + .position(x: previewLocation.x, y: previewLocation.y) + .zIndex(20) + .allowsHitTesting(false) + } } + .allowsHitTesting(false) } .navigationBackButtonBehavior() .navigationTitleTypography( @@ -1765,16 +1749,26 @@ private struct TodoDropPlaceholder: View { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(isActive ? colors.error.opacity(0.10) : colors.surfaceVariant.opacity(0.18)) .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke( - isActive ? colors.error.opacity(0.64) : colors.onSurfaceVariant.opacity(0.18), - style: StrokeStyle(lineWidth: isActive ? 1.5 : 1, dash: [7, 7]) - ) + placeholderStroke ) .frame(height: isActive ? 70 : 52) .animation(.easeInOut(duration: 0.18), value: isActive) .accessibilityHidden(true) } + + @ViewBuilder + private var placeholderStroke: some View { + if isActive { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(colors.error.opacity(0.72), lineWidth: 1.5) + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke( + colors.onSurfaceVariant.opacity(0.18), + style: StrokeStyle(lineWidth: 1, dash: [7, 7]) + ) + } + } } private struct TodoInAppDragModifier: ViewModifier { @@ -1786,34 +1780,51 @@ private struct TodoInAppDragModifier: ViewModifier { let onCancel: () -> Void @State private var didStart = false + @State private var latestLocation: CGPoint? func body(content: Content) -> some View { if enabled { - content.highPriorityGesture( - LongPressGesture(minimumDuration: 0.22) - .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .global)) + content + .simultaneousGesture( + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + latestLocation = value.location + guard didStart else { + return + } + onMove(todo, value.location) + } + .onEnded { value in + latestLocation = value.location + guard didStart else { + latestLocation = nil + return + } + didStart = false + onEnd(todo, value.location) + latestLocation = nil + } + ) + .highPriorityGesture( + LongPressGesture(minimumDuration: 0.22) .onChanged { value in - guard case let .second(true, drag?) = value else { + guard value, !didStart, let latestLocation else { return } - if !didStart { - didStart = true - onStart(todo, drag.location) - } else { - onMove(todo, drag.location) - } + didStart = true + onStart(todo, latestLocation) } - .onEnded { value in - defer { - didStart = false - } - guard case let .second(true, drag?) = value else { + .onEnded { completed in + guard completed else { onCancel() return } - onEnd(todo, drag.location) + if !didStart, let latestLocation { + didStart = true + onStart(todo, latestLocation) + } } - ) + ) } else { content } From e44da49ddf3a81c0b441166301e73d1a034331d4 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 02:51:12 -0400 Subject: [PATCH 18/24] Refactor Todo drag-and-drop interaction to use a UIKit-based gesture bridge Replace the pure SwiftUI `DragGesture` and `LongPressGesture` implementation with a `UIViewRepresentable` bridge using `UILongPressGestureRecognizer`. This change improves the reliability and responsiveness of long-press-to-drag interactions within scrollable views. - **Improved Gesture Handling**: Transition from SwiftUI gestures to `UILongPressGestureRecognizer` to better manage gesture transitions and simultaneous recognition. - **Enhanced Precision**: Implement hit-testing logic in `gestureRecognizerShouldBegin` to ensure the gesture only triggers when interacting with the specific todo item's bounds. - **Scroll View Integration**: Automatically attach the gesture recognizer to the nearest enclosing scroll view or superview to ensure consistent coordinate mapping and behavior. - **State Management**: Encapsulate gesture logic within a `Coordinator` class that manages start, move, end, and cancel states, providing more robust feedback to the `TodoListScreen`. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../Tday/Feature/Todos/TodoListScreen.swift | 227 ++++++++++++++---- 1 file changed, 185 insertions(+), 42 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 166d5855..7848d79c 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -1779,58 +1779,201 @@ private struct TodoInAppDragModifier: ViewModifier { let onEnd: (TodoItem, CGPoint?) -> Void let onCancel: () -> Void - @State private var didStart = false - @State private var latestLocation: CGPoint? - func body(content: Content) -> some View { if enabled { content - .simultaneousGesture( - DragGesture(minimumDistance: 0, coordinateSpace: .global) - .onChanged { value in - latestLocation = value.location - guard didStart else { - return - } - onMove(todo, value.location) - } - .onEnded { value in - latestLocation = value.location - guard didStart else { - latestLocation = nil - return - } - didStart = false - onEnd(todo, value.location) - latestLocation = nil - } - ) - .highPriorityGesture( - LongPressGesture(minimumDuration: 0.22) - .onChanged { value in - guard value, !didStart, let latestLocation else { - return - } - didStart = true - onStart(todo, latestLocation) - } - .onEnded { completed in - guard completed else { - onCancel() - return - } - if !didStart, let latestLocation { - didStart = true - onStart(todo, latestLocation) - } + .background { + GeometryReader { _ in + TodoInAppLongPressBridge( + enabled: enabled, + todo: todo, + onStart: onStart, + onMove: onMove, + onEnd: onEnd, + onCancel: onCancel + ) + .allowsHitTesting(false) } - ) + } } else { content } } } +private struct TodoInAppLongPressBridge: UIViewRepresentable { + let enabled: Bool + let todo: TodoItem + let onStart: (TodoItem, CGPoint) -> Void + let onMove: (TodoItem, CGPoint) -> Void + let onEnd: (TodoItem, CGPoint?) -> Void + let onCancel: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + enabled: enabled, + todo: todo, + onStart: onStart, + onMove: onMove, + onEnd: onEnd, + onCancel: onCancel + ) + } + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + context.coordinator.markerView = view + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.enabled = enabled + context.coordinator.todo = todo + context.coordinator.onStart = onStart + context.coordinator.onMove = onMove + context.coordinator.onEnd = onEnd + context.coordinator.onCancel = onCancel + DispatchQueue.main.async { + context.coordinator.attach(to: uiView.enclosingScrollView() ?? uiView.superview, markerView: uiView) + } + } + + static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) { + coordinator.detach() + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var enabled: Bool + var todo: TodoItem + var onStart: (TodoItem, CGPoint) -> Void + var onMove: (TodoItem, CGPoint) -> Void + var onEnd: (TodoItem, CGPoint?) -> Void + var onCancel: () -> Void + + weak var markerView: UIView? + private weak var attachedView: UIView? + private let recognizer: UILongPressGestureRecognizer + private var isDragging = false + + init( + enabled: Bool, + todo: TodoItem, + onStart: @escaping (TodoItem, CGPoint) -> Void, + onMove: @escaping (TodoItem, CGPoint) -> Void, + onEnd: @escaping (TodoItem, CGPoint?) -> Void, + onCancel: @escaping () -> Void + ) { + self.enabled = enabled + self.todo = todo + self.onStart = onStart + self.onMove = onMove + self.onEnd = onEnd + self.onCancel = onCancel + self.recognizer = UILongPressGestureRecognizer() + super.init() + + recognizer.minimumPressDuration = 0.22 + recognizer.allowableMovement = 24 + recognizer.cancelsTouchesInView = false + recognizer.delaysTouchesBegan = false + recognizer.delaysTouchesEnded = false + recognizer.delegate = self + recognizer.addTarget(self, action: #selector(handleLongPress(_:))) + } + + func attach(to view: UIView?, markerView: UIView) { + self.markerView = markerView + guard enabled, let view else { + detach() + return + } + + guard attachedView !== view else { + return + } + + detach() + attachedView = view + view.addGestureRecognizer(recognizer) + } + + func detach() { + if isDragging { + isDragging = false + onCancel() + } + attachedView?.removeGestureRecognizer(recognizer) + attachedView = nil + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard enabled, let markerView else { + return false + } + + let localPoint = gestureRecognizer.location(in: markerView) + return markerView.bounds.insetBy(dx: -6, dy: -6).contains(localPoint) + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard enabled, let markerView else { + return false + } + + let localPoint = touch.location(in: markerView) + return markerView.bounds.insetBy(dx: -6, dy: -6).contains(localPoint) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { + let location = globalLocation(for: recognizer) + switch recognizer.state { + case .began: + guard enabled else { + return + } + isDragging = true + onStart(todo, location) + case .changed: + guard isDragging else { + return + } + onMove(todo, location) + case .ended: + guard isDragging else { + return + } + isDragging = false + onEnd(todo, location) + case .cancelled, .failed: + guard isDragging else { + return + } + isDragging = false + onCancel() + default: + break + } + } + + private func globalLocation(for recognizer: UILongPressGestureRecognizer) -> CGPoint { + guard let view = recognizer.view else { + return .zero + } + + return view.convert(recognizer.location(in: view), to: nil) + } + } +} + private struct TodoInAppDropTargetFrameModifier: ViewModifier { let targetID: String let section: TodoTimelineSection From 126d2852a1b9983ffc6b2212280efd58f6a0cbc7 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 03:45:29 -0400 Subject: [PATCH 19/24] feat: implement native in-app task rescheduling and drag-and-drop for Calendar Introduces a robust in-app drag-and-drop system for rescheduling tasks within the calendar and todo views across both iOS and Android. This replaces generic task updates with a specialized `moveTodo` operation that preserves time and optimizes sync payloads. - **In-App Drag-and-Drop (iOS/SwiftUI)**: - Replaces system `onDrag` with a custom `CalendarInAppDragModifier` using `UILongPressGestureRecognizer` for a more responsive dragging experience. - Implements a global overlay for drag previews and uses `PreferenceKeys` to track date drop targets. - Adds `CalendarTaskDragPreview` to provide visual feedback during the drag operation. - **In-App Drag-and-Drop (Android/Compose)**: - Implements a pointer-input-based drag system in `CalendarScreen` that calculates drop targets via global coordinates. - Adds `CalendarTaskDragPreview` to show task details (title, time, list icon, priority) while dragging. - Expands the list of supported icons for list summaries in the calendar view. - **Data & Sync Optimization**: - **`TodoRepository`**: Adds `moveTodo` to both platforms to handle date-specific updates with optimized mutation tracking (coalescing multiple moves on the same task). - **`SyncManager`**: Refines patching logic to recognize "due-only moves," preventing unnecessary resetting of fields like `rrule`, `description`, or `listID` on the server when only the date changes. - **Optimistic Updates**: Task dates are updated in the UI state immediately before the repository call to ensure a lag-free experience. - **UI Refinements**: - Updates `CalendarPendingTaskRow` to display list and priority indicators. - Improves drag preview styling with rounded corners, shadows, and adjusted opacities. - Ensures dragging is disabled in "Day" view to avoid layout conflicts. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../compose/core/data/sync/SyncManager.kt | 32 +- .../compose/core/data/todo/TodoRepository.kt | 117 ++++ .../feature/calendar/CalendarScreen.kt | 449 ++++++++++-- .../feature/calendar/CalendarViewModel.kt | 35 +- .../compose/feature/todos/TodoListScreen.kt | 13 +- .../feature/todos/TodoListViewModel.kt | 49 +- .../Tday/Core/Data/Sync/SyncManager.swift | 14 +- .../Tday/Core/Data/Todo/TodoRepository.swift | 87 +++ .../Feature/Calendar/CalendarScreen.swift | 642 ++++++++++++++++-- .../Feature/Calendar/CalendarViewModel.swift | 15 +- .../Tday/Feature/Todos/TodoListScreen.swift | 10 +- .../Feature/Todos/TodoListViewModel.swift | 15 +- 12 files changed, 1325 insertions(+), 153 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt index 6026d6a9..22ebbcef 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt @@ -318,12 +318,30 @@ class SyncManager @Inject constructor( } val remoteTodo = remoteSnapshot.todos.firstOrNull { it.canonicalId == targetId } - val descriptionForApi = mutation.description - ?: if (remoteTodo?.description != null) "" else null - val rruleForApi = mutation.rrule - ?: if (!remoteTodo?.rrule.isNullOrBlank()) "" else null - val listIdForApi = resolvedListId - ?: if (!remoteTodo?.listId.isNullOrBlank()) "" else null + val isDueOnlyMove = mutation.dueEpochMs != null && + mutation.title == null && + mutation.description == null && + mutation.priority == null && + mutation.pinned == null && + mutation.completed == null && + mutation.rrule == null && + mutation.listId == null + val descriptionForApi = if (isDueOnlyMove) { + null + } else { + mutation.description + ?: if (remoteTodo?.description != null) "" else null + } + val rruleForApi = if (isDueOnlyMove) { + null + } else { + mutation.rrule ?: if (!remoteTodo?.rrule.isNullOrBlank()) "" else null + } + val listIdForApi = if (isDueOnlyMove) { + null + } else { + resolvedListId ?: if (!remoteTodo?.listId.isNullOrBlank()) "" else null + } if (mutation.instanceDateEpochMs != null) { requireApiBody( @@ -356,7 +374,7 @@ class SyncManager @Inject constructor( rrule = rruleForApi, listID = listIdForApi, dateChanged = true, - rruleChanged = true, + rruleChanged = if (isDueOnlyMove) null else true, instanceDate = null, ), ), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt index afde972d..f855ed2a 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt @@ -337,6 +337,123 @@ class TodoRepository @Inject constructor( } } + suspend fun moveTodo(todo: TodoItem, due: Instant) { + val canonicalId = todo.canonicalId + if (canonicalId.isBlank()) return + + val instanceDateEpochMs = todo.instanceDateEpochMillis + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + val pendingMutation = PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.UPDATE_TODO, + targetId = canonicalId, + timestampEpochMs = timestampMs, + dueEpochMs = due.toEpochMilli(), + instanceDateEpochMs = instanceDateEpochMs, + ) + + val isLocalOnly = canonicalId.startsWith(LOCAL_TODO_PREFIX) + cacheManager.updateOfflineState { state -> + val hasExistingUpdateMutation = state.pendingMutations.any { mutation -> + mutation.kind == MutationKind.UPDATE_TODO && + mutation.targetId == canonicalId && + mutation.instanceDateEpochMs == instanceDateEpochMs + } + val updatedMutations = state.pendingMutations + .map { mutation -> + when { + mutation.kind == MutationKind.CREATE_TODO && mutation.targetId == canonicalId -> { + mutation.copy( + dueEpochMs = due.toEpochMilli(), + timestampEpochMs = timestampMs, + ) + } + + mutation.kind == MutationKind.UPDATE_TODO && + mutation.targetId == canonicalId && + mutation.instanceDateEpochMs == instanceDateEpochMs -> { + mutation.copy( + dueEpochMs = due.toEpochMilli(), + timestampEpochMs = timestampMs, + ) + } + + else -> mutation + } + } + state.copy( + todos = state.todos.map { cached -> + val isTarget = cached.canonicalId == canonicalId && + (instanceDateEpochMs == null || cached.instanceDateEpochMs == instanceDateEpochMs) + if (isTarget) { + cached.copy( + dueEpochMs = due.toEpochMilli(), + updatedAtEpochMs = timestampMs, + ) + } else { + cached + } + }, + pendingMutations = if (isLocalOnly || hasExistingUpdateMutation) { + updatedMutations + } else { + updatedMutations + pendingMutation + }, + ) + } + + if (isLocalOnly) { + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + val immediateError = runCatching { + if (instanceDateEpochMs != null) { + requireApiBody( + api.patchTodoInstanceByBody( + TodoInstanceUpdateRequest( + todoId = canonicalId, + instanceDate = Instant.ofEpochMilli(instanceDateEpochMs).toString(), + due = due.toString(), + ), + ), + "Could not reschedule recurring task instance", + ) + } else { + requireApiBody( + api.patchTodoByBody( + UpdateTodoRequest( + id = canonicalId, + due = due.toString(), + dateChanged = true, + instanceDate = null, + ), + ), + "Could not reschedule task", + ) + } + }.exceptionOrNull() + + if (immediateError != null && isLikelyUnrecoverableMutationError( + immediateError, + pendingMutation + ) + ) { + throw immediateError + } + + if (immediateError == null) { + cacheManager.updateOfflineState { state -> + state.copy( + pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }, + ) + } + } else { + Log.w(LOG_TAG, "moveTodo deferred todo=$canonicalId reason=${immediateError.message}") + } + } + suspend fun deleteTodo(todo: TodoItem) { val timestampMs = System.currentTimeMillis() val canonicalId = todo.canonicalId diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index c500e220..33fd1138 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -1,7 +1,5 @@ package com.ohmz.tday.compose.feature.calendar -import android.content.ClipData -import android.view.View import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColorAsState @@ -20,7 +18,6 @@ import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.draganddrop.dragAndDropSource import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateScrollBy @@ -50,29 +47,81 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.DirectionsRun import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.automirrored.rounded.MenuBook +import androidx.compose.material.icons.rounded.AcUnit +import androidx.compose.material.icons.rounded.AccountBalance +import androidx.compose.material.icons.rounded.AccountBalanceWallet import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Architecture +import androidx.compose.material.icons.rounded.Backpack +import androidx.compose.material.icons.rounded.BeachAccess +import androidx.compose.material.icons.rounded.Bookmark import androidx.compose.material.icons.rounded.BorderColor +import androidx.compose.material.icons.rounded.Build +import androidx.compose.material.icons.rounded.Cake import androidx.compose.material.icons.rounded.CalendarMonth +import androidx.compose.material.icons.rounded.CalendarToday +import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material.icons.rounded.CardGiftcard +import androidx.compose.material.icons.rounded.ChangeHistory +import androidx.compose.material.icons.rounded.ChatBubbleOutline import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.ChevronLeft import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.ChildCare +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.Computer +import androidx.compose.material.icons.rounded.ContentCut import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.DesktopWindows +import androidx.compose.material.icons.rounded.DirectionsBoat import androidx.compose.material.icons.rounded.DirectionsCar +import androidx.compose.material.icons.rounded.Eco +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.FamilyRestroom +import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FitnessCenter import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.Headphones import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Inbox +import androidx.compose.material.icons.rounded.Inventory +import androidx.compose.material.icons.rounded.Key +import androidx.compose.material.icons.rounded.Lightbulb import androidx.compose.material.icons.rounded.LocalBar -import androidx.compose.material.icons.rounded.LocalHospital +import androidx.compose.material.icons.rounded.LocalMall +import androidx.compose.material.icons.rounded.LocationCity +import androidx.compose.material.icons.rounded.Medication +import androidx.compose.material.icons.rounded.Mood import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Payments +import androidx.compose.material.icons.rounded.Pets +import androidx.compose.material.icons.rounded.PriorityHigh import androidx.compose.material.icons.rounded.RadioButtonUnchecked import androidx.compose.material.icons.rounded.Restaurant import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.School +import androidx.compose.material.icons.rounded.ShoppingBasket +import androidx.compose.material.icons.rounded.ShoppingCart +import androidx.compose.material.icons.rounded.SportsBaseball +import androidx.compose.material.icons.rounded.SportsBasketball +import androidx.compose.material.icons.rounded.SportsEsports +import androidx.compose.material.icons.rounded.SportsFootball +import androidx.compose.material.icons.rounded.SportsSoccer +import androidx.compose.material.icons.rounded.SportsTennis +import androidx.compose.material.icons.rounded.Square +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.Train +import androidx.compose.material.icons.rounded.WaterDrop import androidx.compose.material.icons.rounded.WbSunny +import androidx.compose.material.icons.rounded.Whatshot import androidx.compose.material.icons.rounded.Work import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card @@ -89,10 +138,12 @@ import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -100,14 +151,15 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget -import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance @@ -115,6 +167,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource @@ -122,10 +177,12 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat import com.ohmz.tday.compose.R @@ -147,6 +204,7 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.util.Locale +import kotlin.math.roundToInt private val CalendarAccentPurple = Color(0xFF7D67B6) private val CalendarTodayBlue = Color(0xFF509AE6) @@ -178,6 +236,8 @@ private val CalendarPeriodCardPageHeight = 78.dp private val CalendarPeriodWeekDayCellHeight = 72.dp private val CalendarPeriodPageHorizontalGutter = 2.dp private val CalendarPeriodCardBottomPadding = 18.dp +private val CalendarTaskDragDueTimeFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()) private fun shouldShowDateDivider( afterItemIndex: Int, @@ -194,6 +254,16 @@ private data class CalendarTaskRescheduleDrop( val targetDate: LocalDate, ) +private data class CalendarTaskDragState( + val todo: TodoItem, + val position: Offset, +) + +private data class CalendarDateDropTargetBounds( + val date: LocalDate, + val bounds: Rect, +) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun CalendarScreen( @@ -298,6 +368,7 @@ fun CalendarScreen( val selectedViewMode = remember(selectedViewKey) { CalendarViewMode.entries.firstOrNull { it.name == selectedViewKey } ?: CalendarViewMode.MONTH } + val calendarTaskRescheduleEnabled = selectedViewMode != CalendarViewMode.DAY val tasksByDate = remember(uiState.items, zoneId) { uiState.items .groupBy { LocalDate.ofInstant(it.due, zoneId) } @@ -320,8 +391,20 @@ fun CalendarScreen( var showCreateTaskSheet by rememberSaveable { mutableStateOf(false) } var createDueEpochMs by rememberSaveable { mutableStateOf(null) } var draggedCalendarTodoId by rememberSaveable { mutableStateOf(null) } + var activeCalendarDrag by remember { mutableStateOf(null) } + var calendarDragContainerOrigin by remember { mutableStateOf(Offset.Zero) } + val calendarDropTargetBounds = + remember { mutableStateMapOf() } var activeDropDateIso by remember { mutableStateOf(null) } var pendingRescheduleDrop by remember { mutableStateOf(null) } + LaunchedEffect(selectedViewMode) { + if (selectedViewMode == CalendarViewMode.DAY) { + draggedCalendarTodoId = null + activeCalendarDrag = null + activeDropDateIso = null + calendarDropTargetBounds.clear() + } + } val editTarget = remember(editTargetId, uiState.items) { editTargetId?.let { targetId -> uiState.items.firstOrNull { it.id == targetId } @@ -351,7 +434,9 @@ fun CalendarScreen( } fun requestTaskReschedule(todo: TodoItem, targetDate: LocalDate) { draggedCalendarTodoId = null + activeCalendarDrag = null activeDropDateIso = null + calendarDropTargetBounds.clear() val currentDate = LocalDate.ofInstant(todo.due, zoneId) if (currentDate == targetDate) return ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) @@ -362,6 +447,44 @@ fun CalendarScreen( selectDate(targetDate) } } + + fun activeCalendarDropDate(position: Offset): LocalDate? { + return calendarDropTargetBounds.values + .asSequence() + .filter { target -> target.bounds.contains(position) } + .minByOrNull { target -> target.bounds.width * target.bounds.height } + ?.date + } + + fun updateActiveCalendarDropTarget(position: Offset) { + activeDropDateIso = activeCalendarDropDate(position)?.toString() + } + + fun finishCalendarDrag(position: Offset?) { + val drag = activeCalendarDrag + val targetDate = position?.let(::activeCalendarDropDate) + ?: activeDropDate + activeCalendarDrag = null + draggedCalendarTodoId = null + activeDropDateIso = null + calendarDropTargetBounds.clear() + if (drag != null && targetDate != null) { + requestTaskReschedule(drag.todo, targetDate) + } + } + + fun cancelCalendarDrag() { + activeCalendarDrag = null + draggedCalendarTodoId = null + activeDropDateIso = null + calendarDropTargetBounds.clear() + } + + LaunchedEffect(draggedCalendarTodoId) { + if (draggedCalendarTodoId == null) { + calendarDropTargetBounds.clear() + } + } LaunchedEffect(listState.isScrollInProgress, monthTitleSnapThresholdPx) { if (listState.isScrollInProgress) return@LaunchedEffect if (listState.firstVisibleItemIndex != 0) return@LaunchedEffect @@ -401,7 +524,10 @@ fun CalendarScreen( Box( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(padding) + .onGloballyPositioned { coordinates -> + calendarDragContainerOrigin = coordinates.positionInRoot() + }, ) { CompositionLocalProvider(LocalOverscrollConfiguration provides null) { LazyColumn( @@ -468,6 +594,7 @@ fun CalendarScreen( tasksByDate = tasksByDate, draggedTodo = draggedCalendarTodo, activeDropDate = activeDropDate, + dropTargets = calendarDropTargetBounds, canSelectDate = ::canNavigateTo, todayJumpRequest = todayJumpRequest, onTodayJumpHandled = ::clearTodayJumpRequest, @@ -493,6 +620,7 @@ fun CalendarScreen( tasksByDate = tasksByDate, draggedTodo = draggedCalendarTodo, activeDropDate = activeDropDate, + dropTargets = calendarDropTargetBounds, canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), canSelectDate = ::canNavigateTo, todayJumpRequest = todayJumpRequest, @@ -511,8 +639,6 @@ fun CalendarScreen( selectedDate = selectedDate, today = today, tasksByDate = tasksByDate, - draggedTodo = draggedCalendarTodo, - activeDropDate = activeDropDate, canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), canSelectDate = ::canNavigateTo, todayJumpRequest = todayJumpRequest, @@ -520,11 +646,6 @@ fun CalendarScreen( onPrevDay = { selectDate(selectedDate.minusDays(1)) }, onNextDay = { selectDate(selectedDate.plusDays(1)) }, onSelectDate = ::selectDate, - onDropDateChanged = { date -> - activeDropDateIso = date?.toString() - }, - onMoveTaskToDate = ::requestTaskReschedule, - resolveTodo = resolveTodoForDrop, ) } } @@ -556,14 +677,29 @@ fun CalendarScreen( items = selectedDatePendingTasks, zoneId = zoneId, ), + dragEnabled = calendarTaskRescheduleEnabled, onComplete = { onCompleteTask(todo) }, onInfo = { editTargetId = todo.id }, onDelete = { onDelete(todo) }, - dragging = draggedCalendarTodo?.id == todo.id, - onDragStart = { + dragging = calendarTaskRescheduleEnabled && draggedCalendarTodo?.id == todo.id, + onDragStart = { position -> activeDropDateIso = null draggedCalendarTodoId = todo.id + activeCalendarDrag = CalendarTaskDragState( + todo = todo, + position = position, + ) + updateActiveCalendarDropTarget(position) + }, + onDragMove = { position -> + activeCalendarDrag = CalendarTaskDragState( + todo = todo, + position = position, + ) + updateActiveCalendarDropTarget(position) }, + onDragEnd = ::finishCalendarDrag, + onDragCancel = ::cancelCalendarDrag, ) } } @@ -583,6 +719,22 @@ fun CalendarScreen( item { Spacer(modifier = Modifier.height(96.dp)) } } } + + activeCalendarDrag?.let { drag -> + CalendarTaskDragPreview( + modifier = Modifier + .offset { + val localPosition = drag.position - calendarDragContainerOrigin + IntOffset( + x = (localPosition.x - with(density) { 130.dp.toPx() }).roundToInt(), + y = (localPosition.y - with(density) { 34.dp.toPx() }).roundToInt(), + ) + } + .zIndex(20f), + todo = drag.todo, + lists = uiState.lists, + ) + } } } @@ -716,6 +868,7 @@ private fun CalendarWeekCard( tasksByDate: Map>, draggedTodo: TodoItem?, activeDropDate: LocalDate?, + dropTargets: MutableMap, canGoPrevWeek: Boolean, canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, @@ -901,6 +1054,7 @@ private fun CalendarWeekCard( isEnabled = isEnabled, isDropTarget = activeDropDate == day, draggedTodo = draggedTodo.takeIf { isEnabled }, + dropTargets = dropTargets, onClick = { onSelectDate(day) }, onDropDateChanged = onDropDateChanged, onMoveTaskToDate = onMoveTaskToDate, @@ -923,6 +1077,7 @@ private fun CalendarWeekDayCell( isEnabled: Boolean, isDropTarget: Boolean, draggedTodo: TodoItem?, + dropTargets: MutableMap, onClick: () -> Unit, onDropDateChanged: (LocalDate?) -> Unit, onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, @@ -967,6 +1122,12 @@ private fun CalendarWeekDayCell( onMoveTaskToDate = onMoveTaskToDate, resolveTodo = resolveTodo, ) + .calendarInAppDateDropTarget( + targetId = "week-$date", + date = date, + enabled = isEnabled && draggedTodo != null, + dropTargets = dropTargets, + ) .graphicsLayer { alpha = if (isEnabled) 1f else 0.48f }, contentAlignment = Alignment.Center, ) { @@ -1068,13 +1229,41 @@ private fun DragAndDropEvent.todoIdText(): String? { return null } +private fun Modifier.calendarInAppDateDropTarget( + targetId: String, + date: LocalDate, + enabled: Boolean, + dropTargets: MutableMap, +): Modifier { + if (!enabled) return this + + return composed { + DisposableEffect(targetId) { + onDispose { + dropTargets.remove(targetId) + } + } + onGloballyPositioned { coordinates -> + val position = coordinates.positionInRoot() + val size = coordinates.size + dropTargets[targetId] = CalendarDateDropTargetBounds( + date = date, + bounds = Rect( + left = position.x, + top = position.y, + right = position.x + size.width, + bottom = position.y + size.height, + ), + ) + } + } +} + @Composable private fun CalendarDayCard( selectedDate: LocalDate, today: LocalDate, tasksByDate: Map>, - draggedTodo: TodoItem?, - activeDropDate: LocalDate?, canGoPrevDay: Boolean, canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, @@ -1082,9 +1271,6 @@ private fun CalendarDayCard( onPrevDay: () -> Unit, onNextDay: () -> Unit, onSelectDate: (LocalDate) -> Unit, - onDropDateChanged: (LocalDate?) -> Unit, - onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, - resolveTodo: (String) -> TodoItem?, ) { val colorScheme = MaterialTheme.colorScheme val coroutineScope = rememberCoroutineScope() @@ -1231,26 +1417,11 @@ private fun CalendarDayCard( .height(CalendarPeriodCardPageHeight), ) { displayDate -> val taskCount = tasksByDate[displayDate]?.size ?: 0 - val isEnabled = canSelectDate(displayDate) Column( modifier = Modifier .fillMaxWidth() - .calendarDateDropTarget( - date = displayDate, - draggedTodo = draggedTodo.takeIf { isEnabled }, - enabled = isEnabled, - onDropDateChanged = onDropDateChanged, - onMoveTaskToDate = onMoveTaskToDate, - resolveTodo = resolveTodo, - ) .clip(RoundedCornerShape(16.dp)) - .background( - if (activeDropDate == displayDate) { - colorScheme.error.copy(alpha = 0.12f) - } else { - Color.Transparent - }, - ) + .background(Color.Transparent) .padding(horizontal = 6.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { @@ -1260,7 +1431,6 @@ private fun CalendarDayCard( fontSize = CalendarDaySummaryTitleSize, ), color = when { - activeDropDate == displayDate -> colorScheme.error displayDate == today -> CalendarAccentPurple else -> colorScheme.onSurface }, @@ -1471,6 +1641,7 @@ private fun CalendarMonthCard( tasksByDate: Map>, draggedTodo: TodoItem?, activeDropDate: LocalDate?, + dropTargets: MutableMap, canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, onTodayJumpHandled: (Int) -> Unit, @@ -1672,6 +1843,7 @@ private fun CalendarMonthCard( isEnabled = isEnabled, isDropTarget = activeDropDate == cell.date, draggedTodo = draggedTodo.takeIf { isEnabled }, + dropTargets = dropTargets, onClick = { onSelectDate(cell.date) }, onDropDateChanged = onDropDateChanged, onMoveTaskToDate = onMoveTaskToDate, @@ -1746,6 +1918,7 @@ private fun CalendarDayCell( isEnabled: Boolean, isDropTarget: Boolean, draggedTodo: TodoItem?, + dropTargets: MutableMap, onClick: () -> Unit, onDropDateChanged: (LocalDate?) -> Unit, onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, @@ -1814,6 +1987,12 @@ private fun CalendarDayCell( onMoveTaskToDate = onMoveTaskToDate, resolveTodo = resolveTodo, ) + .calendarInAppDateDropTarget( + targetId = "month-${cell.date}", + date = cell.date, + enabled = isEnabled && draggedTodo != null, + dropTargets = dropTargets, + ) .clickable( enabled = isEnabled, interactionSource = interactionSource, @@ -1881,6 +2060,73 @@ private fun CalendarDayCell( } } +@Composable +private fun CalendarTaskDragPreview( + modifier: Modifier = Modifier, + todo: TodoItem, + lists: List, +) { + val colorScheme = MaterialTheme.colorScheme + val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } + val previewShape = RoundedCornerShape(18.dp) + Card( + modifier = modifier + .sizeIn(minWidth = 220.dp, maxWidth = 280.dp), + shape = previewShape, + colors = CardDefaults.cardColors(containerColor = colorScheme.surface.copy(alpha = 0.88f)), + border = BorderStroke(1.dp, colorScheme.outlineVariant.copy(alpha = 0.55f)), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.RadioButtonUnchecked, + contentDescription = null, + tint = colorScheme.onSurfaceVariant.copy(alpha = 0.76f), + modifier = Modifier.size(22.dp), + ) + Column( + modifier = Modifier.weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = todo.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.ExtraBold, + color = colorScheme.onSurface, + maxLines = 1, + ) + Text( + text = CalendarTaskDragDueTimeFormatter.format(todo.due), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + if (listMeta != null) { + Icon( + imageVector = listIconForKey(listMeta.iconKey), + contentDescription = null, + tint = listAccentColor(listMeta.color), + modifier = Modifier.size(18.dp), + ) + } + if (isHighPriority(todo.priority)) { + Icon( + imageVector = Icons.Rounded.Flag, + contentDescription = null, + tint = priorityColor(todo.priority), + modifier = Modifier.size(18.dp), + ) + } + } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun CalendarTodoRow( @@ -1888,11 +2134,15 @@ private fun CalendarTodoRow( todo: TodoItem, lists: List, showDateDivider: Boolean, + dragEnabled: Boolean, onComplete: () -> Unit, onInfo: () -> Unit, onDelete: () -> Unit, dragging: Boolean, - onDragStart: () -> Unit, + onDragStart: (Offset) -> Unit, + onDragMove: (Offset) -> Unit, + onDragEnd: (Offset?) -> Unit, + onDragCancel: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -1904,6 +2154,8 @@ private fun CalendarTodoRow( var targetOffsetX by remember(todo.id) { mutableFloatStateOf(0f) } var swipeHinting by remember(todo.id) { mutableStateOf(false) } var pendingCompletion by remember(todo.id) { mutableStateOf(false) } + var rowOriginInRoot by remember(todo.id) { mutableStateOf(Offset.Zero) } + var dragPointerPosition by remember(todo.id) { mutableStateOf(null) } val animatedOffsetX by animateFloatAsState( targetValue = targetOffsetX, animationSpec = spring(stiffness = Spring.StiffnessLow), @@ -1973,27 +2225,46 @@ private fun CalendarTodoRow( Card( modifier = Modifier .fillMaxSize() + .onGloballyPositioned { coordinates -> + rowOriginInRoot = coordinates.positionInRoot() + } .graphicsLayer { translationX = animatedOffsetX } - .dragAndDropSource { - detectDragGesturesAfterLongPress( - onDragStart = { - onDragStart() - ViewCompat.performHapticFeedback( - view, - HapticFeedbackConstantsCompat.CLOCK_TICK, - ) - startTransfer( - DragAndDropTransferData( - clipData = ClipData.newPlainText("todo-id", todo.id), - flags = View.DRAG_FLAG_GLOBAL, - ), + .then( + if (dragEnabled) { + Modifier.pointerInput(todo.id) { + detectDragGesturesAfterLongPress( + onDragStart = { localOffset -> + targetOffsetX = 0f + val startPosition = rowOriginInRoot + localOffset + dragPointerPosition = startPosition + onDragStart(startPosition) + onDragMove(startPosition) + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + }, + onDrag = { change, dragAmount -> + change.consume() + val nextPosition = + (dragPointerPosition ?: rowOriginInRoot) + dragAmount + dragPointerPosition = nextPosition + onDragMove(nextPosition) + }, + onDragEnd = { + onDragEnd(dragPointerPosition) + dragPointerPosition = null + }, + onDragCancel = { + dragPointerPosition = null + onDragCancel() + }, ) - }, - onDrag = { change, _ -> - change.consume() - }, - ) - } + } + } else { + Modifier + }, + ) .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> @@ -2450,22 +2721,74 @@ private fun listAccentColor(colorKey: String?): Color { private fun listIconForKey(iconKey: String?): ImageVector { return when (iconKey?.trim()?.lowercase(Locale.getDefault())) { "sun" -> Icons.Rounded.WbSunny - "calendar" -> Icons.Rounded.CalendarMonth + "calendar" -> Icons.Rounded.CalendarToday "schedule" -> Icons.Rounded.Schedule "flag" -> Icons.Rounded.Flag "check" -> Icons.Rounded.Check + "smile" -> Icons.Rounded.Mood + "list" -> Icons.AutoMirrored.Rounded.List + "bookmark" -> Icons.Rounded.Bookmark + "key" -> Icons.Rounded.Key + "gift" -> Icons.Rounded.CardGiftcard + "cake" -> Icons.Rounded.Cake + "school" -> Icons.Rounded.School + "bag" -> Icons.Rounded.Backpack + "edit" -> Icons.Rounded.Edit + "document" -> Icons.Rounded.Description "inbox" -> Icons.Rounded.Inbox "book" -> Icons.AutoMirrored.Rounded.MenuBook - "briefcase" -> Icons.Rounded.Work - "health" -> Icons.Rounded.LocalHospital + "work", "briefcase" -> Icons.Rounded.Work + "wallet" -> Icons.Rounded.AccountBalanceWallet + "money" -> Icons.Rounded.Payments + "health" -> Icons.Rounded.Medication "fitness" -> Icons.Rounded.FitnessCenter + "run" -> Icons.AutoMirrored.Rounded.DirectionsRun "food" -> Icons.Rounded.Restaurant - "cocktail" -> Icons.Rounded.LocalBar + "drink", "cocktail" -> Icons.Rounded.LocalBar + "monitor" -> Icons.Rounded.DesktopWindows "music" -> Icons.Rounded.MusicNote - "travel" -> Icons.Rounded.Flight + "computer" -> Icons.Rounded.Computer + "game" -> Icons.Rounded.SportsEsports + "headphones" -> Icons.Rounded.Headphones + "eco" -> Icons.Rounded.Eco + "pets" -> Icons.Rounded.Pets + "child" -> Icons.Rounded.ChildCare + "family" -> Icons.Rounded.FamilyRestroom + "basket" -> Icons.Rounded.ShoppingBasket + "cart" -> Icons.Rounded.ShoppingCart + "mall" -> Icons.Rounded.LocalMall + "inventory" -> Icons.Rounded.Inventory + "soccer" -> Icons.Rounded.SportsSoccer + "baseball" -> Icons.Rounded.SportsBaseball + "basketball" -> Icons.Rounded.SportsBasketball + "football" -> Icons.Rounded.SportsFootball + "tennis" -> Icons.Rounded.SportsTennis + "train" -> Icons.Rounded.Train + "flight", "travel" -> Icons.Rounded.Flight + "boat" -> Icons.Rounded.DirectionsBoat "car" -> Icons.Rounded.DirectionsCar + "umbrella" -> Icons.Rounded.BeachAccess + "drop" -> Icons.Rounded.WaterDrop + "snow" -> Icons.Rounded.AcUnit + "fire" -> Icons.Rounded.Whatshot + "tools" -> Icons.Rounded.Build + "scissors" -> Icons.Rounded.ContentCut + "architecture" -> Icons.Rounded.Architecture + "bank" -> Icons.Rounded.AccountBalance + "code" -> Icons.Rounded.Code + "idea" -> Icons.Rounded.Lightbulb + "chat" -> Icons.Rounded.ChatBubbleOutline + "alert" -> Icons.Rounded.PriorityHigh + "star" -> Icons.Rounded.Star + "heart" -> Icons.Rounded.Favorite + "circle" -> Icons.Rounded.Circle + "square" -> Icons.Rounded.Square + "triangle" -> Icons.Rounded.ChangeHistory "home" -> Icons.Rounded.Home - else -> Icons.AutoMirrored.Rounded.List + "city" -> Icons.Rounded.LocationCity + "camera" -> Icons.Rounded.CameraAlt + "palette" -> Icons.Rounded.Palette + else -> Icons.Rounded.Inbox } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt index e95c0190..49aa9101 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt @@ -14,7 +14,7 @@ import com.ohmz.tday.compose.core.model.TaskRescheduleScope import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoListMode import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse -import com.ohmz.tday.compose.core.model.createMovedTaskPayload +import com.ohmz.tday.compose.core.model.movedDuePreservingTime import com.ohmz.tday.compose.core.model.repositoryTargetForReschedule import com.ohmz.tday.compose.core.notification.TaskReminderScheduler import com.ohmz.tday.compose.core.ui.userFacingMessage @@ -243,11 +243,34 @@ class CalendarViewModel @Inject constructor( } fun moveTask(todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) { - updateTaskInternal( - visibleTodo = todo, - repositoryTodo = todo.repositoryTargetForReschedule(scope), - payload = createMovedTaskPayload(todo, targetDate), - ) + val movedDue = movedDuePreservingTime(todo.due, targetDate) + val previousState = _uiState.value + val updatedTodo = todo.copy(due = movedDue) + + _uiState.update { current -> + current.copy( + items = current.items.map { item -> + if (item.id == todo.id) updatedTodo else item + }, + errorMessage = null, + ) + } + + viewModelScope.launch { + runCatching { + todoRepository.moveTodo( + todo = todo.repositoryTargetForReschedule(scope), + due = movedDue, + ) + }.onSuccess { + rescheduleReminders() + loadInternal(forceSync = false, showLoading = false) + }.onFailure { error -> + _uiState.value = previousState.copy( + errorMessage = error.userFacingMessage("Could not update task."), + ) + } + } } private fun updateTaskInternal( diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index 1a430c20..aced6ef1 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -1905,17 +1905,14 @@ private fun TimelineTaskDragPreview( val colorScheme = MaterialTheme.colorScheme val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val showListIndicator = listMeta != null && mode != TodoListMode.LIST + val previewShape = RoundedCornerShape(18.dp) Card( modifier = modifier - .sizeIn(minWidth = 220.dp, maxWidth = 280.dp) - .graphicsLayer { - shadowElevation = 18f - alpha = 0.96f - }, - shape = RoundedCornerShape(18.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), + .sizeIn(minWidth = 220.dp, maxWidth = 280.dp), + shape = previewShape, + colors = CardDefaults.cardColors(containerColor = colorScheme.surface.copy(alpha = 0.88f)), border = BorderStroke(1.dp, colorScheme.outlineVariant.copy(alpha = 0.55f)), - elevation = CardDefaults.cardElevation(defaultElevation = 10.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), ) { Row( modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt index 264d8fb8..3166ee00 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt @@ -16,7 +16,7 @@ import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoListMode import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter -import com.ohmz.tday.compose.core.model.createMovedTaskPayload +import com.ohmz.tday.compose.core.model.movedDuePreservingTime import com.ohmz.tday.compose.core.model.repositoryTargetForReschedule import com.ohmz.tday.compose.core.notification.TaskReminderScheduler import com.ohmz.tday.compose.core.ui.userFacingMessage @@ -303,11 +303,48 @@ class TodoListViewModel @Inject constructor( } fun moveTask(todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) { - updateTaskInternal( - visibleTodo = todo, - repositoryTodo = todo.repositoryTargetForReschedule(scope), - payload = createMovedTaskPayload(todo, targetDate), - ) + val movedDue = movedDuePreservingTime(todo.due, targetDate) + val previousState = _uiState.value + val mode = previousState.mode + val currentListId = previousState.listId + val updatedTodo = todo.copy(due = movedDue) + + _uiState.update { current -> + current.copy( + items = current.items.map { item -> + if (item.id == todo.id) updatedTodo else item + }, + errorMessage = null, + ) + } + + viewModelScope.launch { + runCatching { + todoRepository.moveTodo( + todo = todo.repositoryTargetForReschedule(scope), + due = movedDue, + ) + }.onSuccess { + rescheduleReminders() + runCatching { + val todos = todoRepository.fetchTodosCached(mode = mode, listId = currentListId) + val lists = listRepository.fetchLists() + todos to lists + }.onSuccess { (todos, lists) -> + _uiState.update { current -> + current.copy( + lists = if (current.lists == lists) current.lists else lists, + items = if (current.items == todos) current.items else todos, + errorMessage = null, + ) + } + }.onFailure { refreshInternal(forceSync = false, showLoading = false) } + }.onFailure { error -> + _uiState.value = previousState.copy( + errorMessage = error.userFacingMessage("Could not update task."), + ) + } + } } private fun updateTaskInternal( diff --git a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift index 7d038bf8..634ad0d8 100644 --- a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift @@ -371,6 +371,14 @@ final class SyncManager { let remoteUpdatedAt = remoteSnapshot.todoUpdatedAtByCanonical[targetID] ?? 0 guard remoteUpdatedAt <= mutation.timestampEpochMs else { return } let resolvedListID = mutation.listId.flatMap { resolvedListIDs[$0] ?? $0 } + let isDueOnlyMove = mutation.dueEpochMs != nil && + mutation.title == nil && + mutation.description == nil && + mutation.priority == nil && + mutation.pinned == nil && + mutation.completed == nil && + mutation.rrule == nil && + mutation.listId == nil if let instanceDateEpochMs = mutation.instanceDateEpochMs { _ = try await api.patchTodoInstanceByBody( payload: TodoInstancePatchRequest( @@ -392,10 +400,10 @@ final class SyncManager { priority: mutation.priority, completed: mutation.completed, due: mutation.dueEpochMs.map { Date(epochMilliseconds: $0).ISO8601Format() }, - rrule: mutation.rrule, - listID: resolvedListID, + rrule: isDueOnlyMove ? nil : mutation.rrule, + listID: isDueOnlyMove ? nil : resolvedListID, dateChanged: true, - rruleChanged: true, + rruleChanged: isDueOnlyMove ? nil : true, instanceDate: nil ) ) diff --git a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift index ac3b906d..a64e5a4a 100644 --- a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift @@ -181,6 +181,93 @@ final class TodoRepository { } } + func moveTodo(_ todo: TodoItem, due: Date) async throws { + let now = Date().epochMilliseconds + let dueEpochMs = due.epochMilliseconds + let isLocalOnly = todo.canonicalId.hasPrefix(LOCAL_TODO_PREFIX) + + _ = try await cacheManager.updateOfflineState { state in + var nextState = state + let hasExistingUpdateMutation = state.pendingMutations.contains { mutation in + mutation.kind == .updateTodo && + mutation.targetId == todo.canonicalId && + mutation.instanceDateEpochMs == todo.instanceDateEpochMilliseconds + } + nextState.todos = state.todos.map { current in + let sameTodo = current.canonicalId == todo.canonicalId && current.instanceDateEpochMs == todo.instanceDateEpochMilliseconds + guard sameTodo else { return current } + return CachedTodoRecord( + id: current.id, + canonicalId: current.canonicalId, + title: current.title, + description: current.description, + priority: current.priority, + dueEpochMs: dueEpochMs, + rrule: current.rrule, + instanceDateEpochMs: current.instanceDateEpochMs, + pinned: current.pinned, + completed: current.completed, + listId: current.listId, + updatedAtEpochMs: now + ) + } + nextState.pendingMutations = state.pendingMutations.map { mutation in + let samePendingUpdate = mutation.kind == .updateTodo && + mutation.targetId == todo.canonicalId && + mutation.instanceDateEpochMs == todo.instanceDateEpochMilliseconds + guard samePendingUpdate || (mutation.kind == .createTodo && mutation.targetId == todo.canonicalId) else { + return mutation + } + return PendingMutationRecord( + mutationId: mutation.mutationId, + kind: mutation.kind, + targetId: mutation.targetId, + timestampEpochMs: now, + title: mutation.title, + description: mutation.description, + priority: mutation.priority, + dueEpochMs: dueEpochMs, + rrule: mutation.rrule, + listId: mutation.listId, + pinned: mutation.pinned, + completed: mutation.completed, + instanceDateEpochMs: mutation.instanceDateEpochMs, + name: mutation.name, + color: mutation.color, + iconKey: mutation.iconKey + ) + } + if !isLocalOnly && !hasExistingUpdateMutation { + nextState.pendingMutations.append( + PendingMutationRecord( + mutationId: UUID().uuidString, + kind: .updateTodo, + targetId: todo.canonicalId, + timestampEpochMs: now, + title: nil, + description: nil, + priority: nil, + dueEpochMs: dueEpochMs, + rrule: nil, + listId: nil, + pinned: nil, + completed: nil, + instanceDateEpochMs: todo.instanceDateEpochMilliseconds, + name: nil, + color: nil, + iconKey: nil + ) + ) + } + return nextState + } + + let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) + if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { + throw error + } + } + func deleteTodo(_ todo: TodoItem) async throws { let now = Date().epochMilliseconds _ = try await cacheManager.updateOfflineState { state in diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index c13103c1..892fac1e 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -12,6 +12,24 @@ private final class CalendarTaskDragSession { private init() {} } +private struct CalendarInAppDrag: Equatable { + let todo: TodoItem + var location: CGPoint +} + +private struct CalendarDateDropTargetFrame: Equatable { + let date: Date + let frame: CGRect +} + +private struct CalendarDateDropTargetFramePreferenceKey: PreferenceKey { + static var defaultValue: [String: CalendarDateDropTargetFrame] = [:] + + static func reduce(value: inout [String: CalendarDateDropTargetFrame], nextValue: () -> [String: CalendarDateDropTargetFrame]) { + value.merge(nextValue(), uniquingKeysWith: { _, newValue in newValue }) + } +} + private enum CalendarTitleHandoff { static let collapseDistance: CGFloat = 180 static let expandedTitleHeight: CGFloat = 56 @@ -77,7 +95,9 @@ struct CalendarScreen: View { @State private var todayJumpRequestID = 0 @State private var todayJumpRequest: CalendarTodayJumpRequest? @State private var draggedTodo: TodoItem? + @State private var inAppDrag: CalendarInAppDrag? @State private var activeDropDate: Date? + @State private var dateDropTargetFrames: [String: CalendarDateDropTargetFrame] = [:] @State private var pendingRescheduleDrop: CalendarTaskRescheduleDrop? init(container: AppContainer) { @@ -94,6 +114,10 @@ struct CalendarScreen: View { } } + private var calendarTaskRescheduleEnabled: Bool { + displayMode != .day + } + private var selectedDateHeaderText: String { let formatter = DateFormatter() formatter.dateFormat = "EEE, MMM d" @@ -179,22 +203,30 @@ struct CalendarScreen: View { Section { if !pendingItems.isEmpty { - ForEach(Array(pendingItems.enumerated()), id: \.element.id) { index, todo in + ForEach(pendingItems) { todo in CalendarPendingTaskRow( todo: todo, + list: todo.listId.flatMap { listId in + viewModel.lists.first(where: { $0.id == listId }) + }, onComplete: { Task { await viewModel.complete(todo) } } ) .opacity(draggedTodo?.id == todo.id && activeDropDate != nil ? 0.55 : 1) - .onDrag { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - draggedTodo = todo - CalendarTaskDragSession.shared.todo = todo - CalendarTaskDragSession.shared.handledDropSignature = nil - return NSItemProvider(object: todo.id as NSString) - } .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) - .listRowBackground(Color.clear) + .background(colors.background) + .listRowBackground(colors.background) .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) + .modifier( + CalendarInAppDragModifier( + enabled: calendarTaskRescheduleEnabled, + todo: todo, + onStart: beginInAppDrag, + onMove: updateInAppDrag, + onEnd: finishInAppDrag, + onCancel: cancelInAppDrag + ) + ) .todoTrailingSwipeActions( onEdit: { editingTodo = todo @@ -211,9 +243,6 @@ struct CalendarScreen: View { } .tint(.green) } - if shouldShowDateDivider(after: index, in: pendingItems) { - TimelineRowDivider() - } } } } header: { @@ -224,6 +253,9 @@ struct CalendarScreen: View { .listRowInsets(EdgeInsets(top: 8, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .timelinePinnedSectionHeaderBackground() } + .listRowBackground(colors.background) + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) } .listStyle(.plain) .scrollContentBackground(.hidden) @@ -232,6 +264,30 @@ struct CalendarScreen: View { .environment(\.defaultMinListRowHeight, 1) .disableVerticalScrollBounce() .background(colors.background) + .onPreferenceChange(CalendarDateDropTargetFramePreferenceKey.self) { frames in + dateDropTargetFrames = frames + } + .onChange(of: displayMode) { _, mode in + if mode == .day { + cancelInAppDrag() + } + } + .overlay(alignment: .topLeading) { + GeometryReader { proxy in + if let inAppDrag { + let rootFrame = proxy.frame(in: .global) + let previewLocation = CGPoint( + x: inAppDrag.location.x - rootFrame.minX, + y: inAppDrag.location.y - rootFrame.minY + ) + CalendarTaskDragPreview(todo: inAppDrag.todo) + .position(x: previewLocation.x, y: previewLocation.y) + .zIndex(20) + .allowsHitTesting(false) + } + } + .allowsHitTesting(false) + } .navigationBackButtonBehavior() .navigationTitleTypography( largeTitleColor: calendarAccentColor, @@ -324,14 +380,6 @@ struct CalendarScreen: View { Calendar.current.isDate(date, inSameDayAs: selectedDate) } - private func shouldShowDateDivider(after index: Int, in items: [TodoItem]) -> Bool { - guard items.indices.contains(index), - items.indices.contains(index + 1) else { - return false - } - return !Calendar.current.isDate(items[index].due, inSameDayAs: items[index + 1].due) - } - @ViewBuilder private var calendarModeCard: some View { switch displayMode { @@ -377,17 +425,12 @@ struct CalendarScreen: View { today: Date(), tasksByDay: pendingItemsByDay, accentColor: calendarAccentColor, - draggedTodo: draggedTodo, - activeDropDate: activeDropDate, canGoPreviousDay: canGoPreviousDay, canSelectDate: { canNavigate(to: $0) }, todayJumpRequest: todayJumpRequest, onPreviousDay: { navigateDay(by: -1) }, onNextDay: { navigateDay(by: 1) }, - onSelectDate: { selectDate($0) }, - onDropDateChange: { activeDropDate = $0 }, - onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) }, - resolveTodo: resolveTodoForDrop + onSelectDate: { selectDate($0) } ) } } @@ -424,7 +467,9 @@ struct CalendarScreen: View { private func requestReschedule(_ todo: TodoItem, to targetDate: Date) { draggedTodo = nil + inAppDrag = nil activeDropDate = nil + dateDropTargetFrames = [:] CalendarTaskDragSession.shared.todo = nil let targetDay = Calendar.current.startOfDay(for: targetDate) let dropSignature = "\(todo.id)|\(targetDay.timeIntervalSince1970)" @@ -453,6 +498,52 @@ struct CalendarScreen: View { viewModel.items.first { $0.id == id || $0.canonicalId == id } } + private func beginInAppDrag(_ todo: TodoItem, at location: CGPoint) { + if draggedTodo?.id != todo.id { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + draggedTodo = todo + CalendarTaskDragSession.shared.todo = todo + CalendarTaskDragSession.shared.handledDropSignature = nil + inAppDrag = CalendarInAppDrag(todo: todo, location: location) + updateInAppDrag(todo, to: location) + } + + private func updateInAppDrag(_ todo: TodoItem, to location: CGPoint) { + inAppDrag = CalendarInAppDrag(todo: todo, location: location) + activeDropDate = dropDate(at: location) + } + + private func finishInAppDrag(_ todo: TodoItem, at location: CGPoint?) { + let targetDate = location.flatMap(dropDate(at:)) ?? activeDropDate + activeDropDate = nil + draggedTodo = nil + inAppDrag = nil + dateDropTargetFrames = [:] + if let targetDate { + requestReschedule(todo, to: targetDate) + } else { + CalendarTaskDragSession.shared.todo = nil + } + } + + private func cancelInAppDrag() { + activeDropDate = nil + draggedTodo = nil + inAppDrag = nil + dateDropTargetFrames = [:] + CalendarTaskDragSession.shared.todo = nil + } + + private func dropDate(at location: CGPoint) -> Date? { + dateDropTargetFrames.values + .filter { $0.frame.contains(location) } + .min { lhs, rhs in + (lhs.frame.width * lhs.frame.height) < (rhs.frame.width * rhs.frame.height) + } + .map { Calendar.current.startOfDay(for: $0.date) } + } + private func commitPendingReschedule(scope: TaskRescheduleScope) { guard let drop = pendingRescheduleDrop else { return @@ -980,6 +1071,7 @@ private struct CalendarWeekDayCell: View { onMove: onMoveTaskToDate, onDateChange: onDropDateChange ) + .calendarInAppDateDropTargetFrame(date: date, enabled: isEnabled) .opacity(isEnabled ? 1 : 0.48) } @@ -1117,7 +1209,35 @@ private struct CalendarDateDropDelegate: DropDelegate { } } +private struct CalendarInAppDateDropTargetFrameModifier: ViewModifier { + let date: Date + let enabled: Bool + + @ViewBuilder + func body(content: Content) -> some View { + content.background { + if enabled { + GeometryReader { proxy in + Color.clear.preference( + key: CalendarDateDropTargetFramePreferenceKey.self, + value: [ + String(Calendar.current.startOfDay(for: date).timeIntervalSince1970): CalendarDateDropTargetFrame( + date: Calendar.current.startOfDay(for: date), + frame: proxy.frame(in: .global) + ) + ] + ) + } + } + } + } +} + private extension View { + func calendarInAppDateDropTargetFrame(date: Date, enabled: Bool) -> some View { + modifier(CalendarInAppDateDropTargetFrameModifier(date: date, enabled: enabled)) + } + func calendarTaskDropTarget( date: Date, canDrop: Bool, @@ -1171,17 +1291,12 @@ private struct CalendarDayCard: View { let today: Date let tasksByDay: [Date: [TodoItem]] let accentColor: Color - let draggedTodo: TodoItem? - let activeDropDate: Date? let canGoPreviousDay: Bool let canSelectDate: (Date) -> Bool let todayJumpRequest: CalendarTodayJumpRequest? let onPreviousDay: () -> Void let onNextDay: () -> Void let onSelectDate: (Date) -> Void - let onDropDateChange: (Date?) -> Void - let onMoveTaskToDate: (TodoItem, Date) -> Void - let resolveTodo: (String) -> TodoItem? @Environment(\.tdayColors) private var colors @State private var pageSelection = calendarNativePagerCenterIndex @@ -1266,14 +1381,11 @@ private struct CalendarDayCard: View { } private func daySummary(for date: Date) -> some View { - let isEnabled = canSelectDate(date) - let isDropTarget = activeDropDate.map { Calendar.current.isDate($0, inSameDayAs: date) } ?? false return VStack(alignment: .leading, spacing: 14) { Text(dateTitle(for: date)) .font(.tdayRounded(size: 25, weight: .heavy)) .foregroundStyle( - isDropTarget ? colors.error : - (Calendar.current.isDate(date, inSameDayAs: today) ? accentColor : colors.onSurface) + Calendar.current.isDate(date, inSameDayAs: today) ? accentColor : colors.onSurface ) Text(taskCountText(for: date)) @@ -1283,18 +1395,6 @@ private struct CalendarDayCard: View { .padding(.horizontal, 6) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) - .background( - isDropTarget ? colors.error.opacity(0.12) : .clear, - in: RoundedRectangle(cornerRadius: 16, style: .continuous) - ) - .calendarTaskDropTarget( - date: date, - canDrop: isEnabled, - draggedTodo: draggedTodo, - resolveTodo: resolveTodo, - onMove: onMoveTaskToDate, - onDateChange: onDropDateChange - ) } private func resetPageSelection() { @@ -1469,6 +1569,7 @@ private struct CalendarMonthDayCell: View { onMove: onMoveTaskToDate, onDateChange: onDropDateChange ) + .calendarInAppDateDropTargetFrame(date: day.date, enabled: isEnabled) .opacity(day.isCurrentMonth ? 1 : 0.45) } @@ -2085,13 +2186,256 @@ private extension UIView { } } +private struct CalendarInAppDragModifier: ViewModifier { + let enabled: Bool + let todo: TodoItem + let onStart: (TodoItem, CGPoint) -> Void + let onMove: (TodoItem, CGPoint) -> Void + let onEnd: (TodoItem, CGPoint?) -> Void + let onCancel: () -> Void + + func body(content: Content) -> some View { + if enabled { + content.background { + GeometryReader { _ in + CalendarInAppLongPressBridge( + todo: todo, + onStart: onStart, + onMove: onMove, + onEnd: onEnd, + onCancel: onCancel + ) + .allowsHitTesting(false) + } + } + } else { + content + } + } +} + +private struct CalendarInAppLongPressBridge: UIViewRepresentable { + let todo: TodoItem + let onStart: (TodoItem, CGPoint) -> Void + let onMove: (TodoItem, CGPoint) -> Void + let onEnd: (TodoItem, CGPoint?) -> Void + let onCancel: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + todo: todo, + onStart: onStart, + onMove: onMove, + onEnd: onEnd, + onCancel: onCancel + ) + } + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + context.coordinator.markerView = view + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.todo = todo + context.coordinator.onStart = onStart + context.coordinator.onMove = onMove + context.coordinator.onEnd = onEnd + context.coordinator.onCancel = onCancel + DispatchQueue.main.async { + context.coordinator.attach(to: uiView.calendarEnclosingScrollView() ?? uiView.superview, markerView: uiView) + } + } + + static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) { + coordinator.detach() + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var todo: TodoItem + var onStart: (TodoItem, CGPoint) -> Void + var onMove: (TodoItem, CGPoint) -> Void + var onEnd: (TodoItem, CGPoint?) -> Void + var onCancel: () -> Void + + weak var markerView: UIView? + private weak var attachedView: UIView? + private let recognizer: UILongPressGestureRecognizer + private var isDragging = false + + init( + todo: TodoItem, + onStart: @escaping (TodoItem, CGPoint) -> Void, + onMove: @escaping (TodoItem, CGPoint) -> Void, + onEnd: @escaping (TodoItem, CGPoint?) -> Void, + onCancel: @escaping () -> Void + ) { + self.todo = todo + self.onStart = onStart + self.onMove = onMove + self.onEnd = onEnd + self.onCancel = onCancel + self.recognizer = UILongPressGestureRecognizer() + super.init() + + recognizer.minimumPressDuration = 0.22 + recognizer.allowableMovement = 24 + recognizer.cancelsTouchesInView = false + recognizer.delaysTouchesBegan = false + recognizer.delaysTouchesEnded = false + recognizer.delegate = self + recognizer.addTarget(self, action: #selector(handleLongPress(_:))) + } + + func attach(to view: UIView?, markerView: UIView) { + self.markerView = markerView + guard let view else { + detach() + return + } + + guard attachedView !== view else { + return + } + + detach() + attachedView = view + view.addGestureRecognizer(recognizer) + } + + func detach() { + if isDragging { + isDragging = false + onCancel() + } + attachedView?.removeGestureRecognizer(recognizer) + attachedView = nil + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let markerView else { + return false + } + + let localPoint = gestureRecognizer.location(in: markerView) + return markerView.bounds.insetBy(dx: -6, dy: -6).contains(localPoint) + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard let markerView else { + return false + } + + let localPoint = touch.location(in: markerView) + return markerView.bounds.insetBy(dx: -6, dy: -6).contains(localPoint) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { + let location = globalLocation(for: recognizer) + switch recognizer.state { + case .began: + isDragging = true + onStart(todo, location) + case .changed: + guard isDragging else { + return + } + onMove(todo, location) + case .ended: + guard isDragging else { + return + } + isDragging = false + onEnd(todo, location) + case .cancelled, .failed: + guard isDragging else { + return + } + isDragging = false + onCancel() + default: + break + } + } + + private func globalLocation(for recognizer: UILongPressGestureRecognizer) -> CGPoint { + guard let view = recognizer.view else { + return .zero + } + + return view.convert(recognizer.location(in: view), to: nil) + } + } +} + +private struct CalendarTaskDragPreview: View { + let todo: TodoItem + + @Environment(\.tdayColors) private var colors + + var body: some View { + let previewShape = RoundedRectangle(cornerRadius: 18, style: .continuous) + + HStack(spacing: 10) { + Image(systemName: "circle") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.76)) + + VStack(alignment: .leading, spacing: 3) { + Text(todo.title) + .font(.tdayRounded(size: 16, weight: .bold)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + + Text(todo.due.formatted(date: .omitted, time: .shortened)) + .font(.tdayRounded(size: 12, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant) + .lineLimit(1) + } + + Spacer(minLength: 0) + + if todo.priority.lowercased() == "high" { + Image(systemName: "flag.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(priorityColor(todo.priority)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(width: 260, alignment: .leading) + .background(colors.surface) + .clipShape(previewShape) + .overlay( + previewShape.stroke(colors.onSurfaceVariant.opacity(0.14), lineWidth: 1) + ) + .contentShape(previewShape) + .compositingGroup() + .shadow(color: Color.black.opacity(0.18), radius: 16, x: 0, y: 8) + .opacity(0.96) + } +} + private struct CalendarPendingTaskRow: View { let todo: TodoItem + let list: ListSummary? let onComplete: () -> Void @Environment(\.tdayColors) private var colors var body: some View { + let showPriorityFlag = todo.priority.lowercased() == "high" + VStack(spacing: 0) { HStack(alignment: .center, spacing: 12) { Button(action: onComplete) { @@ -2120,9 +2464,213 @@ private struct CalendarPendingTaskRow: View { } Spacer(minLength: 0) + + if list != nil || showPriorityFlag { + HStack(spacing: 8) { + if let list { + Image(systemName: calendarListSymbolName(for: list.iconKey)) + .font(.system(size: TodoTimelineMetrics.minimalRowIndicatorSize, weight: .semibold)) + .foregroundStyle(calendarListAccentColor(for: list.color)) + } + if showPriorityFlag { + Image(systemName: "flag.fill") + .font(.system(size: TodoTimelineMetrics.minimalRowIndicatorSize, weight: .semibold)) + .foregroundStyle(priorityColor(todo.priority)) + } + } + .padding(.trailing, TodoTimelineMetrics.minimalRowTrailingIndicatorPadding) + } } .padding(.vertical, TodoTimelineMetrics.minimalRowVerticalPadding) .contentShape(Rectangle()) } + .frame(maxWidth: .infinity, alignment: .leading) + .background(colors.background) + } +} + +private func calendarListAccentColor(for key: String?) -> Color { + switch key { + case "RED": + return calendarHexColor(0xE65E52) + case "ORANGE": + return calendarHexColor(0xF29F38) + case "YELLOW": + return calendarHexColor(0xF3D04A) + case "LIME": + return calendarHexColor(0x8ACF56) + case "BLUE": + return calendarHexColor(0x5C9FE7) + case "PURPLE": + return calendarHexColor(0x8D6CE2) + case "PINK": + return calendarHexColor(0xDF6DAA) + case "TEAL": + return calendarHexColor(0x4EB5B0) + case "CORAL": + return calendarHexColor(0xE3876D) + case "GOLD": + return calendarHexColor(0xCFAB57) + case "DEEP_BLUE": + return calendarHexColor(0x4B73D6) + case "ROSE": + return calendarHexColor(0xD9799A) + case "LIGHT_RED": + return calendarHexColor(0xE48888) + case "BRICK": + return calendarHexColor(0xB86A5C) + case "SLATE": + return calendarHexColor(0x7B8593) + default: + return calendarHexColor(0x5C9FE7) } } + +private func calendarListSymbolName(for key: String?) -> String { + switch key { + case "sun": + return "sun.max.fill" + case "calendar": + return "calendar" + case "schedule": + return "clock" + case "flag": + return "flag.fill" + case "check": + return "checkmark" + case "smile": + return "face.smiling" + case "list": + return "list.bullet" + case "bookmark": + return "bookmark.fill" + case "key": + return "key.fill" + case "gift": + return "gift.fill" + case "cake": + return "birthday.cake.fill" + case "school": + return "graduationcap.fill" + case "bag": + return "backpack.fill" + case "edit": + return "pencil" + case "document": + return "doc.text.fill" + case "book": + return "book.closed.fill" + case "work": + return "briefcase.fill" + case "wallet": + return "wallet.pass.fill" + case "money": + return "dollarsign.circle.fill" + case "fitness": + return "dumbbell.fill" + case "run": + return "figure.run" + case "food": + return "fork.knife" + case "drink": + return "wineglass.fill" + case "health": + return "cross.case.fill" + case "monitor": + return "display" + case "music": + return "music.note" + case "computer": + return "desktopcomputer" + case "game": + return "gamecontroller.fill" + case "headphones": + return "headphones" + case "eco": + return "leaf.fill" + case "pets": + return "pawprint.fill" + case "child": + return "figure.2.and.child.holdinghands" + case "family": + return "person.3.fill" + case "basket": + return "basket.fill" + case "cart": + return "cart.fill" + case "mall": + return "bag.fill" + case "inventory": + return "archivebox.fill" + case "soccer": + return "soccerball" + case "baseball": + return "baseball.fill" + case "basketball": + return "basketball.fill" + case "football": + return "football.fill" + case "tennis": + return "tennis.racket" + case "train": + return "tram.fill" + case "flight": + return "airplane" + case "boat": + return "ferry.fill" + case "car": + return "car.fill" + case "umbrella": + return "umbrella.fill" + case "drop": + return "drop.fill" + case "snow": + return "snowflake" + case "fire": + return "flame.fill" + case "tools": + return "hammer.fill" + case "scissors": + return "scissors" + case "architecture", "bank": + return "building.columns.fill" + case "code": + return "chevron.left.forwardslash.chevron.right" + case "idea": + return "lightbulb.fill" + case "chat": + return "bubble.left.fill" + case "alert": + return "exclamationmark.triangle.fill" + case "star": + return "star.fill" + case "heart": + return "heart.fill" + case "circle": + return "circle.fill" + case "square": + return "square.fill" + case "triangle": + return "triangle.fill" + case "home": + return "house.fill" + case "city": + return "building.2.fill" + case "camera": + return "camera.fill" + case "palette": + return "paintpalette.fill" + default: + return "tray.fill" + } +} + +private func calendarHexColor(_ hex: UInt) -> Color { + Color( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255, + opacity: 1 + ) +} diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift index 0778e079..5255e470 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift @@ -77,14 +77,19 @@ final class CalendarViewModel { func moveTask(_ todo: TodoItem, toDay targetDay: Date, scope: TaskRescheduleScope) async { let calendar = Calendar.current guard !calendar.isDate(todo.due, inSameDayAs: targetDay), - let payload = movedTaskPayload(todo: todo, targetDay: targetDay, calendar: calendar) else { + let movedDue = movedDuePreservingTime(due: todo.due, targetDay: targetDay, calendar: calendar) else { return } - await updateTask( - todo.repositoryTargetForReschedule(scope: scope), - payload: payload - ) + do { + try await container.todoRepository.moveTodo( + todo.repositoryTargetForReschedule(scope: scope), + due: movedDue + ) + hydrateFromCache() + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not update task.") + } } func delete(_ todo: TodoItem) async { diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 7848d79c..e38f9231 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -1703,6 +1703,8 @@ private struct TodoDragPreview: View { @Environment(\.tdayColors) private var colors var body: some View { + let previewShape = RoundedRectangle(cornerRadius: 18, style: .continuous) + HStack(spacing: 10) { Image(systemName: "circle") .font(.system(size: 22, weight: .regular)) @@ -1730,11 +1732,13 @@ private struct TodoDragPreview: View { .padding(.horizontal, 14) .padding(.vertical, 11) .frame(width: 260, alignment: .leading) - .background(colors.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .background(colors.surface) + .clipShape(previewShape) .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(colors.onSurfaceVariant.opacity(0.14), lineWidth: 1) + previewShape.stroke(colors.onSurfaceVariant.opacity(0.14), lineWidth: 1) ) + .contentShape(previewShape) + .compositingGroup() .shadow(color: Color.black.opacity(0.18), radius: 16, x: 0, y: 8) .opacity(0.96) } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift index c7ba1604..bfa72bde 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift @@ -111,14 +111,19 @@ final class TodoListViewModel { return } - guard let payload = movedTaskPayload(todo: todo, targetDay: targetDay, calendar: calendar) else { + guard let movedDue = movedDuePreservingTime(due: todo.due, targetDay: targetDay, calendar: calendar) else { return } - await updateTask( - todo.repositoryTargetForReschedule(scope: scope), - payload: payload - ) + do { + try await container.todoRepository.moveTodo( + todo.repositoryTargetForReschedule(scope: scope), + due: movedDue + ) + hydrateFromCache() + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not update task.") + } } func complete(_ todo: TodoItem) async { From 63898591ec8054cfeffe72fdafb90043f4ffb30a Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 04:02:42 -0400 Subject: [PATCH 20/24] Refactor Todo drag-and-drop interaction to use a UIKit-based gesture bridge Replace the pure SwiftUI `DragGesture` and `LongPressGesture` implementation with a `UIViewRepresentable` bridge using `UILongPressGestureRecognizer`. This change improves the reliability and responsiveness of long-press-to-drag interactions within scrollable views. - **Improved Gesture Handling**: Transition from SwiftUI gestures to `UILongPressGestureRecognizer` to better manage gesture transitions and simultaneous recognition. - **Enhanced Precision**: Implement hit-testing logic in `gestureRecognizerShouldBegin` to ensure the gesture only triggers when interacting with the specific todo item's bounds. - **Scroll View Integration**: Automatically attach the gesture recognizer to the nearest enclosing scroll view or superview to ensure consistent coordinate mapping and behavior. - **State Management**: Encapsulate gesture logic within a `Coordinator` class that manages start, move, end, and cancel states, providing more robust feedback to the `TodoListScreen`. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../tday/compose/feature/calendar/CalendarPager.kt | 13 ++++++++++--- .../Tday/Feature/Calendar/CalendarScreen.swift | 3 --- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt index ded95f04..1185247f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt @@ -41,7 +41,6 @@ internal fun CalendarPagingContent( var handledSettledPage by remember { mutableStateOf(null) } LaunchedEffect(centerPageIndex, pages) { - handledSettledPage = null if (pagerState.currentPage != centerPageIndex) { pagerState.scrollToPage(centerPageIndex) } @@ -49,7 +48,11 @@ internal fun CalendarPagingContent( LaunchedEffect(pagerState.settledPage, centerPageIndex, pages) { val settledPage = pagerState.settledPage - if (settledPage == centerPageIndex || handledSettledPage == settledPage) return@LaunchedEffect + if (settledPage == centerPageIndex) { + handledSettledPage = null + return@LaunchedEffect + } + if (handledSettledPage != null) return@LaunchedEffect val settledSlot = pages.getOrNull(settledPage)?.slot ?: return@LaunchedEffect handledSettledPage = settledPage onSettledAwayFromCenter(settledSlot) @@ -58,7 +61,11 @@ internal fun CalendarPagingContent( HorizontalPager( state = pagerState, modifier = modifier, - key = { page -> pages.getOrNull(page)?.slot ?: page }, + key = { page -> + pages.getOrNull(page)?.let { calendarPage -> + "${calendarPage.slot}:${calendarPage.value}" + } ?: page + }, beyondViewportPageCount = 1, ) { page -> pages.getOrNull(page)?.let { calendarPage -> diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 892fac1e..95a507d4 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -469,7 +469,6 @@ struct CalendarScreen: View { draggedTodo = nil inAppDrag = nil activeDropDate = nil - dateDropTargetFrames = [:] CalendarTaskDragSession.shared.todo = nil let targetDay = Calendar.current.startOfDay(for: targetDate) let dropSignature = "\(todo.id)|\(targetDay.timeIntervalSince1970)" @@ -519,7 +518,6 @@ struct CalendarScreen: View { activeDropDate = nil draggedTodo = nil inAppDrag = nil - dateDropTargetFrames = [:] if let targetDate { requestReschedule(todo, to: targetDate) } else { @@ -531,7 +529,6 @@ struct CalendarScreen: View { activeDropDate = nil draggedTodo = nil inAppDrag = nil - dateDropTargetFrames = [:] CalendarTaskDragSession.shared.todo = nil } From e401181f256400e6d7abbc027646910d31324e39 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 04:30:38 -0400 Subject: [PATCH 21/24] Refactor calendar paging and styling across iOS and Android. ### iOS (SwiftUI) - **UI Adjustments**: Added `CalendarTaskListMetrics` to standardize row padding and updated `CalendarScreen` to use `listRowSpacing(0)` and `listSectionSpacing(0)` for a tighter layout. - **Cleanup**: Removed unused `isMonthDropTarget` and `isWeekDropTarget` logic that was incorrectly styling headers with an error color during drag-and-drop. ### Android (Compose) - **Paging Architecture**: Replaced the 3-slot "infinite" pager (Previous/Current/Next) with a fixed-count `HorizontalPager` using `ChronoUnit` to calculate page indices for Month (240 pages), Week (1040 pages), and Day (3650 pages) views. - **Navigation**: - Introduced `CalendarPagerScrollRequest` to handle programmatic jumps (e.g., "Today"). - Updated `CalendarMonthCard`, `CalendarWeekCard`, and `CalendarDayCard` to manage state based on page indices rather than relative slots. - Simplified `CalendarPagingContent` to use standard `PagerState` with `snapshotFlow` for settling logic. - **UI**: Removed `AnimatedContent` wrapping the calendar cards to improve transition performance and simplified header text coloring. - **Cleanup**: Removed unused imports and the legacy `CalendarPagerSlot` / `CalendarPagerPage` models. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../compose/feature/calendar/CalendarPager.kt | 88 +-- .../feature/calendar/CalendarScreen.kt | 501 +++++++----------- .../Feature/Calendar/CalendarScreen.swift | 20 +- 3 files changed, 236 insertions(+), 373 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt index 1185247f..a8b31e98 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt @@ -2,14 +2,14 @@ package com.ohmz.tday.compose.feature.calendar import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.distinctUntilChanged import java.time.LocalDate internal data class CalendarTodayJumpRequest( @@ -17,62 +17,62 @@ internal data class CalendarTodayJumpRequest( val targetDate: LocalDate, ) -internal enum class CalendarPagerSlot { - PREVIOUS, - CURRENT, - NEXT, -} - -internal data class CalendarPagerPage( - val slot: CalendarPagerSlot, - val value: T, +internal data class CalendarPagerScrollRequest( + val id: Int, + val page: Int, ) +private const val CalendarPagerPreloadRadius = 1 + @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun CalendarPagingContent( - pages: List>, - pagerState: PagerState, - centerPageIndex: Int, - onSettledAwayFromCenter: (CalendarPagerSlot) -> Unit, +internal fun CalendarPagingContent( + pageCount: Int, + currentPage: Int, + onPageSettled: (Int) -> Unit, modifier: Modifier = Modifier, - pageContent: @Composable (T) -> Unit, + scrollRequest: CalendarPagerScrollRequest? = null, + onScrollRequestHandled: (Int) -> Unit = {}, + pageKey: (Int) -> Any = { it }, + pageContent: @Composable (Int) -> Unit, ) { - var handledSettledPage by remember { mutableStateOf(null) } + val boundedPageCount = pageCount.coerceAtLeast(1) + val targetPage = currentPage.coerceIn(0, boundedPageCount - 1) + val pagerState = rememberPagerState(initialPage = targetPage) { boundedPageCount } + val latestTargetPage by rememberUpdatedState(targetPage) + val latestOnPageSettled by rememberUpdatedState(onPageSettled) - LaunchedEffect(centerPageIndex, pages) { - if (pagerState.currentPage != centerPageIndex) { - pagerState.scrollToPage(centerPageIndex) + LaunchedEffect(targetPage, boundedPageCount) { + if (!pagerState.isScrollInProgress && pagerState.currentPage != targetPage) { + pagerState.scrollToPage(targetPage) } } - LaunchedEffect(pagerState.settledPage, centerPageIndex, pages) { - val settledPage = pagerState.settledPage - if (settledPage == centerPageIndex) { - handledSettledPage = null - return@LaunchedEffect + LaunchedEffect(scrollRequest?.id, boundedPageCount) { + val request = scrollRequest ?: return@LaunchedEffect + val requestedPage = request.page.coerceIn(0, boundedPageCount - 1) + if (pagerState.currentPage != requestedPage) { + pagerState.animateScrollToPage(requestedPage) } - if (handledSettledPage != null) return@LaunchedEffect - val settledSlot = pages.getOrNull(settledPage)?.slot ?: return@LaunchedEffect - handledSettledPage = settledPage - onSettledAwayFromCenter(settledSlot) + onScrollRequestHandled(request.id) + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage } + .distinctUntilChanged() + .collect { settledPage -> + if (settledPage != latestTargetPage) { + latestOnPageSettled(settledPage) + } + } } HorizontalPager( state = pagerState, modifier = modifier, - key = { page -> - pages.getOrNull(page)?.let { calendarPage -> - "${calendarPage.slot}:${calendarPage.value}" - } ?: page - }, - beyondViewportPageCount = 1, + key = pageKey, + beyondViewportPageCount = (boundedPageCount - 1).coerceAtMost(CalendarPagerPreloadRadius), ) { page -> - pages.getOrNull(page)?.let { calendarPage -> - pageContent(calendarPage.value) - } + pageContent(page) } } - -internal fun List>.indexOfSlot(slot: CalendarPagerSlot): Int = - indexOfFirst { it.slot == slot } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index 33fd1138..42f4a64f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -1,7 +1,5 @@ package com.ohmz.tday.compose.feature.calendar -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring @@ -9,9 +7,6 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration @@ -43,7 +38,6 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -203,6 +197,7 @@ import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.TextStyle +import java.time.temporal.ChronoUnit import java.util.Locale import kotlin.math.roundToInt @@ -238,6 +233,9 @@ private val CalendarPeriodPageHorizontalGutter = 2.dp private val CalendarPeriodCardBottomPadding = 18.dp private val CalendarTaskDragDueTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()) +private const val CalendarMonthPagerPageCount = 240 +private const val CalendarWeekPagerPageCount = 1040 +private const val CalendarDayPagerPageCount = 3650 private fun shouldShowDateDivider( afterItemIndex: Int, @@ -565,89 +563,64 @@ fun CalendarScreen( shape = RoundedCornerShape(CalendarCardCornerRadius), ), ) { - AnimatedContent( - targetState = selectedViewMode, - transitionSpec = { - val enteringForward = targetState.ordinal > initialState.ordinal - val enter = slideInHorizontally( - animationSpec = tween(durationMillis = 200), - initialOffsetX = { fullWidth -> - if (enteringForward) fullWidth / 4 else -fullWidth / 4 - }, - ) - val exit = slideOutHorizontally( - animationSpec = tween(durationMillis = 180), - targetOffsetX = { fullWidth -> - if (enteringForward) -fullWidth / 4 else fullWidth / 4 - }, - ) - (enter togetherWith exit).using(SizeTransform(clip = true)) - }, - label = "calendarViewModeAnimatedContent", - ) { mode -> - when (mode) { - CalendarViewMode.MONTH -> CalendarMonthCard( - visibleMonth = visibleMonth, - canGoPrevMonth = visibleMonth > minNavigableMonth, - selectedDate = selectedDate, - today = today, - tasksByDate = tasksByDate, - draggedTodo = draggedCalendarTodo, - activeDropDate = activeDropDate, - dropTargets = calendarDropTargetBounds, - canSelectDate = ::canNavigateTo, - todayJumpRequest = todayJumpRequest, - onTodayJumpHandled = ::clearTodayJumpRequest, - onPrevMonth = { - if (visibleMonth > minNavigableMonth) { - visibleMonthIso = visibleMonth.minusMonths(1).toString() - } - }, - onNextMonth = { - visibleMonthIso = visibleMonth.plusMonths(1).toString() - }, - onSelectDate = ::selectDate, - onDropDateChanged = { date -> - activeDropDateIso = date?.toString() - }, - onMoveTaskToDate = ::requestTaskReschedule, - resolveTodo = resolveTodoForDrop, - ) + when (selectedViewMode) { + CalendarViewMode.MONTH -> CalendarMonthCard( + visibleMonth = visibleMonth, + minNavigableMonth = minNavigableMonth, + canGoPrevMonth = visibleMonth > minNavigableMonth, + selectedDate = selectedDate, + today = today, + tasksByDate = tasksByDate, + draggedTodo = draggedCalendarTodo, + activeDropDate = activeDropDate, + dropTargets = calendarDropTargetBounds, + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, + onVisibleMonthChanged = { targetMonth -> + if (targetMonth >= minNavigableMonth) { + visibleMonthIso = targetMonth.toString() + } + }, + onSelectDate = ::selectDate, + onDropDateChanged = { date -> + activeDropDateIso = date?.toString() + }, + onMoveTaskToDate = ::requestTaskReschedule, + resolveTodo = resolveTodoForDrop, + ) - CalendarViewMode.WEEK -> CalendarWeekCard( - selectedDate = selectedDate, - today = today, - tasksByDate = tasksByDate, - draggedTodo = draggedCalendarTodo, - activeDropDate = activeDropDate, - dropTargets = calendarDropTargetBounds, - canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), - canSelectDate = ::canNavigateTo, - todayJumpRequest = todayJumpRequest, - onTodayJumpHandled = ::clearTodayJumpRequest, - onPrevWeek = { selectDate(selectedDate.minusWeeks(1)) }, - onNextWeek = { selectDate(selectedDate.plusWeeks(1)) }, - onSelectDate = ::selectDate, - onDropDateChanged = { date -> - activeDropDateIso = date?.toString() - }, - onMoveTaskToDate = ::requestTaskReschedule, - resolveTodo = resolveTodoForDrop, - ) + CalendarViewMode.WEEK -> CalendarWeekCard( + selectedDate = selectedDate, + minNavigableMonth = minNavigableMonth, + today = today, + tasksByDate = tasksByDate, + draggedTodo = draggedCalendarTodo, + activeDropDate = activeDropDate, + dropTargets = calendarDropTargetBounds, + canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, + onSelectDate = ::selectDate, + onDropDateChanged = { date -> + activeDropDateIso = date?.toString() + }, + onMoveTaskToDate = ::requestTaskReschedule, + resolveTodo = resolveTodoForDrop, + ) - CalendarViewMode.DAY -> CalendarDayCard( - selectedDate = selectedDate, - today = today, - tasksByDate = tasksByDate, - canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), - canSelectDate = ::canNavigateTo, - todayJumpRequest = todayJumpRequest, - onTodayJumpHandled = ::clearTodayJumpRequest, - onPrevDay = { selectDate(selectedDate.minusDays(1)) }, - onNextDay = { selectDate(selectedDate.plusDays(1)) }, - onSelectDate = ::selectDate, - ) - } + CalendarViewMode.DAY -> CalendarDayCard( + selectedDate = selectedDate, + minNavigableMonth = minNavigableMonth, + today = today, + tasksByDate = tasksByDate, + canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, + onSelectDate = ::selectDate, + ) } } } @@ -864,6 +837,7 @@ private fun CalendarViewModeTabs( @Composable private fun CalendarWeekCard( selectedDate: LocalDate, + minNavigableMonth: YearMonth, today: LocalDate, tasksByDate: Map>, draggedTodo: TodoItem?, @@ -873,97 +847,63 @@ private fun CalendarWeekCard( canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, onTodayJumpHandled: (Int) -> Unit, - onPrevWeek: () -> Unit, - onNextWeek: () -> Unit, onSelectDate: (LocalDate) -> Unit, onDropDateChanged: (LocalDate?) -> Unit, onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, resolveTodo: (String) -> TodoItem?, ) { val colorScheme = MaterialTheme.colorScheme + val minWeekStart = remember(minNavigableMonth) { startOfWeek(minNavigableMonth.atDay(1)) } val weekStart = remember(selectedDate) { startOfWeek(selectedDate) } val coroutineScope = rememberCoroutineScope() - var pendingTodayJump by remember { mutableStateOf(null) } - val todayJumpDirection = pendingTodayJump?.let { request -> - val targetWeek = startOfWeek(request.targetDate) - when { - targetWeek < weekStart -> CalendarPagerSlot.PREVIOUS - targetWeek > weekStart -> CalendarPagerSlot.NEXT - else -> null - } + val selectedDayOffset = remember(selectedDate) { + (selectedDate.dayOfWeek.value % 7).toLong() } - val previousPageWeek = if (todayJumpDirection == CalendarPagerSlot.PREVIOUS) { - pendingTodayJump?.targetDate?.let(::startOfWeek) - } else if (canGoPrevWeek) { - weekStart.minusWeeks(1) - } else { - null - } - val nextPageWeek = if (todayJumpDirection == CalendarPagerSlot.NEXT) { - pendingTodayJump?.targetDate?.let(::startOfWeek) ?: weekStart.plusWeeks(1) - } else { - weekStart.plusWeeks(1) + val currentPage = remember(minWeekStart, weekStart) { + ChronoUnit.WEEKS.between(minWeekStart, weekStart) + .toInt() + .coerceIn(0, CalendarWeekPagerPageCount - 1) } - val pages = remember(previousPageWeek, weekStart, nextPageWeek) { - buildList { - previousPageWeek?.let { add(CalendarPagerPage(CalendarPagerSlot.PREVIOUS, it)) } - add(CalendarPagerPage(CalendarPagerSlot.CURRENT, weekStart)) - add(CalendarPagerPage(CalendarPagerSlot.NEXT, nextPageWeek)) - } - } - val centerPageIndex = pages.indexOfSlot(CalendarPagerSlot.CURRENT).coerceAtLeast(0) - val pagerState = rememberPagerState(initialPage = centerPageIndex) { pages.size } - val isPagingAtRest = pagerState.settledPage == centerPageIndex && !pagerState.isScrollInProgress + var scrollRequest by remember { mutableStateOf(null) } + val isPagingAtRest = scrollRequest == null - fun requestPage(slot: CalendarPagerSlot) { - val targetIndex = pages.indexOfSlot(slot) - if (targetIndex < 0 || !isPagingAtRest) return + fun requestPage(offset: Int) { + val targetIndex = (currentPage + offset).coerceIn(0, CalendarWeekPagerPageCount - 1) + if (targetIndex == currentPage || !isPagingAtRest) return coroutineScope.launch { - pagerState.animateScrollToPage(targetIndex) + scrollRequest = CalendarPagerScrollRequest( + id = System.nanoTime().toInt(), + page = targetIndex, + ) } } - fun settlePage(slot: CalendarPagerSlot) { - pendingTodayJump?.let { request -> - pendingTodayJump = null - onSelectDate(request.targetDate) - onTodayJumpHandled(request.id) - return - } + fun dateForPage(page: Int): LocalDate { + return minWeekStart.plusWeeks(page.toLong()).plusDays(selectedDayOffset) + } - when (slot) { - CalendarPagerSlot.PREVIOUS -> onPrevWeek() - CalendarPagerSlot.NEXT -> onNextWeek() - CalendarPagerSlot.CURRENT -> Unit + fun settlePage(page: Int) { + val targetDate = dateForPage(page) + if (canSelectDate(targetDate)) { + onSelectDate(targetDate) } } LaunchedEffect(todayJumpRequest) { val request = todayJumpRequest ?: return@LaunchedEffect - if (!isPagingAtRest) { - onTodayJumpHandled(request.id) - return@LaunchedEffect - } val targetWeek = startOfWeek(request.targetDate) if (targetWeek == weekStart) { onSelectDate(request.targetDate) onTodayJumpHandled(request.id) } else { - pendingTodayJump = request + val targetPage = ChronoUnit.WEEKS.between(minWeekStart, targetWeek) + .toInt() + .coerceIn(0, CalendarWeekPagerPageCount - 1) + scrollRequest = CalendarPagerScrollRequest(request.id, targetPage) onTodayJumpHandled(request.id) } } - LaunchedEffect(pendingTodayJump?.id, pages) { - val request = pendingTodayJump ?: return@LaunchedEffect - val targetWeek = startOfWeek(request.targetDate) - val targetSlot = if (targetWeek < weekStart) CalendarPagerSlot.PREVIOUS else CalendarPagerSlot.NEXT - val targetIndex = pages.indexOfSlot(targetSlot) - if (targetIndex >= 0 && pagerState.currentPage == centerPageIndex) { - pagerState.animateScrollToPage(targetIndex) - } - } - Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(CalendarCardCornerRadius), @@ -992,7 +932,7 @@ private fun CalendarWeekCard( icon = Icons.Rounded.ChevronLeft, contentDescription = stringResource(R.string.calendar_prev_week), enabled = canGoPrevWeek && isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.PREVIOUS) }, + onClick = { requestPage(-1) }, ) Box( modifier = Modifier.weight(1f), @@ -1004,34 +944,35 @@ private fun CalendarWeekCard( fontSize = CalendarPeriodHeaderTitleSize, ), fontWeight = FontWeight.ExtraBold, - color = if ( - activeDropDate != null && - activeDropDate >= weekStart && - activeDropDate <= weekStart.plusDays(6) - ) { - colorScheme.error - } else { - colorScheme.onSurface - }, + color = colorScheme.onSurface, ) } MiniCalendarNavButton( icon = Icons.Rounded.ChevronRight, contentDescription = stringResource(R.string.calendar_next_week), enabled = isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.NEXT) }, + onClick = { requestPage(1) }, ) } CalendarPagingContent( - pages = pages, - pagerState = pagerState, - centerPageIndex = centerPageIndex, - onSettledAwayFromCenter = ::settlePage, + pageCount = CalendarWeekPagerPageCount, + currentPage = currentPage, + onPageSettled = ::settlePage, + scrollRequest = scrollRequest, + onScrollRequestHandled = { requestId -> + if (scrollRequest?.id == requestId) { + scrollRequest = null + } + }, + pageKey = { page -> "week-${minWeekStart.plusWeeks(page.toLong())}" }, modifier = Modifier .fillMaxWidth() .height(CalendarPeriodCardPageHeight), - ) { displayWeekStart -> + ) { page -> + val displayWeekStart = remember(minWeekStart, page) { + minWeekStart.plusWeeks(page.toLong()) + } val weekDays = remember(displayWeekStart) { List(7) { offset -> displayWeekStart.plusDays(offset.toLong()) } } @@ -1262,100 +1203,59 @@ private fun Modifier.calendarInAppDateDropTarget( @Composable private fun CalendarDayCard( selectedDate: LocalDate, + minNavigableMonth: YearMonth, today: LocalDate, tasksByDate: Map>, canGoPrevDay: Boolean, canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, onTodayJumpHandled: (Int) -> Unit, - onPrevDay: () -> Unit, - onNextDay: () -> Unit, onSelectDate: (LocalDate) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val coroutineScope = rememberCoroutineScope() - var pendingTodayJump by remember { mutableStateOf(null) } - val todayJumpDirection = pendingTodayJump?.let { request -> - when { - request.targetDate < selectedDate -> CalendarPagerSlot.PREVIOUS - request.targetDate > selectedDate -> CalendarPagerSlot.NEXT - else -> null - } - } - val previousPageDay = if (todayJumpDirection == CalendarPagerSlot.PREVIOUS) { - pendingTodayJump?.targetDate - } else if (canGoPrevDay) { - selectedDate.minusDays(1) - } else { - null - } - val nextPageDay = if (todayJumpDirection == CalendarPagerSlot.NEXT) { - pendingTodayJump?.targetDate ?: selectedDate.plusDays(1) - } else { - selectedDate.plusDays(1) - } - val pages = remember(previousPageDay, selectedDate, nextPageDay) { - buildList { - previousPageDay?.let { add(CalendarPagerPage(CalendarPagerSlot.PREVIOUS, it)) } - add(CalendarPagerPage(CalendarPagerSlot.CURRENT, selectedDate)) - add(CalendarPagerPage(CalendarPagerSlot.NEXT, nextPageDay)) - } - } - val centerPageIndex = pages.indexOfSlot(CalendarPagerSlot.CURRENT).coerceAtLeast(0) - val pagerState = rememberPagerState(initialPage = centerPageIndex) { pages.size } - val isPagingAtRest = pagerState.settledPage == centerPageIndex && !pagerState.isScrollInProgress - - fun requestPage(slot: CalendarPagerSlot) { - val targetIndex = pages.indexOfSlot(slot) - if (targetIndex < 0 || !isPagingAtRest) return + val minDate = remember(minNavigableMonth) { minNavigableMonth.atDay(1) } + val currentPage = remember(minDate, selectedDate) { + ChronoUnit.DAYS.between(minDate, selectedDate) + .toInt() + .coerceIn(0, CalendarDayPagerPageCount - 1) + } + var scrollRequest by remember { mutableStateOf(null) } + val isPagingAtRest = scrollRequest == null + + fun requestPage(offset: Int) { + val targetIndex = (currentPage + offset).coerceIn(0, CalendarDayPagerPageCount - 1) + if (targetIndex == currentPage || !isPagingAtRest) return coroutineScope.launch { - pagerState.animateScrollToPage(targetIndex) + scrollRequest = CalendarPagerScrollRequest( + id = System.nanoTime().toInt(), + page = targetIndex, + ) } } - fun settlePage(slot: CalendarPagerSlot) { - pendingTodayJump?.let { request -> - pendingTodayJump = null - onSelectDate(request.targetDate) - onTodayJumpHandled(request.id) - return - } + fun dateForPage(page: Int): LocalDate { + return minDate.plusDays(page.toLong()) + } - when (slot) { - CalendarPagerSlot.PREVIOUS -> onPrevDay() - CalendarPagerSlot.NEXT -> onNextDay() - CalendarPagerSlot.CURRENT -> Unit - } + fun settlePage(page: Int) { + onSelectDate(dateForPage(page)) } LaunchedEffect(todayJumpRequest) { val request = todayJumpRequest ?: return@LaunchedEffect - if (!isPagingAtRest) { - onTodayJumpHandled(request.id) - return@LaunchedEffect - } if (request.targetDate == selectedDate) { onSelectDate(request.targetDate) onTodayJumpHandled(request.id) } else { - pendingTodayJump = request + val targetPage = ChronoUnit.DAYS.between(minDate, request.targetDate) + .toInt() + .coerceIn(0, CalendarDayPagerPageCount - 1) + scrollRequest = CalendarPagerScrollRequest(request.id, targetPage) onTodayJumpHandled(request.id) } } - LaunchedEffect(pendingTodayJump?.id, pages) { - val request = pendingTodayJump ?: return@LaunchedEffect - val targetSlot = if (request.targetDate < selectedDate) { - CalendarPagerSlot.PREVIOUS - } else { - CalendarPagerSlot.NEXT - } - val targetIndex = pages.indexOfSlot(targetSlot) - if (targetIndex >= 0 && pagerState.currentPage == centerPageIndex) { - pagerState.animateScrollToPage(targetIndex) - } - } - Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(CalendarCardCornerRadius), @@ -1384,7 +1284,7 @@ private fun CalendarDayCard( icon = Icons.Rounded.ChevronLeft, contentDescription = stringResource(R.string.calendar_prev_day), enabled = canGoPrevDay && isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.PREVIOUS) }, + onClick = { requestPage(-1) }, ) Box( modifier = Modifier.weight(1f), @@ -1403,19 +1303,26 @@ private fun CalendarDayCard( icon = Icons.Rounded.ChevronRight, contentDescription = stringResource(R.string.calendar_next_day), enabled = isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.NEXT) }, + onClick = { requestPage(1) }, ) } CalendarPagingContent( - pages = pages, - pagerState = pagerState, - centerPageIndex = centerPageIndex, - onSettledAwayFromCenter = ::settlePage, + pageCount = CalendarDayPagerPageCount, + currentPage = currentPage, + onPageSettled = ::settlePage, + scrollRequest = scrollRequest, + onScrollRequestHandled = { requestId -> + if (scrollRequest?.id == requestId) { + scrollRequest = null + } + }, + pageKey = { page -> "day-${dateForPage(page)}" }, modifier = Modifier .fillMaxWidth() .height(CalendarPeriodCardPageHeight), - ) { displayDate -> + ) { page -> + val displayDate = remember(minDate, page) { dateForPage(page) } val taskCount = tasksByDate[displayDate]?.size ?: 0 Column( modifier = Modifier @@ -1635,6 +1542,7 @@ private fun CalendarCircleButton( @Composable private fun CalendarMonthCard( visibleMonth: YearMonth, + minNavigableMonth: YearMonth, canGoPrevMonth: Boolean, selectedDate: LocalDate, today: LocalDate, @@ -1645,8 +1553,7 @@ private fun CalendarMonthCard( canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, onTodayJumpHandled: (Int) -> Unit, - onPrevMonth: () -> Unit, - onNextMonth: () -> Unit, + onVisibleMonthChanged: (YearMonth) -> Unit, onSelectDate: (LocalDate) -> Unit, onDropDateChanged: (LocalDate?) -> Unit, onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, @@ -1654,91 +1561,48 @@ private fun CalendarMonthCard( ) { val colorScheme = MaterialTheme.colorScheme val coroutineScope = rememberCoroutineScope() - var pendingTodayJump by remember { mutableStateOf(null) } - val todayJumpDirection = pendingTodayJump?.let { request -> - val targetMonth = YearMonth.from(request.targetDate) - when { - targetMonth < visibleMonth -> CalendarPagerSlot.PREVIOUS - targetMonth > visibleMonth -> CalendarPagerSlot.NEXT - else -> null - } - } - val previousPageMonth = if (todayJumpDirection == CalendarPagerSlot.PREVIOUS) { - pendingTodayJump?.targetDate?.let(YearMonth::from) - } else if (canGoPrevMonth) { - visibleMonth.minusMonths(1) - } else { - null + val currentPage = remember(minNavigableMonth, visibleMonth) { + ChronoUnit.MONTHS.between(minNavigableMonth, visibleMonth) + .toInt() + .coerceIn(0, CalendarMonthPagerPageCount - 1) } - val nextPageMonth = if (todayJumpDirection == CalendarPagerSlot.NEXT) { - pendingTodayJump?.targetDate?.let(YearMonth::from) ?: visibleMonth.plusMonths(1) - } else { - visibleMonth.plusMonths(1) - } - val pages = remember(previousPageMonth, visibleMonth, nextPageMonth) { - buildList { - previousPageMonth?.let { add(CalendarPagerPage(CalendarPagerSlot.PREVIOUS, it)) } - add(CalendarPagerPage(CalendarPagerSlot.CURRENT, visibleMonth)) - add(CalendarPagerPage(CalendarPagerSlot.NEXT, nextPageMonth)) - } - } - val centerPageIndex = pages.indexOfSlot(CalendarPagerSlot.CURRENT).coerceAtLeast(0) - val pagerState = rememberPagerState(initialPage = centerPageIndex) { pages.size } - val isPagingAtRest = pagerState.settledPage == centerPageIndex && !pagerState.isScrollInProgress + var scrollRequest by remember { mutableStateOf(null) } + val isPagingAtRest = scrollRequest == null - fun requestPage(slot: CalendarPagerSlot) { - val targetIndex = pages.indexOfSlot(slot) - if (targetIndex < 0 || !isPagingAtRest) return + fun requestPage(offset: Int) { + val targetIndex = (currentPage + offset).coerceIn(0, CalendarMonthPagerPageCount - 1) + if (targetIndex == currentPage || !isPagingAtRest) return coroutineScope.launch { - pagerState.animateScrollToPage(targetIndex) + scrollRequest = CalendarPagerScrollRequest( + id = System.nanoTime().toInt(), + page = targetIndex, + ) } } - fun settlePage(slot: CalendarPagerSlot) { - pendingTodayJump?.let { request -> - pendingTodayJump = null - onSelectDate(request.targetDate) - onTodayJumpHandled(request.id) - return - } + fun monthForPage(page: Int): YearMonth { + return minNavigableMonth.plusMonths(page.toLong()) + } - when (slot) { - CalendarPagerSlot.PREVIOUS -> onPrevMonth() - CalendarPagerSlot.NEXT -> onNextMonth() - CalendarPagerSlot.CURRENT -> Unit - } + fun settlePage(page: Int) { + onVisibleMonthChanged(monthForPage(page)) } LaunchedEffect(todayJumpRequest) { val request = todayJumpRequest ?: return@LaunchedEffect - if (!isPagingAtRest) { - onTodayJumpHandled(request.id) - return@LaunchedEffect - } val targetMonth = YearMonth.from(request.targetDate) if (targetMonth == visibleMonth) { onSelectDate(request.targetDate) onTodayJumpHandled(request.id) } else { - pendingTodayJump = request + val targetPage = ChronoUnit.MONTHS.between(minNavigableMonth, targetMonth) + .toInt() + .coerceIn(0, CalendarMonthPagerPageCount - 1) + scrollRequest = CalendarPagerScrollRequest(request.id, targetPage) onTodayJumpHandled(request.id) } } - LaunchedEffect(pendingTodayJump?.id, pages) { - val request = pendingTodayJump ?: return@LaunchedEffect - val targetMonth = YearMonth.from(request.targetDate) - val targetSlot = if (targetMonth < visibleMonth) { - CalendarPagerSlot.PREVIOUS - } else { - CalendarPagerSlot.NEXT - } - val targetIndex = pages.indexOfSlot(targetSlot) - if (targetIndex >= 0 && pagerState.currentPage == centerPageIndex) { - pagerState.animateScrollToPage(targetIndex) - } - } - Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(CalendarCardCornerRadius), @@ -1767,7 +1631,7 @@ private fun CalendarMonthCard( icon = Icons.Rounded.ChevronLeft, contentDescription = stringResource(R.string.calendar_prev_month), enabled = canGoPrevMonth && isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.PREVIOUS) }, + onClick = { requestPage(-1) }, ) Box( modifier = Modifier.weight(1f), @@ -1780,18 +1644,14 @@ private fun CalendarMonthCard( fontSize = CalendarMonthHeaderTitleSize, ), fontWeight = FontWeight.ExtraBold, - color = if (activeDropDate?.let { YearMonth.from(it) } == visibleMonth) { - colorScheme.error - } else { - colorScheme.onSurface - }, + color = colorScheme.onSurface, ) } MiniCalendarNavButton( icon = Icons.Rounded.ChevronRight, contentDescription = stringResource(R.string.calendar_next_month), enabled = isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.NEXT) }, + onClick = { requestPage(1) }, ) } @@ -1814,14 +1674,21 @@ private fun CalendarMonthCard( } CalendarPagingContent( - pages = pages, - pagerState = pagerState, - centerPageIndex = centerPageIndex, - onSettledAwayFromCenter = ::settlePage, + pageCount = CalendarMonthPagerPageCount, + currentPage = currentPage, + onPageSettled = ::settlePage, + scrollRequest = scrollRequest, + onScrollRequestHandled = { requestId -> + if (scrollRequest?.id == requestId) { + scrollRequest = null + } + }, + pageKey = { page -> "month-${monthForPage(page)}" }, modifier = Modifier .fillMaxWidth() .height(CalendarMonthGridHeight), - ) { displayMonth -> + ) { page -> + val displayMonth = remember(minNavigableMonth, page) { monthForPage(page) } val monthDays = remember(displayMonth) { buildMonthCells(displayMonth) } Column( modifier = Modifier.fillMaxWidth(), diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 95a507d4..902cbc7d 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -68,6 +68,10 @@ private enum CalendarMonthGridMetrics { static let cellCornerRadius: CGFloat = 16 } +private enum CalendarTaskListMetrics { + static let rowVerticalPadding: CGFloat = 6 +} + private let calendarTodayTintColor = Color(red: 80.0 / 255.0, green: 154.0 / 255.0, blue: 230.0 / 255.0) private struct CalendarTodayJumpRequest: Equatable { @@ -260,6 +264,7 @@ struct CalendarScreen: View { .listStyle(.plain) .scrollContentBackground(.hidden) .contentMargins(.top, 0, for: .scrollContent) + .listRowSpacing(0) .listSectionSpacing(0) .environment(\.defaultMinListRowHeight, 1) .disableVerticalScrollBounce() @@ -631,9 +636,6 @@ private struct CalendarMonthGrid: View { let isPagingAtRest = pageSelection == calendarNativePagerCenterIndex let isPreviousEnabled = canGoPrevious && isPagingAtRest let isNextEnabled = isPagingAtRest - let isMonthDropTarget = activeDropDate - .map { calendarMonthStart(for: $0) == calendarMonthStart(for: displayMonth) } ?? false - return VStack(spacing: CalendarPeriodCardMetrics.contentSpacing) { HStack { CalendarNavButton( @@ -647,7 +649,7 @@ private struct CalendarMonthGrid: View { Text(monthTitle(for: displayMonth)) .font(.tdayRounded(size: 21, weight: .heavy)) - .foregroundStyle(isMonthDropTarget ? colors.error : colors.onSurface) + .foregroundStyle(colors.onSurface) Spacer(minLength: 0) @@ -853,12 +855,6 @@ private struct CalendarWeekCard: View { let previousPageWeekDate = jumpDirection == .previous ? pendingTodayJump?.targetDate : previousWeekDate let nextPageWeekDate = jumpDirection == .next ? pendingTodayJump?.targetDate : nextWeekDate let isPagingAtRest = pageSelection == calendarNativePagerCenterIndex - let weekEnd = Calendar.current.date(byAdding: .day, value: 6, to: weekStart) ?? weekStart - let isWeekDropTarget = activeDropDate.map { activeDate in - let activeDay = Calendar.current.startOfDay(for: activeDate) - return activeDay >= weekStart && activeDay <= weekEnd - } ?? false - return VStack(spacing: CalendarPeriodCardMetrics.contentSpacing) { HStack { CalendarNavButton( @@ -872,7 +868,7 @@ private struct CalendarWeekCard: View { Text(calendarWeekRangeText(from: weekStart)) .font(.tdayRounded(size: 21, weight: .heavy)) - .foregroundStyle(isWeekDropTarget ? colors.error : colors.onSurface) + .foregroundStyle(colors.onSurface) .lineLimit(1) .minimumScaleFactor(0.82) @@ -2478,7 +2474,7 @@ private struct CalendarPendingTaskRow: View { .padding(.trailing, TodoTimelineMetrics.minimalRowTrailingIndicatorPadding) } } - .padding(.vertical, TodoTimelineMetrics.minimalRowVerticalPadding) + .padding(.vertical, CalendarTaskListMetrics.rowVerticalPadding) .contentShape(Rectangle()) } .frame(maxWidth: .infinity, alignment: .leading) From ed2e434b9c7fbce66512a70b84302babb19f222e Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 04:37:27 -0400 Subject: [PATCH 22/24] feat(calendar, todo): enhance animations and layout transitions Refine UI transitions and layout stability for the calendar and todo list screens across iOS and Android. - **iOS Calendar**: - Introduce `CalendarModeCardMetrics` to define explicit heights for month and period views, preventing layout jumps during mode switching. - Implement a spring-based `calendarModeTransitionAnimation` for smoother display mode toggling. - Apply opacity and scale transitions to the calendar card during mode changes. - **iOS Todo List**: - Introduce `todoDropPlaceholderAnimation` to standardize spring-based animations for drag-and-drop interactions. - Encapsulate `activeDropSectionId` updates within `setActiveDropSection` to ensure all drag-related state changes are animated consistently. - Update `timelineRowTransition` to use spring animations for row insertions and removals. - **Android Calendar**: - Add `animateContentSize` with a custom spring spec to the calendar card container to smoothly handle height transitions between different views. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- .../feature/calendar/CalendarScreen.kt | 7 ++ .../Feature/Calendar/CalendarScreen.swift | 81 ++++++++++++------- .../Tday/Feature/Todos/TodoListScreen.swift | 40 +++++---- 3 files changed, 86 insertions(+), 42 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index 42f4a64f..7e6620df 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -1,6 +1,7 @@ package com.ohmz.tday.compose.feature.calendar import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -552,6 +553,12 @@ fun CalendarScreen( Box( modifier = Modifier .fillMaxWidth() + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) .shadow( elevation = 2.dp, shape = RoundedCornerShape(CalendarCardCornerRadius), diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 902cbc7d..9307eb7c 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -58,6 +58,8 @@ private enum CalendarPeriodCardMetrics { private enum CalendarMonthGridMetrics { static let spacing: CGFloat = 8 static let height: CGFloat = 292 + static let weekdayHeight: CGFloat = 18 + static let cardBottomPadding: CGFloat = 20 static let dayCellHeight: CGFloat = 42 static let dayHighlightWidth: CGFloat = 42 static let dayHighlightHeight: CGFloat = 40 @@ -69,10 +71,27 @@ private enum CalendarMonthGridMetrics { } private enum CalendarTaskListMetrics { - static let rowVerticalPadding: CGFloat = 6 + static let rowSpacing: CGFloat = 0 + static let rowVerticalPadding: CGFloat = 4 +} + +private enum CalendarModeCardMetrics { + static let monthHeight = CalendarPeriodCardMetrics.topPadding + + CalendarPeriodCardMetrics.headerHeight + + CalendarPeriodCardMetrics.contentSpacing + + CalendarMonthGridMetrics.weekdayHeight + + CalendarMonthGridMetrics.spacing + + CalendarMonthGridMetrics.height + + CalendarMonthGridMetrics.cardBottomPadding + static let periodHeight = CalendarPeriodCardMetrics.topPadding + + CalendarPeriodCardMetrics.headerHeight + + CalendarPeriodCardMetrics.contentSpacing + + CalendarPeriodCardMetrics.pageHeight + + CalendarPeriodCardMetrics.bottomPadding } private let calendarTodayTintColor = Color(red: 80.0 / 255.0, green: 154.0 / 255.0, blue: 230.0 / 255.0) +private let calendarModeTransitionAnimation = Animation.spring(response: 0.34, dampingFraction: 0.9, blendDuration: 0.02) private struct CalendarTodayJumpRequest: Equatable { let id: Int @@ -128,6 +147,15 @@ struct CalendarScreen: View { return formatter.string(from: selectedDate) } + private var calendarModeCardHeight: CGFloat { + switch displayMode { + case .month: + return CalendarModeCardMetrics.monthHeight + case .week, .day: + return CalendarModeCardMetrics.periodHeight + } + } + private var titleCollapseProgress: CGFloat { let distance = CalendarTitleHandoff.collapseDistance guard distance > 0 else { return 0 } @@ -163,7 +191,7 @@ struct CalendarScreen: View { selectedMode: displayMode, accentColor: calendarAccentColor, onSelect: { mode in - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(calendarModeTransitionAnimation) { displayMode = mode if mode != .month { visibleMonth = calendarMonthStart(for: selectedDate) @@ -191,6 +219,11 @@ struct CalendarScreen: View { .listRowSeparator(.hidden) calendarModeCard + .id(displayMode) + .transition(.opacity.combined(with: .scale(scale: 0.985, anchor: .top))) + .frame(height: calendarModeCardHeight, alignment: .top) + .clipped() + .animation(calendarModeTransitionAnimation, value: displayMode) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -205,8 +238,15 @@ struct CalendarScreen: View { } } - Section { - if !pendingItems.isEmpty { + Text("Tasks due \(selectedDateHeaderText)") + .font(.tdayRounded(size: 22, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .textCase(nil) + .listRowInsets(EdgeInsets(top: 8, leading: TodoTimelineMetrics.horizontalPadding, bottom: 4, trailing: TodoTimelineMetrics.horizontalPadding)) + .timelinePinnedSectionHeaderBackground() + + if !pendingItems.isEmpty { + VStack(spacing: CalendarTaskListMetrics.rowSpacing) { ForEach(pendingItems) { todo in CalendarPendingTaskRow( todo: todo, @@ -216,11 +256,7 @@ struct CalendarScreen: View { onComplete: { Task { await viewModel.complete(todo) } } ) .opacity(draggedTodo?.id == todo.id && activeDropDate != nil ? 0.55 : 1) - .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .background(colors.background) - .listRowBackground(colors.background) - .listRowSeparator(.hidden) - .listSectionSeparator(.hidden) .modifier( CalendarInAppDragModifier( enabled: calendarTaskRescheduleEnabled, @@ -239,28 +275,17 @@ struct CalendarScreen: View { Task { await viewModel.delete(todo) } } ) - .swipeActions(edge: .leading, allowsFullSwipe: true) { - Button { - Task { await viewModel.complete(todo) } - } label: { - Label("Complete", systemImage: "checkmark") - } - .tint(.green) - } } } - } header: { - Text("Tasks due \(selectedDateHeaderText)") - .font(.tdayRounded(size: 22, weight: .heavy)) - .foregroundStyle(colors.onSurface) - .textCase(nil) - .listRowInsets(EdgeInsets(top: 8, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) - .timelinePinnedSectionHeaderBackground() + .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowBackground(colors.background) + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) } - .listRowBackground(colors.background) - .listRowSeparator(.hidden) - .listSectionSeparator(.hidden) } + .listRowBackground(colors.background) + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) .listStyle(.plain) .scrollContentBackground(.hidden) .contentMargins(.top, 0, for: .scrollContent) @@ -670,7 +695,7 @@ private struct CalendarMonthGrid: View { .font(.tdayRounded(size: 12, weight: .heavy)) .foregroundStyle(colors.onSurfaceVariant.opacity(0.48)) .frame(maxWidth: .infinity) - .frame(height: 18) + .frame(height: CalendarMonthGridMetrics.weekdayHeight) } } @@ -688,7 +713,7 @@ private struct CalendarMonthGrid: View { } .padding(.horizontal, CalendarPeriodCardMetrics.horizontalPadding) .padding(.top, CalendarPeriodCardMetrics.topPadding) - .padding(.bottom, 20) + .padding(.bottom, CalendarMonthGridMetrics.cardBottomPadding) .frame(maxWidth: .infinity) } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index e38f9231..ed4bdcef 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -72,6 +72,8 @@ enum TodoTimelineMetrics { } } +private let todoDropPlaceholderAnimation = Animation.spring(response: 0.28, dampingFraction: 0.88, blendDuration: 0.02) + struct TimelinePinnedSectionHeaderBackground: ViewModifier { @Environment(\.tdayColors) private var colors @@ -505,7 +507,7 @@ struct TodoListScreen: View { } private func handleItemsChanged() { - activeDropSectionId = nil + setActiveDropSection(nil) draggedTodo = nil inAppDrag = nil dropTargetFrames = [:] @@ -516,7 +518,7 @@ struct TodoListScreen: View { } private func requestReschedule(_ todo: TodoItem, to targetDate: Date) { - activeDropSectionId = nil + setActiveDropSection(nil) draggedTodo = nil inAppDrag = nil dropTargetFrames = [:] @@ -543,6 +545,13 @@ struct TodoListScreen: View { viewModel.items.first { $0.id == id || $0.canonicalId == id } } + private func setActiveDropSection(_ sectionId: String?) { + guard activeDropSectionId != sectionId else { return } + withAnimation(todoDropPlaceholderAnimation) { + activeDropSectionId = sectionId + } + } + private func beginInAppDrag(_ todo: TodoItem, at location: CGPoint) { if draggedTodo?.id != todo.id { UIImpactFeedbackGenerator(style: .light).impactOccurred() @@ -554,14 +563,14 @@ struct TodoListScreen: View { private func updateInAppDrag(_ todo: TodoItem, to location: CGPoint) { inAppDrag = TodoInAppDrag(todo: todo, location: location) - activeDropSectionId = dropSectionID(at: location) + setActiveDropSection(dropSectionID(at: location)) } private func finishInAppDrag(_ todo: TodoItem, at location: CGPoint?) { let targetSectionID = location.flatMap(dropSectionID(at:)) ?? activeDropSectionId let targetDate = targetSectionID .flatMap { sectionID in groupedSections.first { $0.id == sectionID }?.targetDate } - activeDropSectionId = nil + setActiveDropSection(nil) draggedTodo = nil inAppDrag = nil dropTargetFrames = [:] @@ -571,7 +580,7 @@ struct TodoListScreen: View { } private func cancelInAppDrag() { - activeDropSectionId = nil + setActiveDropSection(nil) draggedTodo = nil inAppDrag = nil dropTargetFrames = [:] @@ -726,6 +735,7 @@ struct TodoListScreen: View { ) .listRowInsets(EdgeInsets(top: 4, leading: 20, bottom: 6, trailing: 20)) .listRowBackground(colors.surface) + .transition(timelineRowTransition()) .scheduledTodoDropTarget( section: section, draggedTodo: draggedTodo, @@ -734,7 +744,7 @@ struct TodoListScreen: View { requestReschedule(todo, to: targetDate) }, onSectionChange: { sectionId in - activeDropSectionId = sectionId + setActiveDropSection(sectionId) } ) } @@ -755,7 +765,7 @@ struct TodoListScreen: View { requestReschedule(todo, to: targetDate) }, onSectionChange: { sectionId in - activeDropSectionId = sectionId + setActiveDropSection(sectionId) } ) } @@ -778,7 +788,7 @@ struct TodoListScreen: View { requestReschedule(todo, to: targetDate) }, onSectionChange: { sectionId in - activeDropSectionId = sectionId + setActiveDropSection(sectionId) } ) } @@ -788,6 +798,7 @@ struct TodoListScreen: View { .scrollContentBackground(.hidden) .background(colors.background) .disableVerticalScrollBounce() + .animation(todoDropPlaceholderAnimation, value: activeDropSectionId) .animation(.easeInOut(duration: 0.22), value: timelineItemAnimationKey) } @@ -898,6 +909,7 @@ struct TodoListScreen: View { .listRowSpacing(0) .listSectionSpacing(0) .environment(\.defaultMinListRowHeight, 1) + .animation(todoDropPlaceholderAnimation, value: activeDropSectionId) .animation(.easeInOut(duration: 0.22), value: timelineItemAnimationKey) } @@ -977,7 +989,7 @@ struct TodoListScreen: View { requestReschedule(droppedTodo, to: targetDate) }, onSectionChange: { sectionId in - activeDropSectionId = sectionId + setActiveDropSection(sectionId) } ) .modifier( @@ -1076,7 +1088,7 @@ struct TodoListScreen: View { requestReschedule(droppedTodo, to: targetDate) }, onSectionChange: { sectionId in - activeDropSectionId = sectionId + setActiveDropSection(sectionId) } ) .modifier( @@ -1139,7 +1151,7 @@ struct TodoListScreen: View { requestReschedule(todo, to: targetDate) }, onSectionChange: { sectionId in - activeDropSectionId = sectionId + setActiveDropSection(sectionId) } ) } @@ -1190,7 +1202,7 @@ struct TodoListScreen: View { requestReschedule(todo, to: targetDate) }, onSectionChange: { sectionId in - activeDropSectionId = sectionId + setActiveDropSection(sectionId) } ) .listRowInsets( @@ -1262,10 +1274,10 @@ struct TodoListScreen: View { private func timelineRowTransition() -> AnyTransition { let insertion = AnyTransition.opacity .combined(with: .move(edge: .top)) - .animation(.easeOut(duration: 0.16)) + .animation(todoDropPlaceholderAnimation) let removal = AnyTransition.opacity .combined(with: .move(edge: .top)) - .animation(.easeOut(duration: 0.1)) + .animation(todoDropPlaceholderAnimation) return .asymmetric(insertion: insertion, removal: removal) } From 9e1ede416d48a4f775583369d1feaab0a8d7af56 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 04:42:37 -0400 Subject: [PATCH 23/24] Polish iOS calendar shadows --- .../Core/UI/TaskFloatingActionButton.swift | 18 ++++++- .../Feature/Calendar/CalendarScreen.swift | 47 +++++++++++++++---- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift b/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift index 89d9b1db..07897b4d 100644 --- a/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift +++ b/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift @@ -4,6 +4,8 @@ private let defaultTaskFabFillColor = Color(red: 110.0 / 255.0, green: 168.0 / 2 struct TaskFloatingActionButton: View { var fillColor = defaultTaskFabFillColor + var pressedShadowOpacity = 0.14 + var normalShadowOpacity = 0.24 let action: () -> Void private var borderColor: Color { @@ -23,7 +25,12 @@ struct TaskFloatingActionButton: View { ) .clipShape(Circle()) } - .buttonStyle(TdayPressButtonStyle()) + .buttonStyle( + TdayPressButtonStyle( + pressedShadowOpacity: pressedShadowOpacity, + normalShadowOpacity: normalShadowOpacity + ) + ) .accessibilityLabel("Create Task") } } @@ -56,12 +63,19 @@ private func taskFabBlend(_ color: Color, with other: Color, amount: CGFloat) -> struct TaskFloatingActionButtonDock: View { var fillColor = defaultTaskFabFillColor + var pressedShadowOpacity = 0.14 + var normalShadowOpacity = 0.24 let action: () -> Void var body: some View { HStack { Spacer() - TaskFloatingActionButton(fillColor: fillColor, action: action) + TaskFloatingActionButton( + fillColor: fillColor, + pressedShadowOpacity: pressedShadowOpacity, + normalShadowOpacity: normalShadowOpacity, + action: action + ) .padding(.trailing, 18) .padding(.vertical, 8) } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 9307eb7c..5124661d 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -93,6 +93,34 @@ private enum CalendarModeCardMetrics { private let calendarTodayTintColor = Color(red: 80.0 / 255.0, green: 154.0 / 255.0, blue: 230.0 / 255.0) private let calendarModeTransitionAnimation = Animation.spring(response: 0.34, dampingFraction: 0.9, blendDuration: 0.02) +private struct CalendarCardChromeModifier: ViewModifier { + @Environment(\.tdayColors) private var colors + + func body(content: Content) -> some View { + let shape = RoundedRectangle(cornerRadius: 24, style: .continuous) + + content + .background(colors.surface, in: shape) + .overlay { + shape.stroke(cardStrokeColor, lineWidth: 1) + } + .shadow(color: ambientShadowColor, radius: 18, x: 0, y: 9) + .shadow(color: keyShadowColor, radius: 4, x: 0, y: 2) + } + + private var cardStrokeColor: Color { + colors.isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.035) + } + + private var ambientShadowColor: Color { + Color.black.opacity(colors.isDark ? 0.24 : 0.045) + } + + private var keyShadowColor: Color { + Color.black.opacity(colors.isDark ? 0.18 : 0.04) + } +} + private struct CalendarTodayJumpRequest: Equatable { let id: Int let targetDate: Date @@ -223,6 +251,7 @@ struct CalendarScreen: View { .transition(.opacity.combined(with: .scale(scale: 0.985, anchor: .top))) .frame(height: calendarModeCardHeight, alignment: .top) .clipped() + .modifier(CalendarCardChromeModifier()) .animation(calendarModeTransitionAnimation, value: displayMode) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) @@ -242,7 +271,7 @@ struct CalendarScreen: View { .font(.tdayRounded(size: 22, weight: .heavy)) .foregroundStyle(colors.onSurface) .textCase(nil) - .listRowInsets(EdgeInsets(top: 8, leading: TodoTimelineMetrics.horizontalPadding, bottom: 4, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowInsets(EdgeInsets(top: 14, leading: TodoTimelineMetrics.horizontalPadding, bottom: 4, trailing: TodoTimelineMetrics.horizontalPadding)) .timelinePinnedSectionHeaderBackground() if !pendingItems.isEmpty { @@ -331,7 +360,11 @@ struct CalendarScreen: View { calendarTopInset } .safeAreaInset(edge: .bottom) { - TaskFloatingActionButtonDock(fillColor: calendarAccentColor) { + TaskFloatingActionButtonDock( + fillColor: calendarAccentColor, + pressedShadowOpacity: 0.09, + normalShadowOpacity: 0.16 + ) { showingCreateTask = true } } @@ -647,8 +680,6 @@ private struct CalendarMonthGrid: View { monthContent(for: displayMonth) .onChange(of: todayJumpRequest) { _, request in handleTodayJump(request, from: displayMonth) } .onChange(of: displayMonth) { _, _ in resetPageSelection() } - .background(colors.surface, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) - .shadow(color: Color.black.opacity(0.08), radius: 10, x: 0, y: 5) } private func monthContent(for displayMonth: Date) -> some View { @@ -867,8 +898,6 @@ private struct CalendarWeekCard: View { weekContent(for: displaySelectedDate) .onChange(of: todayJumpRequest) { _, request in handleTodayJump(request, from: displaySelectedDate) } .onChange(of: displaySelectedDate) { _, _ in resetPageSelection() } - .background(colors.surface, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) - .shadow(color: Color.black.opacity(0.08), radius: 10, x: 0, y: 5) } private func weekContent(for displaySelectedDate: Date) -> some View { @@ -1325,8 +1354,6 @@ private struct CalendarDayCard: View { dayContent(for: displayDate) .onChange(of: todayJumpRequest) { _, request in handleTodayJump(request, from: displayDate) } .onChange(of: displayDate) { _, _ in resetPageSelection() } - .background(colors.surface, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) - .shadow(color: Color.black.opacity(0.08), radius: 10, x: 0, y: 5) } private func dayContent(for displayDate: Date) -> some View { @@ -1887,8 +1914,8 @@ private struct CalendarTopBarButton: View { .buttonStyle( TdayPressButtonStyle( shadowColor: .black, - pressedShadowOpacity: chrome == .filled ? 0.14 : 0, - normalShadowOpacity: chrome == .filled ? 0.24 : 0 + pressedShadowOpacity: chrome == .filled ? 0.09 : 0, + normalShadowOpacity: chrome == .filled ? 0.15 : 0 ) ) .foregroundStyle(foregroundColor) From ece9c1d6be56f41295dc383ed73153e8f3fa791c Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 04:48:26 -0400 Subject: [PATCH 24/24] refine(calendar): improve pager stability and cleanup drag state Optimize the calendar view logic across platforms by refining pager state management in Android and removing redundant state resets in iOS. - **Android (Compose)**: - Update `HorizontalPager` key logic to use a combination of slot and value for better identity tracking during page transitions. - Refine `handledSettledPage` logic in `CalendarPager` to prevent redundant settling calls while ensuring correct resets when returning to the center page. - **iOS (SwiftUI)**: - Remove redundant manual resets of `dateDropTargetFrames` in `CalendarScreen` drag-and-drop handlers, streamlining the session cleanup. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 5124661d..16e5328f 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -76,6 +76,7 @@ private enum CalendarTaskListMetrics { } private enum CalendarModeCardMetrics { + static let shadowBleed: CGFloat = 12 static let monthHeight = CalendarPeriodCardMetrics.topPadding + CalendarPeriodCardMetrics.headerHeight + CalendarPeriodCardMetrics.contentSpacing @@ -253,7 +254,7 @@ struct CalendarScreen: View { .clipped() .modifier(CalendarCardChromeModifier()) .animation(calendarModeTransitionAnimation, value: displayMode) - .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: CalendarModeCardMetrics.shadowBleed, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } @@ -271,7 +272,7 @@ struct CalendarScreen: View { .font(.tdayRounded(size: 22, weight: .heavy)) .foregroundStyle(colors.onSurface) .textCase(nil) - .listRowInsets(EdgeInsets(top: 14, leading: TodoTimelineMetrics.horizontalPadding, bottom: 4, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowInsets(EdgeInsets(top: 8, leading: TodoTimelineMetrics.horizontalPadding, bottom: 4, trailing: TodoTimelineMetrics.horizontalPadding)) .timelinePinnedSectionHeaderBackground() if !pendingItems.isEmpty {