diff --git a/apps/mobile/__tests__/lib/anchored-menu.test.ts b/apps/mobile/__tests__/lib/anchored-menu.test.ts new file mode 100644 index 000000000..678093e8e --- /dev/null +++ b/apps/mobile/__tests__/lib/anchored-menu.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_ANCHORED_MENU_MARGIN, + getAnchoredMenuPosition, +} from '@/lib/anchored-menu' + +describe('anchored menu positioning', () => { + it('right-aligns the menu to the anchor by default', () => { + const position = getAnchoredMenuPosition({ + anchorRect: { x: 300, y: 100, width: 24, height: 24 }, + viewportWidth: 400, + viewportHeight: 800, + menuWidth: 200, + menuHeight: 180, + }) + + expect(position).toEqual({ + left: 124, + top: 132, + opensUp: false, + }) + }) + + it('clamps the menu within the viewport edges', () => { + const position = getAnchoredMenuPosition({ + anchorRect: { x: 8, y: 120, width: 24, height: 24 }, + viewportWidth: 240, + viewportHeight: 640, + menuWidth: 220, + menuHeight: 180, + }) + + expect(position.left).toBe(DEFAULT_ANCHORED_MENU_MARGIN) + expect(position.top).toBe(152) + expect(position.opensUp).toBe(false) + }) + + it('opens upward when there is not enough room below the trigger', () => { + const position = getAnchoredMenuPosition({ + anchorRect: { x: 260, y: 580, width: 24, height: 24 }, + viewportWidth: 360, + viewportHeight: 640, + menuWidth: 200, + menuHeight: 180, + }) + + expect(position.left).toBe(84) + expect(position.top).toBe(392) + expect(position.opensUp).toBe(true) + }) +}) diff --git a/apps/mobile/__tests__/lib/google-auth-callback.test.ts b/apps/mobile/__tests__/lib/google-auth-callback.test.ts new file mode 100644 index 000000000..d4ba47faf --- /dev/null +++ b/apps/mobile/__tests__/lib/google-auth-callback.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { + AUTH_CALLBACK_URL, + buildGoogleAuthFallbackUrl, + extractGoogleAuthParams, + hasGoogleAuthCallbackPayload, + resolveGoogleAuthCallbackUrl, +} from '@/lib/google-auth-callback' + +describe('google auth callback helpers', () => { + const nativeCallbackUrl = 'orbit://auth-callback' + + it('treats a bare callback route as having no payload', () => { + const params = extractGoogleAuthParams(AUTH_CALLBACK_URL) + + expect(params).toEqual({ + access_token: undefined, + refresh_token: undefined, + provider_token: undefined, + provider_refresh_token: undefined, + error: undefined, + error_description: undefined, + token: undefined, + refreshToken: undefined, + userId: undefined, + name: undefined, + email: undefined, + }) + expect(hasGoogleAuthCallbackPayload(params)).toBe(false) + expect( + resolveGoogleAuthCallbackUrl({ + rawUrl: AUTH_CALLBACK_URL, + params: {}, + }), + ).toBeNull() + }) + + it('detects callback error params from the query string', () => { + const url = `${AUTH_CALLBACK_URL}?error=access_denied&error_description=User%20cancelled` + const params = extractGoogleAuthParams(url) + + expect(params.error).toBe('access_denied') + expect(params.error_description).toBe('User cancelled') + expect(hasGoogleAuthCallbackPayload(params)).toBe(true) + expect( + resolveGoogleAuthCallbackUrl({ + rawUrl: url, + params: {}, + }), + ).toBe(url) + }) + + it('detects direct backend token payloads', () => { + const fallbackUrl = buildGoogleAuthFallbackUrl({ + token: 'backend-token', + refreshToken: 'refresh-token', + userId: 'user-1', + name: 'Thomas', + email: 'thomas@example.com', + }) + + expect(fallbackUrl).toBe( + `${AUTH_CALLBACK_URL}?token=backend-token&refreshToken=refresh-token&userId=user-1&name=Thomas&email=thomas%40example.com`, + ) + expect( + resolveGoogleAuthCallbackUrl({ + rawUrl: null, + params: { + token: 'backend-token', + refreshToken: 'refresh-token', + userId: 'user-1', + name: 'Thomas', + email: 'thomas@example.com', + }, + }), + ).toBe(fallbackUrl) + }) + + it('detects supabase access and refresh tokens from the hash fragment', () => { + const url = `${AUTH_CALLBACK_URL}#access_token=supa-access&refresh_token=supa-refresh` + const params = extractGoogleAuthParams(url) + + expect(params.access_token).toBe('supa-access') + expect(params.refresh_token).toBe('supa-refresh') + expect(hasGoogleAuthCallbackPayload(params)).toBe(true) + expect( + resolveGoogleAuthCallbackUrl({ + rawUrl: url, + params: {}, + }), + ).toBe(url) + }) + + it('detects native callback URLs with payloads', () => { + const url = `${nativeCallbackUrl}#access_token=supa-access&refresh_token=supa-refresh` + + expect( + resolveGoogleAuthCallbackUrl({ + rawUrl: url, + params: {}, + }), + ).toBe(url) + }) + + it('prefers the auth-session callback URL over route state', () => { + const sessionUrl = `${nativeCallbackUrl}#access_token=session-access&refresh_token=session-refresh` + const routeUrl = `${AUTH_CALLBACK_URL}?error=server_error` + + expect( + resolveGoogleAuthCallbackUrl({ + sessionCallbackUrl: sessionUrl, + rawUrl: routeUrl, + params: { + error: 'server_error', + }, + }), + ).toBe(sessionUrl) + }) +}) diff --git a/apps/mobile/__tests__/lib/habit-progress.test.ts b/apps/mobile/__tests__/lib/habit-progress.test.ts new file mode 100644 index 000000000..365ea8013 --- /dev/null +++ b/apps/mobile/__tests__/lib/habit-progress.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + getHabitProgressStrokeDasharray, + HABIT_PROGRESS_RING_CIRCUMFERENCE, +} from '@/lib/habit-progress' + +describe('habit progress ring', () => { + it('returns a partial arc for incomplete progress', () => { + expect(getHabitProgressStrokeDasharray(33, false)).toBe( + `31.10 ${HABIT_PROGRESS_RING_CIRCUMFERENCE}`, + ) + }) + + it('returns a full arc for completed parents', () => { + expect(getHabitProgressStrokeDasharray(33, true)).toBe( + `94.25 ${HABIT_PROGRESS_RING_CIRCUMFERENCE}`, + ) + }) + + it('clamps progress percent to the valid range', () => { + expect(getHabitProgressStrokeDasharray(180, false)).toBe( + `94.25 ${HABIT_PROGRESS_RING_CIRCUMFERENCE}`, + ) + expect(getHabitProgressStrokeDasharray(-10, false)).toBe( + `0.00 ${HABIT_PROGRESS_RING_CIRCUMFERENCE}`, + ) + }) +}) diff --git a/apps/mobile/__tests__/lib/habit-selection-state.test.ts b/apps/mobile/__tests__/lib/habit-selection-state.test.ts new file mode 100644 index 000000000..cb2bc1f95 --- /dev/null +++ b/apps/mobile/__tests__/lib/habit-selection-state.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + getHabitListExtraData, + shouldResetSelectionForViewChange, +} from '@/lib/habit-selection-state' + +describe('habit selection state helpers', () => { + it('does not reset selection when the active view is unchanged', () => { + expect(shouldResetSelectionForViewChange('today', 'today')).toBe(false) + }) + + it('resets selection when the active view changes', () => { + expect(shouldResetSelectionForViewChange('today', 'all')).toBe(true) + }) + + it('changes the list extraData key when select mode changes', () => { + const selectedIds = new Set() + const recentlyCompletedIds = new Set() + + expect( + getHabitListExtraData(false, selectedIds, recentlyCompletedIds), + ).not.toBe( + getHabitListExtraData(true, selectedIds, recentlyCompletedIds), + ) + }) + + it('changes the list extraData key when selected ids change', () => { + const recentlyCompletedIds = new Set() + + expect( + getHabitListExtraData(false, new Set(), recentlyCompletedIds), + ).not.toBe( + getHabitListExtraData( + false, + new Set(['habit-1']), + recentlyCompletedIds, + ), + ) + }) + + it('changes the list extraData key when recently completed ids change', () => { + const selectedIds = new Set(['habit-1']) + + expect( + getHabitListExtraData(false, selectedIds, new Set()), + ).not.toBe( + getHabitListExtraData( + false, + selectedIds, + new Set(['habit-2']), + ), + ) + }) +}) diff --git a/apps/mobile/__tests__/stores/ui-store.test.ts b/apps/mobile/__tests__/stores/ui-store.test.ts index 063a96bd9..749e6c866 100644 --- a/apps/mobile/__tests__/stores/ui-store.test.ts +++ b/apps/mobile/__tests__/stores/ui-store.test.ts @@ -63,6 +63,32 @@ describe('mobile ui store', () => { expect(useUIStore.getState().selectedHabitIds.size).toBe(0) }) + it('enters bulk select mode without selecting habits', () => { + useUIStore.getState().toggleSelectMode() + + expect(useUIStore.getState().isSelectMode).toBe(true) + expect(useUIStore.getState().selectedHabitIds.size).toBe(0) + }) + + it('enters select mode with the tapped habit and descendants selected', () => { + const { toggleSelectMode, toggleSelectionCascade } = useUIStore.getState() + + if (!useUIStore.getState().isSelectMode) { + toggleSelectMode() + } + + toggleSelectionCascade( + 'habit-1', + () => ['child-1', 'child-2'], + () => false, + ) + + expect(useUIStore.getState().isSelectMode).toBe(true) + expect(useUIStore.getState().selectedHabitIds).toEqual( + new Set(['habit-1', 'child-1', 'child-2']), + ) + }) + it('shows all-done celebration only for completed top-level habits on today filters', () => { useUIStore.setState({ activeFilters: { dateFrom: '2026-04-06', dateTo: '2026-04-06' }, diff --git a/apps/mobile/app.json b/apps/mobile/app.json index c221e316a..8931c2e65 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -5,7 +5,7 @@ "version": "1.0.0", "sdkVersion": "54.0.0", "orientation": "portrait", - "icon": "./assets/logo.png", + "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", "scheme": "orbit", "newArchEnabled": true, @@ -24,7 +24,7 @@ }, "android": { "adaptiveIcon": { - "foregroundImage": "./assets/logo.png", + "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#07060e" }, "package": "org.useorbit.app", diff --git a/apps/mobile/app/(tabs)/calendar.tsx b/apps/mobile/app/(tabs)/calendar.tsx index 85f448a18..6c5ced5c7 100644 --- a/apps/mobile/app/(tabs)/calendar.tsx +++ b/apps/mobile/app/(tabs)/calendar.tsx @@ -5,10 +5,8 @@ import { Text, TouchableOpacity, StyleSheet, - SafeAreaView, ScrollView, Switch, - PanResponder, } from "react-native"; import { useTranslation } from "react-i18next"; import { ChevronLeft, ChevronRight, Search, Check } from "lucide-react-native"; @@ -27,15 +25,18 @@ import { getDate, } from "date-fns"; import { enUS, ptBR } from "date-fns/locale"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { formatAPIDate, parseAPIDate } from "@orbit/shared/utils"; import type { CalendarDayEntry } from "@orbit/shared/types/calendar"; import { plural } from "@/lib/plural"; import { useCalendarData } from "@/hooks/use-habits"; import { useProfile } from "@/hooks/use-profile"; import { useTimeFormat } from "@/hooks/use-time-format"; +import { useHorizontalSwipe } from "@/hooks/use-horizontal-swipe"; import { BottomSheetModal } from "@/components/bottom-sheet-modal"; import { createColors } from "@/lib/theme"; import { useAppTheme } from "@/lib/use-app-theme"; +import { SafeAreaView } from "react-native-safe-area-context"; // --------------------------------------------------------------------------- // Types @@ -277,22 +278,10 @@ export default function CalendarScreen() { setCurrentMonth((m) => addMonths(m, 1)); }, []); - const panResponder = useMemo( - () => - PanResponder.create({ - onMoveShouldSetPanResponder: (_, gestureState) => - Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && - Math.abs(gestureState.dx) > 50, - onPanResponderRelease: (_, gestureState) => { - if (gestureState.dx <= -50) { - nextMonth(); - } else if (gestureState.dx >= 50) { - prevMonth(); - } - }, - }), - [nextMonth, prevMonth], - ); + const swipePanResponder = useHorizontalSwipe({ + onSwipeLeft: nextMonth, + onSwipeRight: prevMonth, + }); const goToToday = useCallback(() => { setCurrentMonth(startOfMonth(new Date())); @@ -387,7 +376,7 @@ export default function CalendarScreen() { ).length; return ( - + ) : ( - + {plural( @@ -657,7 +650,7 @@ export default function CalendarScreen() { {t("calendar.goToDay")} - + )} @@ -858,11 +851,19 @@ function createStyles(colors: AppColors) { alignItems: "center", justifyContent: "center", paddingVertical: 32, + paddingHorizontal: 20, }, emptyDayText: { fontSize: 14, color: colors.textMuted, }, + dayDetailScroll: { + flex: 1, + }, + dayDetailContent: { + paddingHorizontal: 20, + paddingBottom: 32, + }, // Day detail: summary summaryText: { diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index d93210079..91df5fef7 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -9,7 +9,6 @@ import { TextInput, StyleSheet, ScrollView, - Modal, Platform, } from 'react-native' import AsyncStorage from '@react-native-async-storage/async-storage' @@ -67,6 +66,10 @@ import { StreakBadge } from '@/components/gamification/streak-badge' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { ThemeToggle } from '@/components/ui/theme-toggle' import { NotificationBell } from '@/components/navigation/notification-bell' +import { AnchoredMenu } from '@/components/ui/anchored-menu' +import { useHorizontalSwipe } from '@/hooks/use-horizontal-swipe' +import type { MenuAnchorRect } from '@/lib/anchored-menu' +import { shouldResetSelectionForViewChange } from '@/lib/habit-selection-state' import { createColors, radius, shadows } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' @@ -78,114 +81,6 @@ const TAB_VIEWS = ['today', 'all', 'general', 'goals'] as const type FreqKey = 'Day' | 'Week' | 'Month' | 'Year' | 'none' -function ControlsMenu({ - open, - isSelectMode, - showCompleted, - isFetching, - allCollapsed, - onToggleSelectMode, - onToggleCollapse, - onRefresh, - onToggleCompleted, - onClose, -}: { - open: boolean - isSelectMode: boolean - showCompleted: boolean - isFetching: boolean - allCollapsed: boolean - onToggleSelectMode: () => void - onToggleCollapse: () => void - onRefresh: () => void - onToggleCompleted: () => void - onClose: () => void -}) { - const { t } = useTranslation() - const { colors } = useAppTheme() - const styles = useMemo(() => createStyles(colors), [colors]) - - return ( - - - true} - > - - {isSelectMode ? ( - - ) : ( - - )} - - {isSelectMode ? t('common.cancel') : t('common.select')} - - - - - {allCollapsed ? ( - - ) : ( - - )} - - {allCollapsed ? t('habits.expandAll') : t('habits.collapseAll')} - - - - - - - {t('habits.refresh')} - - - - - {showCompleted ? ( - - ) : ( - - )} - - {t('habits.showCompleted')} - - - - - - ) -} - // --------------------------------------------------------------------------- // Today Screen // --------------------------------------------------------------------------- @@ -226,12 +121,16 @@ export default function TodayScreen() { const [selectedFrequency, setSelectedFrequency] = useState(null) const [selectedTagIds, setSelectedTagIds] = useState([]) const [showControlsMenu, setShowControlsMenu] = useState(false) + const [controlsMenuAnchorRect, setControlsMenuAnchorRect] = + useState(null) const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) const [showBulkLogConfirm, setShowBulkLogConfirm] = useState(false) const [showBulkSkipConfirm, setShowBulkSkipConfirm] = useState(false) const [slideDirection, setSlideDirection] = useState<'left' | 'right'>('right') const searchDebounceTimer = useRef | null>(null) const habitListRef = useRef(null) + const controlsButtonRef = useRef(null) + const previousActiveViewRef = useRef(activeView) const dateLabelAnim = useRef(new Animated.Value(0)).current // Modal state @@ -289,6 +188,11 @@ export default function TodayScreen() { setActiveView('today') }, [setSelectedDate, setActiveView]) + const swipePanResponder = useHorizontalSwipe({ + onSwipeLeft: goToNextDay, + onSwipeRight: goToPreviousDay, + }) + const dateLabel = useMemo(() => { if (isToday(selectedDate)) return t('dates.today') if (isYesterday(selectedDate)) return t('dates.yesterday') @@ -384,10 +288,7 @@ export default function TodayScreen() { return ids }, [habitsQuery, visibleTopLevelHabits]) - const allLoadedIds = useMemo( - () => habitListRef.current?.allLoadedIds ?? visibleHabitIds, - [visibleHabitIds], - ) + const allLoadedIds = habitListRef.current?.allLoadedIds ?? visibleHabitIds const allSelected = allLoadedIds.size > 0 && @@ -413,6 +314,16 @@ export default function TodayScreen() { // Clear filters on view change useEffect(() => { + if ( + !shouldResetSelectionForViewChange( + previousActiveViewRef.current, + activeView, + ) + ) { + return + } + + previousActiveViewRef.current = activeView setSelectedFrequency(null) setShowControlsMenu(false) if (isSelectMode) clearSelection() @@ -441,6 +352,27 @@ export default function TodayScreen() { setShowControlsMenu(false) }, []) + const handleToggleCompleted = useCallback(() => { + setShowCompleted((prev) => !prev) + setShowControlsMenu(false) + }, []) + + const measureControlsButton = useCallback(() => { + controlsButtonRef.current?.measureInWindow((x, y, width, height) => { + setControlsMenuAnchorRect({ x, y, width, height }) + setShowControlsMenu(true) + }) + }, []) + + const handleToggleControlsMenu = useCallback(() => { + if (showControlsMenu) { + setShowControlsMenu(false) + return + } + + measureControlsButton() + }, [measureControlsButton, showControlsMenu]) + const handleSelectAll = useCallback(() => { selectAllHabits(Array.from(allLoadedIds)) }, [allLoadedIds, selectAllHabits]) @@ -504,12 +436,19 @@ export default function TodayScreen() { }, []) return ( - + setShowControlsMenu(false)} > {/* ============================================================ HEADER: Orbit logo + streak badge + avatar @@ -749,94 +688,84 @@ export default function TodayScreen() { {/* Controls menu (three dots) */} + + + + + + + + setShowControlsMenu(false)} + width={220} + estimatedHeight={220} + > setShowControlsMenu((prev) => !prev)} + style={styles.controlsMenuItem} + onPress={handleToggleSelectMode} + activeOpacity={0.75} > - + {isSelectMode ? ( + + ) : ( + + )} + + {isSelectMode ? t('common.cancel') : t('common.select')} + - - setShowCompleted((prev) => !prev)} - onClose={() => setShowControlsMenu(false)} - /> + + {!!habitListRef.current?.allCollapsed ? ( + + ) : ( + + )} + + {!!habitListRef.current?.allCollapsed + ? t('habits.expandAll') + : t('habits.collapseAll')} + + - {isSelectMode && ( - + + {t('habits.refresh')} + + + - - {plural(t('common.selected', { n: selectedCount }), selectedCount)} + {showCompleted ? ( + + ) : ( + + )} + + {t('habits.showCompleted')} - - - {allSelected ? ( - - ) : ( - - )} - - - - - - - - - - - - - - - - - )} + + {/* Habit list (using HabitList component with all handlers wired) */} + {isSelectMode && ( + + + {plural(t('common.selected', { n: selectedCount }), selectedCount)} + + + + {allSelected ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + + + )} + {/* ============================================================ MODALS ============================================================ */} @@ -957,6 +953,9 @@ function createStyles(colors: ReturnType) { paddingHorizontal: 20, // --app-px: 1.25rem = 20px paddingBottom: 120, }, + scrollContentWithBulkBar: { + paddingBottom: 220, + }, // Header: pt-8 pb-2 = 32px/8px header: { @@ -1218,23 +1217,6 @@ function createStyles(colors: ReturnType) { padding: 8, borderRadius: radius.xl, }, - controlsMenuBackdrop: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.45)', - paddingTop: 120, - paddingRight: 16, - alignItems: 'flex-end', - }, - controlsMenuPanel: { - minWidth: 210, - backgroundColor: colors.surfaceOverlay, - borderRadius: radius.lg, - borderWidth: 1, - borderColor: colors.borderMuted, - padding: 6, - ...shadows.lg, - elevation: 12, - }, controlsMenuItem: { flexDirection: 'row', alignItems: 'center', diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index f42c403e9..730f1dd05 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -4,13 +4,13 @@ import { Text, TouchableOpacity, StyleSheet, - SafeAreaView, ScrollView, TextInput, Modal, } from 'react-native' import AsyncStorage from '@react-native-async-storage/async-storage' import { useLocalSearchParams, useRouter, type Href } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' import { useTranslation } from 'react-i18next' import { useQueryClient } from '@tanstack/react-query' import { format, parseISO } from 'date-fns' diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 0e1e60285..4cfa4922e 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -5,6 +5,7 @@ import { Stack, usePathname, useRouter, useSegments } from 'expo-router' import { StatusBar } from 'expo-status-bar' import Constants from 'expo-constants' import { Providers } from '@/lib/providers' +import { usePendingGoogleAuthSession } from '@/lib/google-auth-callback' import { useAuthStore } from '@/stores/auth-store' import { useGamificationProfile } from '@/hooks/use-gamification' import { useHasProAccess, useProfile } from '@/hooks/use-profile' @@ -38,6 +39,10 @@ const PushPrompt = isExpoGo function AuthGuard() { const isAuthenticated = useAuthStore((s) => s.isAuthenticated) const isLoading = useAuthStore((s) => s.isLoading) + const { + callbackUrl: pendingGoogleAuthCallbackUrl, + isPending: isPendingGoogleAuthSession, + } = usePendingGoogleAuthSession() const segments = useSegments() const router = useRouter() const firstSegment = segments[0] as string | undefined @@ -50,13 +55,29 @@ function AuthGuard() { firstSegment === 'auth-callback' || firstSegment === 'r' || firstSegment === 'privacy' + const allowAuthCallbackWhileResolving = + firstSegment === 'auth-callback' && + (isPendingGoogleAuthSession || Boolean(pendingGoogleAuthCallbackUrl)) if (!isAuthenticated && !inPublicRoute) { router.replace('/login') - } else if (isAuthenticated && (firstSegment === 'login' || firstSegment === 'auth-callback')) { + } else if (isAuthenticated && firstSegment === 'login') { + router.replace('/') + } else if ( + isAuthenticated && + firstSegment === 'auth-callback' && + !allowAuthCallbackWhileResolving + ) { router.replace('/') } - }, [firstSegment, isAuthenticated, isLoading, router]) + }, [ + firstSegment, + isAuthenticated, + isLoading, + isPendingGoogleAuthSession, + pendingGoogleAuthCallbackUrl, + router, + ]) return null } diff --git a/apps/mobile/app/about.tsx b/apps/mobile/app/about.tsx index f1e18b863..90f71c585 100644 --- a/apps/mobile/app/about.tsx +++ b/apps/mobile/app/about.tsx @@ -4,10 +4,10 @@ import { Text, TouchableOpacity, StyleSheet, - SafeAreaView, ScrollView, } from 'react-native' import { useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' import { useTranslation } from 'react-i18next' import { ArrowLeft, diff --git a/apps/mobile/app/advanced.tsx b/apps/mobile/app/advanced.tsx index 63ca10b60..9257628dd 100644 --- a/apps/mobile/app/advanced.tsx +++ b/apps/mobile/app/advanced.tsx @@ -5,13 +5,13 @@ import { TouchableOpacity, TextInput, StyleSheet, - SafeAreaView, ScrollView, ActivityIndicator, Modal, } from 'react-native' import * as Clipboard from 'expo-clipboard' import { useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' import { useTranslation } from 'react-i18next' import { enUS, ptBR } from 'date-fns/locale' import { diff --git a/apps/mobile/app/auth-callback.tsx b/apps/mobile/app/auth-callback.tsx index bdbfdbe9c..fd743c7b7 100644 --- a/apps/mobile/app/auth-callback.tsx +++ b/apps/mobile/app/auth-callback.tsx @@ -10,28 +10,19 @@ import { getStoredReferralCode, markReferralApplied, } from '@/lib/auth-flow' +import { + AUTH_CALLBACK_URL, + clearPendingGoogleAuthSession, + extractGoogleAuthParams, + resolveGoogleAuthCallbackUrl, + usePendingGoogleAuthSession, +} from '@/lib/google-auth-callback' import { completeGoogleAuthFromUrl } from '@/lib/google-auth' import { useAuthStore } from '@/stores/auth-store' import { createColors } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' type AppColors = ReturnType -const AUTH_CALLBACK_URL = 'https://app.useorbit.org/auth-callback' - -function buildFallbackUrl(params: Record): string | null { - const entries: string[][] = [] - - for (const [key, value] of Object.entries(params)) { - if (typeof value === 'string') { - entries.push([key, value]) - } - } - - if (entries.length === 0) return null - - const searchParams = new URLSearchParams(entries) - return `${AUTH_CALLBACK_URL}?${searchParams.toString()}` -} export default function AuthCallbackScreen() { const { t, i18n } = useTranslation() @@ -51,65 +42,56 @@ export default function AuthCallbackScreen() { const rawUrl = Linking.useURL() const router = useRouter() const login = useAuthStore((s) => s.login) + const { + callbackUrl: sessionCallbackUrl, + isPending: isPendingGoogleAuthSession, + } = usePendingGoogleAuthSession() const processedRef = useRef(false) const [errorMessage, setErrorMessage] = useState(null) const callbackUrl = useMemo( - () => rawUrl ?? buildFallbackUrl(params), - [params, rawUrl], - ) - const hasCallbackData = Boolean( - params.error - || params.error_description - || (params.token && params.userId && params.name && params.email) - || callbackUrl, + () => + resolveGoogleAuthCallbackUrl({ + sessionCallbackUrl, + rawUrl, + params, + callbackUrl: AUTH_CALLBACK_URL, + }), + [params, rawUrl, sessionCallbackUrl], ) useEffect(() => { if (processedRef.current) return - if (!hasCallbackData) return + if (!callbackUrl) return processedRef.current = true + const resolvedCallbackUrl = callbackUrl + clearPendingGoogleAuthSession() async function handleCallback() { try { - if (params.error || params.error_description) { - throw new Error(params.error_description ?? params.error ?? 'Authentication failed') + const callbackParams = extractGoogleAuthParams(resolvedCallbackUrl) + if (callbackParams.error === 'access_denied') { + const storedReturnUrl = await consumeStoredAuthReturnUrl() + router.replace((storedReturnUrl ? getSafeReturnUrl(storedReturnUrl) : '/login') as Href) + return } - if (params.token && params.userId && params.name && params.email) { - await login(params.token, params.refreshToken ?? null, { - userId: params.userId, - name: params.name, - email: params.email, - }) - - const referralCode = await getStoredReferralCode() - if (referralCode) { - await markReferralApplied() - await clearStoredReferralCode() - } - } else { - if (!callbackUrl) { - throw new Error('Authentication failed') - } - - const referralCode = await getStoredReferralCode() - const response = await completeGoogleAuthFromUrl( - callbackUrl, - i18n.language, - referralCode ?? undefined, - ) - - await login(response.token, response.refreshToken, { - userId: response.userId, - name: response.name, - email: response.email, - }) - - if (referralCode) { - await markReferralApplied() - await clearStoredReferralCode() - } + const referralCode = await getStoredReferralCode() + const response = await completeGoogleAuthFromUrl( + resolvedCallbackUrl, + i18n.language, + referralCode ?? undefined, + ) + + await login(response.token, response.refreshToken, { + userId: response.userId, + name: response.name, + email: response.email, + }) + + if (referralCode) { + await markReferralApplied() + await clearStoredReferralCode() } const returnUrl = getSafeReturnUrl(await consumeStoredAuthReturnUrl()) @@ -122,7 +104,19 @@ export default function AuthCallbackScreen() { handleCallback().catch(() => { setErrorMessage(t('auth.callbackError')) }) - }, [callbackUrl, hasCallbackData, i18n.language, login, params, router, t]) + }, [callbackUrl, i18n.language, login, router, t]) + + useEffect(() => { + if (processedRef.current || errorMessage || callbackUrl || isPendingGoogleAuthSession) return + + const timeout = setTimeout(() => { + router.replace('/login' as Href) + }, 250) + + return () => { + clearTimeout(timeout) + } + }, [callbackUrl, errorMessage, isPendingGoogleAuthSession, router]) return ( diff --git a/apps/mobile/app/calendar-sync.tsx b/apps/mobile/app/calendar-sync.tsx index f5d5f2e0a..36d313200 100644 --- a/apps/mobile/app/calendar-sync.tsx +++ b/apps/mobile/app/calendar-sync.tsx @@ -1,14 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, - SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View, } from 'react-native' -import { useRouter } from 'expo-router' +import { useFocusEffect, useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' import { useTranslation } from 'react-i18next' import { AlertTriangle, @@ -24,7 +24,6 @@ import { getErrorMessage } from '@orbit/shared/utils/error-utils' import type { BulkCreateRequest, FrequencyUnit } from '@orbit/shared/types/habit' import { useProfile } from '@/hooks/use-profile' import { useBulkCreateHabits } from '@/hooks/use-habits' -import { useAuthStore } from '@/stores/auth-store' import { apiClient } from '@/lib/api-client' import { radius, shadows } from '@/lib/theme' import { plural } from '@/lib/plural' @@ -210,10 +209,9 @@ function buildImportRequest(events: CalendarEvent[]): BulkCreateRequest { export default function CalendarSyncScreen() { const router = useRouter() const { t, i18n } = useTranslation() - const { profile, isLoading: isProfileLoading, invalidate } = useProfile() + const { profile, isLoading: isProfileLoading } = useProfile() const { colors } = useAppTheme() const styles = useMemo(() => createStyles(colors), [colors]) - const login = useAuthStore((s) => s.login) const bulkCreateHabits = useBulkCreateHabits() const [step, setStep] = useState('loading') @@ -222,7 +220,6 @@ export default function CalendarSyncScreen() { const [errorMessage, setErrorMessage] = useState('') const [importResult, setImportResult] = useState(null) const [isConnecting, setIsConnecting] = useState(false) - const hasCheckedAccessRef = useRef(false) const allSelected = events.length > 0 && selectedIds.size === events.length @@ -253,19 +250,18 @@ export default function CalendarSyncScreen() { } }, [t]) - useEffect(() => { - if (hasCheckedAccessRef.current) return - if (!profile) return + useFocusEffect( + useCallback(() => { + if (!profile) return - hasCheckedAccessRef.current = true - - if (!profile.hasProAccess) { - router.replace('/upgrade') - return - } + if (!profile.hasProAccess) { + router.replace('/upgrade') + return + } - void loadEvents() - }, [loadEvents, profile, router]) + void loadEvents() + }, [loadEvents, profile, router]), + ) const handleBack = useCallback(() => { router.push('/profile') @@ -297,26 +293,19 @@ export default function CalendarSyncScreen() { setErrorMessage('') try { - const response = await startMobileGoogleAuth({ - language: i18n.language, + const result = await startMobileGoogleAuth({ returnUrl: '/calendar-sync', }) - if (!response) return + if (result.type !== 'success') return - await login(response.token, response.refreshToken, { - userId: response.userId, - name: response.name, - email: response.email, - }) - await invalidate() - await loadEvents() + router.replace('/auth-callback') } catch { setErrorMessage(t('auth.googleError')) } finally { setIsConnecting(false) } - }, [i18n.language, invalidate, isConnecting, loadEvents, login, t]) + }, [isConnecting, t]) const handleImportSelected = useCallback(async () => { if (selectedCount === 0) return diff --git a/apps/mobile/app/login.tsx b/apps/mobile/app/login.tsx index abe9c7e80..93f8d0930 100644 --- a/apps/mobile/app/login.tsx +++ b/apps/mobile/app/login.tsx @@ -296,30 +296,14 @@ export default function LoginScreen() { setErrorMessage(null) try { - const referralCode = await getStoredReferralCode() const pendingReturnUrl = typeof params.returnUrl === 'string' ? params.returnUrl : undefined - const response = await startMobileGoogleAuth({ - language: i18n.language, - referralCode: referralCode ?? undefined, + const result = await startMobileGoogleAuth({ returnUrl: pendingReturnUrl, }) - if (!response) return - - await login(response.token, response.refreshToken, { - userId: response.userId, - name: response.name, - email: response.email, - }) + if (result.type !== 'success') return - if (referralCode) { - await markReferralApplied() - await clearStoredReferralCode() - setShowReferralBanner(false) - } - - const returnUrl = getSafeReturnUrl(await consumeStoredAuthReturnUrl()) - router.replace(returnUrl as Href) + router.replace('/auth-callback') } catch (err: unknown) { setErrorMessage(err instanceof Error ? err.message : t('auth.googleError')) } finally { diff --git a/apps/mobile/app/preferences.tsx b/apps/mobile/app/preferences.tsx index a0b98c063..b3fd1ee37 100644 --- a/apps/mobile/app/preferences.tsx +++ b/apps/mobile/app/preferences.tsx @@ -4,13 +4,13 @@ import { Text, TouchableOpacity, StyleSheet, - SafeAreaView, ScrollView, Switch, Linking, } from 'react-native' import AsyncStorage from '@react-native-async-storage/async-storage' import { useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' import { useTranslation } from 'react-i18next' import { ArrowLeft, Check, Lock } from 'lucide-react-native' import { useMutation } from '@tanstack/react-query' diff --git a/apps/mobile/app/privacy.tsx b/apps/mobile/app/privacy.tsx index 5886388f9..4adc82c16 100644 --- a/apps/mobile/app/privacy.tsx +++ b/apps/mobile/app/privacy.tsx @@ -3,11 +3,11 @@ import { Text, TouchableOpacity, StyleSheet, - SafeAreaView, ScrollView, } from 'react-native' import { useMemo } from 'react' import { useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' import { ArrowLeft } from 'lucide-react-native' import { useTranslation } from 'react-i18next' import { createColors } from '@/lib/theme' diff --git a/apps/mobile/app/retrospective.tsx b/apps/mobile/app/retrospective.tsx index 0403e1b4f..7eaffd693 100644 --- a/apps/mobile/app/retrospective.tsx +++ b/apps/mobile/app/retrospective.tsx @@ -4,12 +4,12 @@ import { Text, TouchableOpacity, StyleSheet, - SafeAreaView, ScrollView, ActivityIndicator, Linking, } from 'react-native' import { useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' import { ArrowLeft, Lock, BarChart3 } from 'lucide-react-native' import { useTranslation } from 'react-i18next' import { API } from '@orbit/shared/api' diff --git a/apps/mobile/app/streak.tsx b/apps/mobile/app/streak.tsx index 38b531dde..f5abe77a4 100644 --- a/apps/mobile/app/streak.tsx +++ b/apps/mobile/app/streak.tsx @@ -4,11 +4,11 @@ import { Text, TouchableOpacity, StyleSheet, - SafeAreaView, ScrollView, Modal, } from 'react-native' import { useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' import { useTranslation } from 'react-i18next' import { ArrowLeft, X } from 'lucide-react-native' import { subDays, isToday, format, parseISO } from 'date-fns' diff --git a/apps/mobile/app/support.tsx b/apps/mobile/app/support.tsx index 0287c90f9..7af296c0f 100644 --- a/apps/mobile/app/support.tsx +++ b/apps/mobile/app/support.tsx @@ -5,11 +5,11 @@ import { TouchableOpacity, TextInput, StyleSheet, - SafeAreaView, ScrollView, ActivityIndicator, } from 'react-native' import { useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' import { ArrowLeft, Send } from 'lucide-react-native' import { useTranslation } from 'react-i18next' import { API } from '@orbit/shared/api' diff --git a/apps/mobile/components/bottom-sheet-modal.tsx b/apps/mobile/components/bottom-sheet-modal.tsx index 32d1d106a..fe04372b9 100644 --- a/apps/mobile/components/bottom-sheet-modal.tsx +++ b/apps/mobile/components/bottom-sheet-modal.tsx @@ -1,8 +1,8 @@ -import { useCallback, useRef, useMemo, type ReactNode } from 'react' -import { View, Text, StyleSheet, TouchableOpacity, Modal } from 'react-native' -import BottomSheet, { +import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react' +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native' +import { BottomSheetBackdrop, - BottomSheetView, + BottomSheetModal as GorhomBottomSheetModal, type BottomSheetBackdropProps, } from '@gorhom/bottom-sheet' import { X } from 'lucide-react-native' @@ -33,7 +33,8 @@ export function BottomSheetModal({ }: BottomSheetModalProps) { const { colors } = useAppTheme() const styles = useMemo(() => createStyles(colors), [colors]) - const bottomSheetRef = useRef(null) + const bottomSheetRef = useRef(null) + const isOpenRef = useRef(open) const snapPoints = useMemo( () => snapPointsProp ?? ['50%', '80%'], @@ -53,58 +54,51 @@ export function BottomSheetModal({ [], ) - const handleSheetChanges = useCallback( - (index: number) => { - if (index === -1) { - onClose() - } - }, - [onClose], - ) + useEffect(() => { + isOpenRef.current = open + }, [open]) + + useEffect(() => { + if (open) { + bottomSheetRef.current?.present() + } else { + bottomSheetRef.current?.dismiss() + } + }, [open]) - if (!open) return null + const handleDismiss = useCallback(() => { + if (isOpenRef.current) { + isOpenRef.current = false + onClose() + } + }, [onClose]) return ( - - - - - {/* Header */} - {title && ( - - {title} - - - - - )} + {title ? ( + + {title} + bottomSheetRef.current?.dismiss()} + activeOpacity={0.7} + > + + + + ) : null} - {/* Content */} - {children} - - - - + {children} + ) } @@ -116,9 +110,6 @@ type ThemeColors = ReturnType['colors'] function createStyles(colors: ThemeColors) { return StyleSheet.create({ - modalRoot: { - flex: 1, - }, background: { backgroundColor: colors.surface, borderTopLeftRadius: 24, @@ -129,38 +120,28 @@ function createStyles(colors: ThemeColors) { width: 36, height: 4, }, - contentContainer: { - flex: 1, - minHeight: 0, - }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingHorizontal: 20, - paddingTop: 4, - paddingBottom: 12, - borderBottomWidth: 1, - borderBottomColor: colors.border, + gap: 16, + paddingHorizontal: 24, + paddingTop: 10, + paddingBottom: 16, }, title: { + flex: 1, fontSize: 18, fontWeight: '700', color: colors.textPrimary, }, closeButton: { - width: 32, - height: 32, - borderRadius: 16, + width: 48, + height: 48, + borderRadius: 24, backgroundColor: colors.surfaceElevated, alignItems: 'center', justifyContent: 'center', }, - body: { - flex: 1, - minHeight: 0, - paddingHorizontal: 20, - paddingTop: 16, - }, }) } diff --git a/apps/mobile/components/goals/create-goal-modal.tsx b/apps/mobile/components/goals/create-goal-modal.tsx index f54101fd4..e7e5b8373 100644 --- a/apps/mobile/components/goals/create-goal-modal.tsx +++ b/apps/mobile/components/goals/create-goal-modal.tsx @@ -7,8 +7,10 @@ import { StyleSheet, ActivityIndicator, } from 'react-native' +import { BottomSheetScrollView } from '@gorhom/bottom-sheet' import { Plus, X } from 'lucide-react-native' import { useTranslation } from 'react-i18next' +import { useSafeAreaInsets } from 'react-native-safe-area-context' import { BottomSheetModal } from '@/components/bottom-sheet-modal' import { AppDatePicker } from '@/components/ui/app-date-picker' import { useCreateGoal } from '@/hooks/use-goals' @@ -35,8 +37,9 @@ type AppColors = ReturnType export function CreateGoalModal({ open, onClose }: CreateGoalModalProps) { const { t } = useTranslation() const { colors } = useAppTheme() + const insets = useSafeAreaInsets() const createGoal = useCreateGoal() - const styles = useMemo(() => createStyles(colors), [colors]) + const styles = useMemo(() => createStyles(colors, insets.bottom), [colors, insets.bottom]) // Form state const [description, setDescription] = useState('') @@ -120,7 +123,12 @@ export function CreateGoalModal({ open, onClose }: CreateGoalModalProps) { title={t('goals.create')} snapPoints={['70%', '90%']} > - + {/* Quantity + Unit */} @@ -234,7 +242,7 @@ export function CreateGoalModal({ open, onClose }: CreateGoalModalProps) { {t('goals.create')} )} - + ) } @@ -243,15 +251,20 @@ export function CreateGoalModal({ open, onClose }: CreateGoalModalProps) { // Styles // --------------------------------------------------------------------------- -function createStyles(colors: AppColors) { +function createStyles(colors: AppColors, bottomInset: number) { return StyleSheet.create({ + scroll: { + flex: 1, + }, form: { - gap: 20, - paddingBottom: 40, + paddingTop: 12, + paddingHorizontal: 24, + paddingBottom: Math.max(bottomInset, 16) + 24, + gap: 24, }, row: { flexDirection: 'row', - gap: 12, + gap: 16, }, halfField: { flex: 1, @@ -262,7 +275,7 @@ function createStyles(colors: AppColors) { color: colors.textSecondary, textTransform: 'uppercase', letterSpacing: 0.5, - marginBottom: 6, + marginBottom: 10, }, labelOptional: { fontWeight: '400', @@ -279,6 +292,7 @@ function createStyles(colors: AppColors) { paddingVertical: 12, fontSize: 14, color: colors.textPrimary, + minHeight: 56, }, deadlineRow: { flexDirection: 'row', @@ -299,13 +313,13 @@ function createStyles(colors: AppColors) { fontSize: 12, color: colors.amber400, fontWeight: '500', - marginTop: 4, + marginTop: 8, }, addDeadlineButton: { flexDirection: 'row', alignItems: 'center', gap: 6, - marginTop: 6, + marginTop: 10, }, addDeadlineText: { fontSize: 12, @@ -315,8 +329,10 @@ function createStyles(colors: AppColors) { errorText: { fontSize: 12, color: colors.red400, + lineHeight: 18, }, submitButton: { + marginTop: 4, backgroundColor: colors.primary, borderRadius: radius.xl, paddingVertical: 14, diff --git a/apps/mobile/components/goals/edit-goal-modal.tsx b/apps/mobile/components/goals/edit-goal-modal.tsx index 1468f2830..63867eea5 100644 --- a/apps/mobile/components/goals/edit-goal-modal.tsx +++ b/apps/mobile/components/goals/edit-goal-modal.tsx @@ -7,8 +7,10 @@ import { StyleSheet, ActivityIndicator, } from 'react-native' +import { BottomSheetScrollView } from '@gorhom/bottom-sheet' import { Plus, X } from 'lucide-react-native' import { useTranslation } from 'react-i18next' +import { useSafeAreaInsets } from 'react-native-safe-area-context' import { BottomSheetModal } from '@/components/bottom-sheet-modal' import { AppDatePicker } from '@/components/ui/app-date-picker' import { useUpdateGoal } from '@/hooks/use-goals' @@ -36,8 +38,9 @@ type AppColors = ReturnType export function EditGoalModal({ open, onClose, goal }: EditGoalModalProps) { const { t } = useTranslation() const { colors } = useAppTheme() + const insets = useSafeAreaInsets() const updateGoal = useUpdateGoal() - const styles = useMemo(() => createStyles(colors), [colors]) + const styles = useMemo(() => createStyles(colors, insets.bottom), [colors, insets.bottom]) // Form state const [description, setDescription] = useState('') @@ -122,7 +125,12 @@ export function EditGoalModal({ open, onClose, goal }: EditGoalModalProps) { title={t('goals.detail.edit')} snapPoints={['70%', '90%']} > - + {/* Quantity + Unit */} @@ -232,7 +240,7 @@ export function EditGoalModal({ open, onClose, goal }: EditGoalModalProps) { {t('common.save')} )} - + ) } @@ -241,15 +249,20 @@ export function EditGoalModal({ open, onClose, goal }: EditGoalModalProps) { // Styles // --------------------------------------------------------------------------- -function createStyles(colors: AppColors) { +function createStyles(colors: AppColors, bottomInset: number) { return StyleSheet.create({ + scroll: { + flex: 1, + }, form: { - gap: 20, - paddingBottom: 40, + paddingTop: 12, + paddingHorizontal: 24, + paddingBottom: Math.max(bottomInset, 16) + 24, + gap: 24, }, row: { flexDirection: 'row', - gap: 12, + gap: 16, }, halfField: { flex: 1, @@ -260,7 +273,7 @@ function createStyles(colors: AppColors) { color: colors.textSecondary, textTransform: 'uppercase', letterSpacing: 0.5, - marginBottom: 6, + marginBottom: 10, }, labelOptional: { fontWeight: '400', @@ -277,6 +290,7 @@ function createStyles(colors: AppColors) { paddingVertical: 12, fontSize: 14, color: colors.textPrimary, + minHeight: 56, }, deadlineRow: { flexDirection: 'row', @@ -297,13 +311,13 @@ function createStyles(colors: AppColors) { fontSize: 12, color: colors.amber400, fontWeight: '500', - marginTop: 4, + marginTop: 8, }, addDeadlineButton: { flexDirection: 'row', alignItems: 'center', gap: 6, - marginTop: 6, + marginTop: 10, }, addDeadlineText: { fontSize: 12, @@ -313,8 +327,10 @@ function createStyles(colors: AppColors) { errorText: { fontSize: 12, color: colors.red400, + lineHeight: 18, }, submitButton: { + marginTop: 4, backgroundColor: colors.primary, borderRadius: radius.xl, paddingVertical: 14, diff --git a/apps/mobile/components/goals/goal-detail-drawer.tsx b/apps/mobile/components/goals/goal-detail-drawer.tsx index 567e4c0d2..ba0e2125d 100644 --- a/apps/mobile/components/goals/goal-detail-drawer.tsx +++ b/apps/mobile/components/goals/goal-detail-drawer.tsx @@ -4,11 +4,11 @@ import { Text, TextInput, TouchableOpacity, - ScrollView, StyleSheet, Alert, ActivityIndicator, } from 'react-native' +import { BottomSheetScrollView } from '@gorhom/bottom-sheet' import { format, parseISO } from 'date-fns' import { enUS, ptBR } from 'date-fns/locale' import { @@ -19,6 +19,7 @@ import { Trash2, } from 'lucide-react-native' import { useTranslation } from 'react-i18next' +import { useSafeAreaInsets } from 'react-native-safe-area-context' import { BottomSheetModal } from '@/components/bottom-sheet-modal' import { EditGoalModal } from './edit-goal-modal' import { GoalMetricsPanel } from './goal-metrics-panel' @@ -55,9 +56,10 @@ export function GoalDetailDrawer({ }: GoalDetailDrawerProps) { const { t, i18n } = useTranslation() const { colors } = useAppTheme() + const insets = useSafeAreaInsets() const locale = i18n.language const dateFnsLocale = locale === 'pt-BR' ? ptBR : enUS - const styles = useMemo(() => createStyles(colors), [colors]) + const styles = useMemo(() => createStyles(colors, insets.bottom), [colors, insets.bottom]) // Queries const { data: goalsData } = useGoals() @@ -213,7 +215,8 @@ export function GoalDetailDrawer({ title={goal.title} snapPoints={['60%', '90%']} > - @@ -469,7 +472,7 @@ export function GoalDetailDrawer({ - + {goal && ( @@ -487,10 +490,15 @@ export function GoalDetailDrawer({ // Styles // --------------------------------------------------------------------------- -function createStyles(colors: AppColors) { +function createStyles(colors: AppColors, bottomInset: number) { return StyleSheet.create({ + scroll: { + flex: 1, + }, scrollContent: { - paddingBottom: 40, + paddingTop: 12, + paddingHorizontal: 24, + paddingBottom: Math.max(bottomInset, 16) + 24, gap: 24, }, @@ -501,7 +509,7 @@ function createStyles(colors: AppColors) { color: colors.textSecondary, textTransform: 'uppercase', letterSpacing: 0.5, - marginBottom: 8, + marginBottom: 12, }, // Progress @@ -510,7 +518,7 @@ function createStyles(colors: AppColors) { backgroundColor: colors.surfaceElevated, borderRadius: 9999, overflow: 'hidden', - marginBottom: 8, + marginBottom: 12, }, progressFill: { height: '100%', @@ -519,7 +527,8 @@ function createStyles(colors: AppColors) { progressText: { fontSize: 14, color: colors.textSecondary, - marginBottom: 16, + lineHeight: 21, + marginBottom: 20, }, progressPercent: { color: colors.textMuted, @@ -636,7 +645,7 @@ function createStyles(colors: AppColors) { // Linked habits linkedHabitsSection: { - marginTop: 0, + gap: 12, }, linkedHabitsList: { flexDirection: 'row', @@ -671,7 +680,8 @@ function createStyles(colors: AppColors) { // Actions actionsSection: { gap: 4, - paddingTop: 8, + marginTop: 4, + paddingTop: 12, borderTopWidth: 1, borderTopColor: colors.border, }, @@ -679,7 +689,7 @@ function createStyles(colors: AppColors) { flexDirection: 'row', alignItems: 'center', gap: 12, - paddingHorizontal: 12, + paddingHorizontal: 0, paddingVertical: 12, borderRadius: radius.lg, }, diff --git a/apps/mobile/components/goals/goal-list.tsx b/apps/mobile/components/goals/goal-list.tsx index 4339d1848..08110f04a 100644 --- a/apps/mobile/components/goals/goal-list.tsx +++ b/apps/mobile/components/goals/goal-list.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react' -import { FlatList, StyleSheet, View } from 'react-native' +import { StyleSheet, View } from 'react-native' import type { Goal } from '@orbit/shared/types/goal' import { GoalCard } from '@/components/goal-card' import { GoalDetailDrawer } from './goal-detail-drawer' @@ -22,22 +22,17 @@ export function GoalList({ goals }: Readonly) { setSelectedGoalId(null) }, []) - const renderItem = useCallback( - ({ item }: { item: Goal }) => , - [handleGoalPress], - ) - - const keyExtractor = useCallback((item: Goal) => item.id, []) - return ( - + + {goals.map((goal) => ( + + ))} + {selectedGoalId ? ( - + {statusFilters.map((filter) => { const active = activeFilter === filter.key; return ( @@ -78,7 +89,7 @@ export function GoalsView() { ); })} - + {!isFetched ? ( @@ -107,9 +118,9 @@ function createStyles(colors: ReturnType["colors"]) { paddingTop: 16, }, filtersRow: { - flexDirection: "row", gap: 8, paddingBottom: 16, + paddingRight: 20, }, filterChip: { paddingHorizontal: 16, @@ -118,6 +129,7 @@ function createStyles(colors: ReturnType["colors"]) { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, + flexShrink: 0, }, filterChipActive: { backgroundColor: colors.primary, diff --git a/apps/mobile/components/habit-card.tsx b/apps/mobile/components/habit-card.tsx index 8b6ddb557..b1443e78c 100644 --- a/apps/mobile/components/habit-card.tsx +++ b/apps/mobile/components/habit-card.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from 'react' +import { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { View, Text, @@ -19,10 +19,14 @@ import { ClipboardCheck, Flame, } from 'lucide-react-native' +import Svg, { Circle } from 'react-native-svg' import { useTranslation } from 'react-i18next' import { formatAPIDate } from '@orbit/shared/utils' import type { NormalizedHabit } from '@orbit/shared/types/habit' +import { AnchoredMenu } from '@/components/ui/anchored-menu' import { useTimeFormat } from '@/hooks/use-time-format' +import type { MenuAnchorRect } from '@/lib/anchored-menu' +import { getHabitProgressStrokeDasharray } from '@/lib/habit-progress' import { createColors } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' import { plural } from '@/lib/plural' @@ -204,18 +208,42 @@ export function HabitCard({ // Actions menu const [showActionsMenu, setShowActionsMenu] = useState(false) + const [actionsMenuAnchorRect, setActionsMenuAnchorRect] = + useState(null) + const actionsButtonRef = useRef(null) + + const closeActionsMenu = useCallback(() => { + setShowActionsMenu(false) + }, []) + + const openActionsMenu = useCallback(() => { + actionsButtonRef.current?.measureInWindow((x, y, width, height) => { + setActionsMenuAnchorRect({ x, y, width, height }) + setShowActionsMenu(true) + }) + }, []) + + const toggleActionsMenu = useCallback(() => { + if (showActionsMenu) { + closeActionsMenu() + return + } - const closeActionsMenu = useCallback(() => setShowActionsMenu(false), []) - const toggleActionsMenu = useCallback( - () => setShowActionsMenu((prev) => !prev), - [], - ) + openActionsMenu() + }, [closeActionsMenu, openActionsMenu, showActionsMenu]) // Close menu on select mode useEffect(() => { if (isSelectMode) setShowActionsMenu(false) }, [isSelectMode]) + const handleEnterSelectModeFromMenu = useCallback(() => { + closeActionsMenu() + setTimeout(() => { + onEnterSelectMode?.() + }, 0) + }, [closeActionsMenu, onEnterSelectMode]) + const handleCardPress = useCallback(() => { if (isSelectMode) { onToggleSelection?.() @@ -335,36 +363,46 @@ export function HabitCard({ ]} activeOpacity={0.8} > - {/* Background ring */} - - {/* Progress ring approximation */} - + viewBox="0 0 36 36" + > + + + {/* Center content */} {isDoneForRange ? ( @@ -591,140 +629,135 @@ export function HabitCard({ {/* Actions menu trigger */} {!isSelectMode && ( - - - - )} - - - - {/* Actions menu (inline overlay for RN) */} - {showActionsMenu && ( - <> - - - {showAddSubHabit && ( + { - onAddSubHabit?.() - closeActionsMenu() - }} - activeOpacity={0.7} - > - - - {t('habits.form.addSubHabit')} - - - )} - - { - onMoveParent?.() - closeActionsMenu() - }} - activeOpacity={0.7} - > - - - {t('habits.moveParent.button')} - - - - {canSkip && ( - { - onSkip?.() - closeActionsMenu() - }} + onPress={toggleActionsMenu} + style={[ + styles.moreButton, + { padding: isChild ? 6 : 8 }, + ]} activeOpacity={0.7} > - - - {isPostpone - ? t('habits.actions.postpone') - : t('habits.actions.skip')} - + - )} - - { - onDuplicate?.() - closeActionsMenu() - }} - activeOpacity={0.7} - > - - - {t('habits.actions.duplicate')} - - - - { - onEnterSelectMode?.() - closeActionsMenu() - }} - activeOpacity={0.7} - > - - {t('common.select')} - + + )} + + - + + {showAddSubHabit && ( + { + onAddSubHabit?.() + closeActionsMenu() + }} + activeOpacity={0.7} + > + + + {t('habits.form.addSubHabit')} + + + )} + + { + onMoveParent?.() + closeActionsMenu() + }} + activeOpacity={0.7} + > + + + {t('habits.moveParent.button')} + + - { - onDelete?.() - closeActionsMenu() - }} - activeOpacity={0.7} - > - - - {t('common.delete')} - - + {canSkip && ( + { + onSkip?.() + closeActionsMenu() + }} + activeOpacity={0.7} + > + + + {isPostpone + ? t('habits.actions.postpone') + : t('habits.actions.skip')} + + + )} + + { + onDuplicate?.() + closeActionsMenu() + }} + activeOpacity={0.7} + > + + + {t('habits.actions.duplicate')} + + + + + + {t('common.select')} + + + + + { + onDelete?.() + closeActionsMenu() + }} + activeOpacity={0.7} + > + + + {t('common.delete')} + + - {hasSubHabits && ( - { - onDrillInto?.() - closeActionsMenu() - }} - activeOpacity={0.7} - > - - - {t('habits.actions.openSubHabits')} - - - )} - - - )} + {hasSubHabits && ( + { + onDrillInto?.() + closeActionsMenu() + }} + activeOpacity={0.7} + > + + + {t('habits.actions.openSubHabits')} + + + )} + ) } @@ -826,11 +859,9 @@ function createStyles(colors: ReturnType) { justifyContent: 'center', position: 'relative', }, - ringTrack: { - position: 'absolute', - }, - ringProgress: { + progressRingSvg: { position: 'absolute', + transform: [{ rotate: '-90deg' }], }, progressText: { fontSize: 9, @@ -1070,32 +1101,6 @@ function createStyles(colors: ReturnType) { borderRadius: 9999, }, - // Actions menu - menuBackdrop: { - position: 'absolute', - top: -1000, - left: -1000, - right: -1000, - bottom: -1000, - zIndex: 69, - }, - actionsMenu: { - position: 'absolute', - right: 0, - top: '100%' as unknown as number, - zIndex: 70, - minWidth: 192, // min-w-[12rem] - borderRadius: 16, // rounded-2xl - padding: 6, // p-1.5 - backgroundColor: colors.surfaceOverlay, - borderWidth: 1, - borderColor: colors.border, - shadowColor: '#000', - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.6, - shadowRadius: 20, - elevation: 20, - }, menuItem: { flexDirection: 'row', alignItems: 'center', diff --git a/apps/mobile/components/habit-list.tsx b/apps/mobile/components/habit-list.tsx index cf521f07d..97d1e9b23 100644 --- a/apps/mobile/components/habit-list.tsx +++ b/apps/mobile/components/habit-list.tsx @@ -28,7 +28,12 @@ import { CheckCircle2, } from 'lucide-react-native' import { useTranslation } from 'react-i18next' -import { formatAPIDate, getHabitEmptyStateKey } from '@orbit/shared/utils' +import { + collectSelectableDescendantIds, + collectVisibleHabitTreeIds, + formatAPIDate, + getHabitEmptyStateKey, +} from '@orbit/shared/utils' import type { NormalizedHabit, HabitsFilter } from '@orbit/shared/types/habit' import { useHabits, @@ -39,6 +44,7 @@ import { useMoveHabitParent, } from '@/hooks/use-habits' import { useHabitVisibility } from '@/hooks/use-habit-visibility' +import { getHabitListExtraData } from '@/lib/habit-selection-state' import { useAppTheme } from '@/lib/use-app-theme' import { useUIStore } from '@/stores/ui-store' import { ConfirmDialog } from '@/components/ui/confirm-dialog' @@ -191,6 +197,15 @@ export const HabitList = forwardRef(function Ha () => selectedHabitIds ?? new Set(), [selectedHabitIds], ) + const listExtraData = useMemo( + () => + getHabitListExtraData( + Boolean(isSelectMode), + selectedIds, + recentlyCompletedIds, + ), + [isSelectMode, recentlyCompletedIds, selectedIds], + ) const selectedDateStr = formatAPIDate(selectedDate ?? new Date()) const autoLogParentHabit = autoLogParentId ? habitsById.get(autoLogParentId) ?? null : null @@ -203,27 +218,6 @@ export const HabitList = forwardRef(function Ha recentlyCompletedIds, }) - const getDescendantIds = useCallback( - (parentId: string): string[] => { - const descendantIds: string[] = [] - const stack: string[] = [parentId] - - while (stack.length > 0) { - const currentId = stack.pop() - if (!currentId) continue - - const children = getChildren(currentId) - for (const child of children) { - descendantIds.push(child.id) - stack.push(child.id) - } - } - - return descendantIds - }, - [getChildren], - ) - const isAncestorSelected = useCallback( (habitId: string): boolean => { let current = habitsById.get(habitId)?.parentId ?? null @@ -321,22 +315,20 @@ export const HabitList = forwardRef(function Ha }, [i18n.language, t, view, visibleHabits]) const allLoadedIds = useMemo(() => { - const ids = new Set() - - const visit = (habit: NormalizedHabit) => { - ids.add(habit.id) - for (const child of getVisibleChildren(habit.id)) { - visit(child) - } - } - - for (const habit of visibleHabits) { - visit(habit) - } - - return ids + return collectVisibleHabitTreeIds(visibleHabits, getVisibleChildren) }, [getVisibleChildren, visibleHabits]) + const getDescendantIds = useCallback( + (parentId: string): string[] => { + return collectSelectableDescendantIds( + parentId, + (habitId) => childrenByParent.get(habitId) ?? [], + allLoadedIds, + ) + }, + [allLoadedIds, childrenByParent], + ) + const expandableIds = useMemo(() => { const ids: string[] = [] @@ -772,6 +764,7 @@ export const HabitList = forwardRef(function Ha data={flatItems} renderItem={renderItem} keyExtractor={keyExtractor} + extraData={listExtraData} scrollEnabled={scrollEnabled} nestedScrollEnabled contentContainerStyle={ diff --git a/apps/mobile/components/habits/create-habit-modal.tsx b/apps/mobile/components/habits/create-habit-modal.tsx index 68980a93f..78c3d5c8b 100644 --- a/apps/mobile/components/habits/create-habit-modal.tsx +++ b/apps/mobile/components/habits/create-habit-modal.tsx @@ -384,6 +384,7 @@ function createStyles( flex: 1, }, scrollContent: { + paddingHorizontal: 20, paddingBottom: bottomInset + 120, gap: 20, }, diff --git a/apps/mobile/components/habits/description-viewer.tsx b/apps/mobile/components/habits/description-viewer.tsx index 3edf3c849..94dea252c 100644 --- a/apps/mobile/components/habits/description-viewer.tsx +++ b/apps/mobile/components/habits/description-viewer.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; -import { View, Text, ScrollView, StyleSheet } from "react-native"; +import { Text, StyleSheet } from "react-native"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetModal } from "@/components/bottom-sheet-modal"; -import { radius } from "@/lib/theme"; import { useAppTheme } from "@/lib/use-app-theme"; // --------------------------------------------------------------------------- @@ -39,13 +39,13 @@ export function DescriptionViewer({ title={title} snapPoints={["70%", "90%"]} > - {description} - + ); } @@ -60,6 +60,7 @@ function createStyles(colors: ReturnType["colors"]) { flex: 1, }, scrollContent: { + paddingHorizontal: 20, paddingBottom: 32, }, descriptionText: { diff --git a/apps/mobile/components/habits/edit-habit-modal.tsx b/apps/mobile/components/habits/edit-habit-modal.tsx index 98ade4823..4fbd2883b 100644 --- a/apps/mobile/components/habits/edit-habit-modal.tsx +++ b/apps/mobile/components/habits/edit-habit-modal.tsx @@ -256,6 +256,7 @@ function createStyles( flex: 1, }, scrollContent: { + paddingHorizontal: 20, paddingBottom: bottomInset + 120, gap: 20, }, diff --git a/apps/mobile/components/habits/habit-detail-drawer.tsx b/apps/mobile/components/habits/habit-detail-drawer.tsx index 6859c2ec9..2a974e899 100644 --- a/apps/mobile/components/habits/habit-detail-drawer.tsx +++ b/apps/mobile/components/habits/habit-detail-drawer.tsx @@ -358,6 +358,7 @@ function createStyles( flex: 1, }, scrollContent: { + paddingHorizontal: 20, paddingBottom: 40, gap: 24, }, diff --git a/apps/mobile/components/habits/log-habit-modal.tsx b/apps/mobile/components/habits/log-habit-modal.tsx index a53f35bee..afdc6f9e7 100644 --- a/apps/mobile/components/habits/log-habit-modal.tsx +++ b/apps/mobile/components/habits/log-habit-modal.tsx @@ -154,6 +154,7 @@ function createStyles( return StyleSheet.create({ content: { gap: 16, + paddingHorizontal: 20, paddingBottom: bottomInset + 12, }, fieldGroup: { diff --git a/apps/mobile/components/navigation/notification-bell.tsx b/apps/mobile/components/navigation/notification-bell.tsx index faf2e2bf2..a6da2f050 100644 --- a/apps/mobile/components/navigation/notification-bell.tsx +++ b/apps/mobile/components/navigation/notification-bell.tsx @@ -4,9 +4,9 @@ import { Text, TouchableOpacity, ActivityIndicator, - ScrollView, StyleSheet, } from 'react-native' +import { BottomSheetScrollView } from '@gorhom/bottom-sheet' import { Bell, BellOff, Trash2, X } from 'lucide-react-native' import { useTranslation } from 'react-i18next' import type { NotificationItem } from '@orbit/shared/types/notification' @@ -190,7 +190,8 @@ export function NotificationBell() { )} - renderNotification({ item })) )} - + {/* Detail modal */} @@ -259,6 +260,7 @@ function createStyles(colors: ReturnType['colors'], shadows: alignItems: 'center', justifyContent: 'flex-end', gap: 12, + paddingHorizontal: 20, marginBottom: 8, }, markAllText: { @@ -323,7 +325,11 @@ function createStyles(colors: ReturnType['colors'], shadows: fontSize: 14, color: colors.textMuted, }, + listScroll: { + flex: 1, + }, listContent: { + paddingHorizontal: 20, paddingBottom: 8, }, emptyListContainer: { diff --git a/apps/mobile/components/navigation/notification-detail-modal.tsx b/apps/mobile/components/navigation/notification-detail-modal.tsx index e01934ef9..36571d8a3 100644 --- a/apps/mobile/components/navigation/notification-detail-modal.tsx +++ b/apps/mobile/components/navigation/notification-detail-modal.tsx @@ -137,7 +137,9 @@ export function NotificationDetailModal({ function createStyles(colors: ReturnType['colors']) { return StyleSheet.create({ body: { + paddingHorizontal: 20, gap: 16, + paddingBottom: 24, }, timestamp: { fontSize: 12, diff --git a/apps/mobile/components/onboarding/calendar-import-prompt.tsx b/apps/mobile/components/onboarding/calendar-import-prompt.tsx index 395cc085f..c8a45964a 100644 --- a/apps/mobile/components/onboarding/calendar-import-prompt.tsx +++ b/apps/mobile/components/onboarding/calendar-import-prompt.tsx @@ -95,6 +95,7 @@ function createStyles(colors: ReturnType['colors']) { content: { alignItems: 'center', gap: 18, + paddingHorizontal: 20, paddingBottom: 8, }, iconCircle: { diff --git a/apps/mobile/components/onboarding/feature-guide-drawer.tsx b/apps/mobile/components/onboarding/feature-guide-drawer.tsx index f75be6d5f..6a975eea2 100644 --- a/apps/mobile/components/onboarding/feature-guide-drawer.tsx +++ b/apps/mobile/components/onboarding/feature-guide-drawer.tsx @@ -6,6 +6,7 @@ import { ScrollView, StyleSheet, } from 'react-native' +import { BottomSheetScrollView } from '@gorhom/bottom-sheet' import { useTranslation } from 'react-i18next' import { BottomSheetModal } from '@/components/bottom-sheet-modal' import { radius } from '@/lib/theme' @@ -167,7 +168,8 @@ export function FeatureGuideDrawer({ open, onClose }: Readonly {/* Section content */} - @@ -177,7 +179,7 @@ export function FeatureGuideDrawer({ open, onClose }: Readonly{t(item.descKey)} ))} - + ) } @@ -189,6 +191,7 @@ export function FeatureGuideDrawer({ open, onClose }: Readonly['colors']) { return StyleSheet.create({ tabBar: { + paddingHorizontal: 20, marginBottom: 16, }, tabBarContent: { @@ -215,7 +218,11 @@ function createStyles(colors: ReturnType['colors']) { tabTextActive: { color: colors.white, }, + sectionScroll: { + flex: 1, + }, sectionContent: { + paddingHorizontal: 20, gap: 16, paddingBottom: 24, }, diff --git a/apps/mobile/components/referral/referral-drawer.tsx b/apps/mobile/components/referral/referral-drawer.tsx index 811153a4e..a56ce807f 100644 --- a/apps/mobile/components/referral/referral-drawer.tsx +++ b/apps/mobile/components/referral/referral-drawer.tsx @@ -7,6 +7,7 @@ import { ActivityIndicator, StyleSheet, } from 'react-native' +import { BottomSheetScrollView } from '@gorhom/bottom-sheet' import { Check, Sparkles } from 'lucide-react-native' import { useTranslation } from 'react-i18next' import { useReferral } from '@/hooks/use-referral' @@ -71,7 +72,11 @@ export function ReferralDrawer({ open, onClose }: Readonly) title={t('referral.drawer.title')} snapPoints={['65%', '85%']} > - + {/* Loading */} {isLoading && ( @@ -194,7 +199,7 @@ export function ReferralDrawer({ open, onClose }: Readonly) )} - + ) } @@ -208,7 +213,11 @@ function createStyles( shadows: ReturnType['shadows'], ) { return StyleSheet.create({ + scroll: { + flex: 1, + }, content: { + paddingHorizontal: 20, gap: 16, paddingBottom: 24, }, diff --git a/apps/mobile/components/ui/anchored-menu.tsx b/apps/mobile/components/ui/anchored-menu.tsx new file mode 100644 index 000000000..1fb75cc4f --- /dev/null +++ b/apps/mobile/components/ui/anchored-menu.tsx @@ -0,0 +1,135 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { + Dimensions, + Modal, + StyleSheet, + TouchableOpacity, + View, + type StyleProp, + type ViewStyle, +} from 'react-native' +import { + getAnchoredMenuPosition, + type MenuAnchorRect, +} from '@/lib/anchored-menu' +import { radius } from '@/lib/theme' +import { useAppTheme } from '@/lib/use-app-theme' + +interface AnchoredMenuProps { + visible: boolean + anchorRect: MenuAnchorRect | null + onClose: () => void + children: ReactNode + width?: number + estimatedHeight?: number + panelStyle?: StyleProp +} + +export function AnchoredMenu({ + visible, + anchorRect, + onClose, + children, + width = 200, + estimatedHeight = 220, + panelStyle, +}: Readonly) { + const { colors, shadows } = useAppTheme() + const styles = useMemo(() => createStyles(colors, shadows), [colors, shadows]) + const [menuHeight, setMenuHeight] = useState(estimatedHeight) + + useEffect(() => { + if (visible) { + setMenuHeight(estimatedHeight) + } + }, [estimatedHeight, visible]) + + useEffect(() => { + if (!visible) return + + const subscription = Dimensions.addEventListener('change', onClose) + return () => { + subscription.remove() + } + }, [onClose, visible]) + + const position = useMemo(() => { + if (!anchorRect) return null + + const window = Dimensions.get('window') + return getAnchoredMenuPosition({ + anchorRect, + viewportWidth: window.width, + viewportHeight: window.height, + menuWidth: width, + menuHeight, + }) + }, [anchorRect, menuHeight, width]) + + if (!visible || !anchorRect || !position) { + return null + } + + return ( + + + + { + const nextHeight = event.nativeEvent.layout.height + if (Math.abs(nextHeight - menuHeight) > 1) { + setMenuHeight(nextHeight) + } + }} + > + {children} + + + + ) +} + +function createStyles( + colors: ReturnType['colors'], + shadows: ReturnType['shadows'], +) { + return StyleSheet.create({ + overlay: { + flex: 1, + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.01)', + }, + panel: { + position: 'absolute', + minWidth: 176, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.borderMuted, + backgroundColor: colors.surfaceOverlay, + padding: 6, + ...shadows.lg, + elevation: 16, + }, + }) +} diff --git a/apps/mobile/hooks/use-horizontal-swipe.ts b/apps/mobile/hooks/use-horizontal-swipe.ts new file mode 100644 index 000000000..bdf2b2078 --- /dev/null +++ b/apps/mobile/hooks/use-horizontal-swipe.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react' +import { PanResponder } from 'react-native' + +interface UseHorizontalSwipeOptions { + onSwipeLeft: () => void + onSwipeRight: () => void + minDistance?: number +} + +export function useHorizontalSwipe({ + onSwipeLeft, + onSwipeRight, + minDistance = 50, +}: Readonly) { + return useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_event, gestureState) => + Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && + Math.abs(gestureState.dx) > minDistance, + onPanResponderRelease: (_event, gestureState) => { + if (gestureState.dx <= -minDistance) { + onSwipeLeft() + } else if (gestureState.dx >= minDistance) { + onSwipeRight() + } + }, + }), + [minDistance, onSwipeLeft, onSwipeRight], + ) +} diff --git a/apps/mobile/lib/anchored-menu.ts b/apps/mobile/lib/anchored-menu.ts new file mode 100644 index 000000000..36ceada94 --- /dev/null +++ b/apps/mobile/lib/anchored-menu.ts @@ -0,0 +1,53 @@ +export interface MenuAnchorRect { + x: number + y: number + width: number + height: number +} + +interface AnchoredMenuPositionArgs { + anchorRect: MenuAnchorRect + viewportWidth: number + viewportHeight: number + menuWidth: number + menuHeight: number + margin?: number +} + +export interface AnchoredMenuPosition { + left: number + top: number + opensUp: boolean +} + +export const DEFAULT_ANCHORED_MENU_MARGIN = 8 + +export function getAnchoredMenuPosition({ + anchorRect, + viewportWidth, + viewportHeight, + menuWidth, + menuHeight, + margin = DEFAULT_ANCHORED_MENU_MARGIN, +}: AnchoredMenuPositionArgs): AnchoredMenuPosition { + const preferredLeft = anchorRect.x + anchorRect.width - menuWidth + const maxLeft = viewportWidth - menuWidth - margin + const left = Math.min( + Math.max(preferredLeft, margin), + Math.max(margin, maxLeft), + ) + + const opensUp = + anchorRect.y + anchorRect.height + menuHeight + margin > viewportHeight + + const preferredTop = opensUp + ? anchorRect.y - menuHeight - margin + : anchorRect.y + anchorRect.height + margin + + const top = Math.min( + Math.max(preferredTop, margin), + Math.max(margin, viewportHeight - menuHeight - margin), + ) + + return { left, top, opensUp } +} diff --git a/apps/mobile/lib/google-auth-callback.ts b/apps/mobile/lib/google-auth-callback.ts new file mode 100644 index 000000000..eabb42bf2 --- /dev/null +++ b/apps/mobile/lib/google-auth-callback.ts @@ -0,0 +1,174 @@ +import { useSyncExternalStore } from 'react' + +export const AUTH_CALLBACK_URL = 'https://app.useorbit.org/auth-callback' + +interface PendingGoogleAuthSessionState { + callbackUrl: string | null + isPending: boolean +} + +export interface GoogleAuthParams { + access_token?: string + refresh_token?: string + provider_token?: string + provider_refresh_token?: string + error?: string + error_description?: string + token?: string + refreshToken?: string + userId?: string + name?: string + email?: string +} + +interface ResolveGoogleAuthCallbackUrlInput { + sessionCallbackUrl?: string | null + rawUrl?: string | null + params: Record + callbackUrl?: string +} + +let pendingGoogleAuthSession: PendingGoogleAuthSessionState = { + callbackUrl: null, + isPending: false, +} + +const pendingGoogleAuthListeners = new Set<() => void>() + +function emitPendingGoogleAuthSession() { + pendingGoogleAuthListeners.forEach((listener) => listener()) +} + +function subscribePendingGoogleAuthSession(listener: () => void) { + pendingGoogleAuthListeners.add(listener) + return () => { + pendingGoogleAuthListeners.delete(listener) + } +} + +function getPendingGoogleAuthSessionSnapshot(): PendingGoogleAuthSessionState { + return pendingGoogleAuthSession +} + +export function usePendingGoogleAuthSession() { + return useSyncExternalStore( + subscribePendingGoogleAuthSession, + getPendingGoogleAuthSessionSnapshot, + getPendingGoogleAuthSessionSnapshot, + ) +} + +export function markPendingGoogleAuthSession() { + pendingGoogleAuthSession = { + callbackUrl: null, + isPending: true, + } + emitPendingGoogleAuthSession() +} + +export function setPendingGoogleAuthCallbackUrl(callbackUrl: string) { + pendingGoogleAuthSession = { + callbackUrl, + isPending: false, + } + emitPendingGoogleAuthSession() +} + +export function clearPendingGoogleAuthSession() { + if (!pendingGoogleAuthSession.callbackUrl && !pendingGoogleAuthSession.isPending) { + return + } + + pendingGoogleAuthSession = { + callbackUrl: null, + isPending: false, + } + emitPendingGoogleAuthSession() +} + +export function extractGoogleAuthParams(rawUrl: string): GoogleAuthParams { + const params = new URLSearchParams() + + const [baseUrl, hash = ''] = rawUrl.split('#', 2) + const url = new URL(baseUrl ?? rawUrl) + + url.searchParams.forEach((value, key) => { + params.set(key, value) + }) + + if (hash) { + const hashParams = new URLSearchParams(hash) + hashParams.forEach((value, key) => { + params.set(key, value) + }) + } + + return { + access_token: params.get('access_token') ?? undefined, + refresh_token: params.get('refresh_token') ?? undefined, + provider_token: params.get('provider_token') ?? undefined, + provider_refresh_token: params.get('provider_refresh_token') ?? undefined, + error: params.get('error') ?? undefined, + error_description: params.get('error_description') ?? undefined, + token: params.get('token') ?? undefined, + refreshToken: params.get('refreshToken') ?? undefined, + userId: params.get('userId') ?? undefined, + name: params.get('name') ?? undefined, + email: params.get('email') ?? undefined, + } +} + +export function hasGoogleAuthCallbackPayload( + params: Readonly, +): boolean { + return Boolean( + params.error || + params.error_description || + (params.token && params.userId && params.name && params.email) || + (params.access_token && params.refresh_token), + ) +} + +export function buildGoogleAuthFallbackUrl( + params: Record, + callbackUrl: string = AUTH_CALLBACK_URL, +): string | null { + const entries: string[][] = [] + + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string') { + entries.push([key, value]) + } + } + + if (entries.length === 0) return null + + const searchParams = new URLSearchParams(entries) + return `${callbackUrl}?${searchParams.toString()}` +} + +export function resolveGoogleAuthCallbackUrl( + { + sessionCallbackUrl, + rawUrl, + params, + callbackUrl = AUTH_CALLBACK_URL, + }: ResolveGoogleAuthCallbackUrlInput, +): string | null { + const candidates = [sessionCallbackUrl, rawUrl] + + for (const candidate of candidates) { + if (!candidate) continue + const extracted = extractGoogleAuthParams(candidate) + if (hasGoogleAuthCallbackPayload(extracted)) { + return candidate + } + } + + const fallbackUrl = buildGoogleAuthFallbackUrl(params, callbackUrl) + if (!fallbackUrl) return null + + return hasGoogleAuthCallbackPayload(extractGoogleAuthParams(fallbackUrl)) + ? fallbackUrl + : null +} diff --git a/apps/mobile/lib/google-auth.ts b/apps/mobile/lib/google-auth.ts index 6245c28bd..49574c48c 100644 --- a/apps/mobile/lib/google-auth.ts +++ b/apps/mobile/lib/google-auth.ts @@ -3,58 +3,21 @@ import type { Session } from '@supabase/supabase-js' import { API } from '@orbit/shared/api' import type { BackendLoginResponse } from '@orbit/shared/types/auth' import { isSafeReturnUrl, storeAuthReturnUrl } from './auth-flow' +import { + AUTH_CALLBACK_URL, + extractGoogleAuthParams, + clearPendingGoogleAuthSession, + markPendingGoogleAuthSession, + setPendingGoogleAuthCallbackUrl, +} from './google-auth-callback' import { supabase } from './supabase' const API_BASE = process.env.EXPO_PUBLIC_API_BASE ?? 'https://api.useorbit.org' -const APP_ORIGIN = 'https://app.useorbit.org' -const AUTH_CALLBACK_URL = `${APP_ORIGIN}/auth-callback` const GOOGLE_SCOPES = 'https://www.googleapis.com/auth/calendar.readonly' -interface GoogleAuthParams { - access_token?: string - refresh_token?: string - provider_token?: string - provider_refresh_token?: string - error?: string - error_description?: string - token?: string - refreshToken?: string - userId?: string - name?: string - email?: string -} - -function extractParams(rawUrl: string): GoogleAuthParams { - const params = new URLSearchParams() - - const [baseUrl, hash = ''] = rawUrl.split('#', 2) - const url = new URL(baseUrl ?? rawUrl) - - url.searchParams.forEach((value, key) => { - params.set(key, value) - }) - - if (hash) { - const hashParams = new URLSearchParams(hash) - hashParams.forEach((value, key) => { - params.set(key, value) - }) - } - - return { - access_token: params.get('access_token') ?? undefined, - refresh_token: params.get('refresh_token') ?? undefined, - provider_token: params.get('provider_token') ?? undefined, - provider_refresh_token: params.get('provider_refresh_token') ?? undefined, - error: params.get('error') ?? undefined, - error_description: params.get('error_description') ?? undefined, - token: params.get('token') ?? undefined, - refreshToken: params.get('refreshToken') ?? undefined, - userId: params.get('userId') ?? undefined, - name: params.get('name') ?? undefined, - email: params.get('email') ?? undefined, - } -} +export type MobileGoogleAuthResult = + | { type: 'success'; url: string } + | { type: WebBrowser.WebBrowserResultType } async function exchangeGoogleSession( session: Session, @@ -94,7 +57,7 @@ export async function completeGoogleAuthFromUrl( language: string, referralCode?: string, ): Promise { - const params = extractParams(rawUrl) + const params = extractGoogleAuthParams(rawUrl) if (params.error_description || params.error) { throw new Error(params.error_description ?? params.error ?? 'Authentication failed') @@ -137,41 +100,60 @@ export async function completeGoogleAuthFromUrl( } export async function startMobileGoogleAuth({ - language, - referralCode, returnUrl, }: Readonly<{ - language: string - referralCode?: string returnUrl?: string -}>): Promise { +}>): Promise { if (returnUrl && isSafeReturnUrl(returnUrl)) { await storeAuthReturnUrl(returnUrl) } - const redirectTo = getGoogleAuthRedirectUrl() - const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'google', - options: { - redirectTo, - scopes: GOOGLE_SCOPES, - skipBrowserRedirect: true, - queryParams: { - access_type: 'offline', - prompt: 'consent', + markPendingGoogleAuthSession() + + try { + const redirectTo = getGoogleAuthRedirectUrl() + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo, + scopes: GOOGLE_SCOPES, + skipBrowserRedirect: true, + queryParams: { + access_type: 'offline', + prompt: 'consent', + }, }, - }, - }) + }) - if (error || !data?.url) { - throw new Error(error?.message ?? 'Authentication failed') - } + if (error || !data?.url) { + throw new Error(error?.message ?? 'Authentication failed') + } - const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo) + const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo) - if (result.type !== 'success' || !result.url) { - return null - } + if (result.type !== 'success') { + clearPendingGoogleAuthSession() + return { type: result.type } + } - return completeGoogleAuthFromUrl(result.url, language, referralCode) + if (!result.url) { + clearPendingGoogleAuthSession() + return { type: WebBrowser.WebBrowserResultType.DISMISS } + } + + const params = extractGoogleAuthParams(result.url) + if (params.error === 'access_denied') { + clearPendingGoogleAuthSession() + return { type: WebBrowser.WebBrowserResultType.CANCEL } + } + + setPendingGoogleAuthCallbackUrl(result.url) + return { + type: 'success', + url: result.url, + } + } catch (error) { + clearPendingGoogleAuthSession() + throw error + } } diff --git a/apps/mobile/lib/habit-progress.ts b/apps/mobile/lib/habit-progress.ts new file mode 100644 index 000000000..a0bfc4e34 --- /dev/null +++ b/apps/mobile/lib/habit-progress.ts @@ -0,0 +1,13 @@ +export const HABIT_PROGRESS_RING_CIRCUMFERENCE = 94.25 + +export function getHabitProgressStrokeDasharray( + progressPercent: number, + isDoneForRange: boolean, +): string { + const clampedPercent = Math.max(0, Math.min(100, progressPercent)) + const filledLength = isDoneForRange + ? HABIT_PROGRESS_RING_CIRCUMFERENCE + : (clampedPercent / 100) * HABIT_PROGRESS_RING_CIRCUMFERENCE + + return `${filledLength.toFixed(2)} ${HABIT_PROGRESS_RING_CIRCUMFERENCE}` +} diff --git a/apps/mobile/lib/habit-selection-state.ts b/apps/mobile/lib/habit-selection-state.ts new file mode 100644 index 000000000..06f7064cd --- /dev/null +++ b/apps/mobile/lib/habit-selection-state.ts @@ -0,0 +1,24 @@ +export function shouldResetSelectionForViewChange( + previousView: string, + nextView: string, +): boolean { + return previousView !== nextView +} + +export function getHabitListExtraData( + isSelectMode: boolean, + selectedHabitIds: ReadonlySet, + recentlyCompletedIds: ReadonlySet, +): string { + const sortAlphabetically = (left: string, right: string) => + left.localeCompare(right) + + const selectedKey = Array.from(selectedHabitIds) + .sort(sortAlphabetically) + .join(',') + const completedKey = Array.from(recentlyCompletedIds) + .sort(sortAlphabetically) + .join(',') + + return `${isSelectMode ? '1' : '0'}|${selectedKey}|${completedKey}` +} diff --git a/apps/mobile/lib/providers.tsx b/apps/mobile/lib/providers.tsx index 7a03e87b2..2df4fc40e 100644 --- a/apps/mobile/lib/providers.tsx +++ b/apps/mobile/lib/providers.tsx @@ -1,4 +1,5 @@ import { type ReactNode, useEffect, useState } from 'react' +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' import { QueryClientProvider } from '@tanstack/react-query' import { queryClient, restoreQueryCache, persistQueryCache } from './query-client' import { useAuthStore } from '@/stores/auth-store' @@ -48,7 +49,11 @@ function AuthInitializer({ children }: Readonly<{ children: ReactNode }>) { ) } - return {children} + return ( + + {children} + + ) } export function Providers({ children }: Readonly) { diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_background.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_background.xml.flat index 02a201f56..06091a8d9 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_background.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_background.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_badge_background.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_badge_background.xml.flat index 9310a92c7..33d8085b1 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_badge_background.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_badge_background.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_empty.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_empty.xml.flat index be0f8535e..e0b837b76 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_empty.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_empty.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_filled.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_filled.xml.flat index e721b8ad5..2520826aa 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_filled.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_filled.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_overdue.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_overdue.xml.flat index 77113d8ab..73db2d155 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_overdue.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_circle_overdue.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_count_background.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_count_background.xml.flat index 100a80b5b..5def77350 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_count_background.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_count_background.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_header_dot.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_header_dot.xml.flat index 257ccce29..ddaeb8a87 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_header_dot.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_header_dot.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_background.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_background.xml.flat index 73cfeb8de..b2b642e7e 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_background.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_background.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_child_background.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_child_background.xml.flat index 9d24422fd..f9d9dce36 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_child_background.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_child_background.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_completed_background.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_completed_background.xml.flat index f6a3425f8..cf6e443b6 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_completed_background.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_completed_background.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_overdue_background.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_overdue_background.xml.flat index 77eecc4f2..1ec69e128 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_overdue_background.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_item_overdue_background.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_progress_background.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_progress_background.xml.flat index a2cd53045..079f7a010 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_progress_background.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_progress_background.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_refresh_icon.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_refresh_icon.xml.flat index 22194b874..c3537e920 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_refresh_icon.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_refresh_icon.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_scrollbar_thumb.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_scrollbar_thumb.xml.flat index a1c94c536..c48f1a734 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_scrollbar_thumb.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_scrollbar_thumb.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_time_background.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_time_background.xml.flat index 0d61f242f..983cb3b3d 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_time_background.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/drawable_widget_time_background.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_widget_item.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_widget_item.xml.flat index 09331dfe4..6c1007f67 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_widget_item.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_widget_item.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_widget_layout.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_widget_layout.xml.flat index cb9c50bd8..15cf4e681 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_widget_layout.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_widget_layout.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/xml_orbit_widget_info.xml.flat b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/xml_orbit_widget_info.xml.flat index 42e69e601..22fcaaeed 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/xml_orbit_widget_info.xml.flat and b/apps/mobile/modules/orbit-widget/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/xml_orbit_widget_info.xml.flat differ diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties b/apps/mobile/modules/orbit-widget/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties index 091df3db4..65c40d0da 100644 --- a/apps/mobile/modules/orbit-widget/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties +++ b/apps/mobile/modules/orbit-widget/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties @@ -1,19 +1,19 @@ -#Sun Apr 05 20:19:16 BRT 2026 -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_background.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_background.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_badge_background.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_badge_background.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_circle_empty.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_circle_empty.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_circle_filled.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_circle_filled.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_circle_overdue.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_circle_overdue.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_count_background.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_count_background.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_header_dot.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_header_dot.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_item_background.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_item_background.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_item_child_background.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_item_child_background.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_item_completed_background.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_item_completed_background.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_item_overdue_background.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_item_overdue_background.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_progress_background.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_progress_background.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_refresh_icon.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_refresh_icon.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_scrollbar_thumb.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_scrollbar_thumb.xml -org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_time_background.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_time_background.xml -org.useorbit.app.widget.orbit-widget-main-6\:/layout/widget_item.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\layout\\widget_item.xml -org.useorbit.app.widget.orbit-widget-main-6\:/layout/widget_layout.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\layout\\widget_layout.xml -org.useorbit.app.widget.orbit-widget-main-6\:/xml/orbit_widget_info.xml=C\:\\Users\\thoma\\Documents\\Programming\\Projects\\orbit-ui-mobile\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\xml\\orbit_widget_info.xml +#Mon Apr 06 12:03:05 BRT 2026 +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_background.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_background.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_badge_background.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_badge_background.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_circle_empty.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_circle_empty.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_circle_filled.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_circle_filled.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_circle_overdue.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_circle_overdue.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_count_background.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_count_background.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_header_dot.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_header_dot.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_item_background.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_item_background.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_item_child_background.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_item_child_background.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_item_completed_background.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_item_completed_background.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_item_overdue_background.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_item_overdue_background.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_progress_background.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_progress_background.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_refresh_icon.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_refresh_icon.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_scrollbar_thumb.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_scrollbar_thumb.xml +org.useorbit.app.widget.orbit-widget-main-6\:/drawable/widget_time_background.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\drawable\\widget_time_background.xml +org.useorbit.app.widget.orbit-widget-main-6\:/layout/widget_item.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\layout\\widget_item.xml +org.useorbit.app.widget.orbit-widget-main-6\:/layout/widget_layout.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\layout\\widget_layout.xml +org.useorbit.app.widget.orbit-widget-main-6\:/xml/orbit_widget_info.xml=C\:\\orbit\\apps\\mobile\\modules\\orbit-widget\\android\\build\\intermediates\\packaged_res\\debug\\packageDebugResources\\xml\\orbit_widget_info.xml diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml b/apps/mobile/modules/orbit-widget/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml index 17e0b4767..ccc35b59e 100644 --- a/apps/mobile/modules/orbit-widget/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml +++ b/apps/mobile/modules/orbit-widget/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml @@ -1,2 +1,2 @@ -See your habits for today \ No newline at end of file +See your habits for today \ No newline at end of file diff --git a/apps/mobile/modules/orbit-widget/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt b/apps/mobile/modules/orbit-widget/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt index 77ac75475..388a3c239 100644 --- a/apps/mobile/modules/orbit-widget/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt +++ b/apps/mobile/modules/orbit-widget/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt @@ -5,49 +5,49 @@ 5 6 7 -7-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:2:3-65 -7-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:2:20-62 +7-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:2:3-65 +7-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:2:20-62 8 -8-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:3:3-79 -8-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:3:20-76 +8-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:3:3-79 +8-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:3:20-76 9 10 -10-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:5:3-24:17 +10-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:5:3-24:17 11 C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:6:5-18:16 +11-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:6:5-18:16 12 android:name="org.useorbit.app.widget.OrbitWidgetProvider" -12-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:7:7-42 +12-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:7:7-42 13 android:exported="true" > -13-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:8:7-30 +13-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:8:7-30 14 -14-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:9:7-11:23 +14-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:9:7-11:23 15 -15-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:10:9-76 -15-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:10:17-73 +15-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:10:9-76 +15-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:10:17-73 16 17 -17-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:12:7-14:23 +17-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:12:7-14:23 18 -18-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:13:9-73 -18-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:13:17-70 +18-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:13:9-73 +18-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:13:17-70 19 20 21 C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:15:7-17:53 +21-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:15:7-17:53 22 android:name="android.appwidget.provider" -22-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:16:9-50 +22-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:16:9-50 23 android:resource="@xml/orbit_widget_info" /> -23-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:17:9-50 +23-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:17:9-50 24 25 26 C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:20:5-23:66 +26-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:20:5-23:66 27 android:name="org.useorbit.app.widget.OrbitWidgetService" -27-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:21:7-41 +27-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:21:7-41 28 android:exported="false" -28-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:22:7-31 +28-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:22:7-31 29 android:permission="android.permission.BIND_REMOTEVIEWS" /> -29-->C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:23:7-63 +29-->C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:23:7-63 30 31 32 diff --git a/apps/mobile/modules/orbit-widget/android/build/kotlin/compileDebugKotlin/cacheable/last-build.bin b/apps/mobile/modules/orbit-widget/android/build/kotlin/compileDebugKotlin/cacheable/last-build.bin index e84213c48..3759be69e 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/kotlin/compileDebugKotlin/cacheable/last-build.bin and b/apps/mobile/modules/orbit-widget/android/build/kotlin/compileDebugKotlin/cacheable/last-build.bin differ diff --git a/apps/mobile/modules/orbit-widget/android/build/kotlin/compileDebugKotlin/local-state/build-history.bin b/apps/mobile/modules/orbit-widget/android/build/kotlin/compileDebugKotlin/local-state/build-history.bin index 60b72759b..70a33dcd8 100644 Binary files a/apps/mobile/modules/orbit-widget/android/build/kotlin/compileDebugKotlin/local-state/build-history.bin and b/apps/mobile/modules/orbit-widget/android/build/kotlin/compileDebugKotlin/local-state/build-history.bin differ diff --git a/apps/mobile/modules/orbit-widget/android/build/outputs/logs/manifest-merger-debug-report.txt b/apps/mobile/modules/orbit-widget/android/build/outputs/logs/manifest-merger-debug-report.txt index 591c82fbf..588b33546 100644 --- a/apps/mobile/modules/orbit-widget/android/build/outputs/logs/manifest-merger-debug-report.txt +++ b/apps/mobile/modules/orbit-widget/android/build/outputs/logs/manifest-merger-debug-report.txt @@ -1,58 +1,58 @@ -- Merging decision tree log --- manifest -ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:1:1-25:12 -INJECTED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:1:1-25:12 +ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:1:1-25:12 +INJECTED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:1:1-25:12 package - INJECTED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml + INJECTED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml xmlns:android - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:1:11-69 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:1:11-69 uses-permission#android.permission.INTERNET -ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:2:3-65 +ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:2:3-65 android:name - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:2:20-62 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:2:20-62 uses-permission#android.permission.RECEIVE_BOOT_COMPLETED -ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:3:3-79 +ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:3:3-79 android:name - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:3:20-76 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:3:20-76 application -ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:5:3-24:17 +ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:5:3-24:17 receiver#org.useorbit.app.widget.OrbitWidgetProvider -ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:6:5-18:16 +ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:6:5-18:16 android:exported - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:8:7-30 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:8:7-30 android:name - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:7:7-42 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:7:7-42 intent-filter#action:name:android.appwidget.action.APPWIDGET_UPDATE -ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:9:7-11:23 +ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:9:7-11:23 action#android.appwidget.action.APPWIDGET_UPDATE -ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:10:9-76 +ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:10:9-76 android:name - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:10:17-73 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:10:17-73 intent-filter#action:name:org.useorbit.app.widget.ACTION_REFRESH -ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:12:7-14:23 +ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:12:7-14:23 action#org.useorbit.app.widget.ACTION_REFRESH -ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:13:9-73 +ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:13:9-73 android:name - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:13:17-70 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:13:17-70 meta-data#android.appwidget.provider -ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:15:7-17:53 +ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:15:7-17:53 android:resource - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:17:9-50 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:17:9-50 android:name - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:16:9-50 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:16:9-50 service#org.useorbit.app.widget.OrbitWidgetService -ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:20:5-23:66 +ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:20:5-23:66 android:exported - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:22:7-31 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:22:7-31 android:permission - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:23:7-63 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:23:7-63 android:name - ADDED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:21:7-41 + ADDED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml:21:7-41 uses-sdk -INJECTED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml reason: use-sdk injection requested -INJECTED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml -INJECTED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml +INJECTED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml reason: use-sdk injection requested +INJECTED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml +INJECTED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml android:targetSdkVersion - INJECTED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml + INJECTED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml android:minSdkVersion - INJECTED from C:\Users\thoma\Documents\Programming\Projects\orbit-ui-mobile\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml + INJECTED from C:\orbit\apps\mobile\modules\orbit-widget\android\src\main\AndroidManifest.xml diff --git a/apps/mobile/modules/orbit-widget/index.ts b/apps/mobile/modules/orbit-widget/index.ts index 0b1c12710..28138c43e 100644 --- a/apps/mobile/modules/orbit-widget/index.ts +++ b/apps/mobile/modules/orbit-widget/index.ts @@ -1,5 +1,4 @@ // Reexport the native module. On web, it will be resolved to OrbitWidgetModule.web.ts // and on native platforms to OrbitWidgetModule.ts export { default } from './src/OrbitWidgetModule'; -export { default as OrbitWidgetView } from './src/OrbitWidgetView'; export * from './src/OrbitWidget.types'; diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index b7f7f4291..f0aacf7f8 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -32,6 +32,7 @@ import { import { useTranslations, useLocale } from 'next-intl' import { useQueryClient } from '@tanstack/react-query' import { habitKeys } from '@orbit/shared/query' +import { collectSelectableDescendantIds, formatAPIDate } from '@orbit/shared/utils' import { plural } from '@/lib/plural' import { HabitList, type HabitListHandle } from '@/components/habits/habit-list' import { HabitSummaryCard } from '@/components/habits/habit-summary-card' @@ -46,7 +47,6 @@ import { useProfile } from '@/hooks/use-profile' import { useStreakInfo } from '@/hooks/use-gamification' import { useHabits, useBulkDeleteHabits, useBulkLogHabits, useBulkSkipHabits } from '@/hooks/use-habits' import { useTags } from '@/hooks/use-tags' -import { formatAPIDate } from '@orbit/shared/utils' import type { HabitsFilter } from '@orbit/shared/types/habit' // --------------------------------------------------------------------------- @@ -496,14 +496,11 @@ export default function TodayPage() { // Selection cascade helpers (matches Nuxt getDescendantIds / isAncestorSelected) const getDescendantIds = useCallback( (parentId: string): string[] => { - const childIds = childrenByParent.get(parentId) ?? [] - const loaded = habitListRef.current?.allLoadedIds - const ids: string[] = [] - for (const cid of childIds) { - if (loaded && !loaded.has(cid)) continue - ids.push(cid, ...getDescendantIds(cid)) - } - return ids + return collectSelectableDescendantIds( + parentId, + (habitId) => childrenByParent.get(habitId) ?? [], + habitListRef.current?.allLoadedIds, + ) }, [childrenByParent], ) diff --git a/apps/web/components/habits/habit-list.tsx b/apps/web/components/habits/habit-list.tsx index c1d0481ea..f732d2708 100644 --- a/apps/web/components/habits/habit-list.tsx +++ b/apps/web/components/habits/habit-list.tsx @@ -19,6 +19,11 @@ import { CheckCircle2, } from 'lucide-react' import { useTranslations, useLocale } from 'next-intl' +import { + collectVisibleHabitTreeIds, + formatAPIDate, + getHabitEmptyStateKey, +} from '@orbit/shared/utils' import { HabitCard } from './habit-card' import { HabitDetailDrawer } from './habit-detail-drawer' import { CreateHabitModal } from './create-habit-modal' @@ -55,7 +60,6 @@ import { } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { useUIStore } from '@/stores/ui-store' -import { formatAPIDate, getHabitEmptyStateKey } from '@orbit/shared/utils' import type { NormalizedHabit, HabitsFilter } from '@orbit/shared/types/habit' // --------------------------------------------------------------------------- @@ -440,19 +444,8 @@ export const HabitList = forwardRef(function Ha // All loaded/selectable IDs including descendants const allLoadedIds = useMemo(() => { - const ids = new Set() - function collect(habitId: string) { - ids.add(habitId) - const childIds = childrenByParent.get(habitId) - if (childIds) { - for (const cid of childIds) collect(cid) - } - } - for (const h of habits) { - collect(h.id) - } - return ids - }, [habits, childrenByParent]) + return collectVisibleHabitTreeIds(habits, getVisibleChildren) + }, [getVisibleChildren, habits]) // Children progress -- matches Nuxt computeChildProgress logic const isListView = view === 'all' || view === 'general' diff --git a/packages/shared/src/__tests__/habit-utils.test.ts b/packages/shared/src/__tests__/habit-utils.test.ts index 49b74f4d5..c6530b47d 100644 --- a/packages/shared/src/__tests__/habit-utils.test.ts +++ b/packages/shared/src/__tests__/habit-utils.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { buildCalendarDayMap, + collectSelectableDescendantIds, + collectVisibleHabitTreeIds, determineHabitDayStatus, getHabitEmptyStateKey, } from '../utils/habits' @@ -153,3 +155,41 @@ describe('buildCalendarDayMap', () => { ]) }) }) + +describe('collectSelectableDescendantIds', () => { + it('skips descendants that are not currently selectable or visible', () => { + const tree = new Map([ + ['parent', ['child-1', 'child-2', 'child-3']], + ['child-1', []], + ['child-2', []], + ['child-3', ['grandchild-hidden']], + ['grandchild-hidden', []], + ]) + + const selectableIds = new Set(['parent', 'child-1', 'child-2']) + + expect( + collectSelectableDescendantIds( + 'parent', + (habitId) => tree.get(habitId) ?? [], + selectableIds, + ), + ).toEqual(['child-1', 'child-2']) + }) +}) + +describe('collectVisibleHabitTreeIds', () => { + it('collects only the ids reachable through visible children', () => { + const visibleChildren = new Map([ + ['parent', [{ id: 'child-1' }, { id: 'child-2' }]], + ['child-1', []], + ['child-2', []], + ]) + + expect( + collectVisibleHabitTreeIds([{ id: 'parent' }], (habitId) => { + return visibleChildren.get(habitId) ?? [] + }), + ).toEqual(new Set(['parent', 'child-1', 'child-2'])) + }) +}) diff --git a/packages/shared/src/utils/habits.ts b/packages/shared/src/utils/habits.ts index 6c3d7f278..a4463c4d2 100644 --- a/packages/shared/src/utils/habits.ts +++ b/packages/shared/src/utils/habits.ts @@ -67,3 +67,48 @@ export function buildCalendarDayMap( return map } + +export function collectSelectableDescendantIds( + parentId: string, + getChildIds: (id: string) => string[], + selectableIds?: ReadonlySet, +): string[] { + const descendantIds: string[] = [] + const stack: string[] = [parentId] + + while (stack.length > 0) { + const currentId = stack.pop() + if (!currentId) continue + + for (const childId of getChildIds(currentId)) { + if (selectableIds && !selectableIds.has(childId)) { + continue + } + + descendantIds.push(childId) + stack.push(childId) + } + } + + return descendantIds +} + +export function collectVisibleHabitTreeIds( + habits: Iterable, + getVisibleChildren: (id: string) => T[], +): Set { + const ids = new Set() + + const visit = (habit: T) => { + ids.add(habit.id) + for (const child of getVisibleChildren(habit.id)) { + visit(child) + } + } + + for (const habit of habits) { + visit(habit) + } + + return ids +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index e2f3281bc..98c6b5929 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -4,6 +4,8 @@ export { isValidEmail } from './email' export { getErrorMessage, extractBackendError } from './error-utils' export { buildCalendarDayMap, + collectSelectableDescendantIds, + collectVisibleHabitTreeIds, determineHabitDayStatus, getHabitEmptyStateKey, } from './habits'