From 79dfb11fa4ee957d0eb9e5cf1f04b53e9dae4947 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 20 Sep 2025 22:20:02 -0400 Subject: [PATCH] feat: add device token management and safe area utilities; enhance notification settings and chore components --- src/queries/UserQueries.jsx | 26 ++ src/utils/Colors.jsx | 1 - src/utils/FeatureToggle.js | 126 +++++++++ src/utils/SafeAreaUtils.js | 89 +++++++ src/views/ChoreEdit/ChoreEdit.jsx | 2 + src/views/Chores/ChoreCard.jsx | 46 ++-- src/views/Chores/CompactChoreCard.jsx | 51 ++-- src/views/Chores/MyChores.jsx | 4 +- src/views/Labels/LabelView.jsx | 7 +- src/views/Modals/Inputs/NudgeModal.jsx | 32 ++- src/views/Settings/NotificationSetting.jsx | 284 ++++++++++++++------- src/views/Timer/TimerDetails.jsx | 10 +- src/views/components/ChoreActionMenu.jsx | 36 ++- 13 files changed, 568 insertions(+), 146 deletions(-) create mode 100644 src/utils/FeatureToggle.js create mode 100644 src/utils/SafeAreaUtils.js diff --git a/src/queries/UserQueries.jsx b/src/queries/UserQueries.jsx index d24318f..772f238 100644 --- a/src/queries/UserQueries.jsx +++ b/src/queries/UserQueries.jsx @@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { GetAllCircleMembers, GetAllUsers, + GetDeviceTokens, GetUserProfile, } from '../utils/Fetcher' import { isTokenValid } from '../utils/TokenManager' @@ -52,3 +53,28 @@ export const useUserProfile = () => { refetch: () => queryClient.invalidateQueries(['userProfile']), } } + +export const useDeviceTokens = () => { + const queryClient = useQueryClient() + + const { data, error, isLoading } = useQuery({ + queryKey: ['deviceTokens'], + queryFn: async () => { + if (!isTokenValid()) { + return null + } + const resp = await GetDeviceTokens(true) // Only get active devices + const result = await resp.json() + return result.res || [] + }, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + }) + + return { + data, + error, + isLoading, + refetch: () => queryClient.invalidateQueries(['deviceTokens']), + } +} diff --git a/src/utils/Colors.jsx b/src/utils/Colors.jsx index f82b740..6f20c67 100644 --- a/src/utils/Colors.jsx +++ b/src/utils/Colors.jsx @@ -72,7 +72,6 @@ export const TASK_COLOR = { PENDING_REVIEW: '#8B6CE1', // For the calendar - OVERDUE: '#F03A47', TODAY: '#ffc107', TOMORROW: '#4ec1a2', NEXT_7_DAYS: '#00bcd4', diff --git a/src/utils/FeatureToggle.js b/src/utils/FeatureToggle.js new file mode 100644 index 0000000..adbcc13 --- /dev/null +++ b/src/utils/FeatureToggle.js @@ -0,0 +1,126 @@ +export const FEATURES = { + OFFLINE_MODE: 'experimental_feature_offline_mode', +} + +/** + * Get the current state of a feature flag from localStorage + * @param {string} featureKey - The feature key from FEATURES constant + * @param {boolean} defaultValue - Default value if feature is not set (default: false) + * @returns {boolean} - Whether the feature is enabled + */ +export const isFeatureEnabled = (featureKey, defaultValue = false) => { + try { + const value = localStorage.getItem(featureKey) + + if (value === 'true') return true + if (value === 'false') return false + + if (value === null || value === undefined) return defaultValue + + return Boolean(value) + } catch (error) { + console.warn(`FeatureToggle: Error reading feature "${featureKey}":`, error) + return defaultValue + } +} + +/** + * Set the state of a feature flag in localStorage + * @param {string} featureKey - The feature key from FEATURES constant + * @param {boolean} enabled - Whether to enable the feature + */ +export const setFeatureEnabled = (featureKey, enabled) => { + try { + localStorage.setItem(featureKey, enabled.toString()) + } catch (error) { + console.error( + `FeatureToggle: Error setting feature "${featureKey}":`, + error, + ) + } +} + +export const toggleFeature = featureKey => { + const currentState = isFeatureEnabled(featureKey) + const newState = !currentState + setFeatureEnabled(featureKey, newState) + return newState +} + +export const getAllFeatureStates = () => { + const states = {} + Object.entries(FEATURES).forEach(([name, key]) => { + states[name] = isFeatureEnabled(key) + }) + return states +} + +export const clearAllFeatures = () => { + try { + Object.values(FEATURES).forEach(featureKey => { + localStorage.removeItem(featureKey) + }) + } catch (error) { + console.error('FeatureToggle: Error clearing features:', error) + } +} + +/** + * Check if the current instance is the official donetick.com service + * @returns {Promise} - Whether this is the official donetick.com instance + */ +export const isOfficialDonetickInstance = async () => { + try { + // Import here to avoid circular dependencies + const { Preferences } = await import('@capacitor/preferences') + const { API_URL } = await import('../Config') + + // Get custom server URL from preferences + const { value: customServerUrl } = await Preferences.get({ + key: 'customServerUrl', + }) + + // Use custom URL if set, otherwise fall back to API_URL + const serverUrl = customServerUrl || API_URL + + // Check if the server URL contains donetick.com + return serverUrl.toLowerCase().includes('donetick.com') + } catch (error) { + console.warn('FeatureToggle: Error checking server instance:', error) + // Default to false for safety (self-hosted assumption) + return false + } +} + +/** + * Synchronous version that checks based on current API manager state + * Note: This requires apiManager to be initialized first + * @returns {boolean} - Whether this is the official donetick.com instance + */ +export const isOfficialDonetickInstanceSync = () => { + try { + // Import here to avoid circular dependencies + const { apiManager } = require('../utils/TokenManager') + + const currentApiUrl = apiManager.getApiURL() + + // Check if the API URL contains donetick.com + return currentApiUrl.toLowerCase().includes('donetick.com') + } catch (error) { + console.warn('FeatureToggle: Error checking server instance (sync):', error) + // Default to false for safety (self-hosted assumption) + return true + } +} + +// Export default object for easier imports +export default { + FEATURES, + isFeatureEnabled, + setFeatureEnabled, + toggleFeature, + getAllFeatureStates, + clearAllFeatures, + isOfficialDonetickInstance, + isOfficialDonetickInstanceSync, +} diff --git a/src/utils/SafeAreaUtils.js b/src/utils/SafeAreaUtils.js new file mode 100644 index 0000000..1419839 --- /dev/null +++ b/src/utils/SafeAreaUtils.js @@ -0,0 +1,89 @@ +import { Capacitor } from '@capacitor/core' + +/** + * Utility functions for handling safe area insets consistently across the app + */ + +/** + * Get the appropriate bottom value that accounts for safe area insets + * @param {number|string} baseBottom - The base bottom value (default: 0) + * @param {number|string} extraPadding - Additional padding to add (default: 0) + * @returns {string} - CSS calc() expression for bottom positioning + */ +export const getSafeBottom = (baseBottom = 0, extraPadding = 0) => { + const base = typeof baseBottom === 'number' ? `${baseBottom}px` : baseBottom + const extra = + typeof extraPadding === 'number' ? `${extraPadding}px` : extraPadding + + if (Capacitor.getPlatform() === 'android') { + if (extraPadding) { + return `calc(var(--safe-area-inset-bottom, 0px) + ${base} + ${extra})` + } + return `calc(var(--safe-area-inset-bottom, 0px) + ${base})` + } + + // For iOS and web, safe area is already handled by the system + if (extraPadding) { + return `calc(${base} + ${extra})` + } + return base +} + +/** + * Get safe area padding for bottom elements + * @param {number|string} extraPadding - Additional padding to add + * @returns {string} - CSS calc() expression for padding + */ +export const getSafeBottomPadding = (extraPadding = 0) => { + const extra = + typeof extraPadding === 'number' ? `${extraPadding * 8}px` : extraPadding + + if (Capacitor.getPlatform() === 'android') { + if (extraPadding) { + return `calc(var(--safe-area-inset-bottom, 0px) + ${extra})` + } + return `var(--safe-area-inset-bottom, 0px)` + } + + return extra || '0px' +} + +/** + * Get safe area styles object for common bottom-positioned elements + * @param {object} options - Configuration options + * @param {number|string} options.bottom - Bottom position value + * @param {number|string} options.padding - Additional padding + * @param {'fixed'|'absolute'|'sticky'} options.position - Position type + * @returns {object} - Style object + */ +export const getSafeBottomStyles = ({ + bottom = 0, + padding = 0, + position = 'fixed', +} = {}) => { + return { + position, + bottom: getSafeBottom(bottom, padding), + } +} + +/** + * Hook-like function to get safe area values for use in components + * @returns {object} - Object with safe area utility functions + */ +export const useSafeArea = () => { + return { + getSafeBottom, + getSafeBottomPadding, + getSafeBottomStyles, + isAndroid: Capacitor.getPlatform() === 'android', + isNative: Capacitor.isNativePlatform(), + } +} + +export default { + getSafeBottom, + getSafeBottomPadding, + getSafeBottomStyles, + useSafeArea, +} diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 34c8f54..1ee90ef 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -40,6 +40,7 @@ import { } from '../../utils/Fetcher' import { isPlusAccount } from '../../utils/Helpers' import Priorities from '../../utils/Priorities.jsx' +import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js' import LoadingComponent from '../components/Loading.jsx' import RichTextEditor from '../components/RichTextEditor.jsx' import SubTasks from '../components/SubTask.jsx' @@ -1210,6 +1211,7 @@ const ChoreEdit = () => { left: 0, right: 0, p: 2, // padding + paddingBottom: getSafeBottomPadding(2), // safe area padding for iOS display: 'flex', justifyContent: 'flex-end', gap: 2, diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx index 5b41740..6cfa0b8 100644 --- a/src/views/Chores/ChoreCard.jsx +++ b/src/views/Chores/ChoreCard.jsx @@ -36,6 +36,7 @@ import { useUserProfile } from '../../queries/UserQueries.jsx' import { useNotification } from '../../service/NotificationProvider' import { notInCompletionWindow } from '../../utils/Chores.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' +import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle' import { ApproveChore, DeleteChore, @@ -79,6 +80,7 @@ const ChoreCard = ({ const [confirmModelConfig, setConfirmModelConfig] = React.useState({}) const [isNFCModalOpen, setIsNFCModalOpen] = React.useState(false) const [isNudgeModalOpen, setIsNudgeModalOpen] = React.useState(false) + const [isOfficialInstance, setIsOfficialInstance] = React.useState(false) const navigate = useNavigate() const [isPendingCompletion, setIsPendingCompletion] = React.useState(false) @@ -107,6 +109,14 @@ const ChoreCard = ({ setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0) } checkTouchDevice() + + // Check if this is the official donetick.com instance + try { + setIsOfficialInstance(isOfficialDonetickInstanceSync()) + } catch (error) { + console.warn('Error checking instance type:', error) + setIsOfficialInstance(false) + } }, []) const handleDelete = () => { @@ -826,23 +836,25 @@ const ChoreCard = ({ - { - e.stopPropagation() - resetSwipe() - setIsNudgeModalOpen(true) - }} - sx={{ - width: 40, - height: 40, - mx: 1, - }} - > - - +{isOfficialInstance && ( + { + e.stopPropagation() + resetSwipe() + setIsNudgeModalOpen(true) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + }} + > + + + )} 0) } checkTouchDevice() + + // Check if this is the official donetick.com instance + try { + setIsOfficialInstance(isOfficialDonetickInstanceSync()) + } catch (error) { + console.warn('Error checking instance type:', error) + setIsOfficialInstance(false) + } }, []) // Swipe gesture handlers @@ -441,7 +451,10 @@ const CompactChoreCard = ({ const handleNudge = async ({ choreId, message, notifyAllAssignees }) => { try { - const response = await NudgeChore(choreId, { message, notifyAllAssignees }) + const response = await NudgeChore(choreId, { + message, + notifyAllAssignees, + }) if (response.ok) { const data = await response.json() showNotification({ @@ -823,23 +836,25 @@ const CompactChoreCard = ({ - { - e.stopPropagation() - resetSwipe() - setIsNudgeModalOpen(true) - }} - sx={{ - width: 40, - height: 40, - mx: 1, - }} - > - - +{isOfficialInstance && ( + { + e.stopPropagation() + resetSwipe() + setIsNudgeModalOpen(true) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + }} + > + + + )} { // variant='outlined' sx={{ position: 'fixed', - bottom: 0, + bottom: getSafeBottom(10, 10), left: 10, - p: 2, // padding display: 'flex', justifyContent: 'flex-end', gap: 2, diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index 125962b..2e4fb46 100644 --- a/src/views/Labels/LabelView.jsx +++ b/src/views/Labels/LabelView.jsx @@ -21,6 +21,7 @@ import LABEL_COLORS, { getTextColorFromBackgroundColor, } from '../../utils/Colors' import { DeleteLabel } from '../../utils/Fetcher' +import { getSafeBottom, getSafeBottomStyles } from '../../utils/SafeAreaUtils' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import { useLabels } from './LabelQueries' @@ -221,7 +222,7 @@ const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => { position: 'absolute', right: 0, top: 0, - bottom: 0, + bottom: getSafeBottom(), width: maxSwipeDistance, display: 'flex', alignItems: 'center', @@ -605,10 +606,8 @@ const LabelView = () => { { if (isConfirmed) { - config.onConfirm({ choreId: config.choreId, message, notifyAllAssignees }) + config.onConfirm({ + choreId: config.choreId, + message, + notifyAllAssignees, + }) } else { config.onClose() } @@ -33,6 +40,14 @@ function NudgeModal({ config }) { if (config?.isOpen) { setMessage('') setNotifyAllAssignees(false) + + // Check if this is the official donetick.com instance + try { + setIsOfficialInstance(isOfficialDonetickInstanceSync()) + } catch (error) { + console.warn('Error checking instance type:', error) + setIsOfficialInstance(false) + } } }, [config?.isOpen]) @@ -101,6 +116,20 @@ function NudgeModal({ config }) { customize the message and choose who gets notified. + {!isOfficialInstance && ( + + + Heads up!This feature avaiable on Donetick Cloud! + Since you're using a self-hosted instance, nudges will requires you + to setup Google cloud account and Firebase Cloud Messaging (FCM). + and build the Android or the iOS app by yourself. +
+ Will update if we come up with a solution to make this easier for to + configure. for selfhosters +
+
+ )} + Custom Message (optional)