diff --git a/.github/workflow/deploy.yml b/.github/workflow/deploy.yml
new file mode 100644
index 0000000..e69de29
diff --git a/.github/workflows/testAndDeploy.yml b/.github/workflows/testAndDeploy.yml
new file mode 100644
index 0000000..eeedb0f
--- /dev/null
+++ b/.github/workflows/testAndDeploy.yml
@@ -0,0 +1,382 @@
+name: Test and Deploy Ignite React Native App
+
+on:
+ push:
+ branches: [ main, develop ]
+ pull_request:
+ branches: [ main, develop ]
+ workflow_dispatch:
+ inputs:
+ deploy_environment:
+ description: 'Deployment environment'
+ required: true
+ default: 'staging'
+ type: choice
+ options:
+ - staging
+ - production
+
+env:
+ NODE_VERSION: '18'
+ JAVA_VERSION: '17'
+ XCODE_VERSION: '15.0'
+
+jobs:
+ # Install dependencies and cache them
+ setup:
+ runs-on: ubuntu-latest
+ outputs:
+ cache-key: ${{ steps.cache-key.outputs.key }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'npm'
+
+ - name: Generate cache key
+ id: cache-key
+ run: echo "key=node-modules-${{ hashFiles('**/package.json') }}" >> $GITHUB_OUTPUT
+
+ - name: Cache node modules
+ uses: actions/cache@v3
+ with:
+ path: node_modules
+ key: ${{ steps.cache-key.outputs.key }}
+ restore-keys: |
+ node-modules-
+
+ - name: Install dependencies
+ run: |
+ npm install
+ npx react-native doctor || true
+
+ # Lint and Type Check
+ lint-and-typecheck:
+ needs: setup
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'npm'
+
+ - name: Restore node modules cache
+ uses: actions/cache@v3
+ with:
+ path: node_modules
+ key: ${{ needs.setup.outputs.cache-key }}
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Run ESLint
+ run: npm run lint
+
+ - name: Run TypeScript check
+ run: npx tsc --noEmit
+
+ - name: Check formatting
+ run: npm run format:check || npx prettier --check "**/*.{js,jsx,ts,tsx,json,md}"
+
+ # Run tests
+ test:
+ needs: setup
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'npm'
+
+ - name: Restore node modules cache
+ uses: actions/cache@v3
+ with:
+ path: node_modules
+ key: ${{ needs.setup.outputs.cache-key }}
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Run unit tests
+ run: npm test -- --coverage --watchAll=false
+
+ - name: Upload coverage reports
+ uses: codecov/codecov-action@v3
+ with:
+ file: ./coverage/lcov.info
+ fail_ci_if_error: false
+
+ # Security audit
+ security-audit:
+ needs: setup
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'npm'
+
+ - name: Run security audit
+ run: npm audit --audit-level=high
+
+ - name: Check for vulnerabilities
+ run: npx audit-ci --moderate
+
+ # Build Android APK/AAB
+ build-android:
+ needs: [lint-and-typecheck, test]
+ runs-on: ubuntu-latest
+ if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.event_name == 'workflow_dispatch'
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'npm'
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: ${{ env.JAVA_VERSION }}
+
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@v3
+
+ - name: Restore node modules cache
+ uses: actions/cache@v3
+ with:
+ path: node_modules
+ key: ${{ needs.setup.outputs.cache-key }}
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Cache Gradle
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ android/.gradle
+ key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+
+ - name: Make gradlew executable
+ run: chmod +x android/gradlew
+
+ - name: Clean Android build
+ run: |
+ cd android
+ ./gradlew clean
+
+ - name: Build Android Debug APK
+ if: github.ref == 'refs/heads/develop'
+ run: |
+ cd android
+ ./gradlew assembleDebug
+
+ - name: Build Android Release APK
+ if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
+ env:
+ ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
+ ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+ ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+ ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+ run: |
+ cd android
+ ./gradlew assembleRelease
+
+ - name: Upload Android APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: android-apk
+ path: |
+ android/app/build/outputs/apk/**/*.apk
+ retention-days: 30
+
+ # Build iOS IPA
+ build-ios:
+ needs: [lint-and-typecheck, test]
+ runs-on: macos-latest
+ if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.event_name == 'workflow_dispatch'
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'npm'
+
+ - name: Setup Xcode
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: ${{ env.XCODE_VERSION }}
+
+ - name: Restore node modules cache
+ uses: actions/cache@v3
+ with:
+ path: node_modules
+ key: ${{ needs.setup.outputs.cache-key }}
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Cache CocoaPods
+ uses: actions/cache@v3
+ with:
+ path: ios/Pods
+ key: pods-${{ hashFiles('**/Podfile.lock') }}
+ restore-keys: |
+ pods-
+
+ - name: Install CocoaPods dependencies
+ run: |
+ cd ios
+ bundle install
+ bundle exec pod install
+
+ - name: Build iOS Debug
+ if: github.ref == 'refs/heads/develop'
+ run: |
+ cd ios
+ xcodebuild -workspace Dooit.xcworkspace \
+ -scheme Dooit \
+ -configuration Debug \
+ -destination 'generic/platform=iOS Simulator' \
+ build
+
+ - name: Build iOS Release
+ if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
+ env:
+ IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
+ IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
+ IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
+ run: |
+ cd ios
+ # Setup certificates and provisioning profiles here
+ xcodebuild -workspace Dooit.xcworkspace \
+ -scheme Dooit \
+ -configuration Release \
+ -archivePath Dooit.xcarchive \
+ archive
+
+ - name: Upload iOS Archive
+ if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
+ uses: actions/upload-artifact@v4
+ with:
+ name: ios-archive
+ path: ios/Dooit.xcarchive
+ retention-days: 30
+
+ # Deploy to staging/production
+ deploy:
+ needs: [build-android, build-ios]
+ runs-on: ubuntu-latest
+ if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
+ environment:
+ name: ${{ github.event.inputs.deploy_environment || 'staging' }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Download Android APK
+ uses: actions/download-artifact@v4
+ with:
+ name: android-apk
+ path: ./android-build
+
+ - name: Download iOS Archive
+ uses: actions/download-artifact@v4
+ with:
+ name: ios-archive
+ path: ./ios-build
+
+ - name: Deploy to App Store Connect
+ if: contains(github.event.inputs.deploy_environment, 'production') || github.ref == 'refs/heads/main'
+ env:
+ APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
+ APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
+ APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
+ run: |
+ echo "Deploying to App Store Connect..."
+ # Add your App Store Connect deployment commands here
+
+ - name: Deploy to Google Play Console
+ if: contains(github.event.inputs.deploy_environment, 'production') || github.ref == 'refs/heads/main'
+ env:
+ GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
+ run: |
+ echo "Deploying to Google Play Console..."
+ # Add your Google Play Console deployment commands here
+
+ - name: Deploy to Firebase App Distribution
+ if: contains(github.event.inputs.deploy_environment, 'staging')
+ env:
+ FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
+ FIREBASE_APP_ID_ANDROID: ${{ secrets.FIREBASE_APP_ID_ANDROID }}
+ FIREBASE_APP_ID_IOS: ${{ secrets.FIREBASE_APP_ID_IOS }}
+ run: |
+ npm install -g firebase-tools
+ echo "Deploying to Firebase App Distribution..."
+ # Add Firebase App Distribution commands here
+
+ # Send notifications
+ notify:
+ needs: [deploy]
+ runs-on: ubuntu-latest
+ if: always() && (github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch')
+ steps:
+ - name: Send Slack notification
+ if: always()
+ uses: 8398a7/action-slack@v3
+ with:
+ status: ${{ job.status }}
+ channel: '#mobile-deployments'
+ text: |
+ ${{ github.event_name == 'workflow_dispatch' && 'Manual' || 'Automatic' }} deployment ${{ job.status }}
+ Branch: ${{ github.ref_name }}
+ Environment: ${{ github.event.inputs.deploy_environment || 'staging' }}
+ Commit: ${{ github.sha }}
+ env:
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+ - name: Send email notification
+ if: failure()
+ uses: dawidd6/action-send-mail@v3
+ with:
+ server_address: smtp.gmail.com
+ server_port: 587
+ username: ${{ secrets.EMAIL_USERNAME }}
+ password: ${{ secrets.EMAIL_PASSWORD }}
+ subject: "❌ Dooit App Deployment Failed"
+ body: |
+ The deployment of Dooit app has failed.
+
+ Branch: ${{ github.ref_name }}
+ Commit: ${{ github.sha }}
+ Workflow: ${{ github.workflow }}
+
+ Please check the GitHub Actions logs for more details.
+ to: ${{ secrets.NOTIFICATION_EMAIL }}
+ from: ${{ secrets.EMAIL_USERNAME }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index cb8b804..355a5de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
#
.DS_Store
+.env
+
# Xcode
#
build/
@@ -85,6 +87,10 @@ web-build/
# Configurations
!env.js
+.env.*.js
+.env.local
+.env.*.local
+
/coverage
@@ -95,3 +101,10 @@ web-build/
!.yarn/releases
!.yarn/sdks
!.yarn/versions
+
+google-services.json
+firebase-service-account.json
+dooit-6e563-firebase-adminsdk-fbsvc-a82e6fd47e.json
+key.txt.pub
+key.txt.pub
+dooit-6e563-firebase-adminsdk-fbsvc-a82e6fd47e.json
diff --git a/.txt b/.txt
new file mode 100644
index 0000000..e69de29
diff --git a/README.md b/README.md
index 830563e..8d60509 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ A powerful and user-friendly to-do list application built with React Native and
- Create, edit, and delete tasks
- Mark tasks as complete/incomplete
- Add task descriptions and due dates
- - Real-time synchronization
+ - Real-time synchronizations
- 🔐 User Authentication
- Email/password sign-up and login
diff --git a/app.json b/app.json
index 01dab3c..5af76d4 100644
--- a/app.json
+++ b/app.json
@@ -27,7 +27,8 @@
"allowBackup": false,
"splash": {
"backgroundColor": "#fff"
- }
+ },
+ "googleServicesFile": "./google-services.json"
},
"ios": {
"icon": "./assets/images/app-icon-ios.png",
@@ -35,6 +36,19 @@
"bundleIdentifier": "com.dooit",
"splash": {
"backgroundColor": "#fff"
+ },
+ "privacyManifests": {
+ "NSPrivacyAccessedAPITypes": [
+ {
+ "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
+ "NSPrivacyAccessedAPITypeReasons": [
+ "CA92.1"
+ ]
+ }
+ ]
+ },
+ "infoPlist": {
+ "ITSAppUsesNonExemptEncryption": false
}
},
"web": {
@@ -45,6 +59,13 @@
}
},
"plugins": [
+ [
+ "expo-notifications",
+ {
+ "icon": "./assets/images/notification-icon.png",
+ "color": "#ffffff"
+ }
+ ],
"expo-localization",
"expo-font",
[
diff --git a/app/app.tsx b/app/app.tsx
index b1f990a..ec3bcfb 100644
--- a/app/app.tsx
+++ b/app/app.tsx
@@ -14,26 +14,33 @@ if (__DEV__) {
// Load Reactotron in development only.
// Note that you must be using metro's `inlineRequires` for this to work.
// If you turn it off in metro.config.js, you'll have to manually import it.
- require("./devtools/ReactotronConfig.ts")
+ require("./devtools/ReactotronConfig.ts");
}
-import "./utils/gestureHandler"
-import { initI18n } from "./i18n"
-import { useFonts } from "expo-font"
-import { useEffect, useState } from "react"
-import { initialWindowMetrics, SafeAreaProvider } from "react-native-safe-area-context"
-import * as Linking from "expo-linking"
-import * as SplashScreen from "expo-splash-screen"
-import { useInitialRootStore } from "./models"
-import { AppNavigator, useNavigationPersistence } from "./navigators"
-import * as storage from "./utils/storage"
-import { customFontsToLoad } from "./theme"
-import { KeyboardProvider } from "react-native-keyboard-controller"
-import { loadDateFnsLocale } from "./utils/formatDate"
+import "./utils/gestureHandler";
+import { initI18n } from "./i18n";
+import { useFonts } from "expo-font";
+import { useEffect, useState } from "react";
+import {
+ initialWindowMetrics,
+ SafeAreaProvider,
+} from "react-native-safe-area-context";
+import * as Linking from "expo-linking";
+import * as SplashScreen from "expo-splash-screen";
+import { useInitialRootStore } from "./models";
+import { AppNavigator, useNavigationPersistence } from "./navigators";
+import * as storage from "./utils/storage";
+import { customFontsToLoad } from "./theme";
+import { KeyboardProvider } from "react-native-keyboard-controller";
+import { loadDateFnsLocale } from "./utils/formatDate";
+import "react-native-get-random-values";
+import { api } from "./services/api";
-export const NAVIGATION_PERSISTENCE_KEY = "NAVIGATION_STATE"
+
+
+export const NAVIGATION_PERSISTENCE_KEY = "NAVIGATION_STATE";
// Web linking configuration
-const prefix = Linking.createURL("/")
+const prefix = Linking.createURL("/");
const config = {
screens: {
Login: {
@@ -51,7 +58,7 @@ const config = {
},
},
},
-}
+};
/**
* This is the root component of our app.
@@ -63,24 +70,33 @@ export function App() {
initialNavigationState,
onNavigationStateChange,
isRestored: isNavigationStateRestored,
- } = useNavigationPersistence(storage, NAVIGATION_PERSISTENCE_KEY)
+ } = useNavigationPersistence(storage, NAVIGATION_PERSISTENCE_KEY);
+
+ const [areFontsLoaded, fontLoadError] = useFonts(customFontsToLoad);
+ const [isI18nInitialized, setIsI18nInitialized] = useState(false);
- const [areFontsLoaded, fontLoadError] = useFonts(customFontsToLoad)
- const [isI18nInitialized, setIsI18nInitialized] = useState(false)
+
+ const runTest = async () => {
+ const result = await api.getGlobalHealthcareNews();
+ if (result.kind === "ok") {
+ console.log(result.articles); // Global health articles
+ }
+ };
useEffect(() => {
+
initI18n()
.then(() => setIsI18nInitialized(true))
- .then(() => loadDateFnsLocale())
- }, [])
+ .then(() => loadDateFnsLocale());
+ }, []);
const { rehydrated } = useInitialRootStore(() => {
// This runs after the root store has been initialized and rehydrated.
// If your initialization scripts run very fast, it's good to show the splash screen for just a bit longer to prevent flicker.
// Slightly delaying splash screen hiding for better UX; can be customized or removed as needed,
- setTimeout(SplashScreen.hideAsync, 500)
- })
+ setTimeout(SplashScreen.hideAsync, 500);
+ });
// Before we show the app, we have to wait for our state to be ready.
// In the meantime, don't render anything. This will be the background
@@ -94,13 +110,13 @@ export function App() {
!isI18nInitialized ||
(!areFontsLoaded && !fontLoadError)
) {
- return null
+ return null;
}
const linking = {
prefixes: [prefix],
config,
- }
+ };
// otherwise, we're ready to render the app
return (
@@ -113,5 +129,5 @@ export function App() {
/>
- )
+ );
}
diff --git a/app/components/.txt b/app/components/.txt
new file mode 100644
index 0000000..394720b
--- /dev/null
+++ b/app/components/.txt
@@ -0,0 +1,91 @@
+
+
+ // Simple time picker component
+ const SimpleTimePicker = ({ onTimeSelect }: { onTimeSelect: (time: Date) => void }) => {
+ const times = [];
+
+ // Generate times in 15-minute intervals
+ for (let hour = 0; hour < 24; hour++) {
+ for (let minute = 0; minute < 60; minute += 15) {
+ const time = new Date();
+ time.setHours(hour, minute, 0, 0);
+ times.push(time);
+ }
+ }
+
+ return (
+
+
+
+
+ Select Time
+ setShowTimePicker(false)}>
+ ✕
+
+
+
+ {times.map((time, index) => (
+ {
+ onTimeSelect(time);
+ setShowTimePicker(false);
+ }}
+ >
+ {formatTime(time)}
+
+ ))}
+
+
+
+
+ );
+ };
+
+ const [showTimePicker, setShowTimePicker] = useState(false);
+
+ const [selectedTime, setSelectedTime] = useState(new Date());
+
+
+
+const formatTime = (time: Date) => {
+ return time.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ };
+
+ const $pickerList: ThemedStyle = ({ spacing }) => ({
+ maxHeight: 300,
+});
+
+
+
+
+ {/* Time Selection */}
+
+
+ Time
+
+ setShowTimePicker(true)}
+ style={themed($dateTimeButton)}
+ >
+
+ 🕐 {formatTime(selectedTime)}
+
+
+
+
+
+const $pickerItem: ThemedStyle = ({ colors, spacing }) => ({
+ padding: spacing.md,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.separator,
+});
+
+const $pickerItemText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 16,
+});
\ No newline at end of file
diff --git a/app/components/AlertTongle.tsx b/app/components/AlertTongle.tsx
new file mode 100644
index 0000000..f5a9c59
--- /dev/null
+++ b/app/components/AlertTongle.tsx
@@ -0,0 +1,104 @@
+import { Alert, StyleProp, TextStyle, View, ViewStyle } from "react-native"
+import { observer } from "mobx-react-lite"
+import { useAppTheme } from "@/utils/useAppTheme"
+import type { ThemedStyle } from "@/theme"
+import { Text } from "@/components/Text"
+import React, { useEffect } from "react"
+
+export interface AlertTongleProps {
+ /**
+ * An optional style override useful for padding & margin.
+ */
+ style?: StyleProp
+}
+
+type AlertItem = {
+ title: string
+ message: string
+ buttons?: Array<{
+ text: string
+ onPress?: () => void
+ style?: 'default' | 'cancel' | 'destructive'
+ }>
+}
+
+// Static queue manager
+class AlertQueueManager {
+ private static queue: AlertItem[] = []
+ private static isShowing = false
+ private static currentAlert: AlertItem | null = null
+
+ static enqueue(alert: AlertItem) {
+ if (this.isDuplicate(alert)) return
+
+ this.queue.push(alert)
+ this.showNext()
+ }
+
+ private static isDuplicate(alert: AlertItem): boolean {
+ if (this.isShowing && this.currentAlert?.title === alert.title && this.currentAlert?.message === alert.message) {
+ return true
+ }
+ if (this.queue.some(q => q.title === alert.title && q.message === alert.message)) {
+ return true
+ }
+ return false
+ }
+
+ private static showNext() {
+ if (this.isShowing || this.queue.length === 0) return
+
+ this.isShowing = true
+ const nextAlert = this.queue.shift()!
+ this.currentAlert = nextAlert
+
+ const { title, message, buttons = [{ text: "OK" }] } = nextAlert
+
+ Alert.alert(title, message, buttons.map(button => ({
+ ...button,
+ onPress: () => {
+ button.onPress?.()
+ this.isShowing = false
+ this.currentAlert = null
+ this.showNext()
+ },
+ })))
+ }
+}
+
+// Exportable function
+export const showQueuedAlert = (alert: AlertItem) => {
+ AlertQueueManager.enqueue(alert)
+}
+
+
+// COMPONENT
+export const AlertTongle = observer(function AlertTongle(props: AlertTongleProps) {
+ const { style } = props
+ const $styles = [$container, style]
+ const { themed } = useAppTheme()
+
+ useEffect(() => {
+ // Example trigger
+ showQueuedAlert({
+ title: "Initial Alert",
+ message: "This is triggered from AlertTongle component",
+ })
+ }, [])
+
+ return (
+
+ Hello
+
+ )
+})
+
+const $container: ViewStyle = {
+ justifyContent: "center",
+}
+
+const $text: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 14,
+ color: colors.palette.primary500,
+})
diff --git a/app/components/Calendar.tsx b/app/components/Calendar.tsx
new file mode 100644
index 0000000..df40a2e
--- /dev/null
+++ b/app/components/Calendar.tsx
@@ -0,0 +1,1197 @@
+import {
+ StyleProp,
+ TextStyle,
+ View,
+ ViewStyle,
+ TouchableOpacity,
+ ScrollView,
+ SectionList,
+ RefreshControl,
+} from "react-native";
+import { observer } from "mobx-react-lite";
+import { useAppTheme } from "@/utils/useAppTheme";
+import type { ThemedStyle } from "@/theme";
+import { Text } from "@/components/Text";
+import { useState, useCallback, useMemo } from "react";
+import { useStores } from "@/models";
+
+export interface CalendarProps {
+ /**
+ * An optional style override useful for padding & margin.
+ */
+ style?: StyleProp;
+ /**
+ * Callback when a date is selected
+ */
+ onDateSelect?: (date: Date) => void;
+ /**
+ * Selected date
+ */
+ selectedDate?: Date;
+ /**
+ * Show tasks on calendar
+ */
+ showTasks?: boolean;
+ /**
+ * Minimum selectable date
+ */
+ minDate?: Date;
+ /**
+ * Maximum selectable date
+ */
+ maxDate?: Date;
+ /**
+ * Show agenda list view instead of task summary
+ */
+ showAgenda?: boolean;
+ /**
+ * Callback when a task is pressed in the agenda
+ */
+ onTaskPress?: (task: any) => void;
+ /**
+ * Number of days to show in agenda
+ */
+ agendaDays?: number;
+}
+
+interface AgendaSection {
+ title: string;
+ data: any[];
+ date: Date;
+}
+
+// ENHANCEMENT 1: New interfaces for task types and periods
+interface TaskPeriod {
+ startDate: Date;
+ endDate: Date;
+ task: any;
+ isStart: boolean;
+ isEnd: boolean;
+ isMiddle: boolean;
+ isSingleDay: boolean;
+}
+
+interface DayTaskInfo {
+ singleDayTasks: any[];
+ multiDayPeriods: TaskPeriod[];
+ allTasks: any[];
+}
+
+/**
+ * Custom Calendar component with task integration and agenda list
+ */
+export const Calendar = observer(function Calendar(props: CalendarProps) {
+ const {
+ style,
+ onDateSelect,
+ selectedDate,
+ showTasks = true,
+ minDate,
+ maxDate,
+ showAgenda = false,
+ onTaskPress,
+ agendaDays = 3,
+ } = props;
+
+ const $styles = [$container, style];
+ const { themed } = useAppTheme();
+ const { taskStore } = useStores();
+
+ const [currentDate, setCurrentDate] = useState(new Date());
+ const [viewDate, setViewDate] = useState(new Date());
+ const [refreshing, setRefreshing] = useState(false);
+
+ // Get current month and year
+ const currentMonth = viewDate.getMonth();
+ const currentYear = viewDate.getFullYear();
+
+ // Month names
+ const monthNames = [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+ ];
+
+ // Day names
+ const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+ // Get first day of the month and number of days
+ const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
+ const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
+ const daysInPrevMonth = new Date(currentYear, currentMonth, 0).getDate();
+
+ // ENHANCEMENT 2: Helper function to check if a task spans multiple days
+ const isMultiDayTask = useCallback((task: any): boolean => {
+ if (!task.startDate || !task.dueDate) return false;
+
+ const startDate = new Date(task.startDate);
+ const endDate = new Date(task.dueDate);
+
+ // Reset time to compare only dates
+ startDate.setHours(0, 0, 0, 0);
+ endDate.setHours(0, 0, 0, 0);
+
+ return startDate.getTime() !== endDate.getTime();
+ }, []);
+
+ // ENHANCEMENT 3: Helper function to get task period info for a specific date
+ const getTaskPeriodInfo = useCallback((task: any, date: Date): TaskPeriod | null => {
+ if (!task.startDate || !task.dueDate) return null;
+
+ const startDate = new Date(task.startDate);
+ const endDate = new Date(task.dueDate);
+ const checkDate = new Date(date);
+
+ // Reset times for date comparison
+ startDate.setHours(0, 0, 0, 0);
+ endDate.setHours(0, 0, 0, 0);
+ checkDate.setHours(0, 0, 0, 0);
+
+ const startTime = startDate.getTime();
+ const endTime = endDate.getTime();
+ const checkTime = checkDate.getTime();
+
+ // Check if date falls within task period
+ if (checkTime < startTime || checkTime > endTime) return null;
+
+ const isSingleDay = startTime === endTime;
+ const isStart = checkTime === startTime;
+ const isEnd = checkTime === endTime;
+ const isMiddle = !isStart && !isEnd && !isSingleDay;
+
+ return {
+ startDate,
+ endDate,
+ task,
+ isStart,
+ isEnd,
+ isMiddle,
+ isSingleDay,
+ };
+ }, []);
+
+ // ENHANCEMENT 4: Enhanced task mapping for the current month
+ const monthTasksEnhanced = useMemo(() => {
+ if (!showTasks) return {};
+
+ const tasksMap: { [key: string]: DayTaskInfo } = {};
+ const allTasks = taskStore.tasks.slice();
+
+ // Initialize all days of the month
+ for (let day = 1; day <= daysInMonth; day++) {
+ tasksMap[day.toString()] = {
+ singleDayTasks: [],
+ multiDayPeriods: [],
+ allTasks: [],
+ };
+ }
+
+ allTasks.forEach((task) => {
+ if (task.startDate && task.dueDate) {
+ const startDate = new Date(task.startDate);
+ const endDate = new Date(task.dueDate);
+
+ // Check each day of the month to see if task overlaps
+ for (let day = 1; day <= daysInMonth; day++) {
+ const checkDate = new Date(currentYear, currentMonth, day);
+ const periodInfo = getTaskPeriodInfo(task, checkDate);
+
+ if (periodInfo) {
+ const dayKey = day.toString();
+ tasksMap[dayKey].allTasks.push(task);
+
+ if (periodInfo.isSingleDay) {
+ tasksMap[dayKey].singleDayTasks.push(task);
+ } else {
+ tasksMap[dayKey].multiDayPeriods.push(periodInfo);
+ }
+ }
+ }
+ } else if (task.dueDate) {
+ // Fallback for tasks with only dueDate
+ const taskDate = new Date(task.dueDate);
+ if (
+ taskDate.getMonth() === currentMonth &&
+ taskDate.getFullYear() === currentYear
+ ) {
+ const dayKey = taskDate.getDate().toString();
+ tasksMap[dayKey].singleDayTasks.push(task);
+ tasksMap[dayKey].allTasks.push(task);
+ }
+ }
+ });
+
+ return tasksMap;
+ }, [taskStore.tasks, currentMonth, currentYear, showTasks, daysInMonth, getTaskPeriodInfo]);
+
+ // Format section title
+ const formatSectionTitle = (date: Date): string => {
+ const today = new Date();
+ const tomorrow = new Date(today);
+ tomorrow.setDate(today.getDate() + 1);
+
+ if (
+ date.getDate() === today.getDate() &&
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() === today.getFullYear()
+ ) {
+ return "Today";
+ }
+
+ if (
+ date.getDate() === tomorrow.getDate() &&
+ date.getMonth() === tomorrow.getMonth() &&
+ date.getFullYear() === tomorrow.getFullYear()
+ ) {
+ return "Tomorrow";
+ }
+
+ return date.toLocaleDateString("en-US", {
+ weekday: "long",
+ month: "short",
+ day: "numeric",
+ });
+ };
+
+ // Force re-computation when tasks change by using tasks length as dependency
+ const tasksVersion = useMemo(() => taskStore.tasks.length, [
+ taskStore.tasks.length,
+ ]);
+
+ // CHANGE 1: Updated agendaSections to show tasks from startDate to dueDate
+ const agendaSections = useMemo((): AgendaSection[] => {
+ if (!showAgenda) return [];
+
+ const sections: AgendaSection[] = [];
+ const startDate = selectedDate || new Date();
+
+ // Force fresh computation
+ const allTasks = taskStore.tasks.slice();
+
+ for (let i = 0; i < agendaDays; i++) {
+ const date = new Date(startDate);
+ date.setDate(startDate.getDate() + i);
+
+ // CHANGE 2: Enhanced task filtering to include tasks that span across the date
+ const dayTasks = allTasks.filter((task) => {
+ // Check if task has both startDate and dueDate
+ if (task.startDate && task.dueDate) {
+ const taskStartDate = new Date(task.startDate);
+ const taskEndDate = new Date(task.dueDate);
+ const checkDate = new Date(date);
+
+ // Reset times for date comparison
+ taskStartDate.setHours(0, 0, 0, 0);
+ taskEndDate.setHours(0, 0, 0, 0);
+ checkDate.setHours(0, 0, 0, 0);
+
+ // Include task if the date falls within the task's date range
+ return checkDate.getTime() >= taskStartDate.getTime() &&
+ checkDate.getTime() <= taskEndDate.getTime();
+ }
+
+ // Fallback: if only dueDate exists, use original logic
+ if (task.dueDate) {
+ const taskDate = new Date(task.dueDate);
+ return (
+ taskDate.getDate() === date.getDate() &&
+ taskDate.getMonth() === date.getMonth() &&
+ taskDate.getFullYear() === date.getFullYear()
+ );
+ }
+
+ return false;
+ });
+
+ // Always add section, even if no tasks (to show empty state)
+ sections.push({
+ title: formatSectionTitle(date),
+ data: dayTasks,
+ date: date,
+ });
+ }
+
+ return sections;
+ }, [taskStore.tasks, selectedDate, agendaDays, showAgenda, tasksVersion]);
+
+ // Navigate to previous month
+ const goToPrevMonth = useCallback(() => {
+ setViewDate(new Date(currentYear, currentMonth - 1, 1));
+ }, [currentYear, currentMonth]);
+
+ // Navigate to next month
+ const goToNextMonth = useCallback(() => {
+ setViewDate(new Date(currentYear, currentMonth + 1, 1));
+ }, [currentYear, currentMonth]);
+
+ // Handle date selection
+ const handleDatePress = useCallback(
+ (day: number) => {
+ const selectedDate = new Date(currentYear, currentMonth, day);
+
+ // Check if date is within allowed range
+ if (minDate && selectedDate < minDate) return;
+ if (maxDate && selectedDate > maxDate) return;
+
+ setCurrentDate(selectedDate);
+ onDateSelect?.(selectedDate);
+ },
+ [currentYear, currentMonth, minDate, maxDate, onDateSelect]
+ );
+
+ // Handle refresh
+ const onRefresh = useCallback(() => {
+ setRefreshing(true);
+ // Simulate refresh delay
+ setTimeout(() => {
+ setRefreshing(false);
+ }, 1000);
+ }, []);
+
+ // Check if a date is today
+ const isToday = useCallback(
+ (day: number) => {
+ const today = new Date();
+ return (
+ today.getDate() === day &&
+ today.getMonth() === currentMonth &&
+ today.getFullYear() === currentYear
+ );
+ },
+ [currentMonth, currentYear]
+ );
+
+ // Check if a date is selected
+ const isSelected = useCallback(
+ (day: number) => {
+ if (!selectedDate) return false;
+ return (
+ selectedDate.getDate() === day &&
+ selectedDate.getMonth() === currentMonth &&
+ selectedDate.getFullYear() === currentYear
+ );
+ },
+ [selectedDate, currentMonth, currentYear]
+ );
+
+ // Check if a date is disabled
+ const isDisabled = useCallback(
+ (day: number) => {
+ const date = new Date(currentYear, currentMonth, day);
+ if (minDate && date < minDate) return true;
+ if (maxDate && date > maxDate) return true;
+ return false;
+ },
+ [currentYear, currentMonth, minDate, maxDate]
+ );
+
+ // ENHANCEMENT 5: Render multi-day task period indicator
+ const renderMultiDayIndicator = useCallback((periods: TaskPeriod[]) => {
+ if (periods.length === 0) return null;
+
+ return (
+
+ {periods.slice(0, 3).map((period, index) => (
+
+ ))}
+ {periods.length > 3 && (
+ +{periods.length - 3}
+ )}
+
+ );
+ }, [themed]);
+
+ // ENHANCEMENT 6: Render single day task dots
+ const renderSingleDayDots = useCallback((tasks: any[]) => {
+ if (tasks.length === 0) return null;
+
+ const maxDots = 4;
+ const visibleTasks = tasks.slice(0, maxDots);
+ const remainingCount = tasks.length - maxDots;
+
+ return (
+
+ {visibleTasks.map((task, index) => (
+
+ ))}
+ {remainingCount > 0 && (
+ +{remainingCount}
+ )}
+
+ );
+ }, [themed]);
+
+ // ENHANCEMENT 7: Helper function to get consistent task colors
+ const getTaskColor = useCallback((task: any, index: number) => {
+ const colors = [
+ '#FF6B6B', // Red
+ '#4ECDC4', // Teal
+ '#45B7D1', // Blue
+ '#96CEB4', // Green
+ '#FECA57', // Yellow
+ '#FF9FF3', // Pink
+ '#54A0FF', // Light Blue
+ '#5F27CD', // Purple
+ ];
+
+ // Use task priority for color if available
+ if (task.priority) {
+ switch (task.priority) {
+ case 'high': return '#FF6B6B';
+ case 'medium': return '#FECA57';
+ case 'low': return '#96CEB4';
+ }
+ }
+
+ // Fallback to index-based color
+ return colors[index % colors.length];
+ }, []);
+
+ // Generate calendar days
+ const generateCalendarDays = () => {
+ const days = [];
+
+ // Previous month days
+ for (let i = firstDayOfMonth - 1; i >= 0; i--) {
+ const day = daysInPrevMonth - i;
+ days.push(
+
+ {day}
+
+ );
+ }
+
+ // Current month days
+ for (let day = 1; day <= daysInMonth; day++) {
+ const dayInfo = monthTasksEnhanced[day.toString()] || {
+ singleDayTasks: [],
+ multiDayPeriods: [],
+ allTasks: [],
+ };
+
+ const disabled = isDisabled(day);
+
+ days.push(
+ handleDatePress(day)}
+ disabled={disabled}
+ >
+
+ {day}
+
+
+ {/* ENHANCEMENT 8: Enhanced task indicators */}
+ {showTasks && (
+
+ {/* Multi-day task periods */}
+ {renderMultiDayIndicator(dayInfo.multiDayPeriods)}
+
+ {/* Single day task dots */}
+ {renderSingleDayDots(dayInfo.singleDayTasks)}
+
+ )}
+
+ );
+ }
+
+ // Next month days to fill the grid
+ const remainingDays = 42 - days.length; // 6 rows × 7 days
+ for (let day = 1; day <= remainingDays; day++) {
+ days.push(
+
+ {day}
+
+ );
+ }
+
+ return days;
+ };
+
+ // CHANGE 3: Enhanced agenda item rendering with task period context
+ const renderAgendaItem = useCallback(
+ ({ item, section }: { item: any; section: AgendaSection }) => {
+ // CHANGE 4: Determine task period context for this specific date
+ const currentDate = section.date;
+ const taskPeriodInfo = getTaskPeriodInfo(item, currentDate);
+
+ // CHANGE 5: Generate period indicator text
+ const getPeriodText = () => {
+ if (!taskPeriodInfo || taskPeriodInfo.isSingleDay) return null;
+
+ if (taskPeriodInfo.isStart) return "Starts";
+ if (taskPeriodInfo.isEnd) return "Ends";
+ if (taskPeriodInfo.isMiddle) return "Continues";
+ return null;
+ };
+
+ const periodText = getPeriodText();
+
+ return (
+ onTaskPress?.(item)}
+ activeOpacity={0.7}
+ >
+
+
+ {/* CHANGE 7: Add period context text */}
+ {periodText && (
+
+ {periodText}
+
+ )}
+
+ {item.title}
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+ {/* CHANGE 8: Show task duration for multi-day tasks */}
+ {taskPeriodInfo && !taskPeriodInfo.isSingleDay && (
+
+ {taskPeriodInfo.startDate.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric"
+ })} - {taskPeriodInfo.endDate.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric"
+ })}
+
+ )}
+ {item.dueDate && (
+
+ {new Date(item.taskTime).toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: true,
+ })}
+
+ )}
+
+
+ );
+ },
+ [onTaskPress, themed, getTaskPeriodInfo]
+ );
+
+ // Render empty agenda section
+ const renderEmptyAgendaSection = useCallback(() => {
+ return (
+
+ No tasks scheduled
+
+ );
+ }, [themed]);
+
+ // Render section header
+ const renderSectionHeader = useCallback(
+ ({ section }: { section: AgendaSection }) => {
+ return (
+
+ {section.title}
+
+ {section.date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })}
+
+
+ );
+ },
+ [themed]
+ );
+
+ return (
+
+ {/* Header */}
+
+
+ ‹
+
+
+
+ {monthNames[currentMonth]} {currentYear}
+
+
+
+ ›
+
+
+
+ {/* Day names */}
+
+ {dayNames.map((dayName) => (
+
+ {dayName}
+
+ ))}
+
+
+ {/* Calendar grid */}
+ {generateCalendarDays()}
+
+ {/* Agenda List */}
+ {showAgenda ? (
+
+
+ item.id?.toString() || index.toString()
+ }
+ showsVerticalScrollIndicator={false}
+ refreshControl={
+
+ }
+ stickySectionHeadersEnabled={false}
+ ItemSeparatorComponent={() => (
+
+ )}
+ contentContainerStyle={themed($agendaContentContainer)}
+ />
+
+ ) : (
+ /* Task summary for selected date */
+ showTasks &&
+ selectedDate && (
+
+
+ Tasks for {selectedDate.toLocaleDateString()}
+
+ {monthTasksEnhanced[selectedDate.getDate().toString()]?.allTasks.length > 0 ? (
+
+ {monthTasksEnhanced[selectedDate.getDate().toString()].allTasks.map(
+ (task, index) => (
+ onTaskPress?.(task)}
+ >
+
+
+
+ {task.title}
+
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+
+ )
+ )}
+
+ ) : (
+ No tasks for this date
+ )}
+
+ )
+ )}
+
+ );
+});
+
+
+// #region Styles
+
+const $container: ViewStyle = {
+ backgroundColor: "transparent",
+ flex: 1,
+};
+
+const $header: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+});
+
+const $navButton: ThemedStyle = ({ colors, spacing }) => ({
+ padding: spacing.xs,
+ borderRadius: 20,
+ backgroundColor: colors.palette.neutral100,
+ width: 40,
+ height: 40,
+ justifyContent: "center",
+ alignItems: "center",
+});
+
+const $navButtonText: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.medium,
+ fontSize: 20,
+ color: colors.palette.primary500,
+});
+
+const $monthYearText: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.medium,
+ fontSize: 18,
+ color: colors.text,
+});
+
+const $dayNamesContainer: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+});
+
+const $dayNameContainer: ThemedStyle = () => ({
+ flex: 1,
+ alignItems: "center",
+});
+
+const $dayNameText: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.medium,
+ fontSize: 12,
+ color: colors.textDim,
+});
+
+const $calendarGrid: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ flexWrap: "wrap",
+ paddingHorizontal: spacing.md,
+});
+
+const $dayContainer: ThemedStyle = ({ spacing }) => ({
+ width: `${100 / 7}%`,
+ aspectRatio: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ padding: spacing.xs,
+ position: "relative",
+});
+
+const $todayContainer: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.palette.primary100,
+ borderRadius: 20,
+});
+
+const $selectedContainer: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.palette.primary500,
+ borderRadius: 20,
+});
+
+const $disabledContainer: ThemedStyle = () => ({
+ opacity: 0.3,
+});
+
+const $dayText: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 16,
+ color: colors.text,
+});
+
+const $todayText: ThemedStyle = ({ colors }) => ({
+ color: colors.palette.primary500,
+ fontWeight: "bold",
+});
+
+const $selectedText: ThemedStyle = ({ colors }) => ({
+ color: colors.palette.neutral100,
+ fontWeight: "bold",
+});
+
+const $disabledText: ThemedStyle = ({ colors }) => ({
+ color: colors.textDim,
+});
+
+const $dayTextDisabled: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 16,
+ color: colors.palette.neutral400,
+});
+
+// ENHANCEMENT 9: New styles for enhanced task indicators
+const $taskIndicatorsContainer: ThemedStyle = () => ({
+ position: "absolute",
+ bottom: 2,
+ left: 0,
+ right: 0,
+ alignItems: "center",
+});
+
+// Multi-day task period styles
+const $multiDayContainer: ThemedStyle = () => ({
+ width: "100%",
+ alignItems: "center",
+ marginBottom: 2,
+});
+
+const $multiDayIndicator: ThemedStyle = () => ({
+ height: 3,
+ width: "90%",
+ marginBottom: 1,
+});
+
+const $multiDayStart: ThemedStyle = () => ({
+ borderTopLeftRadius: 2,
+ borderBottomLeftRadius: 2,
+});
+
+const $multiDayEnd: ThemedStyle = () => ({
+ borderTopRightRadius: 2,
+ borderBottomRightRadius: 2,
+});
+
+const $multiDayMiddle: ThemedStyle = () => ({
+ // No border radius for middle segments
+});
+
+const $multiDayOverflow: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 8,
+ color: colors.textDim,
+ marginTop: 1,
+});
+
+// Single-day task dots styles
+const $taskDotsContainer: ThemedStyle = () => ({
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ flexWrap: "wrap",
+ gap: 1,
+});
+
+const $taskDot: ThemedStyle = () => ({
+ width: 4,
+ height: 4,
+ borderRadius: 2,
+});
+
+const $taskDotCompleted: ThemedStyle = () => ({
+ opacity: 0.6,
+});
+
+const $taskDotsOverflow: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 6,
+ color: colors.textDim,
+ marginLeft: 1,
+});
+
+// Legacy styles (kept for backward compatibility)
+const $taskIndicatorContainer: ThemedStyle = () => ({
+ position: "absolute",
+ bottom: 4,
+ flexDirection: "row",
+ gap: 2,
+});
+
+const $taskIndicatorPending: ThemedStyle = ({ colors }) => ({
+ width: 4,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: colors.palette.angry500,
+});
+
+const $taskIndicatorCompleted: ThemedStyle = ({ colors }) => ({
+ width: 4,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: colors.palette.primary500,
+});
+
+// Agenda List Styles
+const $agendaContainer: ThemedStyle = ({ spacing }) => ({
+ flex: 1,
+ marginTop: spacing.md,
+});
+
+const $agendaContentContainer: ThemedStyle = ({ spacing }) => ({
+ paddingHorizontal: spacing.md,
+ paddingBottom: spacing.lg,
+});
+
+const $sectionHeader: ThemedStyle = ({ colors, spacing }) => ({
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.md,
+ backgroundColor: colors.palette.neutral100,
+ borderRadius: 8,
+ marginVertical: spacing.xs,
+});
+
+const $sectionHeaderText: ThemedStyle = ({
+ colors,
+ typography,
+}) => ({
+ fontFamily: typography.primary.medium,
+ fontSize: 16,
+ color: colors.text,
+ textTransform: "capitalize",
+});
+
+const $sectionHeaderDate: ThemedStyle = ({
+ colors,
+ typography,
+}) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 14,
+ color: colors.textDim,
+});
+
+const $agendaItem: ThemedStyle = ({ colors, spacing }) => ({
+ flexDirection: "row",
+ alignItems: "flex-start",
+ backgroundColor: colors.palette.neutral100,
+ borderRadius: 8,
+ padding: spacing.md,
+ marginVertical: spacing.xs,
+ shadowColor: colors.palette.neutral900,
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.1,
+ shadowRadius: 2,
+ elevation: 2,
+});
+
+const $agendaItemIndicator: ThemedStyle = ({ colors }) => ({
+ width: 4,
+ height: 40,
+ borderRadius: 2,
+ backgroundColor: colors.palette.angry500,
+ marginRight: 12,
+ marginTop: 2,
+});
+
+const $agendaItemIndicatorCompleted: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.palette.primary500,
+});
+
+// CHANGE 9: New styles for period-specific indicators
+const $agendaItemIndicatorStart: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.palette.secondary500,
+ borderTopLeftRadius: 2,
+ borderTopRightRadius: 2,
+});
+
+const $agendaItemIndicatorEnd: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.palette.secondary500,
+ borderBottomLeftRadius: 2,
+ borderBottomRightRadius: 2,
+});
+
+const $agendaItemIndicatorMiddle: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.palette.secondary300,
+});
+
+// CHANGE 10: New styles for period text and duration
+const $agendaItemPeriodText: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.medium,
+ fontSize: 10,
+ color: colors.palette.secondary500,
+ textTransform: "uppercase",
+ marginBottom: 2,
+});
+
+const $agendaItemDuration: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 11,
+ color: colors.palette.secondary400,
+ marginBottom: 2,
+ fontStyle: "italic",
+});
+
+const $agendaItemContent: ThemedStyle = () => ({
+ flex: 1,
+});
+
+const $agendaItemTitle: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.medium,
+ fontSize: 16,
+ color: colors.text,
+ marginBottom: 4,
+});
+
+const $agendaItemTitleCompleted: ThemedStyle = ({ colors }) => ({
+ textDecorationLine: "line-through",
+ color: colors.textDim,
+});
+
+const $agendaItemDescription: ThemedStyle = ({
+ colors,
+ typography,
+}) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 14,
+ color: colors.textDim,
+ marginBottom: 4,
+});
+
+const $agendaItemTime: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 12,
+ color: colors.palette.primary500,
+ fontWeight: "600",
+});
+
+const $emptyAgendaSection: ThemedStyle = ({ spacing }) => ({
+ padding: spacing.lg,
+ alignItems: "center",
+});
+
+const $emptyAgendaText: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 14,
+ color: colors.textDim,
+ fontStyle: "italic",
+});
+
+const $itemSeparator: ThemedStyle = () => ({
+ height: 1,
+ backgroundColor: "transparent",
+});
+
+// Original Task Summary Styles (kept for backward compatibility)
+const $taskSummary: ThemedStyle = ({ colors, spacing }) => ({
+ marginTop: spacing.md,
+ padding: spacing.md,
+ backgroundColor: colors.palette.neutral100,
+ borderRadius: 8,
+ maxHeight: 200,
+});
+
+const $taskSummaryTitle: ThemedStyle = ({
+ colors,
+ typography,
+ spacing,
+}) => ({
+ fontFamily: typography.primary.medium,
+ fontSize: 16,
+ color: colors.text,
+ marginBottom: spacing.sm,
+});
+
+const $tasksList: ThemedStyle = () => ({
+ flex: 1,
+});
+
+const $taskItem: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ alignItems: "flex-start",
+ paddingVertical: spacing.xs,
+ gap: spacing.sm,
+});
+
+const $taskStatusIndicator: ThemedStyle = ({ colors }) => ({
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: colors.palette.angry500,
+ marginTop: 4,
+});
+
+const $taskStatusCompleted: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.palette.primary500,
+});
+
+const $taskContent: ThemedStyle = () => ({
+ flex: 1,
+});
+
+const $taskTitle: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.medium,
+ fontSize: 14,
+ color: colors.text,
+});
+
+const $taskTitleCompleted: ThemedStyle = ({ colors }) => ({
+ textDecorationLine: "line-through",
+ color: colors.textDim,
+});
+
+const $taskDescription: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 12,
+ color: colors.textDim,
+ marginTop: 2,
+});
+
+const $noTasksText: ThemedStyle = ({ colors, typography }) => ({
+ fontFamily: typography.primary.normal,
+ fontSize: 14,
+ color: colors.textDim,
+ textAlign: "center",
+ fontStyle: "italic",
+});
\ No newline at end of file
diff --git a/app/components/Icon.tsx b/app/components/Icon.tsx
index e562ebd..13e5114 100644
--- a/app/components/Icon.tsx
+++ b/app/components/Icon.tsx
@@ -111,6 +111,7 @@ export function Icon(props: IconProps) {
export const iconRegistry = {
back: require("../../assets/icons/back.png"),
bell: require("../../assets/icons/bell.png"),
+ calendar: require("../../assets/icons/schedule.png"),
caretLeft: require("../../assets/icons/caretLeft.png"),
caretRight: require("../../assets/icons/caretRight.png"),
check: require("../../assets/icons/check.png"),
@@ -131,6 +132,9 @@ export const iconRegistry = {
slack: require("../../assets/icons/demo/slack.png"),
view: require("../../assets/icons/view.png"),
x: require("../../assets/icons/x.png"),
+ google: require("../../assets/icons/google.png"),
+ spot: require("../../assets/icons/black-circle.png"),
+ news: require("../../assets/icons/newspaper.png"),
}
const $imageStyleBase: ImageStyle = {
diff --git a/app/components/TaskModal.tsx b/app/components/TaskModal.tsx
new file mode 100644
index 0000000..643f9fb
--- /dev/null
+++ b/app/components/TaskModal.tsx
@@ -0,0 +1,1238 @@
+// TaskModal.tsx - Enhanced version with start date, due date, and improved date navigation
+import React, { FC, useState, useEffect } from "react";
+import {
+ StyleProp,
+ ViewStyle,
+ Modal,
+ View,
+ ScrollView,
+ TouchableOpacity,
+ Alert,
+ Dimensions,
+ TextStyle,
+ Platform,
+} from "react-native";
+import { observer } from "mobx-react-lite";
+
+import { useAppTheme } from "@/utils/useAppTheme";
+import type { ThemedStyle, Theme } from "@/theme";
+import { Text } from "@/components/Text";
+import { TextField } from "@/components/TextField";
+import { Button } from "@/components/Button";
+import DateTimePicker from "@react-native-community/datetimepicker";
+
+export interface TaskModalProps {
+ style?: StyleProp;
+ visible: boolean;
+ onClose: () => void;
+ onSave: (task: {
+ title: string;
+ description: string;
+ startDate: Date;
+ dueDate: Date;
+ taskTime: Date;
+ priority: "high" | "medium" | "low";
+ reminder: boolean;
+ }) => void;
+ initialDate?: Date;
+}
+
+export const TaskModal: FC = observer(function TaskModal(
+ props
+) {
+ const { style, visible, onClose, onSave, initialDate } = props;
+ const { themed, theme } = useAppTheme();
+
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [startDate, setStartDate] = useState(initialDate || new Date());
+ const [dueDate, setDueDate] = useState(() => {
+ const date = initialDate || new Date();
+ const tomorrow = new Date(date);
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ return tomorrow;
+ });
+ const [showTimePicker, setShowTimePicker] = useState(false);
+ const [selectedTime, setSelectedTime] = useState(new Date());
+ const [showDatePicker, setShowDatePicker] = useState(false);
+ const [datePickerType, setDatePickerType] = useState<"start" | "due">(
+ "start"
+ );
+ const [selectedPriority, setSelectedPriority] = useState<
+ "high" | "medium" | "low"
+ >("medium");
+ const [reminderEnabled, setReminderEnabled] = useState(true);
+
+ // Update dates when initialDate changes
+ useEffect(() => {
+ if (initialDate) {
+ setStartDate(new Date(initialDate));
+ const tomorrow = new Date(initialDate);
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ setDueDate(tomorrow);
+ }
+ }, [initialDate]);
+
+ const priorities = [
+ { key: "high", label: "🔴 High Priority", description: "Urgent tasks that need immediate attention" },
+ { key: "medium", label: "🟡 Medium Priority", description: "Important tasks with moderate urgency" },
+ { key: "low", label: "🟢 Low Priority", description: "Tasks that can be done when time allows" },
+ ];
+
+ const handleSave = () => {
+ if (!title.trim()) {
+ Alert.alert("Error", "Please enter a task title");
+ return;
+ }
+
+ // Add date validation
+ if (isNaN(startDate.getTime()) || isNaN(dueDate.getTime())) {
+ Alert.alert("Error", "Please select valid dates");
+ return;
+ }
+
+ if (startDate > dueDate) {
+ Alert.alert("Error", "Start date cannot be after due date");
+ return;
+ }
+
+ onSave({
+ title: title.trim(),
+ description: description.trim(),
+ taskTime: selectedTime,
+ startDate: startDate,
+ dueDate: dueDate,
+ priority: selectedPriority,
+ reminder: reminderEnabled,
+ });
+
+ // Reset form
+ setTitle("");
+ setDescription("");
+ setStartDate(new Date());
+ setDueDate(new Date());
+ setSelectedPriority("medium");
+ setReminderEnabled(true);
+ onClose();
+ };
+
+ const formatDate = (date: Date) => {
+ return date.toLocaleDateString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ };
+
+ const formatTime = (time: Date) => {
+ return time.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ };
+
+ const openDatePicker = (type: "start" | "due") => {
+ setDatePickerType(type);
+ setShowDatePicker(true);
+ };
+
+ // Fixed Time Picker Component
+ const SimpleTimePicker = () => {
+ const [selectedHour, setSelectedHour] = useState(selectedTime.getHours());
+ const [selectedMinute, setSelectedMinute] = useState(
+ selectedTime.getMinutes()
+ );
+
+ const hours = Array.from({ length: 24 }, (_, i) => i);
+ const minutes = Array.from({ length: 60 }, (_, i) => i);
+
+ const handleConfirm = () => {
+ const newTime = new Date(selectedTime);
+ newTime.setHours(selectedHour, selectedMinute, 0, 0);
+ setSelectedTime(newTime);
+ setShowTimePicker(false);
+ };
+
+ return (
+
+
+
+
+
+ Select Time
+
+ setShowTimePicker(false)}>
+ ✕
+
+
+
+
+ {/* Hour Picker */}
+
+ Hour
+
+ {hours.map((hour) => (
+ setSelectedHour(hour)}
+ >
+
+ {hour.toString().padStart(2, "0")}
+
+
+ ))}
+
+
+
+ {/* Separator */}
+
+ :
+
+
+ {/* Minute Picker */}
+
+ Minute
+
+ {minutes
+ .filter((m) => m % 5 === 0)
+ .map((minute) => (
+ setSelectedMinute(minute)}
+ >
+
+ {minute.toString().padStart(2, "0")}
+
+
+ ))}
+
+
+
+
+ {/* Quick Time Buttons */}
+
+ {
+ setSelectedHour(9);
+ setSelectedMinute(0);
+ }}
+ >
+ 9:00 AM
+
+ {
+ setSelectedHour(13);
+ setSelectedMinute(0);
+ }}
+ >
+ 1:00 PM
+
+ {
+ setSelectedHour(18);
+ setSelectedMinute(0);
+ }}
+ >
+ 6:00 PM
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+ );
+ };
+
+ // Enhanced date picker with month/year navigation
+ const EnhancedDatePicker = ({
+ onDateSelect,
+ }: {
+ onDateSelect: (date: Date) => void;
+ }) => {
+ const currentDate = datePickerType === "start" ? startDate : dueDate;
+ const [viewDate, setViewDate] = useState(new Date(currentDate));
+ const [showYearPicker, setShowYearPicker] = useState(false);
+
+ const monthNames = [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+ ];
+
+ const getDaysInMonth = (date: Date) => {
+ const year = date.getFullYear();
+ const month = date.getMonth();
+ const firstDay = new Date(year, month, 1);
+ const lastDay = new Date(year, month + 1, 0);
+ const daysInMonth = lastDay.getDate();
+ const startingDayOfWeek = firstDay.getDay();
+
+ const days = [];
+
+ // Add empty cells for days before the month starts
+ for (let i = 0; i < startingDayOfWeek; i++) {
+ days.push(null);
+ }
+
+ // Add days of the month
+ for (let day = 1; day <= daysInMonth; day++) {
+ days.push(new Date(year, month, day));
+ }
+
+ return days;
+ };
+
+ const navigateMonth = (direction: "prev" | "next") => {
+ const newDate = new Date(viewDate);
+ newDate.setMonth(viewDate.getMonth() + (direction === "next" ? 1 : -1));
+ setViewDate(newDate);
+ };
+
+ const navigateYear = (direction: "prev" | "next") => {
+ const newDate = new Date(viewDate);
+ newDate.setFullYear(
+ viewDate.getFullYear() + (direction === "next" ? 1 : -1)
+ );
+ setViewDate(newDate);
+ };
+
+ const selectYear = (year: number) => {
+ const newDate = new Date(viewDate);
+ newDate.setFullYear(year);
+ setViewDate(newDate);
+ setShowYearPicker(false);
+ };
+
+ const isToday = (date: Date) => {
+ const today = new Date();
+ return date.toDateString() === today.toDateString();
+ };
+
+ const isSelected = (date: Date) => {
+ return date.toDateString() === currentDate.toDateString();
+ };
+
+ const yearRange = Array.from(
+ { length: 20 },
+ (_, i) => new Date().getFullYear() - 10 + i
+ );
+
+ if (showYearPicker) {
+ return (
+
+
+
+
+
+ Select Year
+
+ setShowDatePicker(false)}>
+ ✕
+
+
+
+ {yearRange.map((year) => (
+ selectYear(year)}
+ >
+
+ {year}
+
+
+ ))}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Select {datePickerType === "start" ? "Start" : "Due"} Date
+
+ setShowDatePicker(false)}>
+ ✕
+
+
+
+ {/* Month/Year Navigation */}
+
+ navigateMonth("prev")}
+ style={themed($navButton)}
+ >
+ ‹
+
+
+
+ setShowYearPicker(true)}
+ style={themed($monthYearButton)}
+ >
+
+ {monthNames[viewDate.getMonth()]} {viewDate.getFullYear()}
+
+
+
+
+ navigateMonth("next")}
+ style={themed($navButton)}
+ >
+ ›
+
+
+
+ {/* Weekday Headers */}
+
+ {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
+
+ {day}
+
+ ))}
+
+
+ {/* Calendar Grid */}
+
+ {getDaysInMonth(viewDate).map((day, index) => (
+ {
+ if (day) {
+ onDateSelect(day);
+ setShowDatePicker(false);
+ }
+ }}
+ disabled={!day}
+ >
+
+ {day?.getDate()}
+
+
+ ))}
+
+
+ {/* Quick Actions */}
+
+ {
+ onDateSelect(new Date());
+ setShowDatePicker(false);
+ }}
+ >
+ Today
+
+ {
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ onDateSelect(tomorrow);
+ setShowDatePicker(false);
+ }}
+ >
+ Tomorrow
+
+ {
+ const nextWeek = new Date();
+ nextWeek.setDate(nextWeek.getDate() + 7);
+ onDateSelect(nextWeek);
+ setShowDatePicker(false);
+ }}
+ >
+ Next Week
+
+
+
+
+
+ );
+ };
+
+ return (
+
+
+
+
+ {/* Header */}
+
+
+ Add New Task
+
+
+ ✕
+
+
+
+ {/* Task Title */}
+
+
+ Task Title *
+
+
+
+
+ {/* Task Description */}
+
+
+ Description
+
+
+
+
+ {/* Time Selection */}
+
+
+ Time
+
+ setShowTimePicker(true)}
+ style={themed($dateTimeButton)}
+ >
+
+ 🕐 {formatTime(selectedTime)}
+
+
+
+
+ {/* Date Range Section */}
+
+ {/* Start Date */}
+
+
+ Start Date
+
+ openDatePicker("start")}
+ style={themed($dateTimeButton)}
+ >
+
+ 🗓️ {formatDate(startDate)}
+
+
+
+
+ {/* Due Date */}
+
+
+ Due Date
+
+ openDatePicker("due")}
+ style={themed($dateTimeButton)}
+ >
+
+ 📅 {formatDate(dueDate)}
+
+
+
+
+
+ {/* Duration Display */}
+
+
+ Duration:{" "}
+ {Math.ceil(
+ (dueDate.getTime() - startDate.getTime()) /
+ (1000 * 60 * 60 * 24)
+ )}{" "}
+ days
+
+
+
+ {/* Priority Selection */}
+
+
+ Priority
+
+
+ {priorities.map((priority) => (
+ setSelectedPriority(priority.key as any)}
+ style={themed([
+ $priorityButton,
+ selectedPriority === priority.key && $priorityButtonActive,
+ ])}
+ >
+
+ {priority.label}
+
+
+ {priority.description}
+
+
+ ))}
+
+
+
+ {/* Reminder Toggle */}
+
+ setReminderEnabled(!reminderEnabled)}
+ style={themed($reminderToggle)}
+ >
+
+ 🔔
+
+
+ Set Reminder
+
+
+ Get notified when it's time for your task
+
+
+
+
+
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+
+
+ {/* Enhanced Date Picker */}
+ {
+ if (datePickerType === "start") {
+ setStartDate(date);
+ // Auto-adjust due date if it's before start date
+ if (date > dueDate) {
+ setDueDate(date);
+ }
+ } else {
+ setDueDate(date);
+ // Auto-adjust start date if it's after due date
+ if (date < startDate) {
+ setStartDate(date);
+ }
+ }
+ }}
+ />
+
+ {/* Custom Time Picker */}
+
+
+ );
+});
+
+// Styles
+const $modalOverlay: ViewStyle = {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "center",
+ alignItems: "center",
+};
+
+const $modalContainer: ThemedStyle = ({ colors, spacing }) => ({
+ backgroundColor: colors.background,
+ borderRadius: 16,
+ width: Dimensions.get("window").width * 0.9,
+ maxHeight: Dimensions.get("window").height * 0.85,
+ padding: spacing.lg,
+});
+
+const $pickerModal: ThemedStyle = ({ colors, spacing }) => ({
+ backgroundColor: colors.background,
+ borderRadius: 16,
+ width: Dimensions.get("window").width * 0.9,
+ maxHeight: Dimensions.get("window").height * 0.7,
+ padding: spacing.lg,
+});
+
+const $pickerHeader: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: spacing.md,
+});
+
+const $pickerTitle: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 18,
+ fontWeight: "600",
+});
+
+const $dateNavigation: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: spacing.md,
+});
+
+const $navButton: ThemedStyle = ({ colors, spacing }) => ({
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: colors.tint,
+ justifyContent: "center",
+ alignItems: "center",
+});
+
+const $navButtonText: ThemedStyle = ({ colors }) => ({
+ color: colors.background,
+ fontSize: 20,
+ fontWeight: "bold",
+});
+
+const $monthYearContainer: ThemedStyle = () => ({
+ flex: 1,
+ alignItems: "center",
+});
+
+const $monthYearButton: ThemedStyle = ({ colors, spacing }) => ({
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xs,
+ borderRadius: 8,
+ backgroundColor: colors.separator,
+});
+
+const $monthYearText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 16,
+ fontWeight: "600",
+});
+
+const $weekdayHeader: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ justifyContent: "space-around",
+ marginBottom: spacing.sm,
+});
+
+const $weekdayText: ThemedStyle = ({ colors }) => ({
+ color: colors.textDim,
+ fontSize: 12,
+ fontWeight: "600",
+ width: 40,
+ textAlign: "center",
+});
+
+const $calendarGrid: ThemedStyle = () => ({
+ flexDirection: "row",
+ flexWrap: "wrap",
+ justifyContent: "space-around",
+});
+
+const $calendarDay: ThemedStyle = ({ spacing }) => ({
+ width: 40,
+ height: 40,
+ justifyContent: "center",
+ alignItems: "center",
+ marginBottom: spacing.xs,
+});
+
+const $calendarDayEmpty: ThemedStyle = () => ({
+ backgroundColor: "transparent",
+});
+
+const $calendarDayToday: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.separator,
+ borderRadius: 20,
+});
+
+const $calendarDaySelected: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.tint,
+ borderRadius: 20,
+});
+
+const $calendarDayText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 16,
+});
+
+const $calendarDayTextToday: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontWeight: "bold",
+});
+
+const $calendarDayTextSelected: ThemedStyle = ({ colors }) => ({
+ color: colors.background,
+ fontWeight: "bold",
+});
+
+const $quickActions: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ justifyContent: "space-around",
+ marginTop: spacing.md,
+});
+
+const $quickActionButton: ThemedStyle = ({ colors, spacing }) => ({
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderRadius: 8,
+ backgroundColor: colors.separator,
+});
+
+const $quickActionText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 14,
+});
+
+const $yearPickerList: ThemedStyle = () => ({
+ maxHeight: 300,
+});
+
+const $yearPickerItem: ThemedStyle = ({ colors, spacing }) => ({
+ padding: spacing.md,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.separator,
+ alignItems: "center",
+});
+
+const $yearPickerItemActive: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.tint,
+});
+
+const $yearPickerItemText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 16,
+});
+
+const $yearPickerItemTextActive: ThemedStyle = ({ colors }) => ({
+ color: colors.background,
+ fontWeight: "bold",
+});
+
+const $dateRangeSection: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ gap: spacing.md,
+ marginBottom: spacing.lg,
+});
+
+const $dateInputContainer: ThemedStyle = () => ({
+ flex: 1,
+});
+
+const $durationDisplay: ThemedStyle = ({ colors, spacing }) => ({
+ backgroundColor: colors.separator,
+ padding: spacing.sm,
+ borderRadius: 8,
+ alignItems: "center",
+ marginBottom: spacing.lg,
+});
+
+const $durationText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 14,
+ fontWeight: "600",
+});
+
+// Existing styles remain the same...
+const $modalHeader: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: spacing.lg,
+});
+
+const $modalTitle: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 20,
+});
+
+const $closeButton: ThemedStyle = ({ colors }) => ({
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: colors.separator,
+ justifyContent: "center",
+ alignItems: "center",
+});
+
+const $closeButtonText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 16,
+ fontWeight: "bold",
+});
+
+const $inputSection: ThemedStyle = ({ spacing }) => ({
+ marginBottom: spacing.lg,
+});
+
+const $label: ThemedStyle = ({ colors, spacing }) => ({
+ color: colors.text,
+ marginBottom: spacing.xs,
+ fontWeight: "600",
+});
+
+const $textInput: ThemedStyle = ({ colors, spacing }) => ({
+ borderColor: colors.separator,
+ padding: spacing.sm,
+ backgroundColor: colors.background,
+});
+
+const $textAreaInput: ThemedStyle = ({ colors, spacing }) => ({
+ borderColor: colors.separator,
+ padding: spacing.sm,
+ backgroundColor: colors.background,
+ minHeight: 80,
+});
+
+const $dateTimeButton: ThemedStyle = ({ colors, spacing }) => ({
+ borderWidth: 1,
+ borderColor: colors.separator,
+ borderRadius: 8,
+ padding: spacing.md,
+ backgroundColor: colors.background,
+});
+
+const $dateTimeButtonText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 16,
+});
+
+const $priorityContainer: ThemedStyle = ({ spacing }) => ({
+ gap: spacing.sm,
+});
+
+const $priorityButton: ThemedStyle = ({ colors, spacing }) => ({
+ borderWidth: 1,
+ borderColor: colors.separator,
+ borderRadius: 8,
+ padding: spacing.md,
+ backgroundColor: colors.background,
+});
+
+const $priorityButtonActive: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.tint,
+ borderColor: colors.tint,
+});
+
+const $priorityButtonText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 16,
+ fontWeight: "600",
+});
+
+const $priorityButtonTextActive: ThemedStyle = ({ colors }) => ({
+ color: colors.background,
+});
+
+const $priorityDescriptionText: ThemedStyle = ({ colors }) => ({
+ color: colors.textDim,
+ fontSize: 12,
+ marginTop: 2,
+});
+
+const $priorityDescriptionTextActive: ThemedStyle = ({ colors }) => ({
+ color: colors.background,
+});
+
+const $reminderToggle: ThemedStyle = ({ colors, spacing }) => ({
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ padding: spacing.md,
+ borderWidth: 1,
+ borderColor: colors.separator,
+ borderRadius: 8,
+ backgroundColor: colors.background,
+});
+
+const $reminderToggleLeft: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+});
+
+const $reminderToggleIcon: ThemedStyle = () => ({
+ fontSize: 20,
+});
+
+const $reminderLabel: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontWeight: "600",
+});
+
+const $reminderDescription: ThemedStyle = ({ colors }) => ({
+ color: colors.textDim,
+ fontSize: 12,
+});
+
+const $switch: ThemedStyle = ({ colors }) => ({
+ width: 48,
+ height: 28,
+ borderRadius: 14,
+ backgroundColor: colors.separator,
+ padding: 2,
+ justifyContent: "center",
+});
+
+const $switchActive: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.tint,
+});
+
+const $switchThumb: ThemedStyle = ({ colors }) => ({
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: colors.background,
+ shadowColor: "#000",
+ shadowOffset: {
+ width: 0,
+ height: 2,
+ },
+ shadowOpacity: 0.25,
+ shadowRadius: 3.84,
+ elevation: 5,
+});
+
+const $switchThumbActive: ThemedStyle = () => ({
+ transform: [{ translateX: 20 }],
+});
+
+const $buttonContainer: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ gap: spacing.sm,
+ marginTop: spacing.lg,
+});
+
+const $cancelButton: ThemedStyle = () => ({
+ flex: 1,
+});
+
+const $saveButton: ThemedStyle = () => ({
+ flex: 1,
+});
+
+const $pickerList: ThemedStyle = ({ spacing }) => ({
+ maxHeight: 300,
+});
+
+const $pickerItem: ThemedStyle = ({ colors, spacing }) => ({
+ padding: spacing.md,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.separator,
+});
+
+const $pickerItemText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 16,
+});
+
+// Time Picker Styles
+const $timePickerContainer: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ alignItems: "flex-start",
+ marginBottom: spacing.lg,
+});
+
+const $timePickerColumn: ThemedStyle = () => ({
+ flex: 1,
+ alignItems: "center",
+});
+
+const $timePickerLabel: ThemedStyle = ({ colors, spacing }) => ({
+ color: colors.text,
+ fontSize: 16,
+ fontWeight: "600",
+ marginBottom: spacing.sm,
+});
+
+const $timePickerScroll: ThemedStyle = ({ colors }) => ({
+ maxHeight: 150,
+ width: "100%",
+ borderWidth: 1,
+ borderColor: colors.separator,
+ borderRadius: 8,
+});
+
+const $timePickerItem: ThemedStyle = ({ colors, spacing }) => ({
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.md,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.separator,
+ alignItems: "center",
+});
+
+const $timePickerItemSelected: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.tint,
+});
+
+const $timePickerItemText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 16,
+});
+
+const $timePickerItemTextSelected: ThemedStyle = ({ colors }) => ({
+ color: colors.background,
+ fontWeight: "bold",
+});
+
+const $timePickerSeparator: ThemedStyle = ({ spacing }) => ({
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: spacing.md,
+ marginTop: spacing.xl,
+});
+
+const $timePickerSeparatorText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 24,
+ fontWeight: "bold",
+});
+
+const $quickTimeButtons: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ justifyContent: "space-around",
+ marginBottom: spacing.lg,
+});
+
+const $quickTimeButton: ThemedStyle = ({ colors, spacing }) => ({
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderRadius: 8,
+ backgroundColor: colors.separator,
+});
+
+const $quickTimeButtonText: ThemedStyle = ({ colors }) => ({
+ color: colors.text,
+ fontSize: 14,
+});
+
+const $timePickerActions: ThemedStyle = ({ spacing }) => ({
+ flexDirection: "row",
+ gap: spacing.sm,
+});
+
+const $timePickerCancelButton: ThemedStyle = () => ({
+ flex: 1,
+});
+
+const $timePickerConfirmButton: ThemedStyle = () => ({
+ flex: 1,
+});
diff --git a/app/components/index.ts b/app/components/index.ts
index 4350638..9091cea 100644
--- a/app/components/index.ts
+++ b/app/components/index.ts
@@ -10,3 +10,6 @@ export * from "./Text"
export * from "./TextField"
export * from "./Toggle"
export * from "./EmptyState"
+export * from "./Calendar"
+export * from "./TaskModal"
+export * from "./AlertTongle"
diff --git a/app/i18n/en.ts b/app/i18n/en.ts
index 7ab6f8a..afc94fb 100644
--- a/app/i18n/en.ts
+++ b/app/i18n/en.ts
@@ -9,8 +9,8 @@ const en = {
},
welcomeScreen: {
postscript:
- "psst — This probably isn't what your app looks like. (Unless your designer handed you these screens, and in that case, ship it!)",
- readyForLaunch: "Your app, almost ready for launch!",
+ "Dooit helps you stay organized and productive with its intuitive interface and robust features.",
+ readyForLaunch: "Let's Start Something fun",
exciting: "(ohh, this is exciting!)",
letsGo: "Let's go!",
},
@@ -38,9 +38,12 @@ const en = {
"Enter your details below to unlock top secret info. You'll never guess what we've got waiting. Or maybe you will; it's not rocket science here.",
emailFieldLabel: "Email",
passwordFieldLabel: "Password",
+ passwordFieldLabelConf: "Confirm Password",
+
emailFieldPlaceholder: "Enter your email address",
passwordFieldPlaceholder: "Super secret password here",
tapToLogIn: "Tap to log in!",
+ toRegister: "Don't have an account" ,
hint: "Hint: you can use any email address and your favorite password :)",
},
demoNavigator: {
@@ -73,7 +76,7 @@ const en = {
hireUsLink: "Send us a message",
},
demoShowroomScreen: {
- jumpStart: "Components to jump start your project!",
+ jumpStart: "Welcome {{user}}",
lorem2Sentences:
"Nulla cupidatat deserunt amet quis aliquip nostrud do adipisicing. Adipisicing excepteur elit laborum Lorem adipisicing do duis.",
demoHeaderTxExample: "Yay",
@@ -122,6 +125,18 @@ const en = {
},
},
+ signUpScreen: {
+ signUp: "Create Account",
+ enterDetails:
+ "Fill in your details below to access top secret content. It’s not Area 51... but close enough.",
+ emailFieldLabel: "Email",
+ passwordFieldLabel: "Password",
+ emailFieldPlaceholder: "Enter your email address",
+ passwordFieldPlaceholder: "Choose a strong password",
+ ToLogIn: "Already have an account? Log in here!",
+ hint: "Hint: any valid email works. Make your password memorable!",
+ },
+
...demoEn,
}
diff --git a/app/lib/Parse/index.ts b/app/lib/Parse/index.ts
new file mode 100644
index 0000000..335c9d0
--- /dev/null
+++ b/app/lib/Parse/index.ts
@@ -0,0 +1 @@
+export * from './parse'
\ No newline at end of file
diff --git a/app/lib/Parse/parse.ts b/app/lib/Parse/parse.ts
new file mode 100644
index 0000000..d4a9dfc
--- /dev/null
+++ b/app/lib/Parse/parse.ts
@@ -0,0 +1,55 @@
+import { initializeParse } from "@parse/react-native"
+import AsyncStorage from "@react-native-async-storage/async-storage"
+import Parse from "parse/react-native"
+
+// Parse server details
+const PARSE_SERVER_URL = process.env.EXPO_PUBLIC_SERVER_URL
+const PARSE_APP_ID = process.env.EXPO_PUBLIC_APP_ID
+const PARSE_JS_KEY = process.env.EXPO_PUBLIC_JAVASCRIPT_KEY
+
+if (!PARSE_SERVER_URL || !PARSE_APP_ID) {
+ throw new Error("Missing required Parse server configuration. Please check your environment variables.")
+}
+
+// Initialize Parse with AsyncStorage for React Native
+initializeParse(
+ PARSE_SERVER_URL,
+ PARSE_APP_ID,
+ PARSE_JS_KEY
+)
+
+// Configure Parse only once
+if (!Parse.applicationId) {
+ // @ts-ignore - serverURL is writeable at runtime
+ Parse.serverURL = PARSE_SERVER_URL
+ Parse.initialize(PARSE_APP_ID, PARSE_JS_KEY)
+
+ // Set AsyncStorage first
+ Parse.setAsyncStorage(AsyncStorage)
+
+ // Then enable local datastore
+ Parse.enableLocalDatastore()
+}
+
+// Type-safe wrapper functions
+export const getCurrentUser = async (): Promise => {
+ try {
+ return await Parse.User.currentAsync()
+ } catch (error) {
+ console.error("Error getting current user:", error)
+ return null
+ }
+}
+
+export const fetchAllUsers = async (): Promise => {
+ try {
+ const query = new Parse.Query('_User')
+ const results = await query.find()
+ return results as unknown as Parse.User[]
+ } catch (error) {
+ console.error("Error fetching users:", error)
+ return []
+ }
+}
+
+export default Parse
\ No newline at end of file
diff --git a/app/models/AuthenticationStore.ts b/app/models/AuthenticationStore.ts
index 3d35ae5..5169a9e 100644
--- a/app/models/AuthenticationStore.ts
+++ b/app/models/AuthenticationStore.ts
@@ -1,35 +1,360 @@
-import { Instance, SnapshotOut, types } from "mobx-state-tree"
+import { Instance, SnapshotOut, types, flow } from "mobx-state-tree"
+import type { User } from "parse"
+import { withSetPropAction } from "./helpers/withSetPropAction"
+import Parse from "@/lib/Parse/parse"
+
+// Extend the Parse.User type with our custom methods
+export interface IAppUser extends Parse.User {
+ getEmail(): string
+ getUsername(): string
+ getSessionToken(): string
+}
+
+import type { TokenResponse } from "expo-auth-session"
+
+// Google authentication response type
+export interface GoogleAuthResponse {
+ type: "success" | "dismiss" | "cancel" | "opened" | "locked" | "error"
+ errorCode?: string | null
+ error?: any
+ params?: Record
+ authentication?:
+ | (TokenResponse & {
+ idToken?: string
+ accessToken?: string
+ })
+ | null
+ url?: string
+}
+
+// Helper function to check if Parse server is running
+const checkParseServerConnection = async (): Promise => {
+ try {
+ // Try to make a simple health check query
+ const TestObject = Parse.Object.extend("TestConnection")
+ const query = new Parse.Query(TestObject)
+ query.limit(1)
+
+ // This will throw an error if server is not reachable
+ await query.find()
+ return true
+ } catch (error) {
+ console.error("Parse server connection check failed:", error)
+ return false
+ }
+}
export const AuthenticationStoreModel = types
.model("AuthenticationStore")
.props({
authToken: types.maybe(types.string),
authEmail: "",
+ authPassword: "",
+ isLoading: false,
+ error: "",
+ currentUser: types.maybe(types.frozen()),
+ username: types.maybe(types.string),
})
.views((store) => ({
get isAuthenticated() {
- return !!store.authToken
+ return !!store.currentUser || !!store.authToken
},
get validationError() {
- if (store.authEmail.length === 0) return "can't be blank"
- if (store.authEmail.length < 6) return "must be at least 6 characters"
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(store.authEmail))
- return "must be a valid email address"
+ if (store.authEmail.length === 0) return "Email can't be blank"
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(store.authEmail)) {
+ return "Must be a valid email address"
+ }
+ if (store.authPassword.length < 6) {
+ return "Password must be at least 6 characters"
+ }
return ""
},
}))
.actions((store) => ({
- setAuthToken(value?: string) {
- store.authToken = value
- },
setAuthEmail(value: string) {
- store.authEmail = value.replace(/ /g, "")
+ store.authEmail = value.replace(/\s+/g, "")
+ },
+ setAuthPassword(value: string) {
+ store.authPassword = value
+ },
+ setError(value: string) {
+ store.error = value
},
- logout() {
- store.authToken = undefined
+ setAuthToken(value: string | undefined, authPassword: string) {
+ store.authToken = value
+ },
+ resetAuthState() {
store.authEmail = ""
+ store.authPassword = ""
+ store.error = ""
+ store.isLoading = false
},
}))
+ .actions((store) => ({
+ login: flow(function* login() {
+ store.isLoading = true
+ store.error = ""
+
+ try {
+ // Check if Parse server is running first
+ console.log("Checking Parse server connection...")
+ const isServerRunning: boolean = yield checkParseServerConnection()
+
+ if (!isServerRunning) {
+ const errorMessage = "Parse server is not running. Please check your server connection."
+ console.error("Server check failed:", errorMessage)
+ store.error = errorMessage
+ return { success: false, error: errorMessage }
+ }
+
+ console.log("Parse server is running. Proceeding with login...")
+
+ const user: Parse.User = yield Parse.User.logIn(store.authEmail, store.authPassword)
+ store.currentUser = user
+ store.authToken = user.getSessionToken()
+ store.error = ""
+ return { success: true }
+ } catch (error: unknown) {
+ let errorMessage = "Failed to log in"
+
+ // Handle specific Parse server connection errors
+ if (error instanceof Error) {
+ if (
+ error.message.includes("XMLHttpRequest") ||
+ error.message.includes("Network Error") ||
+ error.message.includes("Failed to fetch") ||
+ error.message.includes("ECONNREFUSED")
+ ) {
+ errorMessage = "Cannot connect to server. Please check if the Parse server is running."
+ } else {
+ errorMessage = error.message
+ }
+ }
+
+ console.error("Login error:", errorMessage)
+ store.error = errorMessage
+ return { success: false, error: errorMessage }
+ } finally {
+ store.isLoading = false
+ }
+ }),
+ signUp: flow(function* signUp(username: string) {
+ store.isLoading = true
+ store.error = ""
+
+ try {
+ // Check if Parse server is running first
+ console.log("Checking Parse server connection...")
+ const isServerRunning: boolean = yield checkParseServerConnection()
+
+ if (!isServerRunning) {
+ const errorMessage = "Parse server is not running. Please check your server connection."
+ console.error("Server check failed:", errorMessage)
+ store.error = errorMessage
+ return { success: false, error: errorMessage, throwError: true }
+ }
+
+ console.log("Parse server is running. Proceeding with signup...")
+
+ const user = new Parse.User()
+ user.set("username", username)
+ user.set("email", store.authEmail)
+ user.set("password", store.authPassword)
+
+ const newUser: Parse.User = yield user.signUp()
+ store.currentUser = newUser
+ store.authToken = newUser.getSessionToken()
+ store.error = ""
+ console.log("Signup successful!")
+ return { success: true }
+ } catch (error: unknown) {
+ let errorMessage = "Failed to sign up"
+
+ // Handle specific Parse server connection errors
+ if (error instanceof Error) {
+ if (
+ error.message.includes("XMLHttpRequest") ||
+ error.message.includes("Network Error") ||
+ error.message.includes("Failed to fetch") ||
+ error.message.includes("ECONNREFUSED")
+ ) {
+ errorMessage = "Cannot connect to server. Please check if the Parse server is running."
+ } else {
+ errorMessage = error.message
+ }
+ }
+
+ console.error("Signup error:", errorMessage)
+ store.error = errorMessage
+ return { success: false, error: errorMessage }
+ } finally {
+ store.isLoading = false
+ }
+ }),
+ googleSignIn: flow(function* googleSignIn(
+ response: GoogleAuthResponse,
+ ): Generator, { success: boolean; error?: string; user?: User }, any> {
+ store.isLoading = true
+ store.error = ""
+
+ try {
+ // Early return if response is not successful
+ if (response?.type !== "success") {
+ console.log("Google authentication was not successful")
+ store.error = "Google authentication was cancelled or failed"
+ return { success: false, error: "Google authentication was cancelled or failed" }
+ }
+
+ // Handle case when authentication is null or missing required tokens
+ if (!response.authentication?.idToken || !response.authentication?.accessToken) {
+ console.error("Missing required authentication data in response", response.authentication)
+ store.error = "Incomplete authentication data received from Google"
+ return {
+ success: false,
+ error: "Incomplete authentication data. Please try signing in again.",
+ }
+ }
+
+ // Check if Parse server is running first
+ console.log("Checking Parse server connection...")
+ const isServerRunning: boolean = yield checkParseServerConnection()
+
+ if (!isServerRunning) {
+ const errorMessage = "Parse server is not running. Please check your server connection."
+ console.error("Server check failed:", errorMessage)
+ store.error = errorMessage
+ return { success: false, error: errorMessage }
+ }
+
+ console.log("Parse server is running. Proceeding with Google sign-in...")
+
+ const { idToken, accessToken } = response.authentication || {}
+
+ if (!idToken || !accessToken) {
+ throw new Error("Missing authentication tokens")
+ }
+
+ // Fetch Google user profile
+ const profileResponse: Response = yield fetch(
+ "https://www.googleapis.com/oauth2/v3/userinfo",
+ {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ },
+ )
+
+ if (!profileResponse.ok) {
+ throw new Error(`Failed to fetch user profile: ${profileResponse.status}`)
+ }
+
+ const profile = yield Promise.resolve(profileResponse.json())
+
+ // Validate required profile data
+ const { sub: googleUserId, email, name, picture } = profile
+
+ if (!googleUserId) {
+ throw new Error("Google ID (sub) not found in user profile")
+ }
+
+ if (!email || !name) {
+ throw new Error("Required user information missing from Google profile")
+ }
+
+ // Prepare Parse authentication data
+ const authData = {
+ id: googleUserId,
+ id_token: idToken,
+ access_token: accessToken,
+ }
+
+ // Authenticate with Parse
+ const user: Parse.User = yield Promise.resolve(Parse.User.logInWith("google", { authData }))
+
+ // Update store with authenticated user
+ store.currentUser = user
+ store.authToken = user.getSessionToken()
+ store.error = ""
+
+ console.log("Google login successful:", {
+ userId: googleUserId,
+ email,
+ username: name,
+ hasPicture: Boolean(picture),
+ })
+
+ return { success: true, user }
+ } catch (error: unknown) {
+ let errorMessage = "Failed to sign in with Google"
+
+ // Handle specific Parse server connection errors
+ if (error instanceof Error) {
+ if (
+ error.message.includes("XMLHttpRequest") ||
+ error.message.includes("Network Error") ||
+ error.message.includes("Failed to fetch") ||
+ error.message.includes("ECONNREFUSED")
+ ) {
+ errorMessage = "Cannot connect to server. Please check if the Parse server is running."
+ } else {
+ errorMessage = error.message
+ }
+ }
+
+ console.error("Google sign-in error:", errorMessage)
+ store.error = errorMessage
+ return { success: false, error: errorMessage }
+ } finally {
+ store.isLoading = false
+ }
+ }),
+ logout: flow(function* logout() {
+ try {
+ // Clear current user and token from store first
+ store.currentUser = undefined
+ store.authToken = undefined
+ store.resetAuthState()
+
+ // Then attempt Parse logout (this might fail in React Native)
+ try {
+ yield Parse.User.logOut()
+ } catch (logoutError) {
+ // Log the error but don't fail the logout process
+ console.warn("Parse.User.logOut() failed (this is normal in React Native):", logoutError)
+ }
+
+ return { success: true }
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "Failed to log out"
+ console.error("Logout error:", errorMessage)
+ store.error = errorMessage
+ return { success: false, error: errorMessage }
+ }
+ }),
+ checkCurrentUser: flow(function* checkCurrentUser() {
+ try {
+ const currentUser: Parse.User | null = yield Parse.User.currentAsync()
+ if (currentUser) {
+ store.currentUser = currentUser
+ store.authToken = currentUser.getSessionToken()
+ return true
+ }
+ return false
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "Unknown error"
+ console.error("Error checking current user:", errorMessage)
+ return false
+ }
+ }),
+ // Optional: Add a separate method to check server status
+ checkServerStatus: flow(function* checkServerStatus() {
+ try {
+ const isRunning: boolean = yield checkParseServerConnection()
+ return { isRunning, message: isRunning ? "Server is running" : "Server is not accessible" }
+ } catch (error) {
+ return { isRunning: false, message: "Failed to check server status" }
+ }
+ }),
+ }))
+ .actions(withSetPropAction)
export interface AuthenticationStore extends Instance {}
export interface AuthenticationStoreSnapshot extends SnapshotOut {}
diff --git a/app/models/NewsArticle.test.ts b/app/models/NewsArticle.test.ts
new file mode 100644
index 0000000..3db4a5d
--- /dev/null
+++ b/app/models/NewsArticle.test.ts
@@ -0,0 +1,202 @@
+import { NewsArticleModel } from "./NewsArticle"
+
+describe("NewsArticle", () => {
+ const createTestArticle = (overrides = {}) => {
+ return NewsArticleModel.create({
+ article_id: "test-1",
+ title: "Test Health Article",
+ link: "https://example.com/article",
+ pubDate: "2024-01-15T10:30:00Z",
+ source_id: "test-source",
+ source_name: "Healthcare Today",
+ source_url: "https://healthcaretoday.com",
+ keywords: ["health", "medicine"],
+ creator: ["Dr. Jane Smith"],
+ description: "This is a test article about healthcare developments and medical research findings.",
+ content: "Full article content with detailed information about healthcare trends and medical breakthroughs.",
+ country: ["US", "GH"],
+ category: ["health", "medicine"],
+ source_priority: 1500,
+ sentiment: "positive",
+ image_url: "https://example.com/image.jpg",
+ ...overrides,
+ })
+ }
+
+ test("can be created with minimal props", () => {
+ const instance = NewsArticleModel.create({
+ article_id: "1",
+ title: "Test Article",
+ link: "https://example.com/test",
+ pubDate: new Date().toISOString(),
+ source_id: "source1",
+ source_name: "Test Source",
+ source_url: "https://example.com/source",
+ })
+
+ expect(instance).toBeTruthy()
+ expect(instance.keywords).toEqual([])
+ expect(instance.description).toBe("")
+ expect(instance.language).toBe("en")
+ })
+
+ describe("date views", () => {
+ test("formats dates correctly", () => {
+ const article = createTestArticle({ pubDate: "2024-01-15T10:30:00Z" })
+
+ expect(article.formattedDate).toMatch(/2024/)
+ expect(article.formattedDateTime).toContain("2024")
+ })
+
+ test("handles invalid dates", () => {
+ const article = createTestArticle({ pubDate: "invalid-date" })
+ expect(article.formattedDate).toBe("invalid-date")
+ expect(article.timeAgo).toBe("")
+ })
+
+ test("calculates time ago", () => {
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
+ const article = createTestArticle({ pubDate: oneHourAgo.toISOString() })
+ expect(article.timeAgo).toBe("1h ago")
+ })
+ })
+
+ describe("multimedia views", () => {
+ test("detects multimedia presence", () => {
+ const withImage = createTestArticle({ image_url: "test.jpg", video_url: null })
+ const withVideo = createTestArticle({ image_url: null, video_url: "test.mp4" })
+ const withNeither = createTestArticle({ image_url: null, video_url: null })
+
+ expect(withImage.hasImage).toBe(true)
+ expect(withImage.hasMultimedia).toBe(true)
+ expect(withVideo.hasVideo).toBe(true)
+ expect(withNeither.hasMultimedia).toBe(false)
+ })
+ })
+
+ describe("content views", () => {
+ test("handles categories and countries", () => {
+ const article = createTestArticle({ category: ["health", "medicine"], country: ["US", "CA"] })
+ const empty = createTestArticle({ category: [], country: [] })
+
+ expect(article.primaryCategory).toBe("health")
+ expect(article.allCategories).toBe("health, medicine")
+ expect(article.primaryCountry).toBe("US")
+ expect(empty.primaryCategory).toBe("General")
+ expect(empty.allCountries).toBe("Global")
+ })
+
+ test("handles authors", () => {
+ const withAuthors = createTestArticle({ creator: ["John Doe", "Jane Smith"] })
+ const withoutAuthors = createTestArticle({ creator: [] })
+
+ expect(withAuthors.authorName).toBe("John Doe, Jane Smith")
+ expect(withAuthors.firstAuthor).toBe("John Doe")
+ expect(withoutAuthors.authorName).toBe("Unknown")
+ })
+
+ test("truncates descriptions", () => {
+ const longDesc = "A".repeat(150)
+ const article = createTestArticle({ description: longDesc })
+
+ expect(article.shortDescription).toBe("A".repeat(100) + "...")
+ expect(article.mediumDescription).toBe("A".repeat(150))
+ })
+ })
+
+ describe("health detection", () => {
+ test("detects health-related content", () => {
+ const healthArticle = createTestArticle({
+ title: "Medical Breakthrough",
+ description: "",
+ keywords: []
+ })
+ const nonHealthArticle = createTestArticle({
+ title: "Tech Update",
+ description: "Software news",
+ keywords: ["tech"]
+ })
+
+ expect(healthArticle.isHealthRelated).toBe(true)
+ expect(nonHealthArticle.isHealthRelated).toBe(false)
+ })
+ })
+
+ describe("priority and sentiment", () => {
+ test("categorizes source priority", () => {
+ const highPriority = createTestArticle({ source_priority: 500 })
+ const lowPriority = createTestArticle({ source_priority: 8000 })
+
+ expect(highPriority.sourcePriorityLabel).toBe("High Priority")
+ expect(highPriority.sourcePriorityBadgeColor).toBe("green")
+ expect(lowPriority.sourcePriorityLabel).toBe("Low Priority")
+ expect(lowPriority.sourcePriorityBadgeColor).toBe("gray")
+ })
+
+ test("handles sentiment", () => {
+ const positive = createTestArticle({ sentiment: "positive" })
+ const unknown = createTestArticle({ sentiment: "" })
+
+ expect(positive.sentimentLabel).toBe("Positive")
+ expect(positive.sentimentColor).toBe("green")
+ expect(unknown.sentimentLabel).toBe("Unknown")
+ expect(unknown.sentimentColor).toBe("gray")
+ })
+ })
+
+ describe("Ghana detection", () => {
+ test("identifies Ghana news", () => {
+ const ghanaCode = createTestArticle({ country: ["GH"] })
+ const ghanaName = createTestArticle({ country: ["Ghana"] })
+ const nonGhana = createTestArticle({ country: ["US"] })
+
+ expect(ghanaCode.isGhanaNews).toBe(true)
+ expect(ghanaName.isGhanaNews).toBe(true)
+ expect(nonGhana.isGhanaNews).toBe(false)
+ })
+ })
+
+ describe("utility views", () => {
+ test("provides utility properties", () => {
+ const article = createTestArticle({
+ content: "Test content",
+ title: "Test Title",
+ keywords: ["health", "test"]
+ })
+ const emptyTitle = createTestArticle({ title: "" })
+
+ expect(article.hasContent).toBe(true)
+ expect(article.displayTitle).toBe("Test Title")
+ expect(article.shareUrl).toBe("https://example.com/article")
+ expect(article.keywordsList).toBe("health, test")
+ expect(article.readingTimeEstimate).toMatch(/min read/)
+ expect(emptyTitle.displayTitle).toBe("Untitled Article")
+ })
+ })
+
+ describe("actions", () => {
+ test("updates from API data", () => {
+ const article = createTestArticle({ title: "Original" })
+
+ article.updateFromApi({ title: "Updated", sentiment: "negative" })
+
+ expect(article.title).toBe("Updated")
+ expect(article.sentiment).toBe("negative")
+ })
+
+ test("updates sentiment and AI summary", () => {
+ const article = createTestArticle()
+
+ article.updateSentiment("neutral")
+ article.updateAiSummary("AI summary")
+
+ expect(article.sentiment).toBe("neutral")
+ expect(article.ai_summary).toBe("AI summary")
+ })
+
+ test("markAsRead executes without error", () => {
+ const article = createTestArticle()
+ expect(() => article.markAsRead()).not.toThrow()
+ })
+ })
+})
\ No newline at end of file
diff --git a/app/models/NewsArticle.ts b/app/models/NewsArticle.ts
new file mode 100644
index 0000000..fb41c01
--- /dev/null
+++ b/app/models/NewsArticle.ts
@@ -0,0 +1,242 @@
+import { Instance, SnapshotIn, SnapshotOut, types } from "mobx-state-tree"
+import { withSetPropAction } from "./helpers/withSetPropAction"
+
+/**
+ * NewsArticle model for healthcare news articles with proper null handling
+ */
+export const NewsArticleModel = types
+ .model("NewsArticle")
+ .props({
+ article_id: types.identifier,
+ title: types.optional(types.string, ""),
+ link: types.optional(types.string, ""),
+ keywords: types.maybeNull(types.array(types.string)),
+ creator: types.maybeNull(types.array(types.string)),
+ description: types.maybeNull(types.string), // Allow null from API
+ content: types.maybeNull(types.string), // Allow null from API
+ pubDate: types.string,
+ pubDateTZ: types.optional(types.string, ""),
+ image_url: types.maybeNull(types.string),
+ video_url: types.maybeNull(types.string),
+ source_id: types.string,
+ source_name: types.string,
+ source_priority: types.optional(types.number, 0),
+ source_url: types.string,
+ source_icon: types.optional(types.string, ""),
+ language: types.optional(types.string, "en"),
+ country: types.maybeNull(types.array(types.string)),
+ category: types.maybeNull(types.array(types.string)),
+ ai_tag: types.optional(types.string, ""),
+ sentiment: types.optional(types.string, ""),
+ ai_summary: types.optional(types.string, ""),
+ })
+ .actions(withSetPropAction)
+ .views((article) => ({
+ get formattedDate() {
+ try {
+ return new Date(article.pubDate).toLocaleDateString()
+ } catch {
+ return article.pubDate
+ }
+ },
+
+ get formattedDateTime() {
+ try {
+ return new Date(article.pubDate).toLocaleString()
+ } catch {
+ return article.pubDate
+ }
+ },
+
+ get timeAgo() {
+ try {
+ const now = new Date()
+ const pubDate = new Date(article.pubDate)
+ const diffInMs = now.getTime() - pubDate.getTime()
+ const diffInMinutes = Math.floor(diffInMs / (1000 * 60))
+ const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60))
+ const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24))
+
+ if (diffInMinutes < 1) return "Just now"
+ if (diffInMinutes < 60) return `${diffInMinutes}m ago`
+ if (diffInHours < 24) return `${diffInHours}h ago`
+ if (diffInDays < 30) return `${diffInDays}d ago`
+
+ const diffInMonths = Math.floor(diffInDays / 30)
+ if (diffInMonths < 12) return `${diffInMonths}mo ago`
+
+ const diffInYears = Math.floor(diffInMonths / 12)
+ return `${diffInYears}y ago`
+ } catch {
+ return ""
+ }
+ },
+
+ get hasImage() {
+ return !!article.image_url
+ },
+
+ get hasVideo() {
+ return !!article.video_url
+ },
+
+ get hasMultimedia() {
+ return !!article.image_url || !!article.video_url
+ },
+
+ get primaryCategory() {
+ const categories = article.category || []
+ return categories.length > 0 ? categories[0] : "General"
+ },
+
+ get allCategories() {
+ const categories = article.category || []
+ return categories.join(", ") || "General"
+ },
+
+ get primaryCountry() {
+ const countries = article.country || []
+ return countries.length > 0 ? countries[0] : "Global"
+ },
+
+ get allCountries() {
+ const countries = article.country || []
+ return countries.join(", ") || "Global"
+ },
+
+ get authorName() {
+ const creators = article.creator || []
+ return creators.length > 0 ? creators.join(", ") : "Unknown"
+ },
+
+ get firstAuthor() {
+ const creators = article.creator || []
+ return creators.length > 0 ? creators[0] : "Unknown"
+ },
+
+ get shortDescription() {
+ if (!article.description) return ""
+ if (article.description.length <= 100) return article.description
+ return article.description.substring(0, 100) + "..."
+ },
+
+ get mediumDescription() {
+ if (!article.description) return ""
+ if (article.description.length <= 200) return article.description
+ return article.description.substring(0, 200) + "..."
+ },
+
+ get keywordsList() {
+ const keywords = article.keywords || []
+ return keywords.join(", ")
+ },
+
+ get isHealthRelated() {
+ const healthKeywords = [
+ "health", "medical", "healthcare", "medicine", "hospital", "doctor",
+ "disease", "treatment", "patient", "clinic", "therapy", "diagnosis",
+ "pharmaceutical", "vaccine", "epidemic", "pandemic", "wellness"
+ ]
+ const searchText = `${article.title || ""} ${article.description || ""} ${(article.keywords || []).join(" ")}`.toLowerCase()
+ return healthKeywords.some(keyword => searchText.includes(keyword))
+ },
+
+ get isRecent() {
+ try {
+ const now = new Date()
+ const pubDate = new Date(article.pubDate)
+ const diffInHours = (now.getTime() - pubDate.getTime()) / (1000 * 60 * 60)
+ return diffInHours <= 24 // Within last 24 hours
+ } catch {
+ return false
+ }
+ },
+
+ get isToday() {
+ try {
+ const now = new Date()
+ const pubDate = new Date(article.pubDate)
+ return now.toDateString() === pubDate.toDateString()
+ } catch {
+ return false
+ }
+ },
+
+ get sourcePriorityLabel() {
+ if (article.source_priority <= 1000) return "High Priority"
+ if (article.source_priority <= 5000) return "Medium Priority"
+ return "Low Priority"
+ },
+
+ get sourcePriorityBadgeColor() {
+ if (article.source_priority <= 1000) return "green"
+ if (article.source_priority <= 5000) return "yellow"
+ return "gray"
+ },
+
+ get sentimentLabel() {
+ switch (article.sentiment?.toLowerCase()) {
+ case "positive": return "Positive"
+ case "negative": return "Negative"
+ case "neutral": return "Neutral"
+ default: return "Unknown"
+ }
+ },
+
+ get sentimentColor() {
+ switch (article.sentiment?.toLowerCase()) {
+ case "positive": return "green"
+ case "negative": return "red"
+ case "neutral": return "blue"
+ default: return "gray"
+ }
+ },
+
+ get isGhanaNews() {
+ const countries = article.country || []
+ return countries.some(country => country === "GH" || country === "Ghana")
+ },
+
+ get shareUrl() {
+ return article.link
+ },
+
+ get displayTitle() {
+ return article.title || "Untitled Article"
+ },
+
+ get hasContent() {
+ return !!(article.content && article.content.trim().length > 0)
+ },
+
+ get contentPreview() {
+ if (!article.content) return article.description || ""
+ if (article.content.length <= 150) return article.content
+ return article.content.substring(0, 150) + "..."
+ },
+
+ get readingTimeEstimate() {
+ const wordsPerMinute = 200
+ const contentLength = (article.content || article.description || "").length
+ const wordCount = contentLength / 5 // Rough estimate: 5 characters per word
+ const minutes = Math.ceil(wordCount / wordsPerMinute)
+ return minutes > 0 ? `${minutes} min read` : "< 1 min read"
+ },
+ }))
+ .actions((article) => ({
+ markAsRead() {
+ // Could be used to track read status
+ },
+
+ updateSentiment(sentiment: string) {
+ article.setProp("sentiment", sentiment)
+ },
+
+ updateAiSummary(summary: string) {
+ article.setProp("ai_summary", summary)
+ },
+ }))
+
+export interface NewsArticle extends Instance {}
+export interface NewsArticleSnapshotOut extends SnapshotOut {}
+export interface NewsArticleSnapshotIn extends SnapshotIn {}
\ No newline at end of file
diff --git a/app/models/NewsStore.test.ts b/app/models/NewsStore.test.ts
new file mode 100644
index 0000000..6550369
--- /dev/null
+++ b/app/models/NewsStore.test.ts
@@ -0,0 +1,115 @@
+import { NewsStoreModel } from "./NewsStore"
+
+describe("NewsStore", () => {
+ test("can be created", () => {
+ const instance = NewsStoreModel.create({})
+ expect(instance).toBeTruthy()
+ })
+
+ test("can be created with default values", () => {
+ const instance = NewsStoreModel.create({})
+
+ expect(instance.articles).toEqual([])
+ expect(instance.favorites).toEqual([])
+ expect(instance.favoritesOnly).toBe(false)
+ expect(instance.loading).toBe(false)
+ expect(instance.currentCountry).toBe("")
+ expect(instance.currentQuery).toBe("")
+ expect(instance.totalResults).toBe(0)
+ expect(instance.nextPage).toBe(null)
+ })
+
+ test("can toggle favoritesOnly", () => {
+ const instance = NewsStoreModel.create({})
+
+ expect(instance.favoritesOnly).toBe(false)
+
+ instance.toggleFavoritesOnly()
+ expect(instance.favoritesOnly).toBe(true)
+
+ instance.toggleFavoritesOnly()
+ expect(instance.favoritesOnly).toBe(false)
+ })
+
+ test("can clear articles", () => {
+ const instance = NewsStoreModel.create({
+ articles: [],
+ totalResults: 10,
+ nextPage: "page2",
+ currentQuery: "test query",
+ currentCountry: "US"
+ })
+
+ instance.clearArticles()
+
+ expect(instance.articles).toEqual([])
+ expect(instance.totalResults).toBe(0)
+ expect(instance.nextPage).toBe(null)
+ expect(instance.currentQuery).toBe("")
+ expect(instance.currentCountry).toBe("")
+ })
+
+ test("view properties work correctly", () => {
+ const instance = NewsStoreModel.create({})
+
+ expect(instance.articlesForList).toEqual([])
+ expect(instance.hasMoreArticles).toBe(false)
+ expect(instance.isSearchMode).toBe(false)
+ expect(instance.isCountryMode).toBe(false)
+ expect(instance.currentModeLabel).toBe("Global Healthcare News")
+ expect(instance.favoriteCount).toBe(0)
+ })
+
+ test("currentModeLabel updates based on state", () => {
+ const instance = NewsStoreModel.create({})
+
+ // Default state
+ expect(instance.currentModeLabel).toBe("Global Healthcare News")
+
+ // Country mode
+ instance.setProp("currentCountry", "US")
+ expect(instance.currentModeLabel).toBe("US Healthcare News")
+
+ // Search mode
+ instance.setProp("currentQuery", "covid")
+ expect(instance.currentModeLabel).toBe('Search: "covid" in US')
+
+ // Search without country
+ instance.setProp("currentCountry", "")
+ expect(instance.currentModeLabel).toBe('Search: "covid"')
+ })
+
+ test("mode detection works correctly", () => {
+ const instance = NewsStoreModel.create({})
+
+ // Default state
+ expect(instance.isSearchMode).toBe(false)
+ expect(instance.isCountryMode).toBe(false)
+
+ // Country mode
+ instance.setProp("currentCountry", "US")
+ expect(instance.isSearchMode).toBe(false)
+ expect(instance.isCountryMode).toBe(true)
+
+ // Search mode (overrides country mode)
+ instance.setProp("currentQuery", "covid")
+ expect(instance.isSearchMode).toBe(true)
+ expect(instance.isCountryMode).toBe(false)
+ })
+
+ test("hasMoreArticles works correctly", () => {
+ const instance = NewsStoreModel.create({})
+
+ // No next page
+ expect(instance.hasMoreArticles).toBe(false)
+
+ // Has next page but loading
+ instance.setProp("nextPage", "page2")
+ instance.setProp("loading", true)
+ expect(instance.hasMoreArticles).toBe(false)
+
+ // Has next page and not loading
+ instance.setProp("loading", false)
+ expect(instance.hasMoreArticles).toBe(true)
+ })
+})
\ No newline at end of file
diff --git a/app/models/NewsStore.ts b/app/models/NewsStore.ts
new file mode 100644
index 0000000..8594e0f
--- /dev/null
+++ b/app/models/NewsStore.ts
@@ -0,0 +1,288 @@
+import { Instance, SnapshotIn, SnapshotOut, types } from "mobx-state-tree"
+import { api } from "../services/api"
+import { NewsArticle, NewsArticleModel } from "./NewsArticle"
+import { withSetPropAction } from "./helpers/withSetPropAction"
+
+// TypeScript interfaces for better type safety and documentation
+interface NewsStoreProps {
+ articles: NewsArticle[]
+ favorites: NewsArticle[]
+ favoritesOnly: boolean
+ loading: boolean
+ currentCountry: string
+ currentQuery: string
+ totalResults: number
+ nextPage: string | null
+}
+
+interface NewsStoreActions {
+ fetchGlobalHealthcareNews: (page?: string) => Promise
+ fetchCountryHealthcareNews: (country: string, page?: string) => Promise
+ searchHealthcareNews: (query: string, country?: string, page?: string) => Promise
+ loadMoreArticles: () => Promise
+ addFavorite: (article: NewsArticle) => void
+ removeFavorite: (article: NewsArticle) => void
+ clearArticles: () => void
+ toggleFavorite: (article: NewsArticle) => void
+ toggleFavoritesOnly: () => void
+}
+
+interface NewsStoreViews {
+ articlesForList: NewsArticle[]
+ hasMoreArticles: boolean
+ isSearchMode: boolean
+ isCountryMode: boolean
+ currentModeLabel: string
+ hasFavorite: (article: NewsArticle) => boolean
+ favoriteCount: number
+ getArticlesByCategory: (category: string) => NewsArticle[]
+ getRecentArticles: (days?: number) => NewsArticle[]
+}
+
+export const NewsStoreModel = types
+ .model("NewsStore")
+ .props({
+ articles: types.array(NewsArticleModel),
+ // Store favorites as plain objects instead of references to avoid reference resolution issues
+ favoriteArticles: types.array(NewsArticleModel),
+ favoritesOnly: false,
+ loading: false,
+ currentCountry: types.optional(types.string, ""),
+ currentQuery: types.optional(types.string, ""),
+ totalResults: 0,
+ nextPage: types.maybeNull(types.string),
+ })
+ .actions(withSetPropAction)
+ .actions((store) => {
+ // Core API methods
+ const fetchGlobalHealthcareNews = async (page?: string): Promise => {
+ store.setProp("loading", true)
+ try {
+ const response = await api.getGlobalHealthcareNews(page)
+ if (response.kind === "ok") {
+ // Ensure articles have proper IDs and data before adding to store
+ const validArticles = response.articles.filter(article =>
+ article && article.article_id && typeof article.article_id === 'string'
+ )
+
+ if (page && store.nextPage) {
+ // For pagination, append to existing articles
+ store.setProp("articles", [...store.articles, ...validArticles])
+ } else {
+ // For new search, replace articles
+ store.setProp("articles", validArticles)
+ }
+ store.setProp("totalResults", response.totalResults)
+ store.setProp("nextPage", response.nextPage || null)
+ store.setProp("currentCountry", "")
+ store.setProp("currentQuery", "")
+ } else {
+ console.error(`Error fetching global healthcare news: ${JSON.stringify(response)}`)
+ }
+ } catch (error) {
+ console.error("Error in fetchGlobalHealthcareNews:", error)
+ } finally {
+ store.setProp("loading", false)
+ }
+ }
+
+ const fetchCountryHealthcareNews = async (country: string, page?: string): Promise => {
+ store.setProp("loading", true)
+ try {
+ const response = await api.getCountryHealthcareNews(country, page)
+ if (response.kind === "ok") {
+ // Ensure articles have proper IDs and data before adding to store
+ const validArticles = response.articles.filter(article =>
+ article && article.article_id && typeof article.article_id === 'string'
+ )
+
+ if (page && store.nextPage) {
+ store.setProp("articles", [...store.articles, ...validArticles])
+ } else {
+ store.setProp("articles", validArticles)
+ }
+ store.setProp("totalResults", response.totalResults)
+ store.setProp("nextPage", response.nextPage || null)
+ store.setProp("currentCountry", country)
+ store.setProp("currentQuery", "")
+ } else {
+ console.error(`Error fetching ${country} healthcare news: ${JSON.stringify(response)}`)
+ }
+ } catch (error) {
+ console.error("Error in fetchCountryHealthcareNews:", error)
+ } finally {
+ store.setProp("loading", false)
+ }
+ }
+
+ const searchHealthcareNews = async (query: string, country?: string, page?: string): Promise => {
+ store.setProp("loading", true)
+ try {
+ const response = await api.searchHealthcareNews(query, country, page)
+ if (response.kind === "ok") {
+ // Ensure articles have proper IDs and data before adding to store
+ const validArticles = response.articles.filter(article =>
+ article && article.article_id && typeof article.article_id === 'string'
+ )
+
+ if (page && store.nextPage) {
+ store.setProp("articles", [...store.articles, ...validArticles])
+ } else {
+ store.setProp("articles", validArticles)
+ }
+ store.setProp("totalResults", response.totalResults)
+ store.setProp("nextPage", response.nextPage || null)
+ store.setProp("currentQuery", query)
+ store.setProp("currentCountry", country || "")
+ } else {
+ console.error(`Error searching healthcare news: ${JSON.stringify(response)}`)
+ }
+ } catch (error) {
+ console.error("Error in searchHealthcareNews:", error)
+ } finally {
+ store.setProp("loading", false)
+ }
+ }
+
+ const loadMoreArticles = async (): Promise => {
+ if (!store.nextPage || store.loading) return
+
+ if (store.currentQuery) {
+ await searchHealthcareNews(
+ store.currentQuery,
+ store.currentCountry || undefined,
+ store.nextPage
+ )
+ } else if (store.currentCountry) {
+ await fetchCountryHealthcareNews(store.currentCountry, store.nextPage)
+ } else {
+ await fetchGlobalHealthcareNews(store.nextPage)
+ }
+ }
+
+ const addFavorite = (article: NewsArticle): void => {
+ if (!store.favoriteArticles.some(fav => fav.article_id === article.article_id)) {
+ store.favoriteArticles.push(article)
+ }
+ }
+
+ const removeFavorite = (article: NewsArticle): void => {
+ const index = store.favoriteArticles.findIndex(fav => fav.article_id === article.article_id)
+ if (index !== -1) {
+ store.favoriteArticles.splice(index, 1)
+ }
+ }
+
+ const clearArticles = (): void => {
+ store.setProp("articles", [])
+ store.setProp("totalResults", 0)
+ store.setProp("nextPage", null)
+ store.setProp("currentQuery", "")
+ store.setProp("currentCountry", "")
+ }
+
+ const cleanupInvalidFavorites = (): void => {
+ // Remove any favorites that might have invalid references
+ const validFavorites = store.favoriteArticles.filter(fav =>
+ fav && fav.article_id && typeof fav.article_id === 'string'
+ )
+ if (validFavorites.length !== store.favoriteArticles.length) {
+ store.setProp("favoriteArticles", validFavorites)
+ }
+ }
+
+ const toggleFavoritesOnly = (): void => {
+ store.setProp("favoritesOnly", !store.favoritesOnly)
+ }
+
+ return {
+ fetchGlobalHealthcareNews,
+ fetchCountryHealthcareNews,
+ searchHealthcareNews,
+ loadMoreArticles,
+ addFavorite,
+ removeFavorite,
+ clearArticles,
+ cleanupInvalidFavorites,
+ toggleFavoritesOnly,
+ }
+ })
+ .views((store) => ({
+ get articlesForList(): NewsArticle[] {
+ return store.favoritesOnly ? store.favoriteArticles.slice() : store.articles
+ },
+
+ get hasMoreArticles(): boolean {
+ return !!store.nextPage && !store.loading
+ },
+
+ get isSearchMode(): boolean {
+ return !!store.currentQuery
+ },
+
+ get isCountryMode(): boolean {
+ return !!store.currentCountry && !store.currentQuery
+ },
+
+ get currentModeLabel(): string {
+ if (store.currentQuery) {
+ return `Search: "${store.currentQuery}"${store.currentCountry ? ` in ${store.currentCountry}` : ""}`
+ }
+ if (store.currentCountry) {
+ return `${store.currentCountry} Healthcare News`
+ }
+ return "Global Healthcare News"
+ },
+
+ hasFavorite(article: NewsArticle): boolean {
+ return store.favoriteArticles.some(fav => fav.article_id === article.article_id)
+ },
+
+ get favoriteCount(): number {
+ return store.favoriteArticles.length
+ },
+
+ get favorites(): NewsArticle[] {
+ return store.favoriteArticles.slice()
+ },
+
+ getArticlesByCategory(category: string): NewsArticle[] {
+ return store.articles.filter(article =>
+ article.category?.some(cat => cat.toLowerCase().includes(category.toLowerCase()))
+ )
+ },
+
+ getRecentArticles(days: number = 7): NewsArticle[] {
+ const cutoffDate = new Date()
+ cutoffDate.setDate(cutoffDate.getDate() - days)
+
+ return store.articles.filter(article => {
+ try {
+ const pubDate = new Date(article.pubDate)
+ return pubDate >= cutoffDate
+ } catch {
+ return false
+ }
+ })
+ },
+ }))
+ .actions((store) => ({
+ // Actions that depend on views - must come after views are defined
+ // Clean up method that can be called from UI components
+ toggleFavorite(article: NewsArticle): void {
+ // Clean up any invalid favorites first
+ store.cleanupInvalidFavorites()
+
+ if (store.hasFavorite(article)) {
+ store.removeFavorite(article)
+ } else {
+ store.addFavorite(article)
+ }
+ },
+ }))
+
+export interface NewsStore extends Instance {}
+export interface NewsStoreSnapshotOut extends SnapshotOut {}
+export interface NewsStoreSnapshotIn extends SnapshotIn {}
+
+export const createNewsStoreDefaultModel = () => types.optional(NewsStoreModel, {})
\ No newline at end of file
diff --git a/app/models/RootStore.ts b/app/models/RootStore.ts
index e6d0652..ec9c046 100644
--- a/app/models/RootStore.ts
+++ b/app/models/RootStore.ts
@@ -1,19 +1,70 @@
-import { Instance, SnapshotOut, types } from "mobx-state-tree"
+import { Instance, SnapshotOut, types, flow } from "mobx-state-tree"
+import { NewsStoreModel } from "./NewsStore"
import { AuthenticationStoreModel } from "./AuthenticationStore"
import { EpisodeStoreModel } from "./EpisodeStore"
+import { TaskStoreModel } from "./TaskStore"
+import Parse from "@/lib/Parse/parse"
/**
* A RootStore model.
*/
export const RootStoreModel = types.model("RootStore").props({
+ newsStore: types.optional(NewsStoreModel, {} as any),
authenticationStore: types.optional(AuthenticationStoreModel, {}),
episodeStore: types.optional(EpisodeStoreModel, {}),
+ taskStore: types.optional(TaskStoreModel, {}),
+ isInitialized: false,
+ isInitializing: false,
+ initializationError: "",
})
+.actions((store) => ({
+ initialize: flow(function* initialize() {
+ if (store.isInitialized || store.isInitializing) return
+
+ store.isInitializing = true
+ store.initializationError = ""
+
+ try {
+ // Initialize Parse if not already initialized
+ if (!Parse.applicationId) {
+ Parse.initialize(
+ process.env.EXPO_PUBLIC_SERVER_URL || "",
+ process.env.EXPO_PUBLIC_APP_ID || "",
+ process.env.EXPO_PUBLIC_JAVASCRIPT_KEY || ""
+ )
+ }
+
+ // Check for existing session
+ const isAuthenticated = yield store.authenticationStore.checkCurrentUser()
+
+ // If authenticated, load user data
+ if (isAuthenticated) {
+ yield store.taskStore.fetchTasks()
+ }
+
+ store.isInitialized = true
+ return true
+ } catch (error: any) {
+ console.error("Error initializing app:", error)
+ store.initializationError = error.message || "Failed to initialize app"
+ return false
+ } finally {
+ store.isInitializing = false
+ }
+ }),
+
+ reset() {
+ store.authenticationStore.logout()
+ store.taskStore.tasks.clear()
+ store.isInitialized = false
+ },
+}))
/**
* The RootStore instance.
*/
export interface RootStore extends Instance {}
+
/**
* The data of a RootStore.
*/
diff --git a/app/models/Task.ts b/app/models/Task.ts
new file mode 100644
index 0000000..6cb4281
--- /dev/null
+++ b/app/models/Task.ts
@@ -0,0 +1,37 @@
+import { Instance, SnapshotOut, types } from "mobx-state-tree"
+import { withSetPropAction } from "./helpers/withSetPropAction"
+import Parse from "@/lib/Parse/parse"
+
+export const PriorityType = types.union(
+ types.literal("high"),
+ types.literal("medium"),
+ types.literal("low")
+)
+
+export const TaskModel = types
+ .model("Task")
+ .props({
+ id: types.identifier,
+ title: "",
+ description: "",
+ isCompleted: false,
+ startDate: types.maybe(types.string),
+ dueDate: types.maybe(types.string),
+ createdAt: types.maybe(types.string),
+ updatedAt: types.maybe(types.string),
+ taskTime: types.maybe(types.string),
+ priority: types.maybe(PriorityType),
+ reminderEnabled: types.maybe(types.boolean),
+ })
+ .actions(withSetPropAction)
+ .actions((store) => ({
+ toggleComplete() {
+ store.isCompleted = !store.isCompleted
+ },
+ update(updates: Partial) {
+ Object.assign(store, updates)
+ },
+ }))
+
+export interface Task extends Instance {}
+export interface TaskSnapshot extends SnapshotOut {}
diff --git a/app/models/TaskStore.ts b/app/models/TaskStore.ts
new file mode 100644
index 0000000..0313952
--- /dev/null
+++ b/app/models/TaskStore.ts
@@ -0,0 +1,414 @@
+import { Instance, SnapshotOut, types, flow, getRoot } from "mobx-state-tree"
+import { withSetPropAction } from "./helpers/withSetPropAction"
+import { RootStore } from "./RootStore"
+import Parse from "@/lib/Parse/parse"
+import { Alert } from "react-native"
+import { AlertTongle, showQueuedAlert } from "@/components"
+
+export const TaskModel = types.model("Task", {
+ id: types.string,
+ title: types.string,
+ description: types.string,
+ isCompleted: types.boolean,
+ startDate: types.maybe(types.string),
+ dueDate: types.maybe(types.string),
+ createdAt: types.maybe(types.string),
+ updatedAt: types.maybe(types.string),
+ taskTime: types.maybe(types.string), // Using maybe string to handle both empty strings and null/undefined
+ priority: types.maybe(types.enumeration(["high", "medium", "low"])),
+ reminderEnabled: types.maybe(types.boolean),
+})
+ .actions(withSetPropAction)
+
+export const TaskStoreModel = types
+ .model("TaskStore")
+ .props({
+ tasks: types.array(TaskModel),
+ isLoading: false,
+ error: "",
+ })
+ .views((store) => ({
+ get completedTasks() {
+ return store.tasks.filter((task) => task.isCompleted)
+ },
+ get pendingTasks() {
+ return store.tasks.filter((task) => !task.isCompleted)
+ },
+ get tasksByDueDate() {
+ return [...store.tasks].sort((a, b) => {
+ if (!a.dueDate) return 1
+ if (!b.dueDate) return -1
+ return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
+ })
+ },
+ get tasksByPriority() {
+ return {
+ high: store.tasks.filter((task) => task.priority === "high"),
+ medium: store.tasks.filter((task) => task.priority === "medium"),
+ low: store.tasks.filter((task) => task.priority === "low"),
+ }
+ },
+ get tasksWithReminders() {
+ return store.tasks.filter((task) => task.reminderEnabled)
+ },
+ // NEW: Get tasks for a specific date
+ getTasksForDate(date: string | Date) {
+ const targetDate = typeof date === "string" ? new Date(date) : date
+ const targetDateString = targetDate.toDateString()
+
+ return store.tasks.filter((task) => {
+ if (!task.dueDate) return false
+ const taskDate = new Date(task.dueDate)
+ return taskDate.toDateString() === targetDateString
+ })
+ },
+ }))
+ .actions(withSetPropAction)
+ .actions((store) => ({
+ setError(error: string) {
+ store.error = error
+ },
+ }))
+ .actions((store) => ({
+ fetchTasks: flow(function* fetchTasks() {
+ const rootStore = getRoot(store)
+
+ if (!rootStore.authenticationStore.isAuthenticated) {
+ store.setError("User not authenticated")
+ return []
+ }
+
+ store.isLoading = true
+ store.error = ""
+
+ try {
+ const Task = Parse.Object.extend("Task")
+ const query = new Parse.Query(Task)
+ const currentUser = yield Parse.User.currentAsync()
+ if (!currentUser) throw new Error("No current user found")
+ query.equalTo("user", currentUser)
+ query.ascending("dueDate")
+
+ const results = yield query.find()
+
+ const tasks = results.map((task: any) => {
+ const taskTimeValue = task.get("taskTime")
+ let processedTaskTime: string | undefined = undefined
+
+ if (taskTimeValue) {
+ // Handle different possible types for taskTime
+ if (typeof taskTimeValue === 'string') {
+ processedTaskTime = taskTimeValue
+ } else if (taskTimeValue instanceof Date) {
+ processedTaskTime = taskTimeValue.toISOString()
+ } else {
+ // Convert to string if it's some other type
+ processedTaskTime = String(taskTimeValue)
+ }
+ }
+
+ return {
+ id: task.id,
+ title: task.get("title") || "",
+ description: task.get("description") || "",
+ isCompleted: task.get("isCompleted") || false,
+ startDate: task.get("startDate")?.toISOString(),
+ dueDate: task.get("dueDate")?.toISOString(),
+ taskTime: processedTaskTime,
+ priority: task.get("priority") || "medium",
+ reminderEnabled: task.get("reminderEnabled") || false,
+ createdAt: task.get("createdAt")?.toISOString(),
+ updatedAt: task.get("updatedAt")?.toISOString(),
+ }
+ })
+
+ store.tasks = tasks
+ return tasks
+ } catch (error: any) {
+ console.error("Error fetching tasks:", error)
+ store.setError(error.message || "Failed to fetch tasks")
+ return []
+ } finally {
+ store.isLoading = false
+ }
+ }),
+
+ createTask: flow(function* createTask(taskData: {
+ title: string
+ description?: string
+ dueDate?: string
+ startDate?: string
+ taskTime?: Date
+ priority?: "high" | "medium" | "low"
+ reminderEnabled?: boolean
+ }) {
+ const rootStore = getRoot(store)
+
+ if (!rootStore.authenticationStore.isAuthenticated) {
+ store.setError("User not authenticated")
+ return null
+ }
+
+ store.isLoading = true
+ store.error = ""
+
+ try {
+ const Task = Parse.Object.extend("Task")
+ const task = new Task()
+
+ task.set("title", taskData.title)
+ task.set("description", taskData.description || "")
+ task.set("isCompleted", false)
+ const currentUser = yield Parse.User.currentAsync()
+ if (!currentUser) throw new Error("No current user found")
+ task.set("user", currentUser)
+ task.set("priority", taskData.priority || "medium")
+ task.set("reminderEnabled", taskData.reminderEnabled || false)
+
+ if (taskData.dueDate) {
+ task.set("dueDate", new Date(taskData.dueDate))
+ }
+
+ if (taskData.startDate) {
+ task.set("startDate", new Date(taskData.startDate))
+ }
+
+ // Store taskTime as a string in ISO format
+ if (taskData.taskTime) {
+ task.set("taskTime", taskData.taskTime) // taskData.taskTime is already in ISO string format
+ }
+
+ const result = yield task.save()
+
+ const taskTimeValue = result.get("taskTime")
+ let processedTaskTime: string | undefined = undefined
+
+ if (taskTimeValue) {
+ // Handle different possible types for taskTime
+ if (typeof taskTimeValue === 'string') {
+ processedTaskTime = taskTimeValue
+ } else if (taskTimeValue instanceof Date) {
+ processedTaskTime = taskTimeValue.toISOString()
+ } else {
+ // Convert to string if it's some other type
+ processedTaskTime = String(taskTimeValue)
+ }
+ }
+
+ const newTask = {
+ id: result.id,
+ title: result.get("title"),
+ description: result.get("description"),
+ isCompleted: result.get("isCompleted"),
+ dueDate: result.get("dueDate")?.toISOString(),
+ startDate: result.get("startDate")?.toISOString(),
+ taskTime: processedTaskTime,
+ priority: result.get("priority"),
+ reminderEnabled: result.get("reminderEnabled"),
+ createdAt: result.get("createdAt")?.toISOString(),
+ updatedAt: result.get("updatedAt")?.toISOString(),
+ }
+
+ store.tasks.push(newTask)
+ return newTask
+ } catch (error: any) {
+ console.error("Error creating task:", error)
+
+ // showQueuedAlert({
+ // title: "Error creating task:",
+ // message: error.message
+ // })
+
+ store.setError(error.message || "Failed to create task")
+ return null
+ } finally {
+ store.isLoading = false
+ }
+ }),
+
+ // NEW: Add alias method for backward compatibility
+ addTask: flow(function* addTask(
+ this: any,
+ taskData: {
+ title: string
+ description?: string
+ dueDate?: Date
+ priority?: "high" | "medium" | "low"
+ reminder?: boolean
+ [key: string]: any
+ },
+ ) {
+ // Convert the task data to match createTask format
+ const convertedData = {
+ title: taskData.title,
+ description: taskData.description || "",
+ dueDate: taskData.dueDate?.toISOString(),
+ priority: taskData.priority || "medium",
+ reminderEnabled: taskData.reminder || false,
+ }
+
+ return yield this.createTask(convertedData)
+ }),
+
+ updateTask: flow(function* updateTask(
+ taskId: string,
+ updates: Partial<{
+ title: string
+ description: string
+ isCompleted: boolean
+ startDate: string
+ dueDate: string
+ taskTime: string
+ priority: "high" | "medium" | "low"
+ reminderEnabled: boolean
+ }>,
+ ) {
+ store.isLoading = true
+ store.error = ""
+
+ try {
+ const Task = Parse.Object.extend("Task")
+ const query = new Parse.Query(Task)
+ const currentUser = yield Parse.User.currentAsync()
+ if (!currentUser) throw new Error("No current user found")
+ query.equalTo("user", currentUser)
+
+ const task = yield query.get(taskId)
+
+ if (!task) {
+ throw new Error("Task not found")
+ }
+
+ if (updates.title !== undefined) task.set("title", updates.title)
+ if (updates.description !== undefined) task.set("description", updates.description)
+ if (updates.isCompleted !== undefined) task.set("isCompleted", updates.isCompleted) // ← FIXED: was "ispleted"
+ if (updates.dueDate !== undefined) task.set("dueDate", new Date(updates.dueDate))
+ if (updates.startDate !== undefined) task.set("startDate", new Date(updates.startDate))
+ if (updates.taskTime !== undefined) task.set("taskTime", updates.taskTime) // taskTime is already in ISO string format
+ if (updates.priority !== undefined) task.set("priority", updates.priority)
+ if (updates.reminderEnabled !== undefined) task.set("reminderEnabled", updates.reminderEnabled)
+
+ const result = yield task.save()
+
+ // Update local state using MST actions
+ const taskIndex = store.tasks.findIndex((t) => t.id === taskId)
+ if (taskIndex !== -1) {
+ const task = store.tasks[taskIndex]
+ // Direct property assignment works better than setProp for this case
+ if (updates.title !== undefined) task.title = updates.title
+ if (updates.description !== undefined) task.description = updates.description
+ if (updates.isCompleted !== undefined) task.isCompleted = updates.isCompleted
+ if (updates.dueDate !== undefined) task.dueDate = updates.dueDate
+ if (updates.startDate !== undefined) task.startDate = updates.startDate
+ if (updates.taskTime !== undefined) task.taskTime = updates.taskTime
+ if (updates.priority !== undefined) task.priority = updates.priority
+ if (updates.reminderEnabled !== undefined) task.reminderEnabled = updates.reminderEnabled
+ task.updatedAt = result.get("updatedAt")?.toISOString()
+ }
+
+ return result
+ } catch (error: any) {
+ console.error("Error updating task:", error)
+ store.setError(error.message || "Failed to update task")
+ throw error
+ } finally {
+ store.isLoading = false
+ }
+ }),
+
+ deleteTask: flow(function* deleteTask(taskId: string) {
+ store.isLoading = true
+ store.error = ""
+
+ try {
+ const Task = Parse.Object.extend("Task")
+ const query = new Parse.Query(Task)
+ const currentUser = yield Parse.User.currentAsync()
+ if (!currentUser) throw new Error("No current user found")
+ query.equalTo("user", currentUser)
+
+ const task = yield query.get(taskId)
+
+ if (!task) {
+ throw new Error("Task not found")
+ }
+
+ yield task.destroy()
+
+ // Update local state
+ const taskIndex = store.tasks.findIndex((t) => t.id === taskId)
+ if (taskIndex !== -1) {
+ store.tasks.splice(taskIndex, 1)
+ }
+ Alert.alert("Success", "Task deleted successfully!")
+
+ return true
+ } catch (error: any) {
+ console.error("Error deleting task:", error)
+ store.setError(error.message || "Failed to delete task")
+ return false
+ } finally {
+ store.isLoading = false
+ }
+ }),
+
+ toggleTaskCompletion: flow(function* toggleTaskCompletion(this: any, taskId: string) {
+ const task = store.tasks.find((t) => t.id === taskId)
+ if (!task) return false
+
+ try {
+ yield this.updateTask(taskId, { isCompleted: !task.isCompleted })
+ return true
+ } catch (error) {
+ return false
+ }
+ }),
+
+ toggleTaskReminder: flow(function* toggleTaskReminder(this: any, taskId: string) {
+ const task = store.tasks.find((t) => t.id === taskId)
+ if (!task) return false
+
+ try {
+ yield this.updateTask(taskId, { reminderEnabled: !task.reminderEnabled })
+ return true
+ } catch (error) {
+ return false
+ }
+ }),
+
+ updateTaskPriority: flow(function* updateTaskPriority(
+ this: any,
+ taskId: string,
+ priority: "high" | "medium" | "low",
+ ) {
+ try {
+ yield this.updateTask(taskId, { priority })
+ return true
+ } catch (error) {
+ return false
+ }
+ }),
+
+ getTasksForDateAndPriority(date: string, priority: "high" | "medium" | "low") {
+ return store.tasks.filter((task) => {
+ if (!task.dueDate) return false
+ const taskDate = new Date(task.dueDate).toDateString()
+ const filterDate = new Date(date).toDateString()
+ return taskDate === filterDate && task.priority === priority
+ })
+ },
+
+ getUpcomingReminders(hours: number = 24) {
+ const now = new Date()
+ const futureTime = new Date(now.getTime() + hours * 60 * 60 * 1000)
+
+ return store.tasks.filter((task) => {
+ if (!task.dueDate || !task.reminderEnabled || task.isCompleted) return false
+ const taskDate = new Date(task.dueDate)
+ return taskDate > now && taskDate <= futureTime
+ })
+ },
+ }))
+
+export interface TaskStore extends Instance {}
+export interface TaskStoreSnapshot extends SnapshotOut {}
\ No newline at end of file
diff --git a/app/models/index.ts b/app/models/index.ts
index 24e3346..1822681 100644
--- a/app/models/index.ts
+++ b/app/models/index.ts
@@ -3,3 +3,6 @@ export * from "./helpers/getRootStore"
export * from "./helpers/useStores"
export * from "./helpers/setupRootStore"
export * from "./Firebase"
+export * from "./NewsStore"
+export * from "./NewsStore"
+export * from "./NewsArticle"
diff --git a/app/navigators/AppNavigator.tsx b/app/navigators/AppNavigator.tsx
index b17ccae..ef4f468 100644
--- a/app/navigators/AppNavigator.tsx
+++ b/app/navigators/AppNavigator.tsx
@@ -5,6 +5,7 @@
* and a "main" flow which the user will use once logged in.
*/
import { NavigationContainer, NavigatorScreenParams } from "@react-navigation/native"
+import React from "react"
import { createNativeStackNavigator, NativeStackScreenProps } from "@react-navigation/native-stack"
import { observer } from "mobx-react-lite"
import * as Screens from "@/screens"
@@ -13,7 +14,9 @@ import { useStores } from "../models"
import { DemoNavigator, DemoTabParamList } from "./DemoNavigator"
import { navigationRef, useBackButtonHandler } from "./navigationUtilities"
import { useAppTheme, useThemeProvider } from "@/utils/useAppTheme"
-import { ComponentProps } from "react"
+import { ComponentProps, useEffect } from "react"
+import { Alert } from "react-native"
+import { AlertTongle, showQueuedAlert } from "@/components"
/**
* This type allows TypeScript to know what routes are defined in this navigator
@@ -33,7 +36,9 @@ export type AppStackParamList = {
Login: undefined
Demo: NavigatorScreenParams
// 🔥 Your screens go here
- // IGNITE_GENERATOR_ANCHOR_APP_STACK_PARAM_LIST
+ ChooseAuth: undefined
+ SignUp: undefined
+ // IGNITE_GENERATOR_ANCHOR_APP_STACK_PARAM_LIST
}
/**
@@ -52,13 +57,35 @@ const Stack = createNativeStackNavigator()
const AppStack = observer(function AppStack() {
const {
- authenticationStore: { isAuthenticated },
+ authenticationStore: { isAuthenticated,checkServerStatus },
} = useStores()
const {
theme: { colors },
} = useAppTheme()
+ useEffect(() => {
+ const checkStatus = async () => {
+ const status = await checkServerStatus();
+ if (!status.isRunning) {
+ showQueuedAlert({
+ title: "No connection",
+ message: status.message,
+
+ })
+ }
+ };
+
+ // Initial check
+ checkStatus();
+
+ // Set up interval for periodic checks (every 30 seconds)
+ const intervalId = setInterval(checkStatus, 30000);
+
+ // Clean up the interval when the component unmounts or when checkServerStatus changes
+ return () => clearInterval(intervalId);
+ }, [checkServerStatus])
+
return (
{isAuthenticated ? (
<>
-
>
) : (
<>
+
+
+
>
)}
{/** 🔥 Your screens go here */}
- {/* IGNITE_GENERATOR_ANCHOR_APP_STACK_SCREENS */}
+ {/* IGNITE_GENERATOR_ANCHOR_APP_STACK_SCREENS */}
)
})
@@ -92,8 +121,11 @@ export interface NavigationProps
extends Partial>> {}
export const AppNavigator = observer(function AppNavigator(props: NavigationProps) {
- const { themeScheme, navigationTheme, setThemeContextOverride, ThemeProvider } =
+ const { themeScheme, navigationTheme, ThemeProvider, setThemeContextOverride } =
useThemeProvider()
+ // useEffect(() => {
+ // // setThemeContextOverride("light")
+ // }, [setThemeContextOverride])
const exitRoutes = ["welcome","Demo"] // Add any other routes that should exit the app here
// This hook will handle the back button on Android and exit the app if the user is on an exit route
diff --git a/app/navigators/DemoNavigator.tsx b/app/navigators/DemoNavigator.tsx
index f478333..b3c24e2 100644
--- a/app/navigators/DemoNavigator.tsx
+++ b/app/navigators/DemoNavigator.tsx
@@ -59,9 +59,9 @@ export function DemoNavigator() {
name="DemoShowroom"
component={DemoShowroomScreen}
options={{
- tabBarLabel: translate("demoNavigator:componentsTab"),
+ tabBarLabel: "Calendar",
tabBarIcon: ({ focused }) => (
-
+
),
}}
/>
@@ -72,7 +72,7 @@ export function DemoNavigator() {
options={{
tabBarLabel: translate("demoNavigator:communityTab"),
tabBarIcon: ({ focused }) => (
-
+
),
}}
/>
@@ -81,10 +81,10 @@ export function DemoNavigator() {
name="DemoPodcastList"
component={DemoPodcastListScreen}
options={{
- tabBarAccessibilityLabel: translate("demoNavigator:podcastListTab"),
- tabBarLabel: translate("demoNavigator:podcastListTab"),
+ tabBarAccessibilityLabel: "Tasks",
+ tabBarLabel: "Tasks",
tabBarIcon: ({ focused }) => (
-
+
),
}}
/>
@@ -93,9 +93,9 @@ export function DemoNavigator() {
name="DemoDebug"
component={DemoDebugScreen}
options={{
- tabBarLabel: translate("demoNavigator:debugTab"),
+ tabBarLabel:"Account",
tabBarIcon: ({ focused }) => (
-
+
),
}}
/>
diff --git a/app/navigators/types.ts b/app/navigators/types.ts
new file mode 100644
index 0000000..01903e3
--- /dev/null
+++ b/app/navigators/types.ts
@@ -0,0 +1,13 @@
+import { NativeStackScreenProps } from '@react-navigation/native-stack';
+
+export type RootStackParamList = {
+ Welcome: undefined;
+ ChooseAuth: undefined;
+ Login: undefined;
+ SignUp: undefined;
+ Demo: undefined;
+ // Add other screens here as needed
+};
+
+export type AppStackScreenProps =
+ NativeStackScreenProps;
diff --git a/app/screens/ChooseAuthScreen.tsx b/app/screens/ChooseAuthScreen.tsx
new file mode 100644
index 0000000..1ea0895
--- /dev/null
+++ b/app/screens/ChooseAuthScreen.tsx
@@ -0,0 +1,187 @@
+import { FC, useEffect } from "react";
+import { observer } from "mobx-react-lite";
+import { ViewStyle, View, Image, Alert } from "react-native";
+import { AppStackScreenProps } from "@/navigators/types";
+import { Button, Icon, Screen, Text } from "@/components";
+import { useNavigation } from "@react-navigation/native";
+import { useStores } from "@/models";
+import { useHeader } from "@/utils/useHeader";
+import { ThemedStyle } from "@/theme";
+import { useAppTheme } from "@/utils/useAppTheme";
+import * as WebBrowser from "expo-web-browser";
+import * as Google from "expo-auth-session/providers/google";
+
+WebBrowser.maybeCompleteAuthSession();
+
+interface ChooseAuthScreenProps extends AppStackScreenProps<"ChooseAuth"> {}
+
+const logo = require("../../assets/images/app-icon-android-adaptive-foreground.png");
+
+export const ChooseAuthScreen: FC = observer(
+ function ChooseAuthScreen(_props) {
+ // Pull in one of our MST stores
+ const { authenticationStore } = useStores();
+ const navigation = useNavigation();
+
+ const [request, response, promptAsync] = Google.useAuthRequest({
+ iosClientId: process.env.EXPO_PUBLIC_IOS_GOOGLE_CLIENT_ID,
+ androidClientId: process.env.EXPO_PUBLIC_ANDROID_GOOGLE_CLIENT_ID,
+ webClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID, // optional if using web
+ scopes: ["profile", "email"],
+ });
+
+ // Handle Google authentication response
+ useEffect(() => {
+ const handleGoogleResponse = async () => {
+ if (!response) return;
+
+ // Log the full response for debugging
+ console.log('Google auth response:', response);
+
+ // Handle different response types
+ switch (response.type) {
+ case 'opened':
+ case 'locked':
+ console.log(`Auth session ${response.type}`);
+ return;
+
+ case 'error':
+ console.error('Google auth error:', response.errorCode, response.error);
+ Alert.alert(
+ 'Authentication Error',
+ response.error?.message || 'Failed to sign in with Google. Please try again.'
+ );
+ return;
+
+ case 'cancel':
+ case 'dismiss':
+ console.log('User cancelled the sign in');
+ return;
+
+ case 'success':
+ try {
+ console.log('Processing successful Google auth response');
+ const result = await authenticationStore.googleSignIn(response as any);
+
+ if (result.success) {
+ console.log('Google sign-in successful!');
+ navigation.navigate('Demo', { screen: 'DemoCommunity' });
+ } else {
+ console.log('Google sign-in failed:', result.error);
+ Alert.alert(
+ 'Login Failed',
+ result.error || 'An unexpected error occurred during sign-in.'
+ );
+ }
+ } catch (error) {
+ console.error('Error during Google sign-in:', error);
+ Alert.alert(
+ 'Error',
+ error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.'
+ );
+ }
+ return;
+
+ default:
+ console.warn('Unhandled auth response type:', (response as any).type);
+ return;
+ }
+ };
+
+ handleGoogleResponse();
+ }, [response, authenticationStore, navigation]);
+
+ // Navigate to main app if already authenticated
+ useEffect(() => {
+ if (authenticationStore.isAuthenticated) {
+ navigation.navigate("Welcome");
+ }
+ }, [authenticationStore.isAuthenticated, navigation]);
+
+ const handleGoogleSignIn = async () => {
+ try {
+ await promptAsync();
+ } catch (error) {
+ console.error("Error starting Google sign-in:", error);
+ Alert.alert("Error", "Failed to start Google sign-in");
+ }
+ };
+
+ useHeader(
+ {
+ leftIcon: "back",
+ title: "Welcome to Dooit",
+ onLeftPress: () => navigation.navigate("Welcome"),
+ },
+ [navigation]
+ );
+
+ const {
+ themed,
+ theme: { colors },
+ } = useAppTheme();
+
+ return (
+
+
+
+ navigation.navigate("Login")}
+ preset="reversed"
+ />
+ (
+
+ )}
+ />
+
+ {/* Optional: Show loading state */}
+ {authenticationStore.isLoading && (
+
+ Signing in...
+
+ )}
+
+ {/* Optional: Show error state */}
+ {authenticationStore.error && (
+
+ {authenticationStore.error}
+
+ )}
+
+
+ );
+ }
+);
+
+const $root: ViewStyle = {
+ flex: 1,
+};
+
+const $screenContentContainer: ThemedStyle = ({ spacing }) => ({
+ paddingHorizontal: spacing.lg,
+});
+
+const $buttonContainer: ThemedStyle = ({ spacing }) => ({
+ marginTop: spacing.xs,
+ flex: 1,
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "space-around",
+ gap: spacing.md,
+});
\ No newline at end of file
diff --git a/app/screens/DemoCommunityScreen.tsx b/app/screens/DemoCommunityScreen.tsx
index d957038..bf52c67 100644
--- a/app/screens/DemoCommunityScreen.tsx
+++ b/app/screens/DemoCommunityScreen.tsx
@@ -1,136 +1,510 @@
-import { FC } from "react"
-import { Image, ImageStyle, TextStyle, View, ViewStyle } from "react-native"
-import { ListItem, Screen, Text } from "../components"
-import { DemoTabScreenProps } from "../navigators/DemoNavigator"
-import { $styles } from "../theme"
-import { openLinkInBrowser } from "../utils/openLinkInBrowser"
+import { ComponentType, FC, useCallback, useEffect, useMemo, useState } from "react"
+import {
+ AccessibilityProps,
+ ActivityIndicator,
+ Image,
+ ImageStyle,
+ Platform,
+ StyleSheet,
+ TextStyle,
+ View,
+ ViewStyle,
+} from "react-native"
+import { type ContentStyle } from "@shopify/flash-list"
+import Animated, {
+ Extrapolation,
+ interpolate,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from "react-native-reanimated"
+
+import { Button, type ButtonAccessoryProps } from "@/components/Button"
+import { Card } from "@/components/Card"
+import { EmptyState } from "@/components/EmptyState"
+import { Icon } from "@/components/Icon"
+import { ListView } from "@/components/ListView"
+import { Screen } from "@/components/Screen"
+import { Text } from "@/components/Text"
+import { Switch } from "@/components/Toggle/Switch"
import { isRTL } from "@/i18n"
-import type { ThemedStyle } from "@/theme"
+import { DemoTabScreenProps } from "@/navigators/DemoNavigator"
+import type { NewsArticle } from "@/models/NewsArticle"
+import type { ThemedStyle, Theme } from "@/theme"
import { useAppTheme } from "@/utils/useAppTheme"
+import { $styles } from "@/theme"
+import { delay } from "@/utils/delay"
+import { openLinkInBrowser } from "@/utils/openLinkInBrowser"
+import { useStores } from "@/models"
-const chainReactLogo = require("../../assets/images/demo/cr-logo.png")
-const reactNativeLiveLogo = require("../../assets/images/demo/rnl-logo.png")
-const reactNativeRadioLogo = require("../../assets/images/demo/rnr-logo.png")
-const reactNativeNewsletterLogo = require("../../assets/images/demo/rnn-logo.png")
-
-export const DemoCommunityScreen: FC> =
- function DemoCommunityScreen(_props) {
- const { themed } = useAppTheme()
- return (
-
-
-
-
-
-
- openLinkInBrowser("https://community.infinite.red/")}
- />
-
-
- openLinkInBrowser("https://github.com/infinitered/ignite")}
- />
+const ICON_SIZE = 14
-
-
-
-
-
- }
- onPress={() => openLinkInBrowser("https://reactnativeradio.com/")}
- />
-
-
-
- }
- onPress={() => openLinkInBrowser("https://reactnativenewsletter.com/")}
- />
-
-
-
- }
- onPress={() => openLinkInBrowser("https://rn.live/")}
- />
-
-
-
- }
- onPress={() => openLinkInBrowser("https://cr.infinite.red/")}
- />
-
-
- openLinkInBrowser("https://infinite.red/contact")}
+// Custom hooks for better separation of concerns
+const useNewsData = () => {
+ const { newsStore } = useStores()
+ const [refreshing, setRefreshing] = useState(false)
+
+ const loadInitialData = useCallback(async () => {
+ await newsStore.fetchGlobalHealthcareNews()
+ }, [newsStore])
+
+ const manualRefresh = useCallback(async () => {
+ setRefreshing(true)
+ try {
+ await Promise.allSettled([
+ newsStore.currentQuery
+ ? newsStore.searchHealthcareNews(newsStore.currentQuery, newsStore.currentCountry || undefined)
+ : newsStore.currentCountry
+ ? newsStore.fetchCountryHealthcareNews(newsStore.currentCountry)
+ : newsStore.fetchGlobalHealthcareNews(),
+ delay(750)
+ ])
+ } finally {
+ setRefreshing(false)
+ }
+ }, [newsStore])
+
+ const loadMore = useCallback(async () => {
+ if (!newsStore.hasMoreArticles) return
+
+ try {
+ await newsStore.loadMoreArticles()
+ } catch (error) {
+ console.error('Error loading more articles:', error)
+ }
+ }, [newsStore])
+
+ return {
+ newsStore,
+ refreshing,
+ loadInitialData,
+ manualRefresh,
+ loadMore,
+ }
+}
+
+// Separated header component for better organization
+const NewsListHeader: FC<{
+ currentModeLabel: string
+ favoritesOnly: boolean
+ favoriteCount: number
+ totalResults: number
+ onToggleFavorites: () => void
+ hasArticles: boolean
+}> = ({
+ currentModeLabel,
+ favoritesOnly,
+ favoriteCount,
+ totalResults,
+ onToggleFavorites,
+ hasArticles
+}) => {
+ const { themed } = useAppTheme()
+
+ return (
+
+
+
+
+ {(favoritesOnly || hasArticles) && (
+
+
+
+ )}
+
+ {totalResults > 0 && (
+
-
- )
+ )}
+
+ )
+}
+
+// Separated footer component
+const NewsListFooter: FC<{ loading: boolean; hasArticles: boolean }> = ({
+ loading,
+ hasArticles
+}) => {
+ const { themed } = useAppTheme()
+
+ if (!loading || !hasArticles) return null
+
+ return (
+
+
+
+
+ )
+}
+
+// Separated empty state component
+const NewsEmptyState: FC<{
+ loading: boolean
+ favoritesOnly: boolean
+ onRefresh: () => void
+}> = ({ loading, favoritesOnly, onRefresh }) => {
+ const { themed } = useAppTheme()
+
+ if (loading) {
+ return
}
-const $title: ThemedStyle = ({ spacing }) => ({
+ return (
+
+ )
+}
+
+// Main component - now much cleaner
+export const DemoCommunityScreen: FC> = () => {
+ const { themed } = useAppTheme()
+ const {
+ newsStore,
+ refreshing,
+ loadInitialData,
+ manualRefresh,
+ loadMore
+ } = useNewsData()
+
+ const {
+ articlesForList,
+ loading,
+ favoritesOnly,
+ currentModeLabel,
+ totalResults,
+ favoriteCount,
+ } = newsStore
+
+ // Load initial data
+ useEffect(() => {
+ loadInitialData()
+ }, [loadInitialData])
+
+ const handleToggleFavorites = useCallback(() => {
+ newsStore.toggleFavoritesOnly()
+ }, [newsStore])
+
+ const handleToggleFavorite = useCallback((article: NewsArticle) => {
+ newsStore.toggleFavorite(article)
+ }, [newsStore])
+
+ const renderItem = useCallback(({ item }: { item: NewsArticle }) => (
+ handleToggleFavorite(item)}
+ isFavorite={newsStore.hasFavorite(item)}
+ />
+ ), [handleToggleFavorite, newsStore])
+
+ return (
+
+
+ contentContainerStyle={themed([$styles.container, $listContentContainer])}
+ data={articlesForList}
+ extraData={articlesForList.length}
+ refreshing={refreshing}
+ estimatedItemSize={200}
+ onRefresh={manualRefresh}
+ onEndReached={loadMore}
+ onEndReachedThreshold={0.5}
+ ListEmptyComponent={
+
+ }
+ ListHeaderComponent={
+ 0}
+ />
+ }
+ ListFooterComponent={
+ 0}
+ />
+ }
+ renderItem={renderItem}
+ />
+
+ )
+}
+
+// Animated heart button component
+const AnimatedHeartButton: FC<{
+ isFavorite: boolean
+ onPress: () => void
+}> = ({ isFavorite, onPress }) => {
+ const { theme: { colors }, themed } = useAppTheme()
+ const liked = useSharedValue(isFavorite ? 1 : 0)
+
+ useEffect(() => {
+ liked.value = withSpring(isFavorite ? 1 : 0)
+ }, [isFavorite, liked])
+
+ const animatedLikeButtonStyles = useAnimatedStyle(() => ({
+ transform: [{ scale: interpolate(liked.value, [0, 1], [1, 0], Extrapolation.EXTEND) }],
+ opacity: interpolate(liked.value, [0, 1], [1, 0], Extrapolation.CLAMP),
+ }))
+
+ const animatedUnlikeButtonStyles = useAnimatedStyle(() => ({
+ transform: [{ scale: liked.value }],
+ opacity: liked.value,
+ }))
+
+ const handlePress = useCallback(() => {
+ onPress()
+ liked.value = withSpring(liked.value ? 0 : 1)
+ }, [liked, onPress])
+
+ const ButtonLeftAccessory: ComponentType = useMemo(
+ () => function ButtonLeftAccessory() {
+ return (
+
+
+
+
+
+
+
+
+ )
+ },
+ [animatedLikeButtonStyles, animatedUnlikeButtonStyles, colors, themed],
+ )
+
+ return (
+
+
+
+ )
+}
+
+// News article card component - simplified
+const NewsArticleCard: FC<{
+ article: NewsArticle
+ onPressFavorite: () => void
+ isFavorite: boolean
+}> = ({ article, onPressFavorite, isFavorite }) => {
+ const { themed } = useAppTheme()
+
+ const imageSource = useMemo(() =>
+ article.image_url ? { uri: article.image_url } : undefined
+ , [article.image_url])
+
+ const accessibilityProps = useMemo(
+ (): AccessibilityProps => ({
+ accessibilityLabel: article.title,
+ accessibilityActions: [{ name: "longpress", label: "Toggle favorite" }],
+ onAccessibilityAction: ({ nativeEvent }) => {
+ if (nativeEvent.actionName === "longpress") {
+ onPressFavorite()
+ }
+ },
+ accessibilityRole: "button",
+ accessibilityHint: "Tap to view article, long press to toggle favorite",
+ }),
+ [article.title, onPressFavorite, isFavorite],
+ )
+
+ const handlePressCard = useCallback(() => {
+ openLinkInBrowser(article.link)
+ }, [article.link])
+
+ return (
+
+
+
+ {article.primaryCountry && (
+
+ )}
+
+ }
+ content={article.title}
+ RightComponent={
+ imageSource ? (
+
+ ) : undefined
+ }
+ FooterComponent={
+
+ {article.shortDescription && (
+
+ )}
+
+
+ }
+ />
+ )
+}
+
+// Styles remain the same
+const $listContentContainer: ThemedStyle = ({ spacing }) => ({
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.lg + spacing.xl,
+ paddingBottom: spacing.lg,
+})
+
+const $heading: ThemedStyle = ({ spacing }) => ({
+ marginBottom: spacing.md,
+})
+
+const $subtitle: ThemedStyle = ({ colors, spacing }) => ({
+ color: colors.textDim,
+ marginTop: spacing.xs,
+})
+
+const $resultsCount: ThemedStyle = ({ colors, spacing }) => ({
+ color: colors.textDim,
+ marginTop: spacing.sm,
+})
+
+const $item: ThemedStyle = ({ colors, spacing }) => ({
+ padding: spacing.md,
+ marginTop: spacing.md,
+ minHeight: 140,
+ backgroundColor: colors.palette.neutral100,
+})
+
+const $itemThumbnail: ThemedStyle = ({ spacing }) => ({
+ marginTop: spacing.sm,
+ borderRadius: 8,
+ alignSelf: "flex-start",
+ width: 80,
+ height: 80,
+})
+
+const $description: ThemedStyle = ({ colors, spacing }) => ({
+ color: colors.textDim,
+ marginTop: spacing.xs,
marginBottom: spacing.sm,
})
-const $tagline: ThemedStyle = ({ spacing }) => ({
- marginBottom: spacing.xxl,
+const $toggle: ThemedStyle = ({ spacing }) => ({
+ marginTop: spacing.md,
})
-const $description: ThemedStyle = ({ spacing }) => ({
- marginBottom: spacing.lg,
+const $labelStyle: TextStyle = {
+ textAlign: "left",
+}
+
+const $iconContainer: ThemedStyle = ({ spacing }) => ({
+ height: ICON_SIZE,
+ width: ICON_SIZE,
+ marginEnd: spacing.sm,
})
-const $sectionTitle: ThemedStyle = ({ spacing }) => ({
- marginTop: spacing.xxl,
+const $metadata: ThemedStyle = ({ spacing }) => ({
+ marginTop: spacing.xs,
})
-const $logoContainer: ThemedStyle = ({ spacing }) => ({
+const $metadataText: ThemedStyle = ({ colors, spacing }) => ({
+ color: colors.textDim,
marginEnd: spacing.md,
- flexWrap: "wrap",
- alignContent: "center",
- alignSelf: "stretch",
+ marginBottom: spacing.xs,
})
-const $logo: ImageStyle = {
- height: 38,
- width: 38,
-}
+const $favoriteButton: ThemedStyle = ({ colors, spacing }) => ({
+ borderRadius: 17,
+ marginTop: spacing.md,
+ justifyContent: "flex-start",
+ backgroundColor: colors.palette.neutral300,
+ borderColor: colors.palette.neutral300,
+ paddingHorizontal: spacing.md,
+ paddingTop: spacing.xxxs,
+ paddingBottom: 0,
+ minHeight: 32,
+ alignSelf: "flex-start",
+})
+
+const $unFavoriteButton: ThemedStyle = ({ colors }) => ({
+ borderColor: colors.palette.primary100,
+ backgroundColor: colors.palette.primary100,
+})
+
+const $loadingFooter: ThemedStyle = ({ spacing }) => ({
+ paddingVertical: spacing.lg,
+ alignItems: "center",
+})
+
+const $loadingText: ThemedStyle = ({ colors, spacing }) => ({
+ color: colors.textDim,
+ marginTop: spacing.sm,
+})
+
+const $emptyState: ThemedStyle = ({ spacing }) => ({
+ marginTop: spacing.xxl,
+})
+
+const $emptyStateImage: ImageStyle = {
+ transform: [{ scaleX: isRTL ? -1 : 1 }],
+}
\ No newline at end of file
diff --git a/app/screens/DemoDebugScreen.tsx b/app/screens/DemoDebugScreen.tsx
index 50af476..f9e5ab7 100644
--- a/app/screens/DemoDebugScreen.tsx
+++ b/app/screens/DemoDebugScreen.tsx
@@ -1,5 +1,5 @@
-import { FC, useCallback, useMemo } from "react"
-import * as Application from "expo-application"
+import { FC, useCallback, useMemo, useState, useEffect } from "react";
+import * as Application from "expo-application";
import {
LayoutAnimation,
Linking,
@@ -8,35 +8,74 @@ import {
useColorScheme,
View,
ViewStyle,
-} from "react-native"
-import { Button, ListItem, Screen, Text } from "../components"
-import { DemoTabScreenProps } from "../navigators/DemoNavigator"
-import type { ThemedStyle } from "@/theme"
-import { $styles } from "../theme"
-import { isRTL } from "@/i18n"
-import { useStores } from "../models"
-import { useAppTheme } from "@/utils/useAppTheme"
+ Alert,
+} from "react-native";
+import { Button, ListItem, Screen, Text } from "../components";
+import { DemoTabScreenProps } from "../navigators/DemoNavigator";
+import type { ThemedStyle } from "@/theme";
+import { $styles } from "../theme";
+import { isRTL } from "@/i18n";
+import { useStores } from "../models";
+import { useAppTheme } from "@/utils/useAppTheme";
+import {
+ registerForPushNotificationsAsync,
+ sendPushNotification,
+ setupNotificationListeners,
+ NotificationPermissionError
+} from "../services/notifications/notification"; // Adjust path as needed
/**
* @param {string} url - The URL to open in the browser.
* @returns {void} - No return value.
*/
function openLinkInBrowser(url: string) {
- Linking.canOpenURL(url).then((canOpen) => canOpen && Linking.openURL(url))
+ Linking.canOpenURL(url).then((canOpen) => canOpen && Linking.openURL(url));
}
-const usingHermes = typeof HermesInternal === "object" && HermesInternal !== null
+const usingHermes =
+ typeof HermesInternal === "object" && HermesInternal !== null;
-export const DemoDebugScreen: FC> = function DemoDebugScreen(
- _props,
-) {
- const { setThemeContextOverride, themeContext, themed } = useAppTheme()
+export const DemoDebugScreen: FC> = function DemoDebugScreen(_props) {
+ const { setThemeContextOverride, themeContext, themed, theme } = useAppTheme();
const {
authenticationStore: { logout },
- } = useStores()
+ } = useStores();
+
+ // Push notification state
+ const [expoPushToken, setExpoPushToken] = useState('');
+ const [notificationError, setNotificationError] = useState(null);
+ const [isNotificationLoading, setIsNotificationLoading] = useState(false);
// @ts-expect-error
- const usingFabric = global.nativeFabricUIManager != null
+ const usingFabric = global.nativeFabricUIManager != null;
+
+ // Setup push notifications
+ useEffect(() => {
+ const initializeNotifications = async () => {
+ setIsNotificationLoading(true);
+ try {
+ const token = await registerForPushNotificationsAsync(
+ theme.colors.palette.primary500
+ );
+ setExpoPushToken(token || '');
+ setNotificationError(null);
+ } catch (error) {
+ setNotificationError(error as NotificationPermissionError);
+ console.error('Push notification setup failed:', error);
+ } finally {
+ setIsNotificationLoading(false);
+ }
+ };
+
+ initializeNotifications();
+
+ // Set up notification listeners
+ const cleanup = setupNotificationListeners();
+
+ return cleanup;
+ }, [theme.colors.palette.primary500]);
const demoReactotron = useMemo(
() => async () => {
@@ -49,25 +88,75 @@ export const DemoDebugScreen: FC> = function Dem
appVersion: Application.nativeApplicationVersion,
appBuildVersion: Application.nativeBuildVersion,
hermesEnabled: usingHermes,
+ pushToken: expoPushToken,
},
important: true,
- })
+ });
}
},
- [],
- )
+ [expoPushToken]
+ );
const toggleTheme = useCallback(() => {
- LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) // Animate the transition
- setThemeContextOverride(themeContext === "dark" ? "light" : "dark")
- }, [themeContext, setThemeContextOverride])
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); // Animate the transition
+ setThemeContextOverride(themeContext === "dark" ? "light" : "dark");
+ }, [themeContext, setThemeContextOverride]);
// Resets the theme to the system theme
- const colorScheme = useColorScheme()
+ const colorScheme = useColorScheme();
const resetTheme = useCallback(() => {
- LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
- setThemeContextOverride(undefined)
- }, [setThemeContextOverride])
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+ setThemeContextOverride(undefined);
+ }, [setThemeContextOverride]);
+
+ // Test push notification
+ const testPushNotification = useCallback(async () => {
+ if (!expoPushToken) {
+ Alert.alert('Error', 'No push token available. Make sure notifications are enabled.');
+ return;
+ }
+
+ try {
+ const success = await sendPushNotification(
+ expoPushToken,
+ 'Test Notification 🚀',
+ 'This is a test notification from the debug screen!',
+ {
+ screen: 'debug',
+ timestamp: new Date().toISOString(),
+ testData: 'Hello from Dooit!'
+ }
+ );
+
+ if (success) {
+ Alert.alert('Success', 'Test notification sent! Check your notification tray.');
+ } else {
+ Alert.alert('Error', 'Failed to send test notification. Check console for details.');
+ }
+ } catch (error) {
+ Alert.alert('Error', `Failed to send notification: ${error}`);
+ }
+ }, [expoPushToken]);
+
+ const getNotificationStatus = () => {
+ if (isNotificationLoading) return 'Setting up...';
+ if (notificationError) {
+ switch (notificationError.code) {
+ case 'PERMISSION_DENIED':
+ return 'Permission denied';
+ case 'NO_DEVICE':
+ return 'Simulator (not supported)';
+ case 'NO_PROJECT_ID':
+ return 'Config error';
+ case 'TOKEN_ERROR':
+ return 'Token error';
+ default:
+ return 'Setup failed';
+ }
+ }
+ if (expoPushToken) return 'Ready';
+ return 'Not available';
+ };
return (
> = function Dem
openLinkInBrowser("https://github.com/infinitered/ignite/issues")}
+ onPress={() =>
+ openLinkInBrowser("https://github.com/neweracy/Dooit/issues")
+ }
/>
-
+
Current system theme: {colorScheme}
Current app theme: {themeContext}
@@ -89,6 +184,64 @@ export const DemoDebugScreen: FC> = function Dem
+
+ {/* Push Notifications Section */}
+
+
+
+ Notification Status
+
+ {getNotificationStatus()}
+
+
+ }
+ />
+ {expoPushToken && (
+
+ Push Token
+
+ {expoPushToken}
+
+
+ }
+ />
+ )}
+ {notificationError && (
+
+ Error Details
+
+ {notificationError.message}
+
+
+ }
+ />
+ )}
+
+
+
+
+
+ {/* App Info Section */}
+
> = function Dem
/>
-
-
+
+
- )
-}
+ );
+};
const $container: ThemedStyle = ({ spacing }) => ({
paddingBottom: spacing.xxl,
-})
+});
const $title: ThemedStyle = ({ spacing }) => ({
marginBottom: spacing.xxl,
-})
+});
const $reportBugsLink: ThemedStyle = ({ colors, spacing }) => ({
color: colors.tint,
marginBottom: spacing.lg,
alignSelf: isRTL ? "flex-start" : "flex-end",
-})
+});
const $item: ThemedStyle = ({ spacing }) => ({
marginBottom: spacing.md,
-})
+});
const $itemsContainer: ThemedStyle = ({ spacing }) => ({
marginVertical: spacing.xl,
-})
+});
const $button: ThemedStyle = ({ spacing }) => ({
marginBottom: spacing.xs,
-})
+});
const $buttonContainer: ThemedStyle = ({ spacing }) => ({
marginBottom: spacing.md,
-})
+});
const $hint: ThemedStyle = ({ colors, spacing }) => ({
color: colors.palette.neutral600,
fontSize: 12,
lineHeight: 15,
paddingBottom: spacing.lg,
-})
+});
+
+const $errorText: ThemedStyle = ({ colors }) => ({
+ color: colors.error,
+});
+
+const $tokenText: ThemedStyle = ({ colors, spacing }) => ({
+ fontSize: 10,
+ color: colors.textDim,
+ marginTop: spacing.xs,
+});
\ No newline at end of file
diff --git a/app/screens/DemoPodcastListScreen.tsx b/app/screens/DemoPodcastListScreen.tsx
index d28a17b..99faa35 100644
--- a/app/screens/DemoPodcastListScreen.tsx
+++ b/app/screens/DemoPodcastListScreen.tsx
@@ -3,9 +3,6 @@ import { ComponentType, FC, useCallback, useEffect, useMemo, useState } from "re
import {
AccessibilityProps,
ActivityIndicator,
- Image,
- ImageSourcePropType,
- ImageStyle,
Platform,
StyleSheet,
TextStyle,
@@ -33,53 +30,117 @@ import {
} from "@/components"
import { isRTL, translate } from "@/i18n"
import { useStores } from "../models"
-import { Episode } from "../models/Episode"
import { DemoTabScreenProps } from "../navigators/DemoNavigator"
import type { ThemedStyle } from "@/theme"
import { $styles } from "../theme"
import { delay } from "../utils/delay"
-import { openLinkInBrowser } from "../utils/openLinkInBrowser"
import { useAppTheme } from "@/utils/useAppTheme"
-const ICON_SIZE = 14
+const ICON_SIZE = 18
-const rnrImage1 = require("../../assets/images/demo/rnr-image-1.png")
-const rnrImage2 = require("../../assets/images/demo/rnr-image-2.png")
-const rnrImage3 = require("../../assets/images/demo/rnr-image-3.png")
-const rnrImages = [rnrImage1, rnrImage2, rnrImage3]
+type FilterType = "all" | "completed" | "pending" | "reminders"
+type PriorityFilter = "all" | "high" | "medium" | "low"
+
+import { Instance } from "mobx-state-tree"
+import { TaskModel } from "../models/Task"
+
+type Task = Instance
export const DemoPodcastListScreen: FC> = observer(
function DemoPodcastListScreen(_props) {
- const { episodeStore } = useStores()
+ const { taskStore } = useStores()
const { themed } = useAppTheme()
const [refreshing, setRefreshing] = useState(false)
const [isLoading, setIsLoading] = useState(false)
+ const [filterType, setFilterType] = useState("all")
+ const [priorityFilter, setPriorityFilter] = useState("all")
// initially, kick off a background refresh without the refreshing UI
useEffect(() => {
;(async function load() {
setIsLoading(true)
- await episodeStore.fetchEpisodes()
+ await taskStore.fetchTasks()
setIsLoading(false)
})()
- }, [episodeStore])
+ }, [taskStore])
// simulate a longer refresh, if the refresh is too fast for UX
async function manualRefresh() {
setRefreshing(true)
- await Promise.all([episodeStore.fetchEpisodes(), delay(750)])
+ await Promise.all([taskStore.fetchTasks(), delay(750)])
setRefreshing(false)
}
+ // Type guard to ensure task has the correct shape
+ const isValidTask = (task: any): task is Task => {
+ return (
+ task &&
+ typeof task.id === 'string' &&
+ typeof task.title === 'string' &&
+ typeof task.isCompleted === 'boolean' &&
+ (task.priority === undefined ||
+ task.priority === 'high' ||
+ task.priority === 'medium' ||
+ task.priority === 'low')
+ )
+ }
+
+ // Filter tasks based on current filters
+ const filteredTasks = useMemo(() => {
+ // Type assertion for tasks from the store
+ const getTypedTasks = (tasks: any[]): Task[] => {
+ return tasks.filter(isValidTask)
+ }
+
+ let tasks: Task[] = []
+
+ // Apply completion filter
+ switch (filterType) {
+ case "completed":
+ tasks = getTypedTasks(taskStore.completedTasks)
+ break
+ case "pending":
+ tasks = getTypedTasks(taskStore.pendingTasks)
+ break
+ case "reminders":
+ tasks = getTypedTasks(taskStore.tasksWithReminders)
+ break
+ case "all":
+ default:
+ tasks = getTypedTasks(taskStore.tasksByDueDate)
+ break
+ }
+
+ // Apply priority filter
+ if (priorityFilter !== "all") {
+ tasks = tasks.filter((task) => task.priority === priorityFilter)
+ }
+
+ return tasks
+ }, [taskStore, filterType, priorityFilter])
+
+ const getFilterButtonText = (filter: FilterType) => {
+ switch (filter) {
+ case "all":
+ return `All (${taskStore.tasks.length})`
+ case "completed":
+ return `Completed (${taskStore.completedTasks.length})`
+ case "pending":
+ return `Pending (${taskStore.pendingTasks.length})`
+ case "reminders":
+ return `Reminders (${taskStore.tasksWithReminders.length})`
+ }
+ }
+
return (
-
+
contentContainerStyle={themed([$styles.container, $listContentContainer])}
- data={episodeStore.episodesForList.slice()}
- extraData={episodeStore.favorites.length + episodeStore.episodes.length}
+ data={filteredTasks}
+ extraData={`${filterType}-${priorityFilter}-${taskStore.tasks.length}`}
refreshing={refreshing}
- estimatedItemSize={177}
+ estimatedItemSize={150}
onRefresh={manualRefresh}
ListEmptyComponent={
isLoading ? (
@@ -88,47 +149,74 @@ export const DemoPodcastListScreen: FC> =
)
}
ListHeaderComponent={
-
-
- {(episodeStore.favoritesOnly || episodeStore.episodesForList.length > 0) && (
-
-
- episodeStore.setProp("favoritesOnly", !episodeStore.favoritesOnly)
- }
- labelTx="demoPodcastListScreen:onlyFavorites"
- labelPosition="left"
- labelStyle={$labelStyle}
- accessibilityLabel={translate("demoPodcastListScreen:accessibility.switch")}
- />
+
+
+
+
+
+
+ {/* Filter Buttons */}
+
+
+
+ {(["all", "pending", "completed", "reminders"] as FilterType[]).map((filter) => (
+ setFilterType(filter)}
+ />
+ ))}
+
+
+
+ {/* Priority Filter */}
+
+
+
+ {(["all", "high", "medium", "low"] as PriorityFilter[]).map((priority) => (
+ setPriorityFilter(priority)}
+ />
+ ))}
- )}
+
}
renderItem={({ item }) => (
- episodeStore.toggleFavorite(item)}
+ taskStore.toggleTaskCompletion(item.id)}
+ onToggleReminder={() => taskStore.toggleTaskReminder(item.id)}
+ onDelete={() => taskStore.deleteTask(item.id)}
/>
)}
/>
@@ -137,173 +225,204 @@ export const DemoPodcastListScreen: FC> =
},
)
-const EpisodeCard = observer(function EpisodeCard({
- episode,
- isFavorite,
- onPressFavorite,
+const TaskCard = observer(function TaskCard({
+ task,
+ onToggleComplete,
+ onToggleReminder,
+ onDelete,
}: {
- episode: Episode
- onPressFavorite: () => void
- isFavorite: boolean
+ task: Task
+ onToggleComplete: () => void
+ onToggleReminder: () => void
+ onDelete: () => void
}) {
const {
theme: { colors },
themed,
} = useAppTheme()
- const liked = useSharedValue(isFavorite ? 1 : 0)
- const imageUri = useMemo(() => {
- return rnrImages[Math.floor(Math.random() * rnrImages.length)]
- }, [])
+ const completed = useSharedValue(task.isCompleted ? 1 : 0)
+ const reminderEnabled = useSharedValue(task.reminderEnabled ? 1 : 0)
+
+ // Update animation values when task changes
+ useEffect(() => {
+ completed.value = withSpring(task.isCompleted ? 1 : 0)
+ reminderEnabled.value = withSpring(task.reminderEnabled ? 1 : 0)
+ }, [task.isCompleted, task.reminderEnabled, completed, reminderEnabled])
- // Grey heart
- const animatedLikeButtonStyles = useAnimatedStyle(() => {
+ // Completion checkbox animation
+ const animatedCheckboxStyles = useAnimatedStyle(() => {
return {
- transform: [
- {
- scale: interpolate(liked.value, [0, 1], [1, 0], Extrapolation.EXTEND),
- },
- ],
- opacity: interpolate(liked.value, [0, 1], [1, 0], Extrapolation.CLAMP),
+ transform: [{ scale: interpolate(completed.value, [0, 1], [1, 1.2], Extrapolation.CLAMP) }],
+ opacity: interpolate(completed.value, [0, 1], [0.6, 1], Extrapolation.CLAMP),
}
})
- // Pink heart
- const animatedUnlikeButtonStyles = useAnimatedStyle(() => {
+ // Reminder bell animation
+ const animatedReminderStyles = useAnimatedStyle(() => {
return {
- transform: [
- {
- scale: liked.value,
- },
- ],
- opacity: liked.value,
+ transform: [{ scale: interpolate(reminderEnabled.value, [0, 1], [1, 1.1], Extrapolation.CLAMP) }],
+ opacity: interpolate(reminderEnabled.value, [0, 1], [0.4, 1], Extrapolation.CLAMP),
}
})
- const handlePressFavorite = useCallback(() => {
- onPressFavorite()
- liked.value = withSpring(liked.value ? 0 : 1)
- }, [liked, onPressFavorite])
+ const handleToggleComplete = useCallback(() => {
+ onToggleComplete()
+ completed.value = withSpring(completed.value ? 0 : 1)
+ }, [completed, onToggleComplete])
- /**
- * Android has a "longpress" accessibility action. iOS does not, so we just have to use a hint.
- * @see https://reactnative.dev/docs/accessibility#accessibilityactions
- */
- const accessibilityHintProps = useMemo(
- () =>
- Platform.select({
- ios: {
- accessibilityLabel: episode.title,
- accessibilityHint: translate("demoPodcastListScreen:accessibility.cardHint", {
- action: isFavorite ? "unfavorite" : "favorite",
- }),
- },
- android: {
- accessibilityLabel: episode.title,
- accessibilityActions: [
- {
- name: "longpress",
- label: translate("demoPodcastListScreen:accessibility.favoriteAction"),
- },
- ],
- onAccessibilityAction: ({ nativeEvent }) => {
- if (nativeEvent.actionName === "longpress") {
- handlePressFavorite()
- }
- },
- },
- }),
- [episode.title, handlePressFavorite, isFavorite],
- )
+ const handleToggleReminder = useCallback(() => {
+ onToggleReminder()
+ reminderEnabled.value = withSpring(reminderEnabled.value ? 0 : 1)
+ }, [reminderEnabled, onToggleReminder])
- const handlePressCard = () => {
- openLinkInBrowser(episode.enclosure.link)
+ const formatDate = (dateString?: string) => {
+ if (!dateString) return ""
+ const date = new Date(dateString)
+ return date.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
+ })
}
- const ButtonLeftAccessory: ComponentType = useMemo(
+ const formatTime = (timeString?: string) => {
+ if (!timeString) return ""
+ const time = new Date(timeString)
+ return time.toLocaleTimeString(undefined, {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true
+ })
+ }
+
+ const getPriorityIcon = (priority?: string) => {
+ switch (priority) {
+ case "high":
+ return "spot"
+ case "medium":
+ return "spot"
+ case "low":
+ return "spot"
+ default:
+ return ""
+ }
+ }
+
+ const getPriorityColor = (priority?: string) => {
+ switch (priority) {
+ case "high":
+ return colors.palette.angry500
+ case "medium":
+ return colors.palette.secondary300
+ case "low":
+ return colors.palette.primary300
+ default:
+ return colors.palette.neutral400
+ }
+ }
+
+ const CheckboxAccessory: ComponentType = useMemo(
() =>
- function ButtonLeftAccessory() {
+ function CheckboxAccessory() {
return (
-
-
-
-
-
-
-
-
+
+
+
)
},
- [animatedLikeButtonStyles, animatedUnlikeButtonStyles, colors, themed],
+ [animatedCheckboxStyles, task.isCompleted, colors, themed],
+ )
+
+ const ReminderAccessory: ComponentType = useMemo(
+ () =>
+ function ReminderAccessory() {
+ return (
+
+
+
+ )
+ },
+ [animatedReminderStyles, task.reminderEnabled, colors, themed],
)
return (
-
- {episode.datePublished.textLabel}
-
-
- {episode.duration.textLabel}
-
+
+
+
+
+ {task.dueDate && (
+
+ )}
+ {task.taskTime && (
+
+ )}
}
- content={`${episode.parsedTitleAndSubtitle.title} - ${episode.parsedTitleAndSubtitle.subtitle}`}
- {...accessibilityHintProps}
- RightComponent={}
+ content={task.title}
+ contentStyle={themed([task.isCompleted && $completedText])}
FooterComponent={
-
+ task.description ? (
-
+ ) : undefined
+ }
+ RightComponent={
+
+
+
+
+
+
+
}
/>
)
@@ -318,69 +437,136 @@ const $listContentContainer: ThemedStyle = ({ spacing }) => ({
const $heading: ThemedStyle = ({ spacing }) => ({
marginBottom: spacing.md,
+ alignItems: "center",
+})
+
+const $subtitle: ThemedStyle = ({ colors, spacing }) => ({
+ color: colors.textDim,
+ marginTop: spacing.xs,
+})
+
+const $filterContainer: ThemedStyle = ({ spacing }) => ({
+ marginBottom: spacing.md,
+})
+
+const $filterLabel: ThemedStyle = ({ colors, spacing }) => ({
+ color: colors.textDim,
+ marginBottom: spacing.xs,
+ fontWeight: "600",
+})
+
+const $filterButtonRow: ThemedStyle = ({ spacing }) => ({
+
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: spacing.xs,
+})
+
+const $filterButton: ThemedStyle = ({ colors, spacing }) => ({
+ backgroundColor: colors.palette.neutral200,
+ borderColor: colors.palette.neutral300,
+ borderRadius: 16,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs,
+ minHeight: 32,
+})
+
+const $priorityFilterButton: ThemedStyle = ({ colors, spacing }) => ({
+ backgroundColor: colors.palette.neutral200,
+ borderColor: colors.palette.neutral300,
+ borderRadius: 16,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs,
+ minHeight: 32,
+ flex: 1,
+ maxWidth: "27%",
+})
+
+const $activeFilterButton: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.palette.primary100,
+ borderColor: colors.palette.primary300,
+})
+
+const $filterButtonText: ThemedStyle = ({ colors }) => ({
+ color: colors.textDim,
+ fontSize: 12,
+ fontWeight: "500",
+})
+
+const $activeFilterButtonText: ThemedStyle = ({ colors }) => ({
+ color: colors.palette.primary600,
+ fontWeight: "600",
})
const $item: ThemedStyle = ({ colors, spacing }) => ({
padding: spacing.md,
marginTop: spacing.md,
- minHeight: 120,
+ minHeight: 100,
backgroundColor: colors.palette.neutral100,
})
-const $itemThumbnail: ThemedStyle = ({ spacing }) => ({
- marginTop: spacing.sm,
- borderRadius: 50,
- alignSelf: "flex-start",
+const $completedItem: ThemedStyle = ({ colors }) => ({
+ backgroundColor: colors.palette.neutral100,
+ opacity: 0.8,
})
-const $toggle: ThemedStyle