From 0d457b5048ebe8dbc503822d69c540fb6c78d374 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 00:45:41 +0800 Subject: [PATCH 1/3] Add handling for multiple token refresh attempts in apiClient to prevent infinite loops and improve error management. --- src/api/client.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/api/client.ts b/src/api/client.ts index 9b89a34..4347898 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -42,10 +42,22 @@ apiClient.interceptors.response.use( const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean; + _refreshAttempts?: number; }; if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; + originalRequest._refreshAttempts = + (originalRequest._refreshAttempts || 0) + 1; + + // Prevent infinite refresh loops + if (originalRequest._refreshAttempts > 3) { + await AsyncStorage.removeItem('userToken'); + await AsyncStorage.removeItem('refreshToken'); + await AsyncStorage.removeItem('isDummyToken'); + console.error('Too many token refresh attempts, logging out user'); + return Promise.reject(error); + } try { const refreshToken = await AsyncStorage.getItem('refreshToken'); From aa874c70f2a8ee978e99bc5ef524636634507ad0 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 01:34:01 +0800 Subject: [PATCH 2/3] Add GoalSetting and PetSelection components for user setup process --- src/components/setup/GoalSetting.tsx | 47 +++++++ src/components/setup/PetSelection.tsx | 135 ++++++++++++++++++++ src/screens/SetUp.tsx | 170 ++++---------------------- 3 files changed, 207 insertions(+), 145 deletions(-) create mode 100644 src/components/setup/GoalSetting.tsx create mode 100644 src/components/setup/PetSelection.tsx diff --git a/src/components/setup/GoalSetting.tsx b/src/components/setup/GoalSetting.tsx new file mode 100644 index 0000000..927c5af --- /dev/null +++ b/src/components/setup/GoalSetting.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import {View, Text, TextInput, StyleSheet} from 'react-native'; + +interface GoalSettingProps { + goal: string; + onGoalChange: (goal: string) => void; +} + +const GoalSetting: React.FC = ({goal, onGoalChange}) => { + return ( + + 設定理財目標 + + + ); +}; + +const styles = StyleSheet.create({ + stepContent: { + width: '100%', + alignItems: 'center', + }, + title: { + fontSize: 24, + marginBottom: 20, + color: '#fff', + textAlign: 'center', + }, + input: { + width: '100%', + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 5, + padding: 10, + marginBottom: 20, + backgroundColor: '#fff', + color: '#000', + }, +}); + +export default GoalSetting; diff --git a/src/components/setup/PetSelection.tsx b/src/components/setup/PetSelection.tsx new file mode 100644 index 0000000..eca2742 --- /dev/null +++ b/src/components/setup/PetSelection.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import {View, Text, TouchableOpacity, Image, StyleSheet} from 'react-native'; + +interface Dino { + id: number; + name: string; + imageKey: string; + image: any; +} + +interface PetSelectionProps { + selectedDino: Dino | null; + onDinoSelect: (dino: Dino) => void; +} + +const dinosaurs: Dino[] = [ + { + id: 1, + name: '寵物一', + imageKey: 'blue_1', + image: require('../../assets/characters/blue_1.png'), + }, + { + id: 2, + name: '寵物二', + imageKey: 'blue_2', + image: require('../../assets/characters/blue_2.png'), + }, + { + id: 3, + name: '寵物三', + imageKey: 'green_1', + image: require('../../assets/characters/green_1.png'), + }, + { + id: 4, + name: '寵物四', + imageKey: 'green_2', + image: require('../../assets/characters/green_2.png'), + }, + { + id: 5, + name: '寵物五', + imageKey: 'green_3', + image: require('../../assets/characters/green_3.png'), + }, + { + id: 6, + name: '寵物六', + imageKey: 'main_character', + image: require('../../assets/characters/main_character.png'), + }, + { + id: 7, + name: '寵物七', + imageKey: 'pink_1', + image: require('../../assets/characters/pink_1.png'), + }, + { + id: 8, + name: '寵物八', + imageKey: 'yellow_1', + image: require('../../assets/characters/yellow_1.png'), + }, + { + id: 9, + name: '寵物九', + imageKey: 'yellow_2', + image: require('../../assets/characters/yellow_2.png'), + }, +]; + +const PetSelection: React.FC = ({ + selectedDino, + onDinoSelect, +}) => { + return ( + + 選擇一個萌寵 + + {dinosaurs.map(dino => ( + onDinoSelect(dino)}> + + {dino.name} + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + stepContent: { + width: '100%', + alignItems: 'center', + }, + title: { + fontSize: 24, + marginBottom: 20, + color: '#fff', + textAlign: 'center', + }, + dinosContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + flexWrap: 'wrap', + width: '100%', + }, + dinoItem: { + alignItems: 'center', + padding: 10, + borderRadius: 5, + }, + selectedDinoItem: { + borderWidth: 2, + borderColor: '#007BFF', + }, + dinoImage: { + width: 100, + height: 100, + marginBottom: 10, + }, + dinoName: { + textAlign: 'center', + color: '#fff', + }, +}); + +export default PetSelection; diff --git a/src/screens/SetUp.tsx b/src/screens/SetUp.tsx index 1e86e87..2ad079b 100644 --- a/src/screens/SetUp.tsx +++ b/src/screens/SetUp.tsx @@ -3,9 +3,7 @@ import { View, Text, StyleSheet, - TextInput, TouchableOpacity, - Image, StatusBar, } from 'react-native'; import * as Progress from 'react-native-progress'; @@ -14,144 +12,70 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import {useAuth} from '../contexts/AuthContext'; import Layout from '../components/Layout'; import {colors} from '../theme/colors'; +import GoalSetting from '../components/setup/GoalSetting'; +import PetSelection from '../components/setup/PetSelection'; + +type SetupStep = 'goal' | 'pet'; const SetUp = () => { - const [step, setStep] = useState(1); + const [currentStep, setCurrentStep] = useState('goal'); const [goal, setGoal] = useState(''); const [selectedDino, setSelectedDino] = useState(null); const navigation = useNavigation(); const {user} = useAuth(); - // Mock data for pet images - const dinosaurs = [ - { - id: 1, - name: '寵物一', - imageKey: 'blue_1', - image: require('../assets/characters/blue_1.png'), - }, - { - id: 2, - name: '寵物二', - imageKey: 'blue_2', - image: require('../assets/characters/blue_2.png'), - }, - { - id: 3, - name: '寵物三', - imageKey: 'green_1', - image: require('../assets/characters/green_1.png'), - }, - { - id: 4, - name: '寵物四', - imageKey: 'green_2', - image: require('../assets/characters/green_2.png'), - }, - { - id: 5, - name: '寵物五', - imageKey: 'green_3', - image: require('../assets/characters/green_3.png'), - }, - { - id: 6, - name: '寵物六', - imageKey: 'main_character', - image: require('../assets/characters/main_character.png'), - }, - { - id: 7, - name: '寵物七', - imageKey: 'pink_1', - image: require('../assets/characters/pink_1.png'), - }, - { - id: 8, - name: '寵物八', - imageKey: 'yellow_1', - image: require('../assets/characters/yellow_1.png'), - }, - { - id: 9, - name: '寵物九', - imageKey: 'yellow_2', - image: require('../assets/characters/yellow_2.png'), - }, - ]; - const handleNext = async () => { - if (step === 1) { + if (currentStep === 'goal') { if (goal.trim().length > 0) { - setStep(2); + setCurrentStep('pet'); } - } else if (step === 2) { + } else if (currentStep === 'pet') { if (selectedDino && user?.uid) { const key = `setupDone-${user.uid}`; await AsyncStorage.setItem(key, 'true'); await AsyncStorage.setItem(`dino-${user.uid}`, selectedDino.imageKey); - navigation.reset({index: 0, routes: [{name: 'MainTabs'}]}); + navigation.reset({ + index: 0, + routes: [{name: 'MainTabs' as never}], + }); } } }; const handleBack = () => { - if (step === 2) { - setStep(1); + if (currentStep === 'pet') { + setCurrentStep('goal'); } }; - // Progress: 50% at step 1, 100% at step 2 - const progressValue = step === 1 ? 0.5 : 1.0; + const progressValue = currentStep === 'goal' ? 0.5 : 1.0; return ( - + - {step === 1 ? ( - - 設定理財目標 - - + {currentStep === 'goal' ? ( + ) : ( - - 選擇一個萌寵 - - {dinosaurs.map(dino => ( - setSelectedDino(dino)}> - - {dino.name} - - ))} - - + )} - {step === 2 && ( + {currentStep === 'pet' && ( 上一步 )} - {step === 1 ? '下一步' : '完成!'} + {currentStep === 'goal' ? '下一步' : '完成!'} @@ -165,57 +89,13 @@ const styles = StyleSheet.create({ container: { flex: 1, padding: 20, - justifyContent: 'space-between', // Distribute top content and bottom container + justifyContent: 'space-between', }, content: { flex: 1, justifyContent: 'center', alignItems: 'center', }, - stepContent: { - width: '100%', - alignItems: 'center', - }, - title: { - fontSize: 24, - marginBottom: 20, - color: '#fff', - textAlign: 'center', - }, - input: { - width: '100%', - borderWidth: 1, - borderColor: '#ccc', - borderRadius: 5, - padding: 10, - marginBottom: 20, - backgroundColor: '#fff', - color: '#000', - }, - dinosContainer: { - flexDirection: 'row', - justifyContent: 'space-around', - flexWrap: 'wrap', - width: '100%', - }, - dinoItem: { - alignItems: 'center', - padding: 10, - borderRadius: 5, - }, - selectedDinoItem: { - borderWidth: 2, - borderColor: '#007BFF', - }, - dinoImage: { - width: 100, - height: 100, - marginBottom: 10, - }, - dinoName: { - textAlign: 'center', - color: '#fff', - }, bottomContainer: { marginTop: 10, }, From 5bedb86791edd41e53e7a27f2dd4964c767da6d1 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 02:27:07 +0800 Subject: [PATCH 3/3] Enhance user setup process by adding character selection functionality, updating API endpoints, and improving token validation in request interceptor. --- src/api/client.ts | 8 +- src/api/endpoints.ts | 1 + src/api/userService.ts | 30 ++++++ src/navigation/AppNavigator.tsx | 49 +++------ src/navigation/TabNavigator.tsx | 29 +++++ src/screens/SetUp.tsx | 186 +++++++++++++++++++------------- src/theme/colors.ts | 3 + 7 files changed, 193 insertions(+), 113 deletions(-) create mode 100644 src/navigation/TabNavigator.tsx diff --git a/src/api/client.ts b/src/api/client.ts index 4347898..42c3624 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -22,7 +22,13 @@ apiClient.interceptors.request.use( !config.url?.includes(API_ENDPOINTS.AUTH_LOGIN) && !config.url?.includes(API_ENDPOINTS.AUTH_REGISTER) ) { - config.headers.Authorization = `Bearer ${token}`; + if (token.split('.').length === 3) { + config.headers.Authorization = `Bearer ${token}`; + } else { + console.error('Invalid token format'); + await AsyncStorage.removeItem('userToken'); + await AsyncStorage.removeItem('refreshToken'); + } } return config; }, diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 7bec238..70b5612 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -6,6 +6,7 @@ export const API_ENDPOINTS = { USER_ME: '/users/me', USER_UPDATE: '/users/me', + USER_CHARACTER: '/users/me/character', TRANSACTION: '/transactions', }; diff --git a/src/api/userService.ts b/src/api/userService.ts index 9231866..6f21cfd 100644 --- a/src/api/userService.ts +++ b/src/api/userService.ts @@ -4,12 +4,24 @@ import useSWR from 'swr'; import {useAuth} from '../contexts/AuthContext'; interface UserProfileResponse { + id: string; + email: string; + name: string; + character: { + id: string; + image_url: string; + }; wallet: { diamonds: number; saving: number; }; } +interface SetCharacterRequest { + character_id: string; + image_url: string; +} + export const userService = { getUserProfile: async (): Promise => { const response = await apiClient.get(API_ENDPOINTS.USER_ME); @@ -20,6 +32,13 @@ export const userService = { const response = await apiClient.put(API_ENDPOINTS.USER_UPDATE, userData); return response.data; }, + + setCharacter: async ( + data: SetCharacterRequest, + ): Promise => { + const response = await apiClient.put(API_ENDPOINTS.USER_CHARACTER, data); + return response.data; + }, }; export const useUserProfile = () => { @@ -49,10 +68,21 @@ export const useUserProfileManager = () => { } }; + const setCharacter = async (data: SetCharacterRequest) => { + try { + const updatedUser = await userService.setCharacter(data); + mutate(updatedUser, false); + return updatedUser; + } catch (error) { + throw error; + } + }; + return { user, isLoading, isError, updateProfile, + setCharacter, }; }; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 60956ea..165fc9d 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -1,53 +1,32 @@ -import React, {useEffect, useState} from 'react'; -import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; +import React, {useState, useEffect} from 'react'; import {createStackNavigator} from '@react-navigation/stack'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import {useAuth} from '../contexts/AuthContext'; - -import HomeScreen from '../screens/HomeScreen'; -import GachaScreen from '../screens/GachaScreen'; -import InvestScreen from '../screens/InvestScreen'; -import AnalysisScreen from '../screens/AnalysisScreen'; -import BagScreen from '../screens/BagScreen'; -import SettingsScreen from '../screens/SettingsScreen'; -import TransactionScreen from '../screens/TransactionScreen'; +import {useUserProfile} from '../api/userService'; import SetUp from '../screens/SetUp'; +import TabNavigator from './TabNavigator'; +import SettingsScreen from '../screens/SettingsScreen'; -const Tab = createBottomTabNavigator(); const Stack = createStackNavigator(); -const TabNavigator = () => { - return ( - - - - - - - - - ); -}; - const AppNavigator = () => { const {user} = useAuth(); + const {user: userProfile, isLoading: isUserLoading} = useUserProfile(); const [initialRoute, setInitialRoute] = useState(null); useEffect(() => { const checkSetup = async () => { - if (!user?.uid) {return;} - const key = `setupDone-${user.uid}`; - const setupDone = await AsyncStorage.getItem(key); - setInitialRoute(setupDone === 'true' ? 'MainTabs' : 'SetUp'); + if (!user?.uid || isUserLoading) {return;} + + if (!userProfile?.character?.id) { + setInitialRoute('SetUp'); + } else { + setInitialRoute('MainTabs'); + } }; checkSetup(); - }, [user]); + }, [user, userProfile, isUserLoading]); - if (!initialRoute) {return null;} + if (!initialRoute || isUserLoading) {return null;} return ( { + return ( + + + + + + + + + ); +}; + +export default TabNavigator; diff --git a/src/screens/SetUp.tsx b/src/screens/SetUp.tsx index 2ad079b..6870bbf 100644 --- a/src/screens/SetUp.tsx +++ b/src/screens/SetUp.tsx @@ -5,81 +5,96 @@ import { StyleSheet, TouchableOpacity, StatusBar, + Image, } from 'react-native'; -import * as Progress from 'react-native-progress'; import {useNavigation} from '@react-navigation/native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import {useAuth} from '../contexts/AuthContext'; import Layout from '../components/Layout'; import {colors} from '../theme/colors'; -import GoalSetting from '../components/setup/GoalSetting'; -import PetSelection from '../components/setup/PetSelection'; +import {useUserProfileManager} from '../api/userService'; -type SetupStep = 'goal' | 'pet'; +// Define character keys and their corresponding image sources +const CHARACTER_IMAGES = { + blue_1: require('../assets/characters/blue_1.png'), + blue_2: require('../assets/characters/blue_2.png'), + green_1: require('../assets/characters/green_1.png'), + green_2: require('../assets/characters/green_2.png'), + green_3: require('../assets/characters/green_3.png'), + pink_1: require('../assets/characters/pink_1.png'), + yellow_1: require('../assets/characters/yellow_1.png'), + yellow_2: require('../assets/characters/yellow_2.png'), +} as const; + +type CharacterKey = keyof typeof CHARACTER_IMAGES; + +const getApiPath = (key: CharacterKey) => `/characters/${key}.png`; const SetUp = () => { - const [currentStep, setCurrentStep] = useState('goal'); - const [goal, setGoal] = useState(''); - const [selectedDino, setSelectedDino] = useState(null); + const [selectedCharacter, setSelectedCharacter] = + useState(null); const navigation = useNavigation(); - const {user} = useAuth(); + const {setCharacter} = useUserProfileManager(); - const handleNext = async () => { - if (currentStep === 'goal') { - if (goal.trim().length > 0) { - setCurrentStep('pet'); - } - } else if (currentStep === 'pet') { - if (selectedDino && user?.uid) { - const key = `setupDone-${user.uid}`; - await AsyncStorage.setItem(key, 'true'); - await AsyncStorage.setItem(`dino-${user.uid}`, selectedDino.imageKey); - navigation.reset({ - index: 0, - routes: [{name: 'MainTabs' as never}], - }); - } - } + const handleSelect = (key: CharacterKey) => { + setSelectedCharacter(key); }; - const handleBack = () => { - if (currentStep === 'pet') { - setCurrentStep('goal'); + const handleComplete = async () => { + if (!selectedCharacter) { + return; } - }; - const progressValue = currentStep === 'goal' ? 0.5 : 1.0; + try { + const imageUrl = getApiPath(selectedCharacter); + console.log('Sending character data:', { + character_id: selectedCharacter, + image_url: imageUrl, + }); + + const result = await setCharacter({ + character_id: selectedCharacter, + image_url: imageUrl, + }); + console.log('Character set successfully:', result); + + navigation.reset({ + index: 0, + routes: [{name: 'MainTabs' as never}], + }); + } catch (error: any) { + console.error('Failed to set character:', error); + console.error('Error response:', error.response?.data); + console.error('Error status:', error.response?.status); + console.error('Error headers:', error.response?.headers); + } + }; return ( - + - - {currentStep === 'goal' ? ( - - ) : ( - - )} - - - - - - {currentStep === 'pet' && ( - - 上一步 - - )} - - - {currentStep === 'goal' ? '下一步' : '完成!'} - + 選擇你的角色 + + {Object.entries(CHARACTER_IMAGES).map(([key, imageSource]) => ( + handleSelect(key as CharacterKey)}> + - + ))} + + 完成 + ); @@ -89,33 +104,50 @@ const styles = StyleSheet.create({ container: { flex: 1, padding: 20, - justifyContent: 'space-between', - }, - content: { - flex: 1, - justifyContent: 'center', alignItems: 'center', }, - bottomContainer: { - marginTop: 10, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, }, - navButtonsContainer: { + characterGrid: { flexDirection: 'row', - justifyContent: 'space-between', - marginTop: 10, + flexWrap: 'wrap', + justifyContent: 'center', + gap: 10, }, - navButton: { - backgroundColor: '#007BFF', - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 5, - flex: 1, - marginHorizontal: 5, - alignItems: 'center', + characterButton: { + width: 100, + height: 100, + borderRadius: 10, + borderWidth: 2, + borderColor: colors.primary, + overflow: 'hidden', + }, + selectedCharacter: { + borderColor: colors.accent, + borderWidth: 3, + }, + characterImage: { + width: '100%', + height: '100%', + resizeMode: 'contain', + }, + completeButton: { + backgroundColor: colors.primary, + paddingHorizontal: 40, + paddingVertical: 15, + borderRadius: 25, + marginTop: 30, + }, + disabledButton: { + backgroundColor: colors.disabled, }, - navButtonText: { - color: '#fff', - fontSize: 16, + completeButtonText: { + color: colors.white, + fontSize: 18, + fontWeight: 'bold', }, }); diff --git a/src/theme/colors.ts b/src/theme/colors.ts index b977d90..49fb943 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -9,6 +9,9 @@ export const colors = { moneyBackground: '#8E8989', pressedBackground: '#f0f0f0', borderColor: '#eee', + accent: '#4CAF50', + disabled: '#CCCCCC', + white: '#FFFFFF', } as const; export type ColorKeys = keyof typeof colors;