diff --git a/app.config.ts b/app.config.ts
index add7427..69d8ec3 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -73,5 +73,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
// ===== κΈ°ν =====
scheme: 'frontend',
- plugins: ['expo-router'],
+ plugins: [
+ 'expo-router',
+ [
+ 'expo-notifications',
+ {
+ mode: 'development',
+ },
+ ],
+ ],
});
diff --git a/app/index.tsx b/app/index.tsx
index 9ed26c4..c5e9abf 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -12,6 +12,7 @@ import CalendarPage from '@/components/CalendarPage';
import SettingsSheet from '@/components/SettingsSheet';
import LoginScreen from '@/components/LoginScreen';
import { useAuth } from '@/providers/AuthProvider';
+import { registerForPushNotificationsAsync } from '@/services/pushNotifications';
import { ProfileApiError, updateUserProfile } from '@/services/userProfileService';
const HAS_ONBOARDED_KEY = 'hasOnboarded';
@@ -61,6 +62,21 @@ export default function Home() {
load();
}, []);
+ useEffect(() => {
+ const registerPushToken = async () => {
+ try {
+ const token = await registerForPushNotificationsAsync();
+ if (token) {
+ console.log('Expo push token:', token);
+ }
+ } catch (error) {
+ console.warn('Failed to register for push notifications', error);
+ }
+ };
+
+ registerPushToken();
+ }, []);
+
useEffect(() => {
if (isSignedIn) return;
setIsSettingsOpen(false);
@@ -88,6 +104,10 @@ export default function Home() {
setNeedsOnboarding(true);
}, []);
+ const handleSignInSuccess = useCallback(() => {
+ setNeedsProfileSetup(true);
+ }, []);
+
const handleOnboardingComplete = () => {
setNeedsOnboarding(false);
void persistHasOnboarded();
@@ -148,7 +168,12 @@ export default function Home() {
}
if (!isSignedIn) {
- return ;
+ return (
+
+ );
}
const showOnboarding = needsOnboarding && !needsProfileSetup;
diff --git a/frontend/ios/Pods/Target Support Files/Pods-app/ExpoModulesProvider.swift b/frontend/ios/Pods/Target Support Files/Pods-app/ExpoModulesProvider.swift
deleted file mode 100644
index a907c16..0000000
--- a/frontend/ios/Pods/Target Support Files/Pods-app/ExpoModulesProvider.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Automatically generated by expo-modules-autolinking.
- *
- * This autogenerated class provides a list of classes of native Expo modules,
- * but only these that are written in Swift and use the new API for creating Expo modules.
- */
-
-import ExpoModulesCore
-import Expo
-import ExpoAsset
-import EXConstants
-import ExpoFileSystem
-import ExpoFont
-import ExpoKeepAwake
-import ExpoLinking
-import ExpoHead
-
-@objc(ExpoModulesProvider)
-public class ExpoModulesProvider: ModulesProvider {
- public override func getModuleClasses() -> [AnyModule.Type] {
- return [
- ExpoFetchModule.self,
- AssetModule.self,
- ConstantsModule.self,
- FileSystemModule.self,
- FileSystemLegacyModule.self,
- FontLoaderModule.self,
- FontUtilsModule.self,
- KeepAwakeModule.self,
- ExpoLinkingModule.self,
- ExpoHeadModule.self,
- LinkPreviewNativeModule.self
- ]
- }
-
- public override func getAppDelegateSubscribers() -> [ExpoAppDelegateSubscriber.Type] {
- return [
- FileSystemBackgroundSessionHandler.self,
- LinkingAppDelegateSubscriber.self,
- ExpoHeadAppDelegateSubscriber.self
- ]
- }
-
- public override func getReactDelegateHandlers() -> [ExpoReactDelegateHandlerTupleType] {
- return [
- ]
- }
-
- public override func getAppCodeSignEntitlements() -> AppCodeSignEntitlements {
- return AppCodeSignEntitlements.from(json: #"{}"#)
- }
-}
diff --git a/package.json b/package.json
index cdd7cee..d9ab30e 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,9 @@
"buffer": "^6.0.3",
"expo": "~54.0.29",
"expo-constants": "^18.0.12",
+ "expo-device": "~8.0.10",
"expo-font": "^14.0.10",
+ "expo-notifications": "~0.32.15",
"expo-router": "^6.0.19",
"expo-status-bar": "~3.0.9",
"nativewind": "^4.2.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ab75d84..5f22612 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,9 +32,15 @@ importers:
expo-constants:
specifier: ^18.0.12
version: 18.0.12(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))
+ expo-device:
+ specifier: ~8.0.10
+ version: 8.0.10(expo@54.0.29)
expo-font:
specifier: ^14.0.10
version: 14.0.10(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
+ expo-notifications:
+ specifier: ~0.32.15
+ version: 0.32.15(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-router:
specifier: ^6.0.19
version: 6.0.19(@expo/metro-runtime@6.1.2)(@types/react@19.1.17)(expo-constants@18.0.12)(expo-linking@8.0.10)(expo@54.0.29)(react-dom@19.2.3(react@19.1.0))(react-native-gesture-handler@2.29.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.2.0(react-native-worklets@0.7.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.18.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -844,6 +850,9 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
+ '@ide/backoff@1.0.0':
+ resolution: {integrity: sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==}
+
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
@@ -1522,6 +1531,9 @@ packages:
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
+ assert@2.1.0:
+ resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==}
+
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -1600,6 +1612,9 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0
+ badgin@1.2.3:
+ resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -2114,6 +2129,11 @@ packages:
exec-async@2.2.0:
resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==}
+ expo-application@7.0.8:
+ resolution: {integrity: sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==}
+ peerDependencies:
+ expo: '*'
+
expo-asset@12.0.11:
resolution: {integrity: sha512-pnK/gQ5iritDPBeK54BV35ZpG7yeW5DtgGvJHruIXkyDT9BCoQq3i0AAxfcWG/e4eiRmTzAt5kNVYFJi48uo+A==}
peerDependencies:
@@ -2127,6 +2147,11 @@ packages:
expo: '*'
react-native: '*'
+ expo-device@8.0.10:
+ resolution: {integrity: sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==}
+ peerDependencies:
+ expo: '*'
+
expo-file-system@19.0.21:
resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==}
peerDependencies:
@@ -2162,6 +2187,13 @@ packages:
react: '*'
react-native: '*'
+ expo-notifications@0.32.15:
+ resolution: {integrity: sha512-gnJcauheC2S0Wl0RuJaFkaBRVzCG011j5hlG0TEbsuOCPBuB/F30YEk8yurK8Psv+zHkVfeiJ5AC+nL0LWk0WA==}
+ peerDependencies:
+ expo: '*'
+ react: '*'
+ react-native: '*'
+
expo-router@6.0.19:
resolution: {integrity: sha512-XK+vwpyEmGGamUM/S7+LwtjuBzbTm8VY401h8SOs8Tv1mrLl+7QNvAkk5lJiXgUVKaAIEXl8GxkWhF7mxBUzyg==}
peerDependencies:
@@ -2514,6 +2546,10 @@ packages:
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
+ is-arguments@1.2.0:
+ resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==}
+ engines: {node: '>= 0.4'}
+
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -2586,6 +2622,10 @@ packages:
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
engines: {node: '>= 0.4'}
+ is-nan@1.3.2:
+ resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==}
+ engines: {node: '>= 0.4'}
+
is-negative-zero@2.0.3:
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
engines: {node: '>= 0.4'}
@@ -3256,6 +3296,10 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
+ object-is@1.1.6:
+ resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==}
+ engines: {node: '>= 0.4'}
+
object-keys@1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
@@ -4155,6 +4199,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ ua-parser-js@0.7.41:
+ resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
+ hasBin: true
+
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -4235,6 +4283,9 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ util@0.12.5:
+ resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==}
+
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
@@ -5432,6 +5483,8 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
+ '@ide/backoff@1.0.0': {}
+
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
@@ -6255,6 +6308,14 @@ snapshots:
asap@2.0.6: {}
+ assert@2.1.0:
+ dependencies:
+ call-bind: 1.0.8
+ is-nan: 1.3.2
+ object-is: 1.1.6
+ object.assign: 4.1.7
+ util: 0.12.5
+
async-function@1.0.0: {}
async-limiter@1.0.1: {}
@@ -6398,6 +6459,8 @@ snapshots:
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5)
+ badgin@1.2.3: {}
+
balanced-match@1.0.2: {}
base64-js@1.5.1: {}
@@ -6985,6 +7048,10 @@ snapshots:
exec-async@2.2.0: {}
+ expo-application@7.0.8(expo@54.0.29):
+ dependencies:
+ expo: 54.0.29(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.19)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
+
expo-asset@12.0.11(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
'@expo/image-utils': 0.8.8
@@ -7004,6 +7071,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ expo-device@8.0.10(expo@54.0.29):
+ dependencies:
+ expo: 54.0.29(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.19)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
+ ua-parser-js: 0.7.41
+
expo-file-system@19.0.21(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)):
dependencies:
expo: 54.0.29(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.19)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -7045,6 +7117,21 @@ snapshots:
react: 19.1.0
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
+ expo-notifications@0.32.15(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
+ dependencies:
+ '@expo/image-utils': 0.8.8
+ '@ide/backoff': 1.0.0
+ abort-controller: 3.0.0
+ assert: 2.1.0
+ badgin: 1.2.3
+ expo: 54.0.29(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.19)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
+ expo-application: 7.0.8(expo@54.0.29)
+ expo-constants: 18.0.12(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))
+ react: 19.1.0
+ react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
+ transitivePeerDependencies:
+ - supports-color
+
expo-router@6.0.19(@expo/metro-runtime@6.1.2)(@types/react@19.1.17)(expo-constants@18.0.12)(expo-linking@8.0.10)(expo@54.0.29)(react-dom@19.2.3(react@19.1.0))(react-native-gesture-handler@2.29.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.2.0(react-native-worklets@0.7.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.18.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
'@expo/metro-runtime': 6.1.2(expo@54.0.29)(react-dom@19.2.3(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -7417,6 +7504,11 @@ snapshots:
dependencies:
loose-envify: 1.4.0
+ is-arguments@1.2.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -7491,6 +7583,11 @@ snapshots:
is-map@2.0.3: {}
+ is-nan@1.3.2:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+
is-negative-zero@2.0.3: {}
is-number-object@1.1.1:
@@ -8355,6 +8452,11 @@ snapshots:
object-inspect@1.13.4: {}
+ object-is@1.1.6:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+
object-keys@1.1.1: {}
object.assign@4.1.7:
@@ -9369,6 +9471,8 @@ snapshots:
typescript@5.9.3: {}
+ ua-parser-js@0.7.41: {}
+
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -9434,6 +9538,14 @@ snapshots:
util-deprecate@1.0.2: {}
+ util@0.12.5:
+ dependencies:
+ inherits: 2.0.4
+ is-arguments: 1.2.0
+ is-generator-function: 1.1.2
+ is-typed-array: 1.1.15
+ which-typed-array: 1.1.19
+
utils-merge@1.0.1: {}
uuid@7.0.3: {}
diff --git a/src/components/CalendarPage.tsx b/src/components/CalendarPage.tsx
index 3268817..dac4bc0 100644
--- a/src/components/CalendarPage.tsx
+++ b/src/components/CalendarPage.tsx
@@ -282,7 +282,7 @@ const CalendarPage: React.FC = ({
const insets = useSafeAreaInsets();
return (
-
+
= ({
onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
useNativeDriver: true,
})}
+ contentContainerStyle={{
+ paddingBottom: 40, // π μνλ λ§νΌ
+ }}
>
-
- {year}
-
+ {year}
= ({
>
{month}
-
- μ
-
+ μ
-
+
{WEEKDAY_HANJA[weekday]}
-
+
{fortune?.lunarDate || 'μλ ₯ --'}
@@ -404,7 +400,7 @@ const CalendarPage: React.FC = ({
{day}
-
+
{loading ? (
@@ -413,7 +409,7 @@ const CalendarPage: React.FC = ({
) : fortune ? (
-
+
" {fortune.overview} "
) : (
@@ -435,11 +431,11 @@ const CalendarPage: React.FC = ({
-
+
-
+
μ€λμ μ΄μΈ
@@ -459,8 +455,6 @@ const CalendarPage: React.FC = ({
)}
-
-
@@ -480,9 +474,9 @@ const FortuneItem: React.FC = ({ icon, label, value, isLast })
- {label}
+ {label}
- {value}
+ {value}
);
diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx
index dad5858..dfda5d6 100644
--- a/src/components/LoginScreen.tsx
+++ b/src/components/LoginScreen.tsx
@@ -9,9 +9,10 @@ type AuthMode = 'signIn' | 'signUp';
interface Props {
onSignUpSuccess?: () => void;
+ onSignInSuccess?: () => void;
}
-const LoginScreen: React.FC = ({ onSignUpSuccess }) => {
+const LoginScreen: React.FC = ({ onSignUpSuccess, onSignInSuccess }) => {
const { signIn, signUp, isLoading, error, clearError } = useAuth();
const [mode, setMode] = useState('signIn');
const [username, setUsername] = useState('');
@@ -47,6 +48,7 @@ const LoginScreen: React.FC = ({ onSignUpSuccess }) => {
}
try {
await signIn(usernameTrimmed, password);
+ onSignInSuccess?.();
} catch (err) {
if (err instanceof AuthError && err.code === 'UserNotConfirmedException') {
clearError();
diff --git a/src/services/authService.ts b/src/services/authService.ts
index f737089..6cccd62 100644
--- a/src/services/authService.ts
+++ b/src/services/authService.ts
@@ -1,6 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
AuthenticationDetails,
+ CognitoRefreshToken,
CognitoUser,
CognitoUserAttribute,
CognitoUserPool,
@@ -26,6 +27,7 @@ export interface AuthTokens {
refreshToken: string;
accessTokenExp: number;
idTokenExp: number;
+ refreshTokenExp?: number;
}
export interface SignUpResult {
@@ -37,6 +39,7 @@ export interface SignUpResult {
const AUTH_TOKENS_KEY = 'authTokens';
const AUTH_LAST_USER_KEY = 'authLastUser';
const TOKEN_EXPIRY_LEEWAY_MS = 60_000;
+const REFRESH_TOKEN_EXPIRY_MS = 5 * 24 * 60 * 60 * 1000;
const AUTH_ERROR_MESSAGES: Record = {
UsernameExistsException: 'μ΄λ―Έ κ°μ
λ μμ΄λμ
λλ€. λ‘κ·ΈμΈν΄μ£ΌμΈμ.',
@@ -53,6 +56,8 @@ const AUTH_ERROR_MESSAGES: Record = {
let cachedTokens: AuthTokens | null = null;
let cachedUser: string | null = null;
let cacheLoaded = false;
+let refreshPromise: Promise | null = null;
+let authSessionVersion = 0;
const getErrorCode = (error: unknown) => {
if (!error || typeof error !== 'object') return undefined;
@@ -81,13 +86,26 @@ const getUserPool = () => {
});
};
-const mapSessionTokens = (session: CognitoUserSession): AuthTokens => ({
- accessToken: session.getAccessToken().getJwtToken(),
- idToken: session.getIdToken().getJwtToken(),
- refreshToken: session.getRefreshToken().getToken(),
- accessTokenExp: session.getAccessToken().getExpiration() * 1000,
- idTokenExp: session.getIdToken().getExpiration() * 1000,
-});
+const mapSessionTokens = (
+ session: CognitoUserSession,
+ previousTokens?: AuthTokens | null,
+): AuthTokens => {
+ const refreshToken =
+ session.getRefreshToken().getToken() || previousTokens?.refreshToken || '';
+ const refreshTokenExp =
+ previousTokens?.refreshToken === refreshToken && previousTokens.refreshTokenExp
+ ? previousTokens.refreshTokenExp
+ : Date.now() + REFRESH_TOKEN_EXPIRY_MS;
+
+ return {
+ accessToken: session.getAccessToken().getJwtToken(),
+ idToken: session.getIdToken().getJwtToken(),
+ refreshToken,
+ accessTokenExp: session.getAccessToken().getExpiration() * 1000,
+ idTokenExp: session.getIdToken().getExpiration() * 1000,
+ refreshTokenExp,
+ };
+};
const loadCache = async () => {
if (cacheLoaded) return;
@@ -129,6 +147,20 @@ const saveLastUser = async (username: string | null) => {
const isExpired = (exp: number) => Date.now() > exp - TOKEN_EXPIRY_LEEWAY_MS;
+const isTokenValid = (exp?: number) => Boolean(exp && !isExpired(exp));
+
+const hasValidAccessToken = (tokens: AuthTokens | null) =>
+ Boolean(tokens?.accessToken && isTokenValid(tokens.accessTokenExp));
+
+const hasValidIdToken = (tokens: AuthTokens | null) =>
+ Boolean(tokens?.idToken && isTokenValid(tokens.idTokenExp));
+
+const shouldRefreshTokens = (tokens: AuthTokens | null) =>
+ Boolean(tokens && (!hasValidAccessToken(tokens) || !hasValidIdToken(tokens)));
+
+const isRefreshTokenExpired = (tokens: AuthTokens | null) =>
+ Boolean(tokens?.refreshTokenExp && isExpired(tokens.refreshTokenExp));
+
const pickToken = (tokens: AuthTokens | null) => {
if (!tokens) return null;
const useIdToken = COGNITO_CONFIG.TOKEN_USE === 'id';
@@ -138,6 +170,60 @@ const pickToken = (tokens: AuthTokens | null) => {
return token;
};
+const REFRESH_TOKEN_FAILURE_CODES = new Set([
+ 'NotAuthorizedException',
+ 'InvalidParameterException',
+ 'UserNotFoundException',
+]);
+
+const shouldClearSessionOnRefreshError = (error: unknown) => {
+ const code = getErrorCode(error);
+ return code ? REFRESH_TOKEN_FAILURE_CODES.has(code) : false;
+};
+
+const refreshTokens = async () => {
+ await loadCache();
+ if (!cachedTokens?.refreshToken || !cachedUser) return null;
+ if (refreshPromise) return refreshPromise;
+
+ const refreshTokenValue = cachedTokens.refreshToken;
+ const username = cachedUser;
+ const sessionVersion = authSessionVersion;
+
+ const promise = new Promise((resolve, reject) => {
+ const user = new CognitoUser({
+ Username: username,
+ Pool: getUserPool(),
+ });
+ const refreshToken = new CognitoRefreshToken({ RefreshToken: refreshTokenValue });
+
+ user.refreshSession(refreshToken, async (error, session) => {
+ if (error || !session) {
+ reject(buildAuthError(error, 'μΈμ
κ°±μ μ μ€ν¨νμ΅λλ€.'));
+ return;
+ }
+ if (sessionVersion !== authSessionVersion) {
+ resolve(null);
+ return;
+ }
+ try {
+ const tokens = mapSessionTokens(session, cachedTokens);
+ await saveTokens(tokens);
+ resolve(tokens);
+ } catch (err) {
+ reject(err);
+ }
+ });
+ });
+
+ refreshPromise = promise;
+ promise.finally(() => {
+ refreshPromise = null;
+ });
+
+ return promise;
+};
+
export const getStoredTokens = async () => {
await loadCache();
return cachedTokens;
@@ -150,8 +236,31 @@ export const getLastUsername = async () => {
export const getAuthToken = async () => {
await loadCache();
- const storedToken = pickToken(cachedTokens);
- if (storedToken) return storedToken;
+ const storedTokens = cachedTokens;
+ const storedToken = pickToken(storedTokens);
+ const needsRefresh = shouldRefreshTokens(storedTokens);
+
+ if (storedToken && !needsRefresh) return storedToken;
+
+ if (storedTokens?.refreshToken && cachedUser) {
+ if (isRefreshTokenExpired(storedTokens)) {
+ await signOut();
+ return API_CONFIG.AUTH_TOKEN || null;
+ }
+ try {
+ const refreshedTokens = await refreshTokens();
+ const refreshedToken = pickToken(refreshedTokens);
+ if (refreshedToken) return refreshedToken;
+ } catch (error) {
+ if (shouldClearSessionOnRefreshError(error)) {
+ await signOut();
+ return API_CONFIG.AUTH_TOKEN || null;
+ }
+ }
+
+ if (storedToken) return storedToken;
+ }
+
return API_CONFIG.AUTH_TOKEN || null;
};
@@ -265,5 +374,6 @@ export const resendSignUpCode = async (username: string) => {
};
export const signOut = async () => {
+ authSessionVersion += 1;
await Promise.all([saveTokens(null), saveLastUser(null)]);
};
diff --git a/src/services/pushNotifications.ts b/src/services/pushNotifications.ts
new file mode 100644
index 0000000..223a7ab
--- /dev/null
+++ b/src/services/pushNotifications.ts
@@ -0,0 +1,45 @@
+import Constants from 'expo-constants';
+import * as Device from 'expo-device';
+import * as Notifications from 'expo-notifications';
+import { Platform } from 'react-native';
+
+export const registerForPushNotificationsAsync = async (): Promise => {
+ if (Platform.OS === 'web') {
+ console.info('Push notifications are not supported on web.');
+ return null;
+ }
+
+ if (!Device.isDevice) {
+ console.warn('Push notifications require a physical device.');
+ return null;
+ }
+
+ if (Platform.OS === 'android') {
+ await Notifications.setNotificationChannelAsync('default', {
+ name: 'default',
+ importance: Notifications.AndroidImportance.DEFAULT,
+ });
+ }
+
+ const { status: existingStatus } = await Notifications.getPermissionsAsync();
+ let finalStatus = existingStatus;
+ if (existingStatus !== 'granted') {
+ const { status } = await Notifications.requestPermissionsAsync();
+ finalStatus = status;
+ }
+
+ if (finalStatus !== 'granted') {
+ console.warn('Push notification permission not granted.');
+ return null;
+ }
+
+ const projectId =
+ Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
+ if (!projectId) {
+ console.warn('Expo projectId is missing; unable to request push token.');
+ return null;
+ }
+
+ const token = (await Notifications.getExpoPushTokenAsync({ projectId })).data;
+ return token;
+};