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;
+ },
+};