diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index ea1b015..7ed7dd5 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,5 +1,5 @@ import { Tabs } from 'expo-router'; -import { Home, CreditCard, Star, Settings } from 'lucide-react-native'; +import { Home, CreditCard, Calendar, Star, Settings } from 'lucide-react-native'; import { colors } from '../../constants/colors'; export default function TabsLayout() { @@ -37,6 +37,13 @@ export default function TabsLayout() { tabBarIcon: ({ color, size }) => , }} /> + , + }} + /> e.isPaid).length; + const unpaidCount = events.length - paidCount; + + return ( + + {unpaidCount > 0 && ( + + )} + {paidCount > 0 && ( + + )} + + ); +} + +function DayCell({ + day, + isSelected, + onPress, +}: { + day: CalendarDayData; + isSelected: boolean; + onPress: () => void; +}) { + const hasEvents = day.events.length > 0; + + return ( + + + + {day.date.getDate()} + + + {hasEvents && !isSelected ? : null} + + ); +} + +function SelectedDayPanel({ day }: { day: CalendarDayData }) { + if (!day.events.length) { + return ( + + + No payments on this day + + + ); + } + + return ( + + + + {day.date.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + })} + + + {day.events.length} payment{day.events.length > 1 ? 's' : ''} + + + {day.events.map((event, idx) => ( + + + + {event.isPaid ? ( + + ) : ( + + )} + + + + {event.loanName} + + + {formatCurrency(event.amount)} + + + + + {event.isPaid ? 'Paid' : 'Due'} + + + ))} + + ); +} + +export default function CalendarScreen() { + const { + currentMonth, + weeks, + isLoading, + error, + refetch, + goToNextMonth, + goToPrevMonth, + goToToday, + selectedDay, + selectDay, + allEvents, + exportToCalendar, + scheduleReminders, + paymentStreak, + } = useInstallmentCalendar(); + + const [isRefreshing, setIsRefreshing] = React.useState(false); + + const handleRefresh = React.useCallback(async () => { + setIsRefreshing(true); + await refetch(); + setIsRefreshing(false); + }, [refetch]); + + const handleExport = React.useCallback(async () => { + const success = await exportToCalendar(); + if (success) { + Alert.alert('Calendar Exported', 'Payment dates have been added to your calendar.'); + } else { + Alert.alert('Permission Required', 'Calendar access was not granted. Please enable it in settings.'); + } + }, [exportToCalendar]); + + const handleScheduleReminders = React.useCallback(async () => { + await scheduleReminders(); + Alert.alert('Reminders Set', 'Payment reminders have been scheduled 14, 7, 3, and 1 day before each due date.'); + }, [scheduleReminders]); + + if (isLoading) { + return ( + + + + ); + } + + if (error && !isRefreshing) { + return ( + + { void refetch(); } }} + /> + + ); + } + + if (!allEvents.length && !isLoading) { + return ( + + + + ); + } + + const monthYear = `${MONTH_NAMES[currentMonth.getMonth()]} ${currentMonth.getFullYear()}`; + const totalDue = allEvents.filter((e) => !e.isPaid).length; + const totalPaid = allEvents.filter((e) => e.isPaid).length; + + return ( + + + } + > + {/* Header */} + + + Calendar + + + + + + + + + + + + {/* Streak Card */} + + + + + + + + + Payment Streak + + + Consecutive on-time payments + + + + + {paymentStreak} + + + + + {/* Summary */} + + + + Due + + + {totalDue} + + + + + Paid + + + {totalPaid} + + + + + {/* Calendar Widget */} + + {/* Month Navigation */} + + + + + + + {monthYear} + + + + + + + + {/* Weekday Headers */} + + {WEEKDAY_HEADERS.map((day) => ( + + + {day} + + + ))} + + + {/* Day Grid */} + {weeks.map((week, weekIdx) => ( + + {week.map((day, dayIdx) => { + const isSelected = + selectedDay?.date.getTime() === day.date.getTime(); + return ( + selectDay(isSelected ? null : day)} + /> + ); + })} + + ))} + + + {/* Selected Day Details */} + {selectedDay ? ( + + + + ) : null} + + + ); +} diff --git a/hooks/useInstallmentCalendar.ts b/hooks/useInstallmentCalendar.ts new file mode 100644 index 0000000..79772dc --- /dev/null +++ b/hooks/useInstallmentCalendar.ts @@ -0,0 +1,220 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useLoansStore } from '../stores/loans.store'; +import { loansService } from '../services/loans.service'; +import { notificationsService } from '../services/notifications.service'; +import type { Loan, Installment } from '../types/loan.types'; + +export interface CalendarEventData { + loanId: string; + loanName: string; + amount: number; + dueDate: string; + isPaid: boolean; +} + +export interface CalendarDayData { + date: Date; + dateStr: string; + isCurrentMonth: boolean; + isToday: boolean; + events: CalendarEventData[]; +} + +export interface UseInstallmentCalendarReturn { + currentMonth: Date; + weeks: CalendarDayData[][]; + isLoading: boolean; + error: string | null; + refetch: () => Promise; + goToNextMonth: () => void; + goToPrevMonth: () => void; + goToToday: () => void; + selectedDay: CalendarDayData | null; + selectDay: (day: CalendarDayData | null) => void; + allEvents: CalendarEventData[]; + exportToCalendar: () => Promise; + scheduleReminders: () => Promise; + paymentStreak: number; +} + +const WEEKDAY_HEADERS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +function normalizeDate(dateStr: string): string { + return new Date(dateStr).toISOString().split('T')[0]; +} + +function daysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function buildWeeks(year: number, month: number, events: CalendarEventData[]): CalendarDayData[][] { + const totalDays = daysInMonth(year, month); + const firstDayOfWeek = new Date(year, month, 1).getDay(); + const today = new Date(); + const todayStr = today.toISOString().split('T')[0]; + + const allDays: CalendarDayData[] = []; + + const prevMonthTotal = daysInMonth(year, month - 1); + for (let i = firstDayOfWeek - 1; i >= 0; i--) { + const d = prevMonthTotal - i; + const date = new Date(year, month - 1, d); + const dateStr = normalizeDate(date.toISOString()); + allDays.push({ + date, + dateStr, + isCurrentMonth: false, + isToday: dateStr === todayStr, + events: events.filter((e) => e.dueDate === dateStr), + }); + } + + for (let d = 1; d <= totalDays; d++) { + const date = new Date(year, month, d); + const dateStr = normalizeDate(date.toISOString()); + allDays.push({ + date, + dateStr, + isCurrentMonth: true, + isToday: dateStr === todayStr, + events: events.filter((e) => e.dueDate === dateStr), + }); + } + + const remaining = 42 - allDays.length; + for (let d = 1; d <= remaining; d++) { + const date = new Date(year, month + 1, d); + const dateStr = normalizeDate(date.toISOString()); + allDays.push({ + date, + dateStr, + isCurrentMonth: false, + isToday: dateStr === todayStr, + events: events.filter((e) => e.dueDate === dateStr), + }); + } + + const weeks: CalendarDayData[][] = []; + for (let i = 0; i < allDays.length; i += 7) { + weeks.push(allDays.slice(i, i + 7)); + } + + return weeks; +} + +export function useInstallmentCalendar(): UseInstallmentCalendarReturn { + const loans = useLoansStore((s) => s.loans); + const setLoans = useLoansStore((s) => s.setLoans); + + const [currentMonth, setCurrentMonth] = useState(() => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1); + }); + const [selectedDay, setSelectedDay] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchLoans = useCallback(async () => { + setError(null); + try { + const data = await loansService.getMyLoans(); + setLoans(data); + } catch { + setError('Could not load calendar data. Please try again.'); + } finally { + setIsLoading(false); + } + }, [setLoans]); + + useEffect(() => { + void fetchLoans(); + }, [fetchLoans]); + + const allEvents: CalendarEventData[] = useMemo(() => { + const activeLoans = loans.filter( + (l) => l.status === 'active' || l.status === 'pending', + ); + + return activeLoans.flatMap((loan) => + loan.installments.map((inst) => ({ + loanId: loan.id, + loanName: `Loan #${loan.id.slice(0, 8)}`, + amount: inst.amount, + dueDate: normalizeDate(inst.dueDate), + isPaid: inst.paid, + })), + ); + }, [loans]); + + const weeks = useMemo( + () => buildWeeks(currentMonth.getFullYear(), currentMonth.getMonth(), allEvents), + [currentMonth, allEvents], + ); + + const paymentStreak = useMemo(() => { + const allInstallments: Installment[] = loans.flatMap((l) => l.installments); + return notificationsService.calculateStreak(allInstallments); + }, [loans]); + + const goToNextMonth = useCallback(() => { + setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1)); + setSelectedDay(null); + }, []); + + const goToPrevMonth = useCallback(() => { + setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1)); + setSelectedDay(null); + }, []); + + const goToToday = useCallback(() => { + const now = new Date(); + setCurrentMonth(new Date(now.getFullYear(), now.getMonth(), 1)); + setSelectedDay(null); + }, []); + + const selectDay = useCallback( + (day: CalendarDayData | null) => { + setSelectedDay(day); + }, + [], + ); + + const exportToCalendar = useCallback(async () => { + return notificationsService.exportToCalendar( + allEvents.map((e) => ({ + title: `StepFi Payment - ${e.loanName}`, + notes: `Payment of $${e.amount.toLocaleString()} for ${e.loanName}`, + startDate: e.dueDate, + endDate: e.dueDate, + })), + ); + }, [allEvents]); + + const scheduleReminders = useCallback(async () => { + const allInstallments: Installment[] = loans.flatMap((l) => l.installments); + await notificationsService.scheduleReminders(allInstallments); + }, [loans]); + + return { + currentMonth, + weeks, + isLoading, + error, + refetch: fetchLoans, + goToNextMonth, + goToPrevMonth, + goToToday, + selectedDay, + selectDay, + allEvents, + exportToCalendar, + scheduleReminders, + paymentStreak, + }; +} + +export { WEEKDAY_HEADERS, MONTH_NAMES }; diff --git a/package-lock.json b/package-lock.json index dc47ca2..d85de1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,12 @@ "axios": "^1.16.0", "expo": "^54.0.0", "expo-blur": "~15.0.8", + "expo-calendar": "~15.0.8", "expo-constants": "~18.0.13", "expo-font": "~14.0.11", "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.12", + "expo-notifications": "~0.32.17", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.8", @@ -2533,6 +2535,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -4508,6 +4516,19 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4534,7 +4555,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -4781,6 +4801,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5000,7 +5026,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -5032,7 +5057,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5661,7 +5685,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -5688,7 +5711,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -6623,6 +6645,15 @@ } } }, + "node_modules/expo-application": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", + "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "12.0.13", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz", @@ -6649,6 +6680,16 @@ "react-native": "*" } }, + "node_modules/expo-calendar": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-calendar/-/expo-calendar-15.0.8.tgz", + "integrity": "sha512-i+ojy6zFnWSPb2DYp4L4W4U5iVI+NXnuHr3xysShoV8znNOmixP1TOYuJXt5Lpz+BpHCWseU31gV1E5SSkIKsw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -6751,6 +6792,26 @@ "react-native": "*" } }, + "node_modules/expo-notifications": { + "version": "0.32.17", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz", + "integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.8", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~7.0.8", + "expo-constants": "~18.0.13" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-router": { "version": "6.0.23", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", @@ -7481,7 +7542,6 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -7591,7 +7651,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7849,7 +7908,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -8130,6 +8188,22 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -8246,7 +8320,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8358,7 +8431,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.4", @@ -8399,6 +8471,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", @@ -8442,7 +8530,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8525,7 +8612,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -10073,11 +10159,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10087,7 +10188,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10571,7 +10671,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11878,7 +11977,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -12028,7 +12126,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -13286,6 +13383,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -13679,7 +13789,6 @@ "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", diff --git a/package.json b/package.json index a146588..73fa807 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "axios": "^1.16.0", "expo": "^54.0.0", "expo-blur": "~15.0.8", + "expo-calendar": "~15.0.8", "expo-constants": "~18.0.13", "expo-font": "~14.0.11", "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.12", + "expo-notifications": "~0.32.17", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.8", diff --git a/services/notifications.service.ts b/services/notifications.service.ts new file mode 100644 index 0000000..c6852d4 --- /dev/null +++ b/services/notifications.service.ts @@ -0,0 +1,156 @@ +import { Platform } from 'react-native'; +import type { Installment } from '../types/loan.types'; + +let ExpoNotifications: typeof import('expo-notifications') | null = null; +let ExpoCalendar: typeof import('expo-calendar') | null = null; + +try { + ExpoNotifications = require('expo-notifications'); +} catch {} +try { + ExpoCalendar = require('expo-calendar'); +} catch {} + +export interface CalendarEvent { + title: string; + notes: string; + startDate: string; + endDate: string; +} + +const DAY_MS = 86_400_000; + +function getReminderDates(dueDate: string): Date[] { + const due = new Date(dueDate); + const now = new Date(); + const dates: Date[] = []; + + for (const daysBefore of [14, 7, 3, 1]) { + const reminder = new Date(due.getTime() - daysBefore * DAY_MS); + if (reminder > now) { + dates.push(reminder); + } + } + + return dates; +} + +export const notificationsService = { + async requestNotificationPermissions(): Promise { + if (!ExpoNotifications) return false; + + try { + const { status } = await ExpoNotifications.requestPermissionsAsync(); + return status === 'granted'; + } catch { + return false; + } + }, + + async scheduleReminders(installments: Installment[]): Promise { + if (!ExpoNotifications) return; + + const { status: existingStatus } = await ExpoNotifications.getPermissionsAsync(); + if (existingStatus !== 'granted') { + const granted = await notificationsService.requestNotificationPermissions(); + if (!granted) return; + } + + await ExpoNotifications.cancelAllScheduledNotificationsAsync(); + + if (Platform.OS === 'web') return; + + for (const installment of installments) { + if (installment.paid) continue; + + const reminderDates = getReminderDates(installment.dueDate); + + for (const date of reminderDates) { + const daysBefore = Math.round((new Date(installment.dueDate).getTime() - date.getTime()) / DAY_MS); + + await ExpoNotifications.scheduleNotificationAsync({ + content: { + title: 'Payment Reminder', + body: `$${installment.amount.toLocaleString()} due in ${daysBefore} day${daysBefore > 1 ? 's' : ''}`, + data: { dueDate: installment.dueDate, amount: installment.amount }, + }, + trigger: { type: ExpoNotifications.SchedulableTriggerInputTypes.DATE, date }, + }); + } + } + }, + + async cancelAllReminders(): Promise { + if (!ExpoNotifications) return; + await ExpoNotifications.cancelAllScheduledNotificationsAsync(); + }, + + async requestCalendarPermissions(): Promise { + if (!ExpoCalendar) return false; + + try { + const { status } = await ExpoCalendar.requestCalendarPermissionsAsync(); + return status === 'granted'; + } catch { + return false; + } + }, + + async exportToCalendar(events: CalendarEvent[]): Promise { + if (!ExpoCalendar) return false; + + const granted = await notificationsService.requestCalendarPermissions(); + if (!granted) return false; + if (Platform.OS === 'web') return true; + + try { + const defaultCalendarSource = + Platform.OS === 'ios' + ? await ExpoCalendar.getDefaultCalendarAsync() + : { isLocalAccount: true, name: 'StepFi', type: ExpoCalendar.CalendarSourceType.LOCAL }; + + const calendarId = await ExpoCalendar.createCalendarAsync({ + title: 'StepFi Payments', + color: '#22C55E', + entityType: ExpoCalendar.EntityTypes.EVENT, + source: defaultCalendarSource, + name: 'stepfi-payments', + ownerAccount: 'stepfi', + accessLevel: ExpoCalendar.CalendarAccessLevel.OWNER, + }); + + for (const event of events) { + await ExpoCalendar.createEventAsync(calendarId, { + title: event.title, + notes: event.notes, + startDate: new Date(event.startDate), + endDate: new Date(event.endDate), + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }); + } + + return true; + } catch { + return false; + } + }, + + calculateStreak(installments: Installment[]): number { + if (!installments.length) return 0; + + const sorted = [...installments].sort( + (a, b) => new Date(b.dueDate).getTime() - new Date(a.dueDate).getTime(), + ); + + let streak = 0; + for (const installment of sorted) { + if (installment.paid) { + streak++; + } else { + break; + } + } + + return streak; + }, +};