From e8e902119b9f523c68d9891d2bcaebc22b16c164 Mon Sep 17 00:00:00 2001 From: BinarySandia04 Date: Tue, 21 Apr 2026 23:24:54 +0200 Subject: [PATCH] Added main functionality --- apk/app.json | 4 + apk/components/birthdate-item.tsx | 181 ++++++++++++++++++++++++++-- apk/components/birthdate-list.tsx | 26 +++- apk/context/birthdays-context.tsx | 64 ++++++++++ apk/package-lock.json | 178 ++++++++++++++++++++++----- apk/package.json | 2 + apk/utils/birthday-notifications.ts | 129 ++++++++++++++++++++ 7 files changed, 545 insertions(+), 39 deletions(-) create mode 100644 apk/utils/birthday-notifications.ts diff --git a/apk/app.json b/apk/app.json index d7ec328..1a38be9 100644 --- a/apk/app.json +++ b/apk/app.json @@ -12,6 +12,9 @@ "supportsTablet": true }, "android": { + "permissions": [ + "SCHEDULE_EXACT_ALARM" + ], "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", @@ -27,6 +30,7 @@ }, "plugins": [ "expo-router", + "expo-notifications", [ "expo-splash-screen", { diff --git a/apk/components/birthdate-item.tsx b/apk/components/birthdate-item.tsx index a7a4e7d..95a4273 100644 --- a/apk/components/birthdate-item.tsx +++ b/apk/components/birthdate-item.tsx @@ -1,18 +1,126 @@ -// BirthdayItem.tsx -import { StyleSheet, Text, View } from "react-native"; +import { useState } from "react"; +import { + Modal, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; interface BirthdayItemProps { + id: string; name: string; date: string; - age?: number; + onDelete: (id: string) => void; } -export function BirthdayItem({ name, date, age }: BirthdayItemProps) { +export function BirthdayItem({ id, name, date, onDelete }: BirthdayItemProps) { + const [menuOpen, setMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + + const daysUntilNext = (birthdayDate: string) => { + const today = new Date(); + const parsedDate = new Date(birthdayDate); + const month = parsedDate.getMonth(); + const day = parsedDate.getDate(); + const currentYear = today.getFullYear(); + + let target = new Date(currentYear, month, day); + if (target < today.setHours(0, 0, 0, 0)) { + target = new Date(currentYear + 1, month, day); + } + const msPerDay = 1000 * 60 * 60 * 24; + const diff = target.getTime() - new Date(today.setHours(0, 0, 0, 0)).getTime(); + return Math.ceil(diff / msPerDay); + }; + + const computeNewAge = (birthdayDate: string) => { + const parsedDate = new Date(birthdayDate); + const today = new Date(); + let age = today.getFullYear() - parsedDate.getFullYear(); + + const monthDiff = today.getMonth() - parsedDate.getMonth(); + const dayDiff = today.getDate() - parsedDate.getDate(); + + if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) { + age--; + } + return age; + }; + + const days = daysUntilNext(date); + + const handleOpenMenu = (event: { + nativeEvent: { pageX: number; pageY: number }; + }) => { + setMenuPosition({ + top: event.nativeEvent.pageY + 8, + left: event.nativeEvent.pageX - 136, + }); + setMenuOpen(true); + }; + + const handleDelete = () => { + setMenuOpen(false); + onDelete(id); + }; + return ( - - {name} - {age && Age: {age}} - + <> + + + + {name} + {days === 0 ? ( + + {name} makes {computeNewAge(date)} years today! + + ) : ( + date && Days remaining: {days} + )} + + + + + + + setMenuOpen(false)} + transparent + visible={menuOpen} + > + + setMenuOpen(false)} + style={styles.menuBackdropPressable} + /> + + + + Edit + + + + Delete + + + + + ); } @@ -24,6 +132,27 @@ const styles = StyleSheet.create({ elevation: 2, marginBottom: 10, }, + contentRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }, + textContainer: { + flex: 1, + }, + menuTrigger: { + alignItems: "center", + borderRadius: 999, + height: 36, + justifyContent: "center", + width: 36, + }, + menuTriggerText: { + color: "#444", + fontSize: 22, + lineHeight: 22, + }, name: { fontSize: 16, fontWeight: "bold", @@ -35,4 +164,40 @@ const styles = StyleSheet.create({ fontSize: 12, marginTop: 5, }, + menuBackdrop: { + ...StyleSheet.absoluteFillObject, + }, + menuBackdropPressable: { + ...StyleSheet.absoluteFillObject, + }, + menu: { + backgroundColor: "#fff", + borderRadius: 8, + elevation: 8, + minWidth: 140, + overflow: "hidden", + position: "absolute", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + }, + menuItem: { + paddingHorizontal: 14, + paddingVertical: 12, + }, + menuItemDisabled: { + opacity: 0.45, + }, + menuItemText: { + color: "#222", + fontSize: 14, + fontWeight: "500", + }, + menuItemTextDisabled: { + color: "#666", + }, + deleteText: { + color: "#c53a3a", + }, }); diff --git a/apk/components/birthdate-list.tsx b/apk/components/birthdate-list.tsx index 34b19de..6e31158 100644 --- a/apk/components/birthdate-list.tsx +++ b/apk/components/birthdate-list.tsx @@ -13,7 +13,7 @@ interface SectionData { } export function BirthdayList() { - const { birthdays } = useBirthdays(); + const { birthdays, deleteBirthday } = useBirthdays(); const groupedData = groupBirthdaysByDate(birthdays); return ( @@ -22,7 +22,12 @@ export function BirthdayList() { sections={groupedData} keyExtractor={(item) => item.id} renderItem={({ item }) => ( - + )} style={styles.list} contentContainerStyle={styles.listContent} @@ -48,8 +53,23 @@ export function BirthdayList() { // Helper function to group and sort function groupBirthdaysByDate(data: BirthdayEntry[]): SectionData[] { // Sort by date + const today = new Date(); + const currentYear = today.getFullYear(); + + const daysUntilNext = (month: number, day: number) => { + let target = new Date(currentYear, month, day); + if(target < today.setHours(0, 0, 0, 0)){ + target = new Date(currentYear + 1, month, day); + } + const msPerDay = 1000 * 60 * 60 * 24; + const diff = target - new Date(today.setHours(0,0,0,0)); + return Math.ceil(diff / msPerDay); + }; + const sorted = [...data].sort((a, b) => { - return parseBirthdayDate(a.date).getTime() - parseBirthdayDate(b.date).getTime(); + const dateA = parseBirthdayDate(a); + const dateB = parseBirthdayDate(b); + return daysUntilNext(dateA.getMonth(), dateA.getDate()) - daysUntilNext(dateB.getMonth(), dateB.getDate()); }); // Group by date diff --git a/apk/context/birthdays-context.tsx b/apk/context/birthdays-context.tsx index 3286e8c..15bb522 100644 --- a/apk/context/birthdays-context.tsx +++ b/apk/context/birthdays-context.tsx @@ -2,10 +2,13 @@ import { createContext, ReactNode, useContext, + useEffect, useMemo, useState, } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { syncBirthdayNotifications } from "@/utils/birthday-notifications"; export interface BirthdayEntry { id: string; name: string; @@ -20,14 +23,70 @@ interface AddBirthdayInput { interface BirthdaysContextValue { birthdays: BirthdayEntry[]; addBirthday: (input: AddBirthdayInput) => void; + deleteBirthday: (id: string) => void; } +const STORAGE_KEY = "birthdays"; + const BirthdaysContext = createContext( undefined ); export function BirthdaysProvider({ children }: { children: ReactNode }) { const [birthdays, setBirthdays] = useState([]); + const [isLoaded, setIsLoaded] = useState(false); + + // Load birthdays on app start from storage + useEffect(() => { + const loadBirthdays = async () => { + try { + const stored = await AsyncStorage.getItem(STORAGE_KEY); + if (stored) { + setBirthdays(JSON.parse(stored)); + } + } catch(e) { + console.log("Error loading birthdays", e); + } finally { + setIsLoaded(true); + } + }; + + loadBirthdays(); + }, []); + + // Save birthdays whenever they change + useEffect(() => { + if (!isLoaded) return; + + const saveBirthdays = async () => { + try { + await AsyncStorage.setItem( + STORAGE_KEY, + JSON.stringify(birthdays) + ); + } catch(e) { + console.log("Error saving birthdays", e); + } + }; + + saveBirthdays(); + }, [birthdays, isLoaded]); + + useEffect(() => { + if (!isLoaded) { + return; + } + + const synchronizeNotifications = async () => { + try { + await syncBirthdayNotifications(birthdays); + } catch (error) { + console.log("Error syncing birthday notifications", error); + } + }; + + synchronizeNotifications(); + }, [birthdays, isLoaded]); const value = useMemo( () => ({ @@ -48,6 +107,11 @@ export function BirthdaysProvider({ children }: { children: ReactNode }) { }, ]); }, + deleteBirthday: (id: string) => { + setBirthdays((currentBirthdays) => + currentBirthdays.filter((birthday) => birthday.id !== id) + ); + }, }), [birthdays] ); diff --git a/apk/package-lock.json b/apk/package-lock.json index ca6b609..920f4b4 100644 --- a/apk/package-lock.json +++ b/apk/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/datetimepicker": "^9.1.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", @@ -19,6 +20,7 @@ "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-linking": "~8.0.11", + "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", @@ -2305,6 +2307,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", @@ -2798,6 +2806,18 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native-community/datetimepicker": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-9.1.0.tgz", @@ -4303,6 +4323,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", @@ -4323,7 +4356,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" @@ -4544,6 +4576,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", @@ -4748,7 +4786,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4767,7 +4804,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4781,7 +4817,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", @@ -5271,7 +5306,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", @@ -5298,7 +5332,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", @@ -5390,7 +5423,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5519,7 +5551,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5566,7 +5597,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6112,6 +6142,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.12", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", @@ -6244,6 +6283,26 @@ "react-native": "*" } }, + "node_modules/expo-notifications": { + "version": "0.32.16", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz", + "integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==", + "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", @@ -6951,7 +7010,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" @@ -7045,7 +7103,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" @@ -7073,7 +7130,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7116,7 +7172,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7266,7 +7321,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7307,7 +7361,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" @@ -7336,7 +7389,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7349,7 +7401,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7605,6 +7656,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", @@ -7709,7 +7776,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" @@ -7822,7 +7888,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", @@ -7864,6 +7929,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", @@ -7903,11 +7984,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-regex": { "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", @@ -7990,7 +8079,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" @@ -8881,7 +8969,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8893,6 +8980,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -9516,11 +9615,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" @@ -9530,7 +9644,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", @@ -10005,7 +10118,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" @@ -10956,7 +11068,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", @@ -11106,7 +11217,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", @@ -12327,6 +12437,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/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -12714,7 +12837,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/apk/package.json b/apk/package.json index 432b21f..1281795 100644 --- a/apk/package.json +++ b/apk/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/datetimepicker": "^9.1.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", @@ -22,6 +23,7 @@ "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-linking": "~8.0.11", + "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", diff --git a/apk/utils/birthday-notifications.ts b/apk/utils/birthday-notifications.ts new file mode 100644 index 0000000..ced183b --- /dev/null +++ b/apk/utils/birthday-notifications.ts @@ -0,0 +1,129 @@ +import { Platform } from "react-native"; +import * as Notifications from "expo-notifications"; + +import type { BirthdayEntry } from "@/context/birthdays-context"; + +const CHANNEL_ID = "birthdays"; +const NOTIFICATION_HOUR = 9; +const NOTIFICATION_MINUTE = 0; +const IDENTIFIER_PREFIX = "birthday-"; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldPlaySound: true, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +function getNotificationIdentifier(id: string) { + return `${IDENTIFIER_PREFIX}${id}`; +} + +function parseBirthdayDate(date: string) { + return new Date(`${date}T00:00:00`); +} + +async function configureAndroidChannel() { + if (Platform.OS !== "android") { + return; + } + + await Notifications.setNotificationChannelAsync(CHANNEL_ID, { + name: "Birthdays", + description: "Birthday reminders", + importance: Notifications.AndroidImportance.DEFAULT, + }); +} + +async function hasNotificationPermissionAsync() { + const settings = await Notifications.getPermissionsAsync(); + + return ( + settings.granted || + settings.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL + ); +} + +async function ensureNotificationPermissionAsync() { + await configureAndroidChannel(); + + if (await hasNotificationPermissionAsync()) { + return true; + } + + const settings = await Notifications.requestPermissionsAsync({ + ios: { + allowAlert: true, + allowBadge: false, + allowSound: true, + }, + }); + + return ( + settings.granted || + settings.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL + ); +} + +async function clearStaleBirthdayNotifications(birthdays: BirthdayEntry[]) { + const validIdentifiers = new Set( + birthdays.map((birthday) => getNotificationIdentifier(birthday.id)) + ); + const scheduledNotifications = + await Notifications.getAllScheduledNotificationsAsync(); + + await Promise.all( + scheduledNotifications + .filter( + (notification) => + notification.identifier.startsWith(IDENTIFIER_PREFIX) && + !validIdentifiers.has(notification.identifier) + ) + .map((notification) => + Notifications.cancelScheduledNotificationAsync(notification.identifier) + ) + ); +} + +async function scheduleBirthdayNotification(birthday: BirthdayEntry) { + const parsedDate = parseBirthdayDate(birthday.date); + const month = parsedDate.getMonth(); + const day = parsedDate.getDate(); + + await Notifications.cancelScheduledNotificationAsync( + getNotificationIdentifier(birthday.id) + ); + + await Notifications.scheduleNotificationAsync({ + identifier: getNotificationIdentifier(birthday.id), + content: { + title: `Today is ${birthday.name}'s birthday`, + body: `Wish ${birthday.name} a happy birthday!`, + sound: true, + }, + trigger: { + type: Notifications.SchedulableTriggerInputTypes.YEARLY, + channelId: Platform.OS === "android" ? CHANNEL_ID : undefined, + month, + day, + hour: NOTIFICATION_HOUR, + minute: NOTIFICATION_MINUTE, + }, + }); +} + +export async function syncBirthdayNotifications(birthdays: BirthdayEntry[]) { + if (Platform.OS === "web") { + return; + } + + const permissionGranted = await ensureNotificationPermissionAsync(); + if (!permissionGranted) { + return; + } + + await clearStaleBirthdayNotifications(birthdays); + await Promise.all(birthdays.map(scheduleBirthdayNotification)); +}