From bda709db46c0a4ad8e83e8ff0902578a1f4b5e12 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 05:33:20 +0800 Subject: [PATCH 01/16] feat: add mock data for user profile, transactions, investments, and opportunities; update services to use mock data --- package.json | 1 + src/api/authService.ts | 46 ++++++----------- src/api/investmentService.ts | 80 +++++++++++++++++++++++------- src/api/transactionService.ts | 41 ++++++++++----- src/api/userService.ts | 12 ++--- src/components/TransactionForm.tsx | 8 +-- src/mock/data.ts | 63 +++++++++++++++++++++++ src/screens/TransactionScreen.tsx | 11 ++-- tsconfig.json | 6 ++- yarn.lock | 5 ++ 10 files changed, 195 insertions(+), 78 deletions(-) create mode 100644 src/mock/data.ts diff --git a/package.json b/package.json index a3023c2..c7ce2dc 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@react-native/eslint-config": "0.78.0", "@react-native/metro-config": "0.78.0", "@react-native/typescript-config": "0.78.0", + "@tsconfig/react-native": "^3.0.5", "@types/jest": "^29.5.13", "@types/react": "^19.0.0", "@types/react-native-vector-icons": "^6.4.18", diff --git a/src/api/authService.ts b/src/api/authService.ts index 8f4b7fb..194afc8 100644 --- a/src/api/authService.ts +++ b/src/api/authService.ts @@ -1,6 +1,3 @@ -import apiClient from './client'; -import {API_ENDPOINTS} from './endpoints'; - interface LoginCredentials { email: string; password: string; @@ -12,41 +9,30 @@ interface AuthResponse { expires_in: number; } +const mockAuthResponse: AuthResponse = { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + expires_in: 3600, +}; + export const authService = { - exchangeToken: async (firebaseToken: string): Promise => { - const response = await apiClient.post(API_ENDPOINTS.AUTH_LOGIN, { - firebase_token: firebaseToken, - }); - return response.data; + exchangeToken: async (_: string): Promise => { + return mockAuthResponse; }, - login: async (credentials: LoginCredentials): Promise => { - const response = await apiClient.post( - API_ENDPOINTS.AUTH_LOGIN, - credentials, - ); - return response.data; + login: async (_: LoginCredentials): Promise => { + return mockAuthResponse; }, - register: async (userData: any) => { - const response = await apiClient.post( - API_ENDPOINTS.AUTH_REGISTER, - userData, - ); - return response.data; + register: async (_: any) => { + return mockAuthResponse; }, - logout: async (refreshToken: string) => { - const response = await apiClient.post(API_ENDPOINTS.AUTH_LOGOUT, { - refresh_token: refreshToken, - }); - return response.data; + logout: async (_: string) => { + return {success: true}; }, - refreshToken: async (refreshToken: string): Promise => { - const response = await apiClient.post(API_ENDPOINTS.AUTH_REFRESH, { - refresh_token: refreshToken, - }); - return response.data; + refreshToken: async (_: string): Promise => { + return mockAuthResponse; }, }; diff --git a/src/api/investmentService.ts b/src/api/investmentService.ts index ebeadee..9f4ea52 100644 --- a/src/api/investmentService.ts +++ b/src/api/investmentService.ts @@ -1,37 +1,81 @@ -import apiClient from './client'; -import {API_ENDPOINTS} from './endpoints'; +import {mockInvestments, mockInvestmentOpportunities} from '../mock/data'; +import useSWR from 'swr'; export interface Investment { + id: string; + name: string; amount: number; + returnRate: number; + riskLevel: string; + status: string; +} + +export interface Opportunity { + id: string; + name: string; description: string; - date: string; - category: string; - transaction_type: 'Income' | 'Expense'; + minAmount: number; + expectedReturn: number; + riskLevel: string; } -interface CreateInvestmentRequest { - opportunity_id: string; +export interface CreateInvestmentRequest { + opportunityId: string; amount: number; } -export interface Opportunity {} - -export const transactionService = { +export const investmentService = { getOpportunities: async (): Promise => { - const response = await apiClient.get(API_ENDPOINTS.INVESTMENT); - return response.data; + return mockInvestmentOpportunities; }, getUserInvestments: async (): Promise => { - const response = await apiClient.get(API_ENDPOINTS.USER_INVESTMENT); - return response.data; + return mockInvestments; }, postUserInvestment: async (investment: CreateInvestmentRequest) => { - const response = await apiClient.post( - API_ENDPOINTS.USER_INVESTMENT, - investment, + const opportunity = mockInvestmentOpportunities.find( + o => o.id === investment.opportunityId, ); - return response.data; + if (!opportunity) { + throw new Error('Opportunity not found'); + } + const newInvestment: Investment = { + id: (mockInvestments.length + 1).toString(), + name: opportunity.name, + amount: investment.amount, + returnRate: opportunity.expectedReturn, + riskLevel: opportunity.riskLevel, + status: 'ACTIVE', + }; + mockInvestments.push(newInvestment); + return newInvestment; }, }; + +export const useInvestments = () => { + const {data, error, isLoading, mutate} = useSWR( + 'mock-investments', + () => investmentService.getUserInvestments(), + ); + + return { + investments: data || [], + isLoading, + isError: error, + mutate, + }; +}; + +export const useInvestmentOpportunities = () => { + const {data, error, isLoading} = useSWR( + 'mock-investment-opportunities', + () => investmentService.getOpportunities(), + ); + + return { + opportunities: data || [], + isLoading, + isError: error, + }; +}; diff --git a/src/api/transactionService.ts b/src/api/transactionService.ts index bf4c819..bb6e873 100644 --- a/src/api/transactionService.ts +++ b/src/api/transactionService.ts @@ -1,29 +1,44 @@ -import apiClient from './client'; -import {API_ENDPOINTS} from './endpoints'; +import {mockTransactions} from '../mock/data'; +import useSWR from 'swr'; export interface Transaction { + id: string; + type: 'INCOME' | 'EXPENSE'; amount: number; - description: string; - date: string; category: string; - transaction_type: 'Income' | 'Expense'; + date: string; + description: string; } -interface GetTransactionResponse { +export interface GetTransactionResponse { transactions: Transaction[]; } export const transactionService = { createTransaction: async (transaction: Transaction) => { - const response = await apiClient.post( - API_ENDPOINTS.TRANSACTION, - transaction, - ); - return response.data; + const newTransaction = { + ...transaction, + id: (mockTransactions.length + 1).toString(), + }; + mockTransactions.push(newTransaction); + return newTransaction; }, getTransactions: async (): Promise => { - const response = await apiClient.get(API_ENDPOINTS.TRANSACTION); - return response.data; + return {transactions: mockTransactions}; }, }; + +export const useTransactions = () => { + const {data, error, isLoading, mutate} = useSWR( + 'mock-transactions', + () => transactionService.getTransactions(), + ); + + return { + transactions: data?.transactions || [], + isLoading, + isError: error, + mutate, + }; +}; diff --git a/src/api/userService.ts b/src/api/userService.ts index 9231866..c6c8230 100644 --- a/src/api/userService.ts +++ b/src/api/userService.ts @@ -1,5 +1,4 @@ -import apiClient from './client'; -import {API_ENDPOINTS} from './endpoints'; +import {mockUserProfile} from '../mock/data'; import useSWR from 'swr'; import {useAuth} from '../contexts/AuthContext'; @@ -12,20 +11,19 @@ interface UserProfileResponse { export const userService = { getUserProfile: async (): Promise => { - const response = await apiClient.get(API_ENDPOINTS.USER_ME); - return response.data; + return mockUserProfile; }, updateUserProfile: async (userData: any) => { - const response = await apiClient.put(API_ENDPOINTS.USER_UPDATE, userData); - return response.data; + return {...mockUserProfile, ...userData}; }, }; export const useUserProfile = () => { const {serverToken} = useAuth(); const {data, error, isLoading, mutate} = useSWR( - serverToken ? API_ENDPOINTS.USER_ME : null, + serverToken ? 'mock-user-profile' : null, + () => userService.getUserProfile(), ); return { diff --git a/src/components/TransactionForm.tsx b/src/components/TransactionForm.tsx index de796b7..318bdbc 100644 --- a/src/components/TransactionForm.tsx +++ b/src/components/TransactionForm.tsx @@ -12,7 +12,7 @@ const categories: string[] = [ '水電', '其他', ]; -type TransactionTypes = 'Income' | 'Expense'; +type TransactionTypes = 'INCOME' | 'EXPENSE'; interface TransactionFormProps { onSubmit: ( @@ -24,15 +24,15 @@ interface TransactionFormProps { } const TransactionTypeLabels: Record = { - Income: '收入', - Expense: '支出', + INCOME: '收入', + EXPENSE: '支出', }; const TransactionForm: React.FC = ({onSubmit}) => { const [selectedCategory, setSelectedCategory] = useState( categories[0], ); - const [selectedType, setSelectedType] = useState('Expense'); + const [selectedType, setSelectedType] = useState('EXPENSE'); const [amount, setAmount] = useState(''); const [date, setDate] = useState(new Date()); const [showPicker, setShowPicker] = useState(false); diff --git a/src/mock/data.ts b/src/mock/data.ts new file mode 100644 index 0000000..96d360a --- /dev/null +++ b/src/mock/data.ts @@ -0,0 +1,63 @@ +export const mockUserProfile = { + wallet: { + diamonds: 1000, + saving: 50000, + }, +}; + +export const mockTransactions = [ + { + id: '1', + type: 'INCOME' as const, + amount: 5000, + category: 'SALARY', + date: '2024-03-20', + description: 'Monthly salary', + }, + { + id: '2', + type: 'EXPENSE' as const, + amount: 1000, + category: 'FOOD', + date: '2024-03-19', + description: 'Groceries', + }, +]; + +export const mockInvestments = [ + { + id: '1', + name: 'Tech Fund', + amount: 10000, + returnRate: 0.15, + riskLevel: 'MEDIUM', + status: 'ACTIVE', + }, + { + id: '2', + name: 'Green Energy', + amount: 5000, + returnRate: 0.12, + riskLevel: 'LOW', + status: 'ACTIVE', + }, +]; + +export const mockInvestmentOpportunities = [ + { + id: '1', + name: 'AI Technology Fund', + description: 'Investment in artificial intelligence companies', + minAmount: 1000, + expectedReturn: 0.18, + riskLevel: 'HIGH', + }, + { + id: '2', + name: 'Healthcare Fund', + description: 'Investment in healthcare and biotechnology', + minAmount: 500, + expectedReturn: 0.12, + riskLevel: 'MEDIUM', + }, +]; diff --git a/src/screens/TransactionScreen.tsx b/src/screens/TransactionScreen.tsx index fdffa53..48973f8 100644 --- a/src/screens/TransactionScreen.tsx +++ b/src/screens/TransactionScreen.tsx @@ -17,10 +17,10 @@ const TransactionScreen: React.FC = () => { const [isModalVisible, setModalVisible] = useState(false); const [budget, setBudget] = useState(10000); // setting Budget const totalIncome = transactions - .filter(t => t.transaction_type === 'Income') + .filter(t => t.type === 'INCOME') .reduce((sum, t) => sum + t.amount, 0); const totalExpense = transactions - .filter(t => t.transaction_type === 'Expense') + .filter(t => t.type === 'EXPENSE') .reduce((sum, t) => sum + t.amount, 0); const remainingBudget = budget - totalExpense; // Remain balance @@ -33,15 +33,16 @@ const TransactionScreen: React.FC = () => { const handleAddTransaction = async ( amount: number, category: string, - transaction_type: 'Income' | 'Expense', + transaction_type: 'INCOME' | 'EXPENSE', date: Date, ) => { const newTransaction: Transaction = { + id: Date.now().toString(), amount, description: '', date: formattedDate(date), category, - transaction_type: transaction_type, + type: transaction_type, }; const updatedTransactions = [newTransaction, ...transactions].sort( (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), @@ -110,7 +111,7 @@ const TransactionScreen: React.FC = () => { {item.date} {item.category} - {item.transaction_type === 'Income' ? '+' : '-'} + {item.type === 'INCOME' ? '+' : '-'} {item.amount} diff --git a/tsconfig.json b/tsconfig.json index 304ab4e..3d2e701 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,7 @@ { - "extends": "@react-native/typescript-config/tsconfig.json" + "extends": "@tsconfig/react-native/tsconfig.json", + "compilerOptions": { + "types": ["react-native", "jest", "swr"], + "esModuleInterop": true + } } diff --git a/yarn.lock b/yarn.lock index 3eaed23..367bf5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2347,6 +2347,11 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@tsconfig/react-native@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@tsconfig/react-native/-/react-native-3.0.5.tgz#c4971b1eca2e8cdf7b0d25f40193a782039c1abd" + integrity sha512-0+pmYzHccvwWpFz2Tv5AJxp6UroLALmAy+SX34tKlwaCie1mNbtCv6uOJp7x8pKchgNA9/n6BGrx7uLQvw8p9A== + "@types/babel__core@^7.1.14": version "7.20.5" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz" From 1d391adaa800b64f021173371ca9a006c9ce74c6 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 05:39:57 +0800 Subject: [PATCH 02/16] refactor: update TransactionScreen to display monthly summary with income, expenses, and balance; rename variables for clarity --- src/screens/TransactionScreen.tsx | 56 +++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/src/screens/TransactionScreen.tsx b/src/screens/TransactionScreen.tsx index 48973f8..9726adc 100644 --- a/src/screens/TransactionScreen.tsx +++ b/src/screens/TransactionScreen.tsx @@ -22,7 +22,7 @@ const TransactionScreen: React.FC = () => { const totalExpense = transactions .filter(t => t.type === 'EXPENSE') .reduce((sum, t) => sum + t.amount, 0); - const remainingBudget = budget - totalExpense; // Remain balance + const monthlyBalance = totalIncome - totalExpense; // Monthly balance const formattedDate = (d: Date) => { return `${d.getFullYear()}-${(d.getMonth() + 1) @@ -77,11 +77,30 @@ const TransactionScreen: React.FC = () => { - {/* First block:Expense & Revenue */} + {/* First block:Monthly Summary */} - 本月支出 - -${totalExpense} - 本月收入:${totalIncome} + 本月收支 + + + 收入 + +${totalIncome} + + + 支出 + -${totalExpense} + + + 結餘 + = 0 ? styles.positive : styles.negative, + ]}> + {monthlyBalance >= 0 ? '+' : ''} + {monthlyBalance} + + + {/* Second block:Budget & remain balance */} @@ -97,7 +116,7 @@ const TransactionScreen: React.FC = () => { - 剩餘額度:${remainingBudget} + 剩餘額度:${monthlyBalance} {/* Third block:details */} @@ -278,6 +297,31 @@ const styles = StyleSheet.create({ fontSize: 24, color: '#666', }, + summaryContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 10, + }, + summaryItem: { + flex: 1, + alignItems: 'center', + }, + summaryLabel: { + fontSize: 16, + color: '#666', + marginBottom: 5, + }, + summaryAmount: { + fontSize: 24, + fontWeight: 'bold', + color: '#333', + }, + positive: { + color: '#28a745', + }, + negative: { + color: '#dc3545', + }, }); export default TransactionScreen; From 8db2608f3f415b8da989a675e7ad15fc8195fde0 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 06:24:32 +0800 Subject: [PATCH 03/16] feat: add monthly saving and current saving features to HomeScreen and MainTabs; implement user data loading in SetUp --- package.json | 1 + src/screens/HomeScreen.tsx | 56 ++++-- src/screens/MainTabs.tsx | 126 +++++++++++++ src/screens/SetUp.tsx | 377 +++++++++++++++++++++++++++++++------ yarn.lock | 17 +- 5 files changed, 502 insertions(+), 75 deletions(-) create mode 100644 src/screens/MainTabs.tsx diff --git a/package.json b/package.json index c7ce2dc..a5ee88f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@react-native-picker/picker": "^2.11.0", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", + "@react-navigation/native-stack": "^7.3.10", "@react-navigation/stack": "^7.1.1", "axios": "^1.8.4", "react": "19.0.0", diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 083c979..c46554e 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -7,6 +7,7 @@ import { StatusBar, Image, Platform, + ImageSourcePropType, } from 'react-native'; // TouchableOpacity import {useNavigation} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; @@ -22,7 +23,7 @@ type RootStackParamList = { TransactionScreen: undefined; }; -const dinoImages: {[key: string]: any} = { +const dinoImages: {[key: string]: ImageSourcePropType} = { blue_1: require('../assets/characters/blue_1.png'), blue_2: require('../assets/characters/blue_2.png'), green_1: require('../assets/characters/green_1.png'), @@ -38,32 +39,61 @@ type NavigationProp = StackNavigationProp; const HomeScreen = () => { const {user} = useAuth(); - const [dinoImage, setDinoImage] = useState(null); + const [dinoImage, setDinoImage] = useState(null); + const [monthlySaving, setMonthlySaving] = useState('0'); + const [currentSaving, setCurrentSaving] = useState('0'); + const missions = [ {title: '輸入交易紀錄', amount: 1000, isCompleted: false}, {title: '添加額外收入', amount: 500, isCompleted: false}, {title: '設定預算', amount: 2000, isCompleted: true}, {title: '設定目標存款', amount: 3000, isCompleted: false}, ]; - const targetAmount = 10000; - const currentAmount = 5000; const navigation = useNavigation(); useEffect(() => { - const loadDino = async () => { + const loadUserData = async () => { if (!user?.uid) { return; } + + // Load dino image const key = `dino-${user.uid}`; const imageKey = await AsyncStorage.getItem(key); if (imageKey && dinoImages[imageKey]) { setDinoImage(dinoImages[imageKey]); } + + // Load monthly saving target + const savedMonthlySaving = await AsyncStorage.getItem( + `monthlySaving-${user.uid}`, + ); + if (savedMonthlySaving) { + setMonthlySaving(savedMonthlySaving); + console.log('Loaded monthly saving:', savedMonthlySaving); + } + + // Load current saving + const savedCurrentSaving = await AsyncStorage.getItem( + `currentSaving-${user.uid}`, + ); + if (savedCurrentSaving) { + setCurrentSaving(savedCurrentSaving); + } else { + setCurrentSaving('0'); + } }; - loadDino(); + + loadUserData(); }, [user]); + // Calculate progress percentage + const progressPercentage = Math.min( + (parseInt(currentSaving, 10) / parseInt(monthlySaving, 10)) * 100, + 100, + ); + return ( @@ -73,19 +103,17 @@ const HomeScreen = () => { 一起往目標前進吧! {dinoImage ? ( - + ) : ( )} - + - ${currentAmount}/${targetAmount} + NT$ {parseInt(currentSaving, 10).toLocaleString()} / NT${' '} + {parseInt(monthlySaving, 10).toLocaleString()} @@ -163,8 +191,8 @@ const styles = StyleSheet.create({ marginBottom: 20, alignSelf: 'flex-start', resizeMode: 'contain', - width: 150, - height: 150, + width: 200, + height: 200, }, speechBubble: { backgroundColor: '#fff', diff --git a/src/screens/MainTabs.tsx b/src/screens/MainTabs.tsx new file mode 100644 index 0000000..cfd5334 --- /dev/null +++ b/src/screens/MainTabs.tsx @@ -0,0 +1,126 @@ +import React, {useState, useEffect} from 'react'; +import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; +import {View, Text, StyleSheet} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import {useAuth} from '../contexts/AuthContext'; +import HomeScreen from './HomeScreen'; +import BagScreen from './BagScreen'; +import SettingsScreen from './SettingsScreen'; + +const Tab = createBottomTabNavigator(); + +// Move component definitions outside of MainTabs +const HomeIcon = ({color, size}: {color: string; size: number}) => ( + +); + +const PetIcon = ({color, size}: {color: string; size: number}) => ( + +); + +const ProfileIcon = ({color, size}: {color: string; size: number}) => ( + +); + +const Header = ({monthlySaving}: {monthlySaving: string}) => ( + + 首頁 + + + 本月存錢目標:NT$ {parseInt(monthlySaving, 10).toLocaleString()} + + + +); + +const MainTabs = () => { + const {user} = useAuth(); + const [monthlySaving, setMonthlySaving] = useState('0'); + + useEffect(() => { + const loadUserData = async () => { + if (user?.uid) { + try { + const savedMonthlySaving = await AsyncStorage.getItem( + `monthlySaving-${user.uid}`, + ); + if (savedMonthlySaving) { + setMonthlySaving(savedMonthlySaving); + console.log('Loaded monthly saving:', savedMonthlySaving); + } else { + console.log('No monthly saving found'); + } + } catch (error) { + console.error('Error loading user data:', error); + } + } + }; + + loadUserData(); + }, [user]); + + return ( + + , + header: () =>
, + }} + /> + , + }} + /> + ( + + ), + }} + /> + + ); +}; + +const styles = StyleSheet.create({ + header: { + backgroundColor: '#fff', + padding: 15, + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + headerTitle: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 5, + }, + savingInfo: { + marginTop: 5, + }, + savingText: { + fontSize: 16, + color: '#666', + }, +}); + +export default MainTabs; diff --git a/src/screens/SetUp.tsx b/src/screens/SetUp.tsx index 1e86e87..5d131ba 100644 --- a/src/screens/SetUp.tsx +++ b/src/screens/SetUp.tsx @@ -7,21 +7,133 @@ import { TouchableOpacity, Image, StatusBar, + ScrollView, } from 'react-native'; import * as Progress from 'react-native-progress'; import {useNavigation} from '@react-navigation/native'; +import {NativeStackNavigationProp} from '@react-navigation/native-stack'; import AsyncStorage from '@react-native-async-storage/async-storage'; import {useAuth} from '../contexts/AuthContext'; import Layout from '../components/Layout'; -import {colors} from '../theme/colors'; + +type RootStackParamList = { + MainTabs: undefined; +}; + +type NavigationProp = NativeStackNavigationProp; const SetUp = () => { const [step, setStep] = useState(1); const [goal, setGoal] = useState(''); + const [monthlyIncome, setMonthlyIncome] = useState(''); + const [monthlyExpense, setMonthlyExpense] = useState(''); + const [monthlySaving, setMonthlySaving] = useState(''); const [selectedDino, setSelectedDino] = useState(null); - const navigation = useNavigation(); + const [petMessage, setPetMessage] = useState( + '嗨!我是你的理財小助手。讓我們一起設定你的理財目標吧!', + ); + const navigation = useNavigation(); const {user} = useAuth(); + const handleNumberInput = ( + value: string, + setter: (value: string) => void, + ) => { + const numericValue = value.replace(/[^0-9]/g, ''); + setter(numericValue); + }; + + const suggestSavingGoal = () => { + const income = parseInt(monthlyIncome, 10) || 0; + const expense = parseInt(monthlyExpense, 10) || 0; + + if (income === 0) { + setPetMessage('請先輸入你的每月收入,我才能幫你計算合適的存錢目標喔!'); + return; + } + + if (expense === 0) { + setPetMessage('請先輸入你的每月支出,我才能幫你計算合適的存錢目標喔!'); + return; + } + + if (expense >= income) { + setPetMessage( + '你的支出似乎超過收入了,建議先調整支出,讓收支平衡比較好喔!', + ); + return; + } + + const availableAmount = income - expense; + const expenseRatio = expense / income; + + // 根據支出比例決定存錢比例 + let savingRatio; + if (expenseRatio <= 0.3) { + savingRatio = 0.6; // 支出比例低時,建議存較多 + } else if (expenseRatio <= 0.5) { + savingRatio = 0.5; // 支出比例適中時,建議存一半 + } else if (expenseRatio <= 0.7) { + savingRatio = 0.4; // 支出比例偏高時,建議存較少 + } else { + savingRatio = 0.3; // 支出比例高時,建議存最少 + } + + // 在建議比例的基礎上隨機增減 10% + const randomFactor = 0.9 + Math.random() * 0.2; // 0.9 到 1.1 之間的隨機數 + const suggestedAmount = Math.floor( + availableAmount * savingRatio * randomFactor, + ); + const roundedAmount = Math.floor(suggestedAmount / 100) * 100; + const remainingAmount = availableAmount - roundedAmount; + const savingRatioPercent = (roundedAmount / income) * 100; + + setMonthlySaving(roundedAmount.toString()); + + let message = ''; + if (savingRatioPercent >= 40) { + message = `太棒了!建議每月存 ${roundedAmount.toLocaleString()} 元(收入的 ${Math.round( + savingRatioPercent, + )}%),這樣可以快速累積財富!剩下的 ${remainingAmount.toLocaleString()} 元足夠應付日常開銷和意外支出。`; + } else if (savingRatioPercent >= 30) { + message = `建議每月存 ${roundedAmount.toLocaleString()} 元(收入的 ${Math.round( + savingRatioPercent, + )}%),這是個不錯的存錢比例!剩下的 ${remainingAmount.toLocaleString()} 元可以靈活運用。`; + } else if (savingRatioPercent >= 20) { + message = `建議每月存 ${roundedAmount.toLocaleString()} 元(收入的 ${Math.round( + savingRatioPercent, + )}%),雖然存錢比例不高,但也是個好的開始!剩下的 ${remainingAmount.toLocaleString()} 元要好好規劃使用喔。`; + } else { + message = `建議每月存 ${roundedAmount.toLocaleString()} 元(收入的 ${Math.round( + savingRatioPercent, + )}%),存錢比例偏低,建議考慮增加收入或減少支出,讓存錢比例提高到至少 20%!`; + } + + setPetMessage(message); + }; + + const suggestFinancialGoal = () => { + const saving = parseInt(monthlySaving, 10) || 0; + + if (saving === 0) { + setPetMessage('請先設定每月存錢目標,我才能幫你規劃合適的理財目標喔!'); + return; + } + + const goals = [ + `存到第一桶金 ${(saving * 12).toLocaleString()} 元!`, + `建立緊急預備金 ${(saving * 6).toLocaleString()} 元!`, + `投資理財基金 ${(saving * 24).toLocaleString()} 元!`, + `為夢想存錢 ${(saving * 36).toLocaleString()} 元!`, + ]; + + const randomGoal = goals[Math.floor(Math.random() * goals.length)]; + setGoal(randomGoal); + setPetMessage( + `我建議你可以設定「${randomGoal}」作為你的理財目標,這樣可以讓你的存錢更有方向!`, + ); + }; + // Mock data for pet images const dinosaurs = [ { @@ -82,14 +194,26 @@ const SetUp = () => { const handleNext = async () => { if (step === 1) { - if (goal.trim().length > 0) { + if (selectedDino) { setStep(2); } } else if (step === 2) { - if (selectedDino && user?.uid) { + if ( + goal.trim().length > 0 && + monthlyIncome.trim().length > 0 && + monthlyExpense.trim().length > 0 && + monthlySaving.trim().length > 0 && + user?.uid + ) { const key = `setupDone-${user.uid}`; await AsyncStorage.setItem(key, 'true'); await AsyncStorage.setItem(`dino-${user.uid}`, selectedDino.imageKey); + await AsyncStorage.setItem(`monthlyIncome-${user.uid}`, monthlyIncome); + await AsyncStorage.setItem( + `monthlyExpense-${user.uid}`, + monthlyExpense, + ); + await AsyncStorage.setItem(`monthlySaving-${user.uid}`, monthlySaving); navigation.reset({index: 0, routes: [{name: 'MainTabs'}]}); } } @@ -106,21 +230,10 @@ const SetUp = () => { return ( - + {step === 1 ? ( - - 設定理財目標 - - - ) : ( 選擇一個萌寵 @@ -138,20 +251,101 @@ const SetUp = () => { ))} + ) : ( + + + + + {petMessage} + + + + + 每月收入 + + handleNumberInput(value, setMonthlyIncome) + } + keyboardType="numeric" + /> + + + + 每月支出 + + handleNumberInput(value, setMonthlyExpense) + } + keyboardType="numeric" + /> + + + + 每月存錢目標 + + handleNumberInput(value, setMonthlySaving) + } + keyboardType="numeric" + /> + + + + + 建議存錢目標 + + + + 建議理財目標 + + + )} - + - + {step === 2 && ( - - 上一步 + + 上一步 )} - - - {step === 1 ? '下一步' : '完成!'} + + + {step === 1 ? '下一步' : '完成'} @@ -164,78 +358,141 @@ const SetUp = () => { const styles = StyleSheet.create({ container: { flex: 1, - padding: 20, - justifyContent: 'space-between', // Distribute top content and bottom container }, content: { flex: 1, - justifyContent: 'center', - alignItems: 'center', + padding: 20, }, stepContent: { - width: '100%', - alignItems: 'center', + flex: 1, + }, + scrollContentContainer: { + paddingBottom: 20, }, title: { fontSize: 24, + fontWeight: 'bold', 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%', + justifyContent: 'space-around', + marginTop: 20, }, dinoItem: { + width: '30%', alignItems: 'center', + marginBottom: 20, padding: 10, - borderRadius: 5, + borderRadius: 10, + backgroundColor: '#f5f5f5', }, selectedDinoItem: { + backgroundColor: '#e3f2fd', borderWidth: 2, - borderColor: '#007BFF', + borderColor: '#2196f3', }, dinoImage: { + width: 80, + height: 80, + resizeMode: 'contain', + }, + dinoName: { + marginTop: 5, + fontSize: 14, + }, + petDialogueContainer: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: 20, + }, + petImage: { width: 100, height: 100, - marginBottom: 10, + resizeMode: 'contain', }, - dinoName: { - textAlign: 'center', - color: '#fff', + dialogueBubble: { + flex: 1, + backgroundColor: '#f0f0f0', + borderRadius: 15, + padding: 15, + marginLeft: 10, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 2, }, - bottomContainer: { - marginTop: 10, + dialogueText: { + fontSize: 16, + lineHeight: 24, + }, + inputContainer: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 16, + marginBottom: 5, + fontWeight: '500', + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 5, + padding: 10, + fontSize: 16, }, - navButtonsContainer: { + buttonContainer: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 10, }, - navButton: { - backgroundColor: '#007BFF', - paddingVertical: 12, - paddingHorizontal: 20, + suggestButton: { + backgroundColor: '#e3f2fd', + padding: 10, borderRadius: 5, - flex: 1, - marginHorizontal: 5, - alignItems: 'center', + width: '48%', }, - navButtonText: { + suggestButtonText: { + color: '#2196f3', + textAlign: 'center', + fontWeight: '500', + }, + footer: { + padding: 20, + borderTopWidth: 1, + borderTopColor: '#eee', + }, + buttonRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 20, + }, + backButton: { + backgroundColor: '#f5f5f5', + padding: 15, + borderRadius: 5, + width: '48%', + }, + backButtonText: { + color: '#666', + textAlign: 'center', + fontWeight: '500', + }, + nextButton: { + backgroundColor: '#2196f3', + padding: 15, + borderRadius: 5, + }, + nextButtonText: { color: '#fff', - fontSize: 16, + textAlign: 'center', + fontWeight: '500', }, }); diff --git a/yarn.lock b/yarn.lock index 367bf5a..686e201 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2285,6 +2285,21 @@ dependencies: color "^4.2.3" +"@react-navigation/elements@^2.3.8": + version "2.3.8" + resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.3.8.tgz#ebf32b2f4540b5f6391a937e437ac7f4aed1f2d4" + integrity sha512-2ZVBtPfrkmOxzvIyDu3fPZ6aS4HcXL+TvzPDGa1znY2OP1Llo6wH14AmJHQFDquiInp2656hRMM1BkfJ3yPwew== + dependencies: + color "^4.2.3" + +"@react-navigation/native-stack@^7.3.10": + version "7.3.10" + resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.3.10.tgz#80553b5ccaf27dcf1b67d3d93d7e73fdd985d29f" + integrity sha512-bO/3bZiL/i2dbJQEeqfxIqp1CKzyx+RPdwaiLm6za8cUl877emnxFeAAOSUbN7r/AJgq+U/iCwc3K88mh+4oRQ== + dependencies: + "@react-navigation/elements" "^2.3.8" + warn-once "^0.1.1" + "@react-navigation/native@^7.0.14": version "7.0.14" resolved "https://registry.npmjs.org/@react-navigation/native/-/native-7.0.14.tgz" @@ -7733,7 +7748,7 @@ walker@^1.0.7, walker@^1.0.8: dependencies: makeerror "1.0.12" -warn-once@0.1.1, warn-once@^0.1.0: +warn-once@0.1.1, warn-once@^0.1.0, warn-once@^0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz" integrity sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q== From 531976babaa8d42c1dd85bdb900017f9bf44224f Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 06:34:14 +0800 Subject: [PATCH 04/16] feat: enhance user profile management by integrating AsyncStorage for wallet data; update mock user profile structure and add diamonds to mission component --- src/api/userService.ts | 42 ++++++++++++++++++++++++++++++++++---- src/components/Mission.tsx | 1 + src/mock/data.ts | 16 +++++++++++++-- src/screens/HomeScreen.tsx | 18 ++++++++-------- 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/src/api/userService.ts b/src/api/userService.ts index c6c8230..967638c 100644 --- a/src/api/userService.ts +++ b/src/api/userService.ts @@ -1,6 +1,7 @@ import {mockUserProfile} from '../mock/data'; import useSWR from 'swr'; import {useAuth} from '../contexts/AuthContext'; +import AsyncStorage from '@react-native-async-storage/async-storage'; interface UserProfileResponse { wallet: { @@ -9,13 +10,46 @@ interface UserProfileResponse { }; } +const WALLET_STORAGE_KEY = 'user_wallet'; + export const userService = { getUserProfile: async (): Promise => { - return mockUserProfile; + try { + const storedWallet = await AsyncStorage.getItem(WALLET_STORAGE_KEY); + if (storedWallet) { + return { + ...mockUserProfile, + wallet: JSON.parse(storedWallet), + }; + } + // If no stored wallet, initialize with default values + await AsyncStorage.setItem( + WALLET_STORAGE_KEY, + JSON.stringify(mockUserProfile.wallet), + ); + return mockUserProfile; + } catch (error) { + console.error('Error getting user profile:', error); + return mockUserProfile; + } }, - updateUserProfile: async (userData: any) => { - return {...mockUserProfile, ...userData}; + updateUserProfile: async (userData: Partial) => { + try { + if (userData.wallet) { + await AsyncStorage.setItem( + WALLET_STORAGE_KEY, + JSON.stringify(userData.wallet), + ); + } + return { + ...mockUserProfile, + ...userData, + }; + } catch (error) { + console.error('Error updating user profile:', error); + throw error; + } }, }; @@ -37,7 +71,7 @@ export const useUserProfile = () => { export const useUserProfileManager = () => { const {user, isLoading, isError, mutate} = useUserProfile(); - const updateProfile = async (userData: any) => { + const updateProfile = async (userData: Partial) => { try { const updatedUser = await userService.updateUserProfile(userData); mutate(updatedUser, false); diff --git a/src/components/Mission.tsx b/src/components/Mission.tsx index ff08968..684ac43 100644 --- a/src/components/Mission.tsx +++ b/src/components/Mission.tsx @@ -9,6 +9,7 @@ type MissionProps = { amount: number; isCompleted: boolean; }; + diamonds: number; }; const Mission = ({mission}: MissionProps) => { diff --git a/src/mock/data.ts b/src/mock/data.ts index 96d360a..82e02e8 100644 --- a/src/mock/data.ts +++ b/src/mock/data.ts @@ -1,10 +1,22 @@ -export const mockUserProfile = { +let mockUserProfile = { wallet: { - diamonds: 1000, + diamonds: 5000, saving: 50000, }, }; +export const updateMockUserProfile = ( + updates: Partial, +) => { + mockUserProfile = { + ...mockUserProfile, + ...updates, + }; + return mockUserProfile; +}; + +export {mockUserProfile}; + export const mockTransactions = [ { id: '1', diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index c46554e..d1626a6 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -17,6 +17,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import Layout from '../components/Layout'; import Mission from '../components/Mission'; import {useAuth} from '../contexts/AuthContext'; +import {useUserProfile} from '../api/userService'; import {Dinosaur} from '../svg'; type RootStackParamList = { @@ -39,15 +40,16 @@ type NavigationProp = StackNavigationProp; const HomeScreen = () => { const {user} = useAuth(); + const {user: userProfile} = useUserProfile(); const [dinoImage, setDinoImage] = useState(null); const [monthlySaving, setMonthlySaving] = useState('0'); const [currentSaving, setCurrentSaving] = useState('0'); const missions = [ - {title: '輸入交易紀錄', amount: 1000, isCompleted: false}, + {title: '輸入交易紀錄', amount: 5000, isCompleted: false}, {title: '添加額外收入', amount: 500, isCompleted: false}, {title: '設定預算', amount: 2000, isCompleted: true}, - {title: '設定目標存款', amount: 3000, isCompleted: false}, + {title: '設定目標存款', amount: 3000, isCompleted: true}, ]; const navigation = useNavigation(); @@ -118,9 +120,12 @@ const HomeScreen = () => { - 每日任務 {missions.map((mission, index) => ( - + ))} @@ -156,11 +161,6 @@ const styles = StyleSheet.create({ marginBottom: 30, marginTop: 10, }, - missionTitle: { - fontWeight: 'bold', - fontSize: 24, - marginBottom: 20, - }, button: { backgroundColor: '#007BFF', padding: 10, From ecaf68701f4700169351170e1bfa5ec337fde797 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 06:41:10 +0800 Subject: [PATCH 05/16] feat: integrate AsyncStorage for transaction management; update TransactionForm to handle income and expense categories dynamically, and refactor transaction retrieval and creation logic --- src/api/transactionService.ts | 44 ++++++++++++++++++----- src/components/TransactionForm.tsx | 21 ++++++++--- src/mock/data.ts | 19 ---------- src/screens/TransactionScreen.tsx | 56 ++++++++++++++++++++---------- 4 files changed, 91 insertions(+), 49 deletions(-) diff --git a/src/api/transactionService.ts b/src/api/transactionService.ts index bb6e873..eda329a 100644 --- a/src/api/transactionService.ts +++ b/src/api/transactionService.ts @@ -1,5 +1,5 @@ -import {mockTransactions} from '../mock/data'; import useSWR from 'swr'; +import AsyncStorage from '@react-native-async-storage/async-storage'; export interface Transaction { id: string; @@ -14,18 +14,46 @@ export interface GetTransactionResponse { transactions: Transaction[]; } +const TRANSACTIONS_STORAGE_KEY = 'user_transactions'; + export const transactionService = { createTransaction: async (transaction: Transaction) => { - const newTransaction = { - ...transaction, - id: (mockTransactions.length + 1).toString(), - }; - mockTransactions.push(newTransaction); - return newTransaction; + try { + const storedTransactions = await AsyncStorage.getItem( + TRANSACTIONS_STORAGE_KEY, + ); + const transactions = storedTransactions + ? JSON.parse(storedTransactions) + : []; + const newTransaction = { + ...transaction, + id: Date.now().toString(), + }; + transactions.push(newTransaction); + await AsyncStorage.setItem( + TRANSACTIONS_STORAGE_KEY, + JSON.stringify(transactions), + ); + return newTransaction; + } catch (error) { + console.error('Error creating transaction:', error); + throw error; + } }, getTransactions: async (): Promise => { - return {transactions: mockTransactions}; + try { + const storedTransactions = await AsyncStorage.getItem( + TRANSACTIONS_STORAGE_KEY, + ); + const transactions = storedTransactions + ? JSON.parse(storedTransactions) + : []; + return {transactions}; + } catch (error) { + console.error('Error getting transactions:', error); + return {transactions: []}; + } }, }; diff --git a/src/components/TransactionForm.tsx b/src/components/TransactionForm.tsx index 318bdbc..e636081 100644 --- a/src/components/TransactionForm.tsx +++ b/src/components/TransactionForm.tsx @@ -2,7 +2,7 @@ import React, {useState} from 'react'; import {View, Text, Pressable, StyleSheet, Platform} from 'react-native'; import DateTimePicker from '@react-native-community/datetimepicker'; -const categories: string[] = [ +const expenseCategories: string[] = [ '餐飲', '交通', '娛樂', @@ -12,6 +12,9 @@ const categories: string[] = [ '水電', '其他', ]; + +const incomeCategories: string[] = ['薪資', '獎金', '投資', '兼職', '其他']; + type TransactionTypes = 'INCOME' | 'EXPENSE'; interface TransactionFormProps { @@ -30,7 +33,7 @@ const TransactionTypeLabels: Record = { const TransactionForm: React.FC = ({onSubmit}) => { const [selectedCategory, setSelectedCategory] = useState( - categories[0], + expenseCategories[0], ); const [selectedType, setSelectedType] = useState('EXPENSE'); const [amount, setAmount] = useState(''); @@ -66,6 +69,16 @@ const TransactionForm: React.FC = ({onSubmit}) => { setShowPicker(!showPicker); }; + const handleTypeChange = (type: TransactionTypes) => { + setSelectedType(type); + setSelectedCategory( + type === 'INCOME' ? incomeCategories[0] : expenseCategories[0], + ); + }; + + const currentCategories = + selectedType === 'INCOME' ? incomeCategories : expenseCategories; + return ( 選擇類別 @@ -77,7 +90,7 @@ const TransactionForm: React.FC = ({onSubmit}) => { styles.categoryButton, selectedType === type && styles.selectedCategory, ]} - onPress={() => setSelectedType(type as TransactionTypes)}> + onPress={() => handleTypeChange(type as TransactionTypes)}> {TransactionTypeLabels[type as TransactionTypes]} @@ -87,7 +100,7 @@ const TransactionForm: React.FC = ({onSubmit}) => { 選擇種類 - {categories.map(cat => ( + {currentCategories.map(cat => ( { const [transactions, setTransactions] = useState([]); const [isModalVisible, setModalVisible] = useState(false); - const [budget, setBudget] = useState(10000); // setting Budget + const [budget, setBudget] = useState(10000); const totalIncome = transactions .filter(t => t.type === 'INCOME') .reduce((sum, t) => sum + t.amount, 0); const totalExpense = transactions .filter(t => t.type === 'EXPENSE') .reduce((sum, t) => sum + t.amount, 0); - const monthlyBalance = totalIncome - totalExpense; // Monthly balance + const monthlyBalance = totalIncome - totalExpense; const formattedDate = (d: Date) => { return `${d.getFullYear()}-${(d.getMonth() + 1) @@ -48,7 +48,7 @@ const TransactionScreen: React.FC = () => { (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), ); - setTransactions(updatedTransactions); //sort by newest + setTransactions(updatedTransactions); setModalVisible(false); try { await transactionService.createTransaction(newTransaction); @@ -57,6 +57,11 @@ const TransactionScreen: React.FC = () => { } }; + const getCurrentYearMonth = () => { + const now = new Date(); + return `${now.getFullYear()}年${now.getMonth() + 1}月`; + }; + useEffect(() => { const fetchTransactions = async () => { try { @@ -77,6 +82,8 @@ const TransactionScreen: React.FC = () => { + {getCurrentYearMonth()} + {/* First block:Monthly Summary */} 本月收支 @@ -106,17 +113,23 @@ const TransactionScreen: React.FC = () => { {/* Second block:Budget & remain balance */} - 本月預算 - - ${budget} - setBudget(budget + 1000)}> - 修改 - - + 剩餘額度 + = 0 ? styles.positive : styles.negative, + ]}> + ${budget - totalExpense} + + + + 本月預算:${budget} + setBudget(budget + 1000)}> + 修改 + - 剩餘額度:${monthlyBalance} {/* Third block:details */} @@ -179,19 +192,25 @@ const styles = StyleSheet.create({ flex: 1, padding: 16, }, + yearMonth: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 16, + textAlign: 'center', + }, module: { padding: 16, borderRadius: 10, marginBottom: 10, }, grayBackground: { - backgroundColor: 'rgba(200, 160, 230, 0.8)', // light purple + backgroundColor: 'rgba(200, 160, 230, 0.8)', }, blueBackground: { - backgroundColor: 'rgba(173, 216, 230, 0.8)', // light blue + backgroundColor: 'rgba(173, 216, 230, 0.8)', }, whiteBackground: { - backgroundColor: 'rgba(255, 255, 255, 0.8)', // white + backgroundColor: 'rgba(255, 255, 255, 0.8)', }, label: { fontSize: 18, @@ -204,20 +223,21 @@ const styles = StyleSheet.create({ color: '#333', }, subText: { - fontSize: 14, + fontSize: 16, color: '#666', }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', + marginBottom: 8, }, budgetContainer: { flexDirection: 'row', alignItems: 'center', + justifyContent: 'space-between', }, editButton: { - marginLeft: 10, paddingVertical: 4, paddingHorizontal: 8, backgroundColor: '#007bff', From 3a2507ab3e8b27d7090a5916f6d1f4f0ef354dc2 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 06:59:18 +0800 Subject: [PATCH 06/16] feat: implement mission management service with AsyncStorage integration; add mission initialization and status update functionalities --- src/api/missionService.ts | 79 +++++++++++++++++++++ src/api/userService.ts | 21 ++++++ src/components/Mission.tsx | 11 +-- src/screens/HomeScreen.tsx | 111 +++++++++++++++++++----------- src/screens/TransactionScreen.tsx | 28 ++++++++ 5 files changed, 201 insertions(+), 49 deletions(-) create mode 100644 src/api/missionService.ts diff --git a/src/api/missionService.ts b/src/api/missionService.ts new file mode 100644 index 0000000..73b91ee --- /dev/null +++ b/src/api/missionService.ts @@ -0,0 +1,79 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export interface Mission { + id: string; + title: string; + amount: number; + isCompleted: boolean; +} + +const MISSIONS_STORAGE_KEY = 'user_missions'; + +export const missionService = { + getMissions: async (): Promise => { + try { + const storedMissions = await AsyncStorage.getItem(MISSIONS_STORAGE_KEY); + return storedMissions ? JSON.parse(storedMissions) : []; + } catch (error) { + console.error('Error getting missions:', error); + return []; + } + }, + + updateMissionStatus: async (missionId: string, isCompleted: boolean) => { + try { + const missions = await missionService.getMissions(); + const updatedMissions = missions.map(mission => + mission.id === missionId ? {...mission, isCompleted} : mission, + ); + await AsyncStorage.setItem( + MISSIONS_STORAGE_KEY, + JSON.stringify(updatedMissions), + ); + return updatedMissions; + } catch (error) { + console.error('Error updating mission status:', error); + throw error; + } + }, + + initializeMissions: async () => { + const defaultMissions: Mission[] = [ + { + id: 'transaction', + title: '輸入交易紀錄', + amount: 5000, + isCompleted: false, + }, + { + id: 'income', + title: '添加額外收入', + amount: 500, + isCompleted: false, + }, + { + id: 'budget', + title: '設定預算', + amount: 2000, + isCompleted: true, + }, + { + id: 'saving', + title: '設定目標存款', + amount: 3000, + isCompleted: true, + }, + ]; + + try { + await AsyncStorage.setItem( + MISSIONS_STORAGE_KEY, + JSON.stringify(defaultMissions), + ); + return defaultMissions; + } catch (error) { + console.error('Error initializing missions:', error); + throw error; + } + }, +}; diff --git a/src/api/userService.ts b/src/api/userService.ts index 967638c..3120829 100644 --- a/src/api/userService.ts +++ b/src/api/userService.ts @@ -51,6 +51,27 @@ export const userService = { throw error; } }, + + updateDiamonds: async (amount: number): Promise => { + try { + const profile = await userService.getUserProfile(); + const updatedProfile = { + ...profile, + wallet: { + ...profile.wallet, + diamonds: (profile.wallet?.diamonds || 0) + amount, + }, + }; + await AsyncStorage.setItem( + WALLET_STORAGE_KEY, + JSON.stringify(updatedProfile.wallet), + ); + return updatedProfile; + } catch (error) { + console.error('Error updating diamonds:', error); + throw error; + } + }, }; export const useUserProfile = () => { diff --git a/src/components/Mission.tsx b/src/components/Mission.tsx index 684ac43..60dbdf0 100644 --- a/src/components/Mission.tsx +++ b/src/components/Mission.tsx @@ -1,20 +1,14 @@ import {StyleSheet, View, Text} from 'react-native'; import BouncyCheckbox from 'react-native-bouncy-checkbox'; - import {Diamond} from '../svg'; +import {Mission as MissionType} from '../api/missionService'; type MissionProps = { - mission: { - title: string; - amount: number; - isCompleted: boolean; - }; + mission: MissionType; diamonds: number; }; const Mission = ({mission}: MissionProps) => { - // const [isSelected, setSelection] = useState(false); - return ( @@ -22,7 +16,6 @@ const Mission = ({mission}: MissionProps) => { {mission.isCompleted = isChecked}} size={18} disableText={true} /> diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index d1626a6..d631978 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect} from 'react'; +import React, {useState, useEffect, useCallback} from 'react'; import { View, Text, @@ -9,7 +9,7 @@ import { Platform, ImageSourcePropType, } from 'react-native'; // TouchableOpacity -import {useNavigation} from '@react-navigation/native'; +import {useNavigation, useFocusEffect} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; import * as Progress from 'react-native-progress'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -19,6 +19,8 @@ import Mission from '../components/Mission'; import {useAuth} from '../contexts/AuthContext'; import {useUserProfile} from '../api/userService'; import {Dinosaur} from '../svg'; +import {missionService, Mission as MissionType} from '../api/missionService'; +import {transactionService} from '../api/transactionService'; type RootStackParamList = { TransactionScreen: undefined; @@ -44,51 +46,80 @@ const HomeScreen = () => { const [dinoImage, setDinoImage] = useState(null); const [monthlySaving, setMonthlySaving] = useState('0'); const [currentSaving, setCurrentSaving] = useState('0'); - - const missions = [ - {title: '輸入交易紀錄', amount: 5000, isCompleted: false}, - {title: '添加額外收入', amount: 500, isCompleted: false}, - {title: '設定預算', amount: 2000, isCompleted: true}, - {title: '設定目標存款', amount: 3000, isCompleted: true}, - ]; + const [missions, setMissions] = useState([]); const navigation = useNavigation(); - useEffect(() => { - const loadUserData = async () => { - if (!user?.uid) { - return; - } - - // Load dino image - const key = `dino-${user.uid}`; - const imageKey = await AsyncStorage.getItem(key); - if (imageKey && dinoImages[imageKey]) { - setDinoImage(dinoImages[imageKey]); - } - - // Load monthly saving target - const savedMonthlySaving = await AsyncStorage.getItem( - `monthlySaving-${user.uid}`, + const loadUserData = useCallback(async () => { + if (!user?.uid) { + return; + } + + // Load dino image + const key = `dino-${user.uid}`; + const imageKey = await AsyncStorage.getItem(key); + if (imageKey && dinoImages[imageKey]) { + setDinoImage(dinoImages[imageKey]); + } + + // Load monthly saving target + const savedMonthlySaving = await AsyncStorage.getItem( + `monthlySaving-${user.uid}`, + ); + if (savedMonthlySaving) { + setMonthlySaving(savedMonthlySaving); + } + + // Load current saving + const savedCurrentSaving = await AsyncStorage.getItem( + `currentSaving-${user.uid}`, + ); + if (savedCurrentSaving) { + setCurrentSaving(savedCurrentSaving); + } else { + setCurrentSaving('0'); + } + + // Load missions + let loadedMissions = await missionService.getMissions(); + if (loadedMissions.length === 0) { + loadedMissions = await missionService.initializeMissions(); + } + setMissions(loadedMissions); + + // Check transaction mission + const transactions = await transactionService.getTransactions(); + const hasTransactions = transactions.transactions.length > 0; + if (hasTransactions) { + const updatedMissions = await missionService.updateMissionStatus( + 'transaction', + true, ); - if (savedMonthlySaving) { - setMonthlySaving(savedMonthlySaving); - console.log('Loaded monthly saving:', savedMonthlySaving); - } - - // Load current saving - const savedCurrentSaving = await AsyncStorage.getItem( - `currentSaving-${user.uid}`, + setMissions(updatedMissions); + } + + // Check income mission + const hasIncome = transactions.transactions.some( + t => t.type === 'INCOME' && t.amount >= 500, + ); + if (hasIncome) { + const updatedMissions = await missionService.updateMissionStatus( + 'income', + true, ); - if (savedCurrentSaving) { - setCurrentSaving(savedCurrentSaving); - } else { - setCurrentSaving('0'); - } - }; + setMissions(updatedMissions); + } + }, [user?.uid]); + useEffect(() => { loadUserData(); - }, [user]); + }, [loadUserData]); + + useFocusEffect( + useCallback(() => { + loadUserData(); + }, [loadUserData]), + ); // Calculate progress percentage const progressPercentage = Math.min( diff --git a/src/screens/TransactionScreen.tsx b/src/screens/TransactionScreen.tsx index cb61f2e..f58b5f2 100644 --- a/src/screens/TransactionScreen.tsx +++ b/src/screens/TransactionScreen.tsx @@ -11,6 +11,8 @@ import { import TransactionForm from '../components/TransactionForm'; import Layout from '../components/Layout'; import {transactionService, Transaction} from '../api/transactionService'; +import {missionService} from '../api/missionService'; +import {userService} from '../api/userService'; const TransactionScreen: React.FC = () => { const [transactions, setTransactions] = useState([]); @@ -52,6 +54,32 @@ const TransactionScreen: React.FC = () => { setModalVisible(false); try { await transactionService.createTransaction(newTransaction); + + // Update mission status and add diamonds + const missions = await missionService.getMissions(); + const transactionMission = missions.find(m => m.id === 'transaction'); + const incomeMission = missions.find(m => m.id === 'income'); + + // Check if this is the first transaction + if ( + transactionMission && + !transactionMission.isCompleted && + transactions.length === 0 + ) { + await missionService.updateMissionStatus('transaction', true); + await userService.updateDiamonds(transactionMission.amount); + } + + // Check if this is an income transaction + if ( + incomeMission && + !incomeMission.isCompleted && + transaction_type === 'INCOME' && + amount >= 500 + ) { + await missionService.updateMissionStatus('income', true); + await userService.updateDiamonds(incomeMission.amount); + } } catch (error) { console.error('Error creating transaction:', error); } From ce10962a5f1d0a8c606f37ea0a34528c570d6e6c Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 07:15:56 +0800 Subject: [PATCH 07/16] feat: integrate Redux for state management --- App.tsx | 14 +- package.json | 2 + src/api/client.ts | 24 ++- src/api/userService.ts | 77 ++------ src/contexts/AuthContext.tsx | 280 +++++++++++---------------- src/screens/SetUp.tsx | 210 ++++++++++---------- src/screens/TransactionScreen.tsx | 30 +-- src/store/index.ts | 30 +++ src/store/slices/authSlice.ts | 40 ++++ src/store/slices/missionSlice.ts | 42 ++++ src/store/slices/settingsSlice.ts | 60 ++++++ src/store/slices/transactionSlice.ts | 38 ++++ src/store/slices/userSlice.ts | 33 ++++ src/store/slices/walletSlice.ts | 34 ++++ yarn.lock | 55 ++++++ 15 files changed, 601 insertions(+), 368 deletions(-) create mode 100644 src/store/index.ts create mode 100644 src/store/slices/authSlice.ts create mode 100644 src/store/slices/missionSlice.ts create mode 100644 src/store/slices/settingsSlice.ts create mode 100644 src/store/slices/transactionSlice.ts create mode 100644 src/store/slices/userSlice.ts create mode 100644 src/store/slices/walletSlice.ts diff --git a/App.tsx b/App.tsx index 91a2242..6872efd 100644 --- a/App.tsx +++ b/App.tsx @@ -8,6 +8,8 @@ import LoadingScreen from './src/screens/LoadingScreen'; import {initializeApp, getApps} from '@react-native-firebase/app'; import {SWRProvider} from './src/api/swrConfig'; import Config from 'react-native-config'; +import {Provider} from 'react-redux'; +import {store} from './src/store'; const firebaseConfig = { apiKey: Config.FIREBASE_API_KEY || '', @@ -46,11 +48,13 @@ const AppContent = () => { function App(): React.JSX.Element { return ( - - - - - + + + + + + + ); } diff --git a/package.json b/package.json index a5ee88f..a365643 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@react-navigation/native": "^7.0.14", "@react-navigation/native-stack": "^7.3.10", "@react-navigation/stack": "^7.1.1", + "@reduxjs/toolkit": "^2.7.0", "axios": "^1.8.4", "react": "19.0.0", "react-native": "0.78.0", @@ -38,6 +39,7 @@ "react-native-svg": "^15.11.2", "react-native-switch-selector": "^2.3.0", "react-native-vector-icons": "^10.2.0", + "react-redux": "^9.2.0", "swr": "^2.3.3" }, "devDependencies": { diff --git a/src/api/client.ts b/src/api/client.ts index 9b89a34..7d70d1d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,7 +1,7 @@ import axios, {AxiosError, AxiosRequestConfig} from 'axios'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import Config from 'react-native-config'; import {API_ENDPOINTS} from './endpoints'; +import {store} from '../store'; const API_BASE_URL = Config.API_BASE_URL || 'http://localhost:8080/api'; const SKIP_AUTH = Config.SKIP_AUTH === 'true'; @@ -16,7 +16,9 @@ const apiClient = axios.create({ apiClient.interceptors.request.use( async config => { - const token = await AsyncStorage.getItem('userToken'); + const state = store.getState(); + const token = state.auth.userToken; + if ( token && !config.url?.includes(API_ENDPOINTS.AUTH_LOGIN) && @@ -48,7 +50,8 @@ apiClient.interceptors.response.use( originalRequest._retry = true; try { - const refreshToken = await AsyncStorage.getItem('refreshToken'); + const state = store.getState(); + const refreshToken = state.auth.refreshToken; if (!refreshToken) { throw new Error('No refresh token available'); @@ -63,8 +66,13 @@ apiClient.interceptors.response.use( const {access_token, refresh_token} = response.data; - await AsyncStorage.setItem('userToken', access_token); - await AsyncStorage.setItem('refreshToken', refresh_token); + store.dispatch({ + type: 'auth/setTokens', + payload: { + userToken: access_token, + refreshToken: refresh_token, + }, + }); apiClient.defaults.headers.common.Authorization = `Bearer ${access_token}`; @@ -74,12 +82,8 @@ apiClient.interceptors.response.use( return apiClient(originalRequest); } catch (refreshError) { - await AsyncStorage.removeItem('userToken'); - await AsyncStorage.removeItem('refreshToken'); - await AsyncStorage.removeItem('isDummyToken'); - + store.dispatch({type: 'auth/clearTokens'}); console.error('Token refresh failed, user needs to login again'); - return Promise.reject(error); } } diff --git a/src/api/userService.ts b/src/api/userService.ts index 3120829..c6ffe14 100644 --- a/src/api/userService.ts +++ b/src/api/userService.ts @@ -1,12 +1,9 @@ -import {mockUserProfile} from '../mock/data'; -import useSWR from 'swr'; -import {useAuth} from '../contexts/AuthContext'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import {useState, useEffect} from 'react'; interface UserProfileResponse { wallet: { diamonds: number; - saving: number; }; } @@ -16,39 +13,12 @@ export const userService = { getUserProfile: async (): Promise => { try { const storedWallet = await AsyncStorage.getItem(WALLET_STORAGE_KEY); - if (storedWallet) { - return { - ...mockUserProfile, - wallet: JSON.parse(storedWallet), - }; - } - // If no stored wallet, initialize with default values - await AsyncStorage.setItem( - WALLET_STORAGE_KEY, - JSON.stringify(mockUserProfile.wallet), - ); - return mockUserProfile; - } catch (error) { - console.error('Error getting user profile:', error); - return mockUserProfile; - } - }, - - updateUserProfile: async (userData: Partial) => { - try { - if (userData.wallet) { - await AsyncStorage.setItem( - WALLET_STORAGE_KEY, - JSON.stringify(userData.wallet), - ); - } return { - ...mockUserProfile, - ...userData, + wallet: storedWallet ? JSON.parse(storedWallet) : {diamonds: 0}, }; } catch (error) { - console.error('Error updating user profile:', error); - throw error; + console.error('Error getting user profile:', error); + return {wallet: {diamonds: 0}}; } }, @@ -75,37 +45,16 @@ export const userService = { }; export const useUserProfile = () => { - const {serverToken} = useAuth(); - const {data, error, isLoading, mutate} = useSWR( - serverToken ? 'mock-user-profile' : null, - () => userService.getUserProfile(), - ); + const [user, setUser] = useState(null); - return { - user: data, - isLoading, - isError: error, - mutate, - }; -}; - -export const useUserProfileManager = () => { - const {user, isLoading, isError, mutate} = useUserProfile(); + useEffect(() => { + const loadUserProfile = async () => { + const profile = await userService.getUserProfile(); + setUser(profile); + }; - const updateProfile = async (userData: Partial) => { - try { - const updatedUser = await userService.updateUserProfile(userData); - mutate(updatedUser, false); - return updatedUser; - } catch (error) { - throw error; - } - }; + loadUserProfile(); + }, []); - return { - user, - isLoading, - isError, - updateProfile, - }; + return {user, setUser}; }; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 6fd5947..0183643 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,13 +1,22 @@ -import React, {createContext, useState, useContext, useEffect} from 'react'; +import React, { + createContext, + useState, + useContext, + useEffect, + useCallback, +} from 'react'; import { getAuth, FirebaseAuthTypes, firebase, } from '@react-native-firebase/auth'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import {GoogleSignin} from '@react-native-google-signin/google-signin'; import Config from 'react-native-config'; import {authService} from '../api/authService'; +import {useAppDispatch, useAppSelector} from '../store'; +import {setTokens, clearTokens} from '../store/slices/authSlice'; +import {store} from '../store'; +import AsyncStorage from '@react-native-async-storage/async-storage'; type AuthContextType = { user: FirebaseAuthTypes.User | null; @@ -30,126 +39,83 @@ type AuthContextType = { const auth = getAuth(); -const createDummyUser = (token: string, refreshToken: string): FirebaseAuthTypes.User => { - const dummyUser = { - uid: 'dummy-user-id', - email: 'dummy@example.com', - emailVerified: true, - isAnonymous: false, - displayName: null, - phoneNumber: null, - photoURL: null, - metadata: { - creationTime: new Date().toISOString(), - lastSignInTime: new Date().toISOString(), - }, - providerData: [], - refreshToken: '', - tenantId: null, - delete: async () => {}, - getIdToken: async () => token, - getIdTokenResult: async () => ({ - token, - claims: {}, - authTime: new Date().toISOString(), - issuedAtTime: new Date().toISOString(), - expirationTime: new Date(Date.now() + 3600000).toISOString(), - signInProvider: null, - signInSecondFactor: null, - }), - reload: async () => {}, - toJSON: () => ({}), - multiFactor: { - enrolledFactors: [], - session: null, - }, - stsTokenManager: { - accessToken: token, - refreshToken, - expirationTime: Date.now() + 3600000, - }, - providerId: 'password', - updateEmail: async () => {}, - updatePassword: async () => {}, - updatePhoneNumber: async () => {}, - updateProfile: async () => {}, - linkWithCredential: async () => ({} as any), - linkWithPhoneNumber: async () => ({} as any), - reauthenticateWithCredential: async () => {}, - reauthenticateWithPhoneNumber: async () => {}, - unlink: async () => ({} as any), - linkWithPopup: async () => ({} as any), - linkWithRedirect: async () => ({} as any), - reauthenticateWithPopup: async () => ({} as any), - reauthenticateWithRedirect: async () => ({} as any), - getProviderData: () => [], - getProviderId: () => 'password', - } as unknown as FirebaseAuthTypes.User; - - return dummyUser; -}; - const AuthContext = createContext(undefined); -export const AuthProvider = ({children}: {children: React.ReactNode}) => { +export const AuthProvider: React.FC<{children: React.ReactNode}> = ({ + children, +}) => { + const dispatch = useAppDispatch(); + const {userToken, refreshToken} = useAppSelector(state => state.auth); + const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [token, setToken] = useState(null); - const [serverToken, setServerToken] = useState(null); + const [serverToken, setServerToken] = useState(userToken); const [tokenExchangeError, setTokenExchangeError] = useState( null, ); const skipAuth = Config.SKIP_AUTH === 'true'; - const refreshAccessToken = async () => { - try { - const refreshToken = await AsyncStorage.getItem('refreshToken'); - if (!refreshToken) { + const refreshAccessToken = useCallback( + async (_: string) => { + try { + if (!refreshToken) { + return false; + } + + setTokenExchangeError(null); + const response = await authService.refreshToken(refreshToken); + + dispatch( + setTokens({ + userToken: response.access_token, + refreshToken: response.refresh_token, + }), + ); + + setServerToken(response.access_token); + return true; + } catch (error: any) { + console.error('刷新 token 失敗:', error); + setTokenExchangeError('刷新 token 失敗,請重新登入'); + setServerToken(null); + dispatch(clearTokens()); return false; } + }, + [dispatch, refreshToken], + ); - setTokenExchangeError(null); - const response = await authService.refreshToken(refreshToken); - - await AsyncStorage.setItem('userToken', response.access_token); - await AsyncStorage.setItem('refreshToken', response.refresh_token); - - setServerToken(response.access_token); - return true; - } catch (error: any) { - console.error('刷新 token 失敗:', error); - setTokenExchangeError('刷新 token 失敗,請重新登入'); - setServerToken(null); - await AsyncStorage.removeItem('userToken'); - await AsyncStorage.removeItem('refreshToken'); - return false; - } - }; - - const exchangeFirebaseToken = async (firebaseToken: string) => { - try { - setTokenExchangeError(null); - const response = await authService.exchangeToken(firebaseToken); + const exchangeFirebaseToken = useCallback( + async (firebaseToken: string) => { + try { + setTokenExchangeError(null); + const response = await authService.exchangeToken(firebaseToken); - await AsyncStorage.setItem('userToken', response.access_token); - await AsyncStorage.setItem('refreshToken', response.refresh_token); + dispatch( + setTokens({ + userToken: response.access_token, + refreshToken: response.refresh_token, + }), + ); - setServerToken(response.access_token); + setServerToken(response.access_token); - return response; - } catch (error: any) { - const errorMessage = - error.response?.data?.message || error.message || '與服務器連接失敗'; - setTokenExchangeError(errorMessage); - console.error('Token 交換失敗:', error); + return response; + } catch (error: any) { + const errorMessage = + error.response?.data?.message || error.message || '與服務器連接失敗'; + setTokenExchangeError(errorMessage); + console.error('Token 交換失敗:', error); - setServerToken(null); - await AsyncStorage.removeItem('userToken'); - await AsyncStorage.removeItem('refreshToken'); + setServerToken(null); + dispatch(clearTokens()); - throw error; - } - }; + throw error; + } + }, + [dispatch], + ); const retryTokenExchange = async (): Promise => { if (!user) { @@ -169,74 +135,44 @@ export const AuthProvider = ({children}: {children: React.ReactNode}) => { }; useEffect(() => { - const initAuth = async () => { - if (skipAuth) { - const DUMMY_TOKEN = 'dummy-token-for-development'; - const DUMMY_REFRESH_TOKEN = 'dummy-refresh-token-for-development'; - - await AsyncStorage.setItem('userToken', DUMMY_TOKEN); - await AsyncStorage.setItem('refreshToken', DUMMY_REFRESH_TOKEN); - await AsyncStorage.setItem('isDummyToken', 'true'); - - setToken(DUMMY_TOKEN); - setServerToken(DUMMY_TOKEN); - setUser(createDummyUser(DUMMY_TOKEN, DUMMY_REFRESH_TOKEN)); - setLoading(false); - return; - } else { - const isDummyToken = await AsyncStorage.getItem('isDummyToken'); - if (isDummyToken === 'true') { - console.log( - '檢測到環境變數變更:從 dummy 模式切換到正常模式,清除舊的 token', - ); - await AsyncStorage.removeItem('userToken'); - await AsyncStorage.removeItem('refreshToken'); - await AsyncStorage.removeItem('isDummyToken'); - setToken(null); - setServerToken(null); + const initializeAuth = async () => { + try { + setLoading(true); + const storedToken = await AsyncStorage.getItem('userToken'); + if (storedToken) { + setToken(storedToken); + await exchangeFirebaseToken(storedToken); + await refreshAccessToken(storedToken); } + } catch (error) { + console.error('Error initializing auth:', error); + } finally { + setLoading(false); } + }; - const storedToken = await AsyncStorage.getItem('userToken'); - if (storedToken) { - setServerToken(storedToken); - await refreshAccessToken(); - } - - GoogleSignin.configure({ - webClientId: Config.GOOGLE_WEB_CLIENT_ID, - }); - - const unsubscribe = auth.onAuthStateChanged(async userState => { - setUser(userState); - - if (userState) { - const idToken = await userState.getIdToken(); - setToken(idToken); + initializeAuth(); + }, [dispatch, exchangeFirebaseToken, refreshAccessToken]); - if (!serverToken) { - try { - await exchangeFirebaseToken(idToken); - } catch (error) { - console.error('Firebase token 交換失敗:', error); - } - } - } else { - setToken(null); - setServerToken(null); - setTokenExchangeError(null); - await AsyncStorage.removeItem('userToken'); - await AsyncStorage.removeItem('refreshToken'); + useEffect(() => { + const unsubscribe = auth.onAuthStateChanged(async user => { + setUser(user); + if (user) { + try { + const firebaseToken = await user.getIdToken(); + setToken(firebaseToken); + await exchangeFirebaseToken(firebaseToken); + } catch (error) { + console.error('Error getting Firebase token:', error); } + } else { + setToken(null); + setServerToken(null); + } + }); - setLoading(false); - }); - - return unsubscribe; - }; - - initAuth(); - }, [skipAuth, serverToken]); + return () => unsubscribe(); + }, [exchangeFirebaseToken]); const signIn = async (email: string, password: string) => { try { @@ -351,9 +287,10 @@ export const AuthProvider = ({children}: {children: React.ReactNode}) => { try { if (serverToken) { try { - const refreshToken = await AsyncStorage.getItem('refreshToken'); - if (refreshToken) { - await authService.logout(refreshToken); + const state = store.getState(); + const currentRefreshToken = state.auth.refreshToken; + if (currentRefreshToken) { + await authService.logout(currentRefreshToken); } else { console.warn('找不到 refresh_token 進行登出'); } @@ -370,8 +307,7 @@ export const AuthProvider = ({children}: {children: React.ReactNode}) => { console.error('Google 登出錯誤:', googleError); } - await AsyncStorage.removeItem('userToken'); - await AsyncStorage.removeItem('refreshToken'); + dispatch(clearTokens()); setToken(null); setServerToken(null); @@ -499,7 +435,7 @@ export const AuthProvider = ({children}: {children: React.ReactNode}) => { export const useAuth = () => { const context = useContext(AuthContext); if (context === undefined) { - throw new Error('useAuth 必須在 AuthProvider 內使用'); + throw new Error('useAuth must be used within an AuthProvider'); } return context; }; diff --git a/src/screens/SetUp.tsx b/src/screens/SetUp.tsx index 5d131ba..08314ea 100644 --- a/src/screens/SetUp.tsx +++ b/src/screens/SetUp.tsx @@ -12,9 +12,10 @@ import { import * as Progress from 'react-native-progress'; import {useNavigation} from '@react-navigation/native'; import {NativeStackNavigationProp} from '@react-navigation/native-stack'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import {useAuth} from '../contexts/AuthContext'; import Layout from '../components/Layout'; +import {useAppDispatch} from '../store'; +import {settingsSlice} from '../store/slices/settingsSlice'; type RootStackParamList = { MainTabs: undefined; @@ -34,6 +35,7 @@ const SetUp = () => { ); const navigation = useNavigation(); const {user} = useAuth(); + const dispatch = useAppDispatch(); const handleNumberInput = ( value: string, @@ -205,15 +207,13 @@ const SetUp = () => { monthlySaving.trim().length > 0 && user?.uid ) { - const key = `setupDone-${user.uid}`; - await AsyncStorage.setItem(key, 'true'); - await AsyncStorage.setItem(`dino-${user.uid}`, selectedDino.imageKey); - await AsyncStorage.setItem(`monthlyIncome-${user.uid}`, monthlyIncome); - await AsyncStorage.setItem( - `monthlyExpense-${user.uid}`, - monthlyExpense, - ); - await AsyncStorage.setItem(`monthlySaving-${user.uid}`, monthlySaving); + const trimmedMonthlyIncome = monthlyIncome.trim(); + const trimmedMonthlySaving = monthlySaving.trim(); + + dispatch(settingsSlice.actions.setSetupDone(true)); + dispatch(settingsSlice.actions.setSelectedDino(selectedDino.imageKey)); + dispatch(settingsSlice.actions.setMonthlyIncome(trimmedMonthlyIncome)); + dispatch(settingsSlice.actions.setMonthlySaving(trimmedMonthlySaving)); navigation.reset({index: 0, routes: [{name: 'MainTabs'}]}); } } @@ -233,104 +233,106 @@ const SetUp = () => { - {step === 1 ? ( - - 選擇一個萌寵 - - {dinosaurs.map(dino => ( - setSelectedDino(dino)}> - - {dino.name} - - ))} - - - ) : ( - - - - - {petMessage} + + {step === 1 ? ( + + 選擇一個萌寵 + + {dinosaurs.map(dino => ( + setSelectedDino(dino)}> + + {dino.name} + + ))} + ) : ( + + + + + {petMessage} + + - - 每月收入 - - handleNumberInput(value, setMonthlyIncome) - } - keyboardType="numeric" - /> - + + 每月收入 + + handleNumberInput(value, setMonthlyIncome) + } + keyboardType="numeric" + /> + - - 每月支出 - - handleNumberInput(value, setMonthlyExpense) - } - keyboardType="numeric" - /> - + + 每月支出 + + handleNumberInput(value, setMonthlyExpense) + } + keyboardType="numeric" + /> + - - 每月存錢目標 - - handleNumberInput(value, setMonthlySaving) - } - keyboardType="numeric" - /> - + + 每月存錢目標 + + handleNumberInput(value, setMonthlySaving) + } + keyboardType="numeric" + /> + - - - 建議存錢目標 - - - - 建議理財目標 - - - - )} + + + 建議存錢目標 + + + + 建議理財目標 + + + + )} + @@ -363,6 +365,10 @@ const styles = StyleSheet.create({ flex: 1, padding: 20, }, + stepContainer: { + flex: 1, + width: '100%', + }, stepContent: { flex: 1, }, diff --git a/src/screens/TransactionScreen.tsx b/src/screens/TransactionScreen.tsx index f58b5f2..a79f879 100644 --- a/src/screens/TransactionScreen.tsx +++ b/src/screens/TransactionScreen.tsx @@ -13,11 +13,17 @@ import Layout from '../components/Layout'; import {transactionService, Transaction} from '../api/transactionService'; import {missionService} from '../api/missionService'; import {userService} from '../api/userService'; +import {useAppSelector} from '../store'; const TransactionScreen: React.FC = () => { - const [transactions, setTransactions] = useState([]); const [isModalVisible, setModalVisible] = useState(false); const [budget, setBudget] = useState(10000); + const storeTransactions = useAppSelector( + state => state.transactions.transactions, + ); + const [transactions, setTransactions] = + useState(storeTransactions); + const totalIncome = transactions .filter(t => t.type === 'INCOME') .reduce((sum, t) => sum + t.amount, 0); @@ -46,31 +52,25 @@ const TransactionScreen: React.FC = () => { category, type: transaction_type, }; - const updatedTransactions = [newTransaction, ...transactions].sort( - (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), - ); - setTransactions(updatedTransactions); - setModalVisible(false); try { await transactionService.createTransaction(newTransaction); + const updatedTransactions = await transactionService.getTransactions(); + setTransactions(updatedTransactions.transactions); + setModalVisible(false); - // Update mission status and add diamonds + // Get current missions state const missions = await missionService.getMissions(); const transactionMission = missions.find(m => m.id === 'transaction'); const incomeMission = missions.find(m => m.id === 'income'); - // Check if this is the first transaction - if ( - transactionMission && - !transactionMission.isCompleted && - transactions.length === 0 - ) { + // Check and update transaction mission + if (transactionMission && !transactionMission.isCompleted) { await missionService.updateMissionStatus('transaction', true); await userService.updateDiamonds(transactionMission.amount); } - // Check if this is an income transaction + // Check and update income mission if ( incomeMission && !incomeMission.isCompleted && @@ -81,7 +81,7 @@ const TransactionScreen: React.FC = () => { await userService.updateDiamonds(incomeMission.amount); } } catch (error) { - console.error('Error creating transaction:', error); + console.error('Error handling transaction:', error); } }; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..448dae5 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,30 @@ +import {configureStore} from '@reduxjs/toolkit'; +import {TypedUseSelectorHook, useDispatch, useSelector} from 'react-redux'; +import userReducer from './slices/userSlice'; +import authReducer from './slices/authSlice'; +import settingsReducer from './slices/settingsSlice'; +import transactionReducer from './slices/transactionSlice'; +import missionReducer from './slices/missionSlice'; +import walletReducer from './slices/walletSlice'; + +export const store = configureStore({ + reducer: { + user: userReducer, + auth: authReducer, + settings: settingsReducer, + transactions: transactionReducer, + missions: missionReducer, + wallet: walletReducer, + }, + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + serializableCheck: false, + }), +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/store/slices/authSlice.ts b/src/store/slices/authSlice.ts new file mode 100644 index 0000000..1867fee --- /dev/null +++ b/src/store/slices/authSlice.ts @@ -0,0 +1,40 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +interface AuthState { + userToken: string | null; + refreshToken: string | null; + isDummyToken: boolean; +} + +const initialState: AuthState = { + userToken: null, + refreshToken: null, + isDummyToken: false, +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setTokens: ( + state, + action: PayloadAction<{ + userToken: string; + refreshToken: string; + isDummyToken?: boolean; + }>, + ) => { + state.userToken = action.payload.userToken; + state.refreshToken = action.payload.refreshToken; + state.isDummyToken = action.payload.isDummyToken || false; + }, + clearTokens: state => { + state.userToken = null; + state.refreshToken = null; + state.isDummyToken = false; + }, + }, +}); + +export const {setTokens, clearTokens} = authSlice.actions; +export default authSlice.reducer; diff --git a/src/store/slices/missionSlice.ts b/src/store/slices/missionSlice.ts new file mode 100644 index 0000000..2bd67f3 --- /dev/null +++ b/src/store/slices/missionSlice.ts @@ -0,0 +1,42 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +export interface Mission { + id: string; + title: string; + amount: number; + isCompleted: boolean; +} + +interface MissionState { + missions: Mission[]; +} + +const initialState: MissionState = { + missions: [], +}; + +const missionSlice = createSlice({ + name: 'missions', + initialState, + reducers: { + setMissions: (state, action: PayloadAction) => { + state.missions = action.payload; + }, + addMission: (state, action: PayloadAction) => { + state.missions.push(action.payload); + }, + updateMission: (state, action: PayloadAction) => { + const index = state.missions.findIndex(m => m.id === action.payload.id); + if (index !== -1) { + state.missions[index] = action.payload; + } + }, + clearMissions: state => { + state.missions = []; + }, + }, +}); + +export const {setMissions, addMission, updateMission, clearMissions} = + missionSlice.actions; +export default missionSlice.reducer; diff --git a/src/store/slices/settingsSlice.ts b/src/store/slices/settingsSlice.ts new file mode 100644 index 0000000..77d8710 --- /dev/null +++ b/src/store/slices/settingsSlice.ts @@ -0,0 +1,60 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import type {RootState} from '../index'; + +interface SettingsState { + setupDone: boolean; + selectedDino: string | null; + monthlyIncome: string | null; + monthlySaving: string | null; + currentSaving: string | null; +} + +const initialState: SettingsState = { + setupDone: false, + selectedDino: null, + monthlyIncome: null, + monthlySaving: null, + currentSaving: null, +}; + +export const settingsSlice = createSlice({ + name: 'settings', + initialState, + reducers: { + setSetupDone: (state, action: PayloadAction) => { + state.setupDone = action.payload; + }, + setSelectedDino: (state, action: PayloadAction) => { + state.selectedDino = action.payload; + }, + setMonthlyIncome: (state, action: PayloadAction) => { + state.monthlyIncome = action.payload; + }, + setMonthlySaving: (state, action: PayloadAction) => { + state.monthlySaving = action.payload; + }, + setCurrentSaving: (state, action: PayloadAction) => { + state.currentSaving = action.payload; + }, + clearSettings: state => { + state.setupDone = false; + state.selectedDino = null; + state.monthlyIncome = null; + state.monthlySaving = null; + state.currentSaving = null; + }, + }, +}); + +export const { + setSetupDone, + setSelectedDino, + setMonthlyIncome, + setMonthlySaving, + setCurrentSaving, + clearSettings, +} = settingsSlice.actions; + +export const selectSettings = (state: RootState) => state.settings; + +export default settingsSlice.reducer; diff --git a/src/store/slices/transactionSlice.ts b/src/store/slices/transactionSlice.ts new file mode 100644 index 0000000..b47469f --- /dev/null +++ b/src/store/slices/transactionSlice.ts @@ -0,0 +1,38 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +export interface Transaction { + id: string; + amount: number; + type: 'INCOME' | 'EXPENSE'; + category: string; + date: string; + description: string; +} + +interface TransactionState { + transactions: Transaction[]; +} + +const initialState: TransactionState = { + transactions: [], +}; + +const transactionSlice = createSlice({ + name: 'transactions', + initialState, + reducers: { + setTransactions: (state, action: PayloadAction) => { + state.transactions = action.payload; + }, + addTransaction: (state, action: PayloadAction) => { + state.transactions.push(action.payload); + }, + clearTransactions: state => { + state.transactions = []; + }, + }, +}); + +export const {setTransactions, addTransaction, clearTransactions} = + transactionSlice.actions; +export default transactionSlice.reducer; diff --git a/src/store/slices/userSlice.ts b/src/store/slices/userSlice.ts new file mode 100644 index 0000000..a01499f --- /dev/null +++ b/src/store/slices/userSlice.ts @@ -0,0 +1,33 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +interface UserState { + isAuthenticated: boolean; + userData: { + id?: string; + email?: string; + name?: string; + } | null; +} + +const initialState: UserState = { + isAuthenticated: false, + userData: null, +}; + +const userSlice = createSlice({ + name: 'user', + initialState, + reducers: { + setUser: (state, action: PayloadAction) => { + state.userData = action.payload; + state.isAuthenticated = !!action.payload; + }, + clearUser: state => { + state.userData = null; + state.isAuthenticated = false; + }, + }, +}); + +export const {setUser, clearUser} = userSlice.actions; +export default userSlice.reducer; diff --git a/src/store/slices/walletSlice.ts b/src/store/slices/walletSlice.ts new file mode 100644 index 0000000..bcf913f --- /dev/null +++ b/src/store/slices/walletSlice.ts @@ -0,0 +1,34 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +interface WalletState { + balance: number; + lastUpdated: string | null; +} + +const initialState: WalletState = { + balance: 0, + lastUpdated: null, +}; + +const walletSlice = createSlice({ + name: 'wallet', + initialState, + reducers: { + setWalletBalance: (state, action: PayloadAction) => { + state.balance = action.payload; + state.lastUpdated = new Date().toISOString(); + }, + updateBalance: (state, action: PayloadAction) => { + state.balance += action.payload; + state.lastUpdated = new Date().toISOString(); + }, + clearWallet: state => { + state.balance = 0; + state.lastUpdated = null; + }, + }, +}); + +export const {setWalletBalance, updateBalance, clearWallet} = + walletSlice.actions; +export default walletSlice.reducer; diff --git a/yarn.lock b/yarn.lock index 686e201..639a602 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2326,6 +2326,18 @@ "@react-navigation/elements" "^2.2.5" color "^4.2.3" +"@reduxjs/toolkit@^2.7.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.7.0.tgz#6a8823aa741a5aab2a2ce58e6326243f36fe31f2" + integrity sha512-XVwolG6eTqwV0N8z/oDlN93ITCIGIop6leXlGJI/4EKy+0POYkR+ABHRSdGXY+0MQvJBP8yAzh+EYFxTuvmBiQ== + dependencies: + "@standard-schema/spec" "^1.0.0" + "@standard-schema/utils" "^0.3.0" + immer "^10.0.3" + redux "^5.0.1" + redux-thunk "^3.1.0" + reselect "^5.1.0" + "@sideway/address@^4.1.5": version "4.1.5" resolved "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz" @@ -2362,6 +2374,16 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@standard-schema/spec@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.0.0.tgz#f193b73dc316c4170f2e82a881da0f550d551b9c" + integrity sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA== + +"@standard-schema/utils@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b" + integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== + "@tsconfig/react-native@^3.0.5": version "3.0.5" resolved "https://registry.yarnpkg.com/@tsconfig/react-native/-/react-native-3.0.5.tgz#c4971b1eca2e8cdf7b0d25f40193a782039c1abd" @@ -2497,6 +2519,11 @@ resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== +"@types/use-sync-external-store@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" + integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz" @@ -4674,6 +4701,11 @@ image-size@^1.0.2: dependencies: queue "6.0.2" +immer@^10.0.3: + version "10.1.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" + integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== + import-fresh@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz" @@ -6760,6 +6792,14 @@ react-native@0.78.0: ws "^6.2.3" yargs "^17.6.2" +react-redux@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5" + integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g== + dependencies: + "@types/use-sync-external-store" "^0.0.6" + use-sync-external-store "^1.4.0" + react-refresh@^0.14.0: version "0.14.2" resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz" @@ -6803,6 +6843,16 @@ recast@^0.23.9: tiny-invariant "^1.3.3" tslib "^2.0.1" +redux-thunk@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" + integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== + +redux@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" + integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== + reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: version "1.0.10" resolved "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz" @@ -6892,6 +6942,11 @@ require-main-filename@^2.0.0: resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz" integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== +reselect@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" + integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz" From 124a86d964197c3cc548a05e7f1e6d74e8ae800f Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 07:24:53 +0800 Subject: [PATCH 08/16] refactor: replace Layout component with SafeAreaView in SetUp screen; enhance styling with background color from theme --- src/screens/SetUp.tsx | 228 +++++++++++++++++++++--------------------- 1 file changed, 114 insertions(+), 114 deletions(-) diff --git a/src/screens/SetUp.tsx b/src/screens/SetUp.tsx index 08314ea..92ca74d 100644 --- a/src/screens/SetUp.tsx +++ b/src/screens/SetUp.tsx @@ -8,14 +8,15 @@ import { Image, StatusBar, ScrollView, + SafeAreaView, } from 'react-native'; import * as Progress from 'react-native-progress'; import {useNavigation} from '@react-navigation/native'; import {NativeStackNavigationProp} from '@react-navigation/native-stack'; import {useAuth} from '../contexts/AuthContext'; -import Layout from '../components/Layout'; import {useAppDispatch} from '../store'; import {settingsSlice} from '../store/slices/settingsSlice'; +import {colors} from '../theme/colors'; type RootStackParamList = { MainTabs: undefined; @@ -229,137 +230,136 @@ const SetUp = () => { const progressValue = step === 1 ? 0.5 : 1.0; return ( - + - - - - {step === 1 ? ( - - 選擇一個萌寵 - - {dinosaurs.map(dino => ( - setSelectedDino(dino)}> - - {dino.name} - - ))} - + + + {step === 1 ? ( + + 選擇一個萌寵 + + {dinosaurs.map(dino => ( + setSelectedDino(dino)}> + + {dino.name} + + ))} - ) : ( - - - - - {petMessage} - - - - - 每月收入 - - handleNumberInput(value, setMonthlyIncome) - } - keyboardType="numeric" - /> + + ) : ( + + + + + {petMessage} + - - 每月支出 - - handleNumberInput(value, setMonthlyExpense) - } - keyboardType="numeric" - /> - + + 每月收入 + + handleNumberInput(value, setMonthlyIncome) + } + keyboardType="numeric" + /> + - - 每月存錢目標 - - handleNumberInput(value, setMonthlySaving) - } - keyboardType="numeric" - /> - + + 每月支出 + + handleNumberInput(value, setMonthlyExpense) + } + keyboardType="numeric" + /> + - - - 建議存錢目標 - + + 每月存錢目標 + + handleNumberInput(value, setMonthlySaving) + } + keyboardType="numeric" + /> + - - 建議理財目標 - - - - )} - + + + 建議存錢目標 + + + + 建議理財目標 + + + + )} + - - - - {step === 2 && ( - - 上一步 - - )} - - - {step === 1 ? '下一步' : '完成'} - + + + + {step === 2 && ( + + 上一步 - + )} + + + {step === 1 ? '下一步' : '完成'} + + - + ); }; const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.background, }, content: { flex: 1, From 99948e42855e9ff42bc0e85d10f1478b8dc8da8f Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 07:38:11 +0800 Subject: [PATCH 09/16] refactor: migrate mission and transaction services to use Redux for state management; remove AsyncStorage integration and implement default data handling --- src/api/missionService.ts | 136 ++++++++++++++------------- src/api/transactionService.ts | 75 +++++---------- src/api/userService.ts | 61 ++++++------ src/screens/HomeScreen.tsx | 61 ++++-------- src/screens/TransactionScreen.tsx | 56 ++--------- src/store/slices/missionSlice.ts | 6 +- src/store/slices/settingsSlice.ts | 11 +++ src/store/slices/transactionSlice.ts | 17 ++-- 8 files changed, 175 insertions(+), 248 deletions(-) diff --git a/src/api/missionService.ts b/src/api/missionService.ts index 73b91ee..002cddf 100644 --- a/src/api/missionService.ts +++ b/src/api/missionService.ts @@ -1,79 +1,85 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +import {useAppDispatch, useAppSelector} from '../store'; +import {missionSlice, Mission} from '../store/slices/missionSlice'; +import {settingsSlice} from '../store/slices/settingsSlice'; -export interface Mission { - id: string; - title: string; - amount: number; - isCompleted: boolean; -} - -const MISSIONS_STORAGE_KEY = 'user_missions'; +const defaultMissions: Mission[] = [ + { + id: 'transaction', + title: '輸入交易紀錄', + amount: 5000, + isCompleted: false, + }, + { + id: 'income', + title: '添加額外收入', + amount: 500, + isCompleted: false, + }, + { + id: 'budget', + title: '設定預算', + amount: 2000, + isCompleted: true, + }, + { + id: 'saving', + title: '設定目標存款', + amount: 3000, + isCompleted: true, + }, +]; export const missionService = { getMissions: async (): Promise => { - try { - const storedMissions = await AsyncStorage.getItem(MISSIONS_STORAGE_KEY); - return storedMissions ? JSON.parse(storedMissions) : []; - } catch (error) { - console.error('Error getting missions:', error); - return []; - } + return defaultMissions; }, updateMissionStatus: async (missionId: string, isCompleted: boolean) => { - try { - const missions = await missionService.getMissions(); - const updatedMissions = missions.map(mission => - mission.id === missionId ? {...mission, isCompleted} : mission, - ); - await AsyncStorage.setItem( - MISSIONS_STORAGE_KEY, - JSON.stringify(updatedMissions), - ); - return updatedMissions; - } catch (error) { - console.error('Error updating mission status:', error); - throw error; + const missions = await missionService.getMissions(); + const mission = missions.find(m => m.id === missionId); + + // Only update diamonds if the mission is being completed and wasn't completed before + if (isCompleted && mission && !mission.isCompleted) { + return missions.map(m => (m.id === missionId ? {...m, isCompleted} : m)); } + + return missions.map(m => (m.id === missionId ? {...m, isCompleted} : m)); }, initializeMissions: async () => { - const defaultMissions: Mission[] = [ - { - id: 'transaction', - title: '輸入交易紀錄', - amount: 5000, - isCompleted: false, - }, - { - id: 'income', - title: '添加額外收入', - amount: 500, - isCompleted: false, - }, - { - id: 'budget', - title: '設定預算', - amount: 2000, - isCompleted: true, - }, - { - id: 'saving', - title: '設定目標存款', - amount: 3000, - isCompleted: true, - }, - ]; + return defaultMissions; + }, +}; + +export const useMissions = () => { + const dispatch = useAppDispatch(); + const missions = useAppSelector(state => state.missions.missions); + + const updateMission = async (missionId: string, isCompleted: boolean) => { + const updatedMissions = await missionService.updateMissionStatus( + missionId, + isCompleted, + ); + dispatch(missionSlice.actions.setMissions(updatedMissions)); - try { - await AsyncStorage.setItem( - MISSIONS_STORAGE_KEY, - JSON.stringify(defaultMissions), - ); - return defaultMissions; - } catch (error) { - console.error('Error initializing missions:', error); - throw error; + // Update diamonds if mission is completed + const mission = updatedMissions.find(m => m.id === missionId); + if (mission && isCompleted) { + dispatch(settingsSlice.actions.addDiamonds(mission.amount)); } - }, + + return updatedMissions; + }; + + const initializeMissions = async () => { + const initialMissions = await missionService.initializeMissions(); + dispatch(missionSlice.actions.setMissions(initialMissions)); + return initialMissions; + }; + + return { + missions, + updateMission, + initializeMissions, + }; }; diff --git a/src/api/transactionService.ts b/src/api/transactionService.ts index eda329a..ac199e3 100644 --- a/src/api/transactionService.ts +++ b/src/api/transactionService.ts @@ -1,72 +1,39 @@ -import useSWR from 'swr'; -import AsyncStorage from '@react-native-async-storage/async-storage'; - -export interface Transaction { - id: string; - type: 'INCOME' | 'EXPENSE'; - amount: number; - category: string; - date: string; - description: string; -} +import {useAppDispatch, useAppSelector} from '../store'; +import {transactionSlice, Transaction} from '../store/slices/transactionSlice'; export interface GetTransactionResponse { transactions: Transaction[]; } -const TRANSACTIONS_STORAGE_KEY = 'user_transactions'; - export const transactionService = { createTransaction: async (transaction: Transaction) => { - try { - const storedTransactions = await AsyncStorage.getItem( - TRANSACTIONS_STORAGE_KEY, - ); - const transactions = storedTransactions - ? JSON.parse(storedTransactions) - : []; - const newTransaction = { - ...transaction, - id: Date.now().toString(), - }; - transactions.push(newTransaction); - await AsyncStorage.setItem( - TRANSACTIONS_STORAGE_KEY, - JSON.stringify(transactions), - ); - return newTransaction; - } catch (error) { - console.error('Error creating transaction:', error); - throw error; - } + const newTransaction = { + ...transaction, + id: Date.now().toString(), + }; + return newTransaction; }, getTransactions: async (): Promise => { - try { - const storedTransactions = await AsyncStorage.getItem( - TRANSACTIONS_STORAGE_KEY, - ); - const transactions = storedTransactions - ? JSON.parse(storedTransactions) - : []; - return {transactions}; - } catch (error) { - console.error('Error getting transactions:', error); - return {transactions: []}; - } + // This will be handled by Redux state + return {transactions: []}; }, }; export const useTransactions = () => { - const {data, error, isLoading, mutate} = useSWR( - 'mock-transactions', - () => transactionService.getTransactions(), - ); + const dispatch = useAppDispatch(); + const transactions = useAppSelector(state => state.transactions.transactions); + + const addTransaction = async (transaction: Transaction) => { + const newTransaction = await transactionService.createTransaction( + transaction, + ); + dispatch(transactionSlice.actions.addTransaction(newTransaction)); + return newTransaction; + }; return { - transactions: data?.transactions || [], - isLoading, - isError: error, - mutate, + transactions, + addTransaction, }; }; diff --git a/src/api/userService.ts b/src/api/userService.ts index c6ffe14..6367b50 100644 --- a/src/api/userService.ts +++ b/src/api/userService.ts @@ -1,5 +1,6 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import {useState, useEffect} from 'react'; +import {useAppDispatch, useAppSelector} from '../store'; +import {settingsSlice} from '../store/slices/settingsSlice'; interface UserProfileResponse { wallet: { @@ -7,54 +8,50 @@ interface UserProfileResponse { }; } -const WALLET_STORAGE_KEY = 'user_wallet'; - export const userService = { getUserProfile: async (): Promise => { - try { - const storedWallet = await AsyncStorage.getItem(WALLET_STORAGE_KEY); - return { - wallet: storedWallet ? JSON.parse(storedWallet) : {diamonds: 0}, - }; - } catch (error) { - console.error('Error getting user profile:', error); - return {wallet: {diamonds: 0}}; - } + return { + wallet: { + diamonds: 5000, + }, + }; }, updateDiamonds: async (amount: number): Promise => { - try { - const profile = await userService.getUserProfile(); - const updatedProfile = { - ...profile, - wallet: { - ...profile.wallet, - diamonds: (profile.wallet?.diamonds || 0) + amount, - }, - }; - await AsyncStorage.setItem( - WALLET_STORAGE_KEY, - JSON.stringify(updatedProfile.wallet), - ); - return updatedProfile; - } catch (error) { - console.error('Error updating diamonds:', error); - throw error; - } + return { + wallet: { + diamonds: amount, + }, + }; }, }; export const useUserProfile = () => { + const dispatch = useAppDispatch(); + const diamonds = useAppSelector(state => state.settings.diamonds); const [user, setUser] = useState(null); useEffect(() => { const loadUserProfile = async () => { const profile = await userService.getUserProfile(); setUser(profile); + dispatch(settingsSlice.actions.setDiamonds(profile.wallet.diamonds)); }; loadUserProfile(); - }, []); + }, [dispatch]); + + const updateDiamonds = async (amount: number) => { + try { + dispatch(settingsSlice.actions.addDiamonds(amount)); + const updatedProfile = await userService.updateDiamonds( + diamonds + amount, + ); + setUser(updatedProfile); + } catch (error) { + console.error('Error updating diamonds:', error); + } + }; - return {user, setUser}; + return {user, setUser, diamonds, updateDiamonds}; }; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index d631978..d1d8eec 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -12,15 +12,15 @@ import { import {useNavigation, useFocusEffect} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; import * as Progress from 'react-native-progress'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import Layout from '../components/Layout'; import Mission from '../components/Mission'; import {useAuth} from '../contexts/AuthContext'; import {useUserProfile} from '../api/userService'; import {Dinosaur} from '../svg'; -import {missionService, Mission as MissionType} from '../api/missionService'; +import {useMissions} from '../api/missionService'; import {transactionService} from '../api/transactionService'; +import {useAppSelector} from '../store'; type RootStackParamList = { TransactionScreen: undefined; @@ -44,9 +44,10 @@ const HomeScreen = () => { const {user} = useAuth(); const {user: userProfile} = useUserProfile(); const [dinoImage, setDinoImage] = useState(null); - const [monthlySaving, setMonthlySaving] = useState('0'); - const [currentSaving, setCurrentSaving] = useState('0'); - const [missions, setMissions] = useState([]); + const {monthlySaving, currentSaving, selectedDino} = useAppSelector( + state => state.settings, + ); + const {missions, updateMission, initializeMissions} = useMissions(); const navigation = useNavigation(); @@ -56,46 +57,21 @@ const HomeScreen = () => { } // Load dino image - const key = `dino-${user.uid}`; - const imageKey = await AsyncStorage.getItem(key); - if (imageKey && dinoImages[imageKey]) { - setDinoImage(dinoImages[imageKey]); - } - - // Load monthly saving target - const savedMonthlySaving = await AsyncStorage.getItem( - `monthlySaving-${user.uid}`, - ); - if (savedMonthlySaving) { - setMonthlySaving(savedMonthlySaving); - } - - // Load current saving - const savedCurrentSaving = await AsyncStorage.getItem( - `currentSaving-${user.uid}`, - ); - if (savedCurrentSaving) { - setCurrentSaving(savedCurrentSaving); - } else { - setCurrentSaving('0'); + if (selectedDino && dinoImages[selectedDino]) { + setDinoImage(dinoImages[selectedDino]); } // Load missions - let loadedMissions = await missionService.getMissions(); + let loadedMissions = await initializeMissions(); if (loadedMissions.length === 0) { - loadedMissions = await missionService.initializeMissions(); + loadedMissions = await initializeMissions(); } - setMissions(loadedMissions); // Check transaction mission const transactions = await transactionService.getTransactions(); const hasTransactions = transactions.transactions.length > 0; if (hasTransactions) { - const updatedMissions = await missionService.updateMissionStatus( - 'transaction', - true, - ); - setMissions(updatedMissions); + await updateMission('transaction', true); } // Check income mission @@ -103,13 +79,9 @@ const HomeScreen = () => { t => t.type === 'INCOME' && t.amount >= 500, ); if (hasIncome) { - const updatedMissions = await missionService.updateMissionStatus( - 'income', - true, - ); - setMissions(updatedMissions); + await updateMission('income', true); } - }, [user?.uid]); + }, [user?.uid, selectedDino, initializeMissions, updateMission]); useEffect(() => { loadUserData(); @@ -123,7 +95,8 @@ const HomeScreen = () => { // Calculate progress percentage const progressPercentage = Math.min( - (parseInt(currentSaving, 10) / parseInt(monthlySaving, 10)) * 100, + (parseInt(currentSaving || '0', 10) / parseInt(monthlySaving || '0', 10)) * + 100, 100, ); @@ -145,8 +118,8 @@ const HomeScreen = () => { - NT$ {parseInt(currentSaving, 10).toLocaleString()} / NT${' '} - {parseInt(monthlySaving, 10).toLocaleString()} + NT$ {parseInt(currentSaving || '0', 10).toLocaleString()} / NT${' '} + {parseInt(monthlySaving || '0', 10).toLocaleString()} diff --git a/src/screens/TransactionScreen.tsx b/src/screens/TransactionScreen.tsx index a79f879..6c0acd7 100644 --- a/src/screens/TransactionScreen.tsx +++ b/src/screens/TransactionScreen.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect} from 'react'; +import React, {useState} from 'react'; import { View, Text, @@ -10,19 +10,15 @@ import { } from 'react-native'; import TransactionForm from '../components/TransactionForm'; import Layout from '../components/Layout'; -import {transactionService, Transaction} from '../api/transactionService'; -import {missionService} from '../api/missionService'; -import {userService} from '../api/userService'; -import {useAppSelector} from '../store'; +import {Transaction} from '../store/slices/transactionSlice'; +import {useTransactions} from '../api/transactionService'; +import {useMissions} from '../api/missionService'; const TransactionScreen: React.FC = () => { const [isModalVisible, setModalVisible] = useState(false); const [budget, setBudget] = useState(10000); - const storeTransactions = useAppSelector( - state => state.transactions.transactions, - ); - const [transactions, setTransactions] = - useState(storeTransactions); + const {transactions, addTransaction} = useTransactions(); + const {updateMission} = useMissions(); const totalIncome = transactions .filter(t => t.type === 'INCOME') @@ -54,31 +50,15 @@ const TransactionScreen: React.FC = () => { }; try { - await transactionService.createTransaction(newTransaction); - const updatedTransactions = await transactionService.getTransactions(); - setTransactions(updatedTransactions.transactions); + await addTransaction(newTransaction); setModalVisible(false); - // Get current missions state - const missions = await missionService.getMissions(); - const transactionMission = missions.find(m => m.id === 'transaction'); - const incomeMission = missions.find(m => m.id === 'income'); - // Check and update transaction mission - if (transactionMission && !transactionMission.isCompleted) { - await missionService.updateMissionStatus('transaction', true); - await userService.updateDiamonds(transactionMission.amount); - } + await updateMission('transaction', true); // Check and update income mission - if ( - incomeMission && - !incomeMission.isCompleted && - transaction_type === 'INCOME' && - amount >= 500 - ) { - await missionService.updateMissionStatus('income', true); - await userService.updateDiamonds(incomeMission.amount); + if (transaction_type === 'INCOME' && amount >= 500) { + await updateMission('income', true); } } catch (error) { console.error('Error handling transaction:', error); @@ -90,22 +70,6 @@ const TransactionScreen: React.FC = () => { return `${now.getFullYear()}年${now.getMonth() + 1}月`; }; - useEffect(() => { - const fetchTransactions = async () => { - try { - const response = await transactionService.getTransactions(); - const sortedTransactions = response.transactions.sort( - (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), - ); - setTransactions(sortedTransactions); - } catch (error) { - console.error('Error fetching transactions:', error); - } - }; - - fetchTransactions(); - }, []); - return ( diff --git a/src/store/slices/missionSlice.ts b/src/store/slices/missionSlice.ts index 2bd67f3..1ec0afb 100644 --- a/src/store/slices/missionSlice.ts +++ b/src/store/slices/missionSlice.ts @@ -1,4 +1,5 @@ import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import type {RootState} from '../index'; export interface Mission { id: string; @@ -15,7 +16,7 @@ const initialState: MissionState = { missions: [], }; -const missionSlice = createSlice({ +export const missionSlice = createSlice({ name: 'missions', initialState, reducers: { @@ -39,4 +40,7 @@ const missionSlice = createSlice({ export const {setMissions, addMission, updateMission, clearMissions} = missionSlice.actions; + +export const selectMissions = (state: RootState) => state.missions.missions; + export default missionSlice.reducer; diff --git a/src/store/slices/settingsSlice.ts b/src/store/slices/settingsSlice.ts index 77d8710..59c35ef 100644 --- a/src/store/slices/settingsSlice.ts +++ b/src/store/slices/settingsSlice.ts @@ -7,6 +7,7 @@ interface SettingsState { monthlyIncome: string | null; monthlySaving: string | null; currentSaving: string | null; + diamonds: number; } const initialState: SettingsState = { @@ -15,6 +16,7 @@ const initialState: SettingsState = { monthlyIncome: null, monthlySaving: null, currentSaving: null, + diamonds: 5000, }; export const settingsSlice = createSlice({ @@ -36,12 +38,19 @@ export const settingsSlice = createSlice({ setCurrentSaving: (state, action: PayloadAction) => { state.currentSaving = action.payload; }, + setDiamonds: (state, action: PayloadAction) => { + state.diamonds = action.payload; + }, + addDiamonds: (state, action: PayloadAction) => { + state.diamonds += action.payload; + }, clearSettings: state => { state.setupDone = false; state.selectedDino = null; state.monthlyIncome = null; state.monthlySaving = null; state.currentSaving = null; + state.diamonds = 5000; }, }, }); @@ -52,6 +61,8 @@ export const { setMonthlyIncome, setMonthlySaving, setCurrentSaving, + setDiamonds, + addDiamonds, clearSettings, } = settingsSlice.actions; diff --git a/src/store/slices/transactionSlice.ts b/src/store/slices/transactionSlice.ts index b47469f..9cfac65 100644 --- a/src/store/slices/transactionSlice.ts +++ b/src/store/slices/transactionSlice.ts @@ -1,9 +1,10 @@ import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import type {RootState} from '../index'; export interface Transaction { id: string; - amount: number; type: 'INCOME' | 'EXPENSE'; + amount: number; category: string; date: string; description: string; @@ -17,22 +18,26 @@ const initialState: TransactionState = { transactions: [], }; -const transactionSlice = createSlice({ +export const transactionSlice = createSlice({ name: 'transactions', initialState, reducers: { - setTransactions: (state, action: PayloadAction) => { - state.transactions = action.payload; - }, addTransaction: (state, action: PayloadAction) => { state.transactions.push(action.payload); }, + setTransactions: (state, action: PayloadAction) => { + state.transactions = action.payload; + }, clearTransactions: state => { state.transactions = []; }, }, }); -export const {setTransactions, addTransaction, clearTransactions} = +export const {addTransaction, setTransactions, clearTransactions} = transactionSlice.actions; + +export const selectTransactions = (state: RootState) => + state.transactions.transactions; + export default transactionSlice.reducer; From 365a93bc2da7e8fbf8272e1ba1bd17e8dbd2fb21 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 08:29:06 +0800 Subject: [PATCH 10/16] feat: implement initialization service for app setup; enhance App component to trigger initialization on mount and manage mission states through Redux --- App.tsx | 19 ++++++- src/api/initializationService.ts | 77 +++++++++++++++++++++++++++ src/api/missionService.ts | 58 ++++++++++++++++----- src/api/transactionService.ts | 5 +- src/api/userService.ts | 15 +++--- src/components/Header.tsx | 5 +- src/components/Mission.tsx | 54 +++++++++++++++---- src/contexts/AuthContext.tsx | 14 +++-- src/screens/HomeScreen.tsx | 87 +++++++++++-------------------- src/screens/SetUp.tsx | 34 ++++++++++-- src/screens/TransactionScreen.tsx | 25 +++++++-- src/store/index.ts | 28 ++++++++-- src/store/slices/settingsSlice.ts | 18 +++++++ 13 files changed, 330 insertions(+), 109 deletions(-) create mode 100644 src/api/initializationService.ts diff --git a/App.tsx b/App.tsx index 6872efd..0f06dcf 100644 --- a/App.tsx +++ b/App.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useEffect} from 'react'; import {NavigationContainer} from '@react-navigation/native'; import {createStackNavigator} from '@react-navigation/stack'; import AppNavigator from './src/navigation/AppNavigator'; @@ -10,6 +10,7 @@ import {SWRProvider} from './src/api/swrConfig'; import Config from 'react-native-config'; import {Provider} from 'react-redux'; import {store} from './src/store'; +import {initializationService} from './src/api/initializationService'; const firebaseConfig = { apiKey: Config.FIREBASE_API_KEY || '', @@ -29,6 +30,21 @@ const Stack = createStackNavigator(); const AppContent = () => { const {loading, serverToken} = useAuth(); + useEffect(() => { + console.log('AppContent mounted, starting initialization...'); + const initializeAppData = async () => { + console.log('Calling initializeApp...'); + try { + await initializationService.initializeApp(); + } catch (error) { + console.error('Error initializing app:', error); + } + console.log('Initialization completed'); + }; + + initializeAppData(); + }, []); + if (loading) { return ; } @@ -47,6 +63,7 @@ const AppContent = () => { }; function App(): React.JSX.Element { + console.log('App rendering...'); return ( diff --git a/src/api/initializationService.ts b/src/api/initializationService.ts new file mode 100644 index 0000000..fe13e42 --- /dev/null +++ b/src/api/initializationService.ts @@ -0,0 +1,77 @@ +import {store} from '../store'; +import {missionService} from './missionService'; +import {missionSlice} from '../store/slices/missionSlice'; + +export const initializationService = { + initializeApp: async () => { + try { + // Check if missions exist in store + const state = store.getState(); + console.log('Current missions in store:', state.missions.missions); + + if (!state.missions.missions || state.missions.missions.length === 0) { + // If no missions, set default missions + const defaultMissions = await missionService.getMissions(); + console.log('Setting default missions:', defaultMissions); + store.dispatch(missionSlice.actions.setMissions(defaultMissions)); + } + + // Check missions completion + const {missions, transactions} = store.getState(); + console.log('Missions after initialization:', missions.missions); + + // Check transaction mission + const hasTransactions = transactions.transactions.length > 0; + const transactionMission = missions.missions.find( + m => m.id === 'transaction', + ); + if ( + hasTransactions && + transactionMission && + !transactionMission.isCompleted + ) { + const updatedMissions = await missionService.updateMissionStatus( + 'transaction', + true, + missions.missions, + ); + store.dispatch(missionSlice.actions.setMissions(updatedMissions)); + } + + // Check income mission + const hasIncome = transactions.transactions.some( + t => t.type === 'INCOME' && t.amount >= 500, + ); + const incomeMission = missions.missions.find(m => m.id === 'income'); + if (hasIncome && incomeMission && !incomeMission.isCompleted) { + const updatedMissions = await missionService.updateMissionStatus( + 'income', + true, + missions.missions, + ); + store.dispatch(missionSlice.actions.setMissions(updatedMissions)); + } + } catch (error) { + console.error('Error initializing app:', error); + } + }, + + // Save user data to AsyncStorage + saveUserData: async () => { + try { + const state = store.getState(); + const userData = { + budget: state.settings.budget, + saving: state.settings.saving, + diamonds: state.settings.diamonds, + selectedDino: state.settings.selectedDino, + monthlyIncome: state.settings.monthlyIncome, + monthlySaving: state.settings.monthlySaving, + currentSaving: state.settings.currentSaving, + }; + await AsyncStorage.setItem('userData', JSON.stringify(userData)); + } catch (error) { + console.error('Error saving user data:', error); + } + }, +}; diff --git a/src/api/missionService.ts b/src/api/missionService.ts index 002cddf..cfff13e 100644 --- a/src/api/missionService.ts +++ b/src/api/missionService.ts @@ -34,19 +34,32 @@ export const missionService = { return defaultMissions; }, - updateMissionStatus: async (missionId: string, isCompleted: boolean) => { - const missions = await missionService.getMissions(); - const mission = missions.find(m => m.id === missionId); + updateMissionStatus: async ( + missionId: string, + isCompleted: boolean, + currentMissions: Mission[], + ) => { + const mission = currentMissions.find(m => m.id === missionId); - // Only update diamonds if the mission is being completed and wasn't completed before - if (isCompleted && mission && !mission.isCompleted) { - return missions.map(m => (m.id === missionId ? {...m, isCompleted} : m)); + if (!mission) { + return currentMissions; } - return missions.map(m => (m.id === missionId ? {...m, isCompleted} : m)); + // Only update if the mission is being completed and wasn't completed before + if (isCompleted && !mission.isCompleted) { + return currentMissions.map(m => + m.id === missionId ? {...m, isCompleted: true} : m, + ); + } + + return currentMissions.map(m => + m.id === missionId ? {...m, isCompleted} : m, + ); }, initializeMissions: async () => { + const store = require('../store').store; + store.dispatch(missionSlice.actions.setMissions(defaultMissions)); return defaultMissions; }, }; @@ -56,18 +69,35 @@ export const useMissions = () => { const missions = useAppSelector(state => state.missions.missions); const updateMission = async (missionId: string, isCompleted: boolean) => { + // Get current mission state + const currentMission = missions.find(m => m.id === missionId); + + if (!currentMission) { + return missions; + } + + // Only proceed if the mission exists and is being completed for the first time + if (isCompleted && !currentMission.isCompleted) { + const updatedMissions = await missionService.updateMissionStatus( + missionId, + isCompleted, + missions, + ); + dispatch(missionSlice.actions.setMissions(updatedMissions)); + + // Add diamonds for completing the mission + dispatch(settingsSlice.actions.addDiamonds(currentMission.amount)); + + return updatedMissions; + } + + // If mission is already completed or not being completed, just update the status const updatedMissions = await missionService.updateMissionStatus( missionId, isCompleted, + missions, ); dispatch(missionSlice.actions.setMissions(updatedMissions)); - - // Update diamonds if mission is completed - const mission = updatedMissions.find(m => m.id === missionId); - if (mission && isCompleted) { - dispatch(settingsSlice.actions.addDiamonds(mission.amount)); - } - return updatedMissions; }; diff --git a/src/api/transactionService.ts b/src/api/transactionService.ts index ac199e3..ac0ce20 100644 --- a/src/api/transactionService.ts +++ b/src/api/transactionService.ts @@ -15,8 +15,9 @@ export const transactionService = { }, getTransactions: async (): Promise => { - // This will be handled by Redux state - return {transactions: []}; + // Get transactions from Redux store + const state = require('../store').store.getState(); + return {transactions: state.transactions.transactions}; }, }; diff --git a/src/api/userService.ts b/src/api/userService.ts index 6367b50..63a0b2c 100644 --- a/src/api/userService.ts +++ b/src/api/userService.ts @@ -10,17 +10,21 @@ interface UserProfileResponse { export const userService = { getUserProfile: async (): Promise => { + const store = require('../store').store; + const state = store.getState(); return { wallet: { - diamonds: 5000, + diamonds: state.settings.diamonds, }, }; }, updateDiamonds: async (amount: number): Promise => { + const store = require('../store').store; + const state = store.getState(); return { wallet: { - diamonds: amount, + diamonds: state.settings.diamonds + amount, }, }; }, @@ -35,18 +39,15 @@ export const useUserProfile = () => { const loadUserProfile = async () => { const profile = await userService.getUserProfile(); setUser(profile); - dispatch(settingsSlice.actions.setDiamonds(profile.wallet.diamonds)); }; loadUserProfile(); - }, [dispatch]); + }, [diamonds]); const updateDiamonds = async (amount: number) => { try { dispatch(settingsSlice.actions.addDiamonds(amount)); - const updatedProfile = await userService.updateDiamonds( - diamonds + amount, - ); + const updatedProfile = await userService.updateDiamonds(amount); setUser(updatedProfile); } catch (error) { console.error('Error updating diamonds:', error); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index d4d4ca0..702a49e 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -28,8 +28,9 @@ const formatNumber = (num: number): string => { const Header = () => { const navigation = useNavigation(); - const {user, isLoading} = useUserProfile(); - const diamonds = user?.wallet?.diamonds || 0; + const {diamonds} = useUserProfile(); + const isLoading = false; + console.log('diamonds', diamonds); return ( diff --git a/src/components/Mission.tsx b/src/components/Mission.tsx index 60dbdf0..a314a52 100644 --- a/src/components/Mission.tsx +++ b/src/components/Mission.tsx @@ -1,7 +1,7 @@ -import {StyleSheet, View, Text} from 'react-native'; +import {StyleSheet, View, Text, Platform} from 'react-native'; import BouncyCheckbox from 'react-native-bouncy-checkbox'; import {Diamond} from '../svg'; -import {Mission as MissionType} from '../api/missionService'; +import type {Mission as MissionType} from '../store/slices/missionSlice'; type MissionProps = { mission: MissionType; @@ -15,16 +15,26 @@ const Mission = ({mission}: MissionProps) => { - {mission.title} + + {mission.title} + - {mission.amount} + {mission.amount} ); @@ -32,7 +42,6 @@ const Mission = ({mission}: MissionProps) => { const styles = StyleSheet.create({ mission: { - flex: 1, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', @@ -42,9 +51,29 @@ const styles = StyleSheet.create({ marginBottom: 10, width: '100%', borderRadius: 10, + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + }, + android: { + elevation: 5, + }, + }), }, missionTitle: { fontSize: 14, + color: '#000', + flex: 1, + }, + completedMission: { + textDecorationLine: 'line-through', + color: '#666', }, missionText: { fontSize: 14, @@ -52,16 +81,23 @@ const styles = StyleSheet.create({ checkbox: { marginRight: 10, }, + checkboxIcon: { + borderColor: '#007BFF', + }, missionLeft: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'flex-start', + flex: 1, }, missionReward: { - width: 70, flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', + marginLeft: 10, + }, + rewardAmount: { + fontSize: 14, + fontWeight: 'bold', + marginLeft: 5, }, }); diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 0183643..baf06a5 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -56,6 +56,12 @@ export const AuthProvider: React.FC<{children: React.ReactNode}> = ({ ); const skipAuth = Config.SKIP_AUTH === 'true'; + useEffect(() => { + GoogleSignin.configure({ + webClientId: Config.GOOGLE_WEB_CLIENT_ID, + }); + }, []); + const refreshAccessToken = useCallback( async (_: string) => { try { @@ -155,11 +161,11 @@ export const AuthProvider: React.FC<{children: React.ReactNode}> = ({ }, [dispatch, exchangeFirebaseToken, refreshAccessToken]); useEffect(() => { - const unsubscribe = auth.onAuthStateChanged(async user => { - setUser(user); - if (user) { + const unsubscribe = auth.onAuthStateChanged(async currentUser => { + setUser(currentUser); + if (currentUser) { try { - const firebaseToken = await user.getIdToken(); + const firebaseToken = await currentUser.getIdToken(); setToken(firebaseToken); await exchangeFirebaseToken(firebaseToken); } catch (error) { diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index d1d8eec..1274919 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect, useCallback} from 'react'; +import React, {useState, useEffect} from 'react'; import { View, Text, @@ -8,18 +8,16 @@ import { Image, Platform, ImageSourcePropType, -} from 'react-native'; // TouchableOpacity -import {useNavigation, useFocusEffect} from '@react-navigation/native'; +} from 'react-native'; +import {useNavigation} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; import * as Progress from 'react-native-progress'; import Layout from '../components/Layout'; import Mission from '../components/Mission'; -import {useAuth} from '../contexts/AuthContext'; import {useUserProfile} from '../api/userService'; import {Dinosaur} from '../svg'; import {useMissions} from '../api/missionService'; -import {transactionService} from '../api/transactionService'; import {useAppSelector} from '../store'; type RootStackParamList = { @@ -41,57 +39,19 @@ const dinoImages: {[key: string]: ImageSourcePropType} = { type NavigationProp = StackNavigationProp; const HomeScreen = () => { - const {user} = useAuth(); const {user: userProfile} = useUserProfile(); const [dinoImage, setDinoImage] = useState(null); const {monthlySaving, currentSaving, selectedDino} = useAppSelector( state => state.settings, ); - const {missions, updateMission, initializeMissions} = useMissions(); - + const {missions} = useMissions(); const navigation = useNavigation(); - const loadUserData = useCallback(async () => { - if (!user?.uid) { - return; - } - - // Load dino image + useEffect(() => { if (selectedDino && dinoImages[selectedDino]) { setDinoImage(dinoImages[selectedDino]); } - - // Load missions - let loadedMissions = await initializeMissions(); - if (loadedMissions.length === 0) { - loadedMissions = await initializeMissions(); - } - - // Check transaction mission - const transactions = await transactionService.getTransactions(); - const hasTransactions = transactions.transactions.length > 0; - if (hasTransactions) { - await updateMission('transaction', true); - } - - // Check income mission - const hasIncome = transactions.transactions.some( - t => t.type === 'INCOME' && t.amount >= 500, - ); - if (hasIncome) { - await updateMission('income', true); - } - }, [user?.uid, selectedDino, initializeMissions, updateMission]); - - useEffect(() => { - loadUserData(); - }, [loadUserData]); - - useFocusEffect( - useCallback(() => { - loadUserData(); - }, [loadUserData]), - ); + }, [selectedDino]); // Calculate progress percentage const progressPercentage = Math.min( @@ -124,13 +84,18 @@ const HomeScreen = () => { - {missions.map((mission, index) => ( - - ))} + 任務清單 + {missions && missions.length > 0 ? ( + missions.map(mission => ( + + )) + ) : ( + 目前沒有任務 + )} { const styles = StyleSheet.create({ content: { flex: 1, - justifyContent: 'center', + justifyContent: 'flex-start', alignItems: 'center', padding: 20, }, @@ -158,13 +123,23 @@ const styles = StyleSheet.create({ paddingHorizontal: 10, }, missionContainer: { - flexDirection: 'column', - justifyContent: 'space-between', width: '100%', paddingHorizontal: 20, marginBottom: 30, marginTop: 10, }, + missionTitle: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 10, + color: '#333', + }, + noMissionsText: { + fontSize: 14, + color: '#666', + textAlign: 'center', + marginTop: 10, + }, button: { backgroundColor: '#007BFF', padding: 10, diff --git a/src/screens/SetUp.tsx b/src/screens/SetUp.tsx index 92ca74d..ae879a8 100644 --- a/src/screens/SetUp.tsx +++ b/src/screens/SetUp.tsx @@ -9,6 +9,7 @@ import { StatusBar, ScrollView, SafeAreaView, + Platform, } from 'react-native'; import * as Progress from 'react-native-progress'; import {useNavigation} from '@react-navigation/native'; @@ -263,7 +264,11 @@ const SetUp = () => { - + 每月收入 { /> - + 每月支出 { /> - + 每月存錢目標 { )} {step === 1 ? '下一步' : '完成'} @@ -360,6 +373,10 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.background, + paddingTop: Platform.select({ + android: StatusBar.currentHeight, + ios: 44, // iOS 的安全區域高度 + }), }, content: { flex: 1, @@ -439,7 +456,10 @@ const styles = StyleSheet.create({ lineHeight: 24, }, inputContainer: { - marginBottom: 20, + width: '100%', + }, + inputContainerStep2: { + width: '48%', }, inputLabel: { fontSize: 16, @@ -494,6 +514,10 @@ const styles = StyleSheet.create({ backgroundColor: '#2196f3', padding: 15, borderRadius: 5, + width: '100%', + }, + nextButtonStep2: { + width: '48%', }, nextButtonText: { color: '#fff', diff --git a/src/screens/TransactionScreen.tsx b/src/screens/TransactionScreen.tsx index 6c0acd7..58eb298 100644 --- a/src/screens/TransactionScreen.tsx +++ b/src/screens/TransactionScreen.tsx @@ -40,6 +40,14 @@ const TransactionScreen: React.FC = () => { transaction_type: 'INCOME' | 'EXPENSE', date: Date, ) => { + console.log( + 'Adding transaction:', + amount, + category, + transaction_type, + date, + ); + const newTransaction: Transaction = { id: Date.now().toString(), amount, @@ -50,16 +58,23 @@ const TransactionScreen: React.FC = () => { }; try { + // Add the transaction + console.log('Dispatching addTransaction'); await addTransaction(newTransaction); - setModalVisible(false); - // Check and update transaction mission - await updateMission('transaction', true); - - // Check and update income mission + // Update mission status based on transaction type if (transaction_type === 'INCOME' && amount >= 500) { + // Only update income mission if it's a new income transaction await updateMission('income', true); + } else if (transaction_type === 'EXPENSE') { + // Only update transaction mission if it's a new expense transaction + await updateMission('transaction', true); } + + // Close modal after transaction is added + setModalVisible(false); + + console.log('Transaction added successfully'); } catch (error) { console.error('Error handling transaction:', error); } diff --git a/src/store/index.ts b/src/store/index.ts index 448dae5..a0ac15d 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,4 +1,4 @@ -import {configureStore} from '@reduxjs/toolkit'; +import {configureStore, Middleware} from '@reduxjs/toolkit'; import {TypedUseSelectorHook, useDispatch, useSelector} from 'react-redux'; import userReducer from './slices/userSlice'; import authReducer from './slices/authSlice'; @@ -6,6 +6,27 @@ import settingsReducer from './slices/settingsSlice'; import transactionReducer from './slices/transactionSlice'; import missionReducer from './slices/missionSlice'; import walletReducer from './slices/walletSlice'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// Middleware to save settings to AsyncStorage +const saveSettingsMiddleware: Middleware = () => next => (action: unknown) => { + const result = next(action); + + // Save settings to AsyncStorage whenever they change + if ( + typeof action === 'object' && + action !== null && + 'type' in action && + typeof action.type === 'string' && + action.type.startsWith('settings/') + ) { + const state = store.getState(); + const settings = state.settings; + AsyncStorage.setItem('userData', JSON.stringify(settings)); + } + + return result; +}; export const store = configureStore({ reducer: { @@ -19,12 +40,11 @@ export const store = configureStore({ middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false, - }), + }).concat(saveSettingsMiddleware), }); export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; -// Use throughout your app instead of plain `useDispatch` and `useSelector` -export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppDispatch = () => useDispatch(); export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/store/slices/settingsSlice.ts b/src/store/slices/settingsSlice.ts index 59c35ef..1bb8843 100644 --- a/src/store/slices/settingsSlice.ts +++ b/src/store/slices/settingsSlice.ts @@ -8,6 +8,8 @@ interface SettingsState { monthlySaving: string | null; currentSaving: string | null; diamonds: number; + budget: number; + saving: number; } const initialState: SettingsState = { @@ -17,12 +19,17 @@ const initialState: SettingsState = { monthlySaving: null, currentSaving: null, diamonds: 5000, + budget: 0, + saving: 0, }; export const settingsSlice = createSlice({ name: 'settings', initialState, reducers: { + setSettings: (state, action: PayloadAction>) => { + return {...state, ...action.payload}; + }, setSetupDone: (state, action: PayloadAction) => { state.setupDone = action.payload; }, @@ -44,6 +51,12 @@ export const settingsSlice = createSlice({ addDiamonds: (state, action: PayloadAction) => { state.diamonds += action.payload; }, + setBudget: (state, action: PayloadAction) => { + state.budget = action.payload; + }, + setSaving: (state, action: PayloadAction) => { + state.saving = action.payload; + }, clearSettings: state => { state.setupDone = false; state.selectedDino = null; @@ -51,11 +64,14 @@ export const settingsSlice = createSlice({ state.monthlySaving = null; state.currentSaving = null; state.diamonds = 5000; + state.budget = 0; + state.saving = 0; }, }, }); export const { + setSettings, setSetupDone, setSelectedDino, setMonthlyIncome, @@ -63,6 +79,8 @@ export const { setCurrentSaving, setDiamonds, addDiamonds, + setBudget, + setSaving, clearSettings, } = settingsSlice.actions; From df606f01b3ffe409e86c5328e5d0615e99293a4d Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 10:56:43 +0800 Subject: [PATCH 11/16] feat: integrate character management with Redux; implement character selection and inventory handling in BagScreen and GachaScreen --- src/screens/BagScreen.tsx | 167 +++++++------ src/screens/GachaScreen.tsx | 281 ++++++++++++++-------- src/screens/SetUp.tsx | 6 +- src/store/index.ts | 2 + src/store/selectors/characterSelectors.ts | 41 ++++ src/store/slices/characterSlice.ts | 98 ++++++++ src/store/store.ts | 11 + src/types/character.ts | 10 + 8 files changed, 445 insertions(+), 171 deletions(-) create mode 100644 src/store/selectors/characterSelectors.ts create mode 100644 src/store/slices/characterSlice.ts create mode 100644 src/store/store.ts create mode 100644 src/types/character.ts diff --git a/src/screens/BagScreen.tsx b/src/screens/BagScreen.tsx index 461e232..4d0d333 100644 --- a/src/screens/BagScreen.tsx +++ b/src/screens/BagScreen.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React from 'react'; import { View, Text, @@ -8,10 +8,14 @@ import { TouchableOpacity, Image, } from 'react-native'; +import {useSelector} from 'react-redux'; import Layout from '../components/Layout'; -//import Character from '../components/Character'; +import { + selectAllCharacters, + selectInventory, +} from '../store/selectors/characterSelectors'; +import {Character} from '../types/character'; -// image (delete) const characterImages = { blue_1: require('../assets/characters/blue_1.png'), blue_2: require('../assets/characters/blue_2.png'), @@ -24,82 +28,77 @@ const characterImages = { main_character: require('../assets/characters/main_character.png'), }; -type CharacterKey = keyof typeof characterImages; -interface CharacterType { - id: number; - key: CharacterKey; - hasOwned: boolean; - isUsed: boolean; -} - const BagScreen = () => { - const [characters, setCharacters] = useState([]); - const [mainCharacterKey, setMainCharacterKey] = - useState('main_character'); + const allCharacters = useSelector(selectAllCharacters); + const inventory = useSelector(selectInventory); + const [selectedCharacter, setSelectedCharacter] = + React.useState(null); - const fetchCharacters = async () => { - // GET /components/characters - const mockData: CharacterType[] = [ - {id: 1, key: 'blue_1', hasOwned: true, isUsed: false}, - {id: 2, key: 'blue_2', hasOwned: true, isUsed: true}, - {id: 3, key: 'green_1', hasOwned: false, isUsed: false}, - {id: 4, key: 'green_2', hasOwned: true, isUsed: false}, - {id: 5, key: 'pink_1', hasOwned: true, isUsed: false}, - {id: 6, key: 'yellow_1', hasOwned: false, isUsed: false}, - {id: 7, key: 'yellow_2', hasOwned: false, isUsed: false}, - {id: 8, key: 'green_3', hasOwned: true, isUsed: false}, - ]; - setCharacters(mockData); - const current = mockData.find(c => c.isUsed); - if (current) {setMainCharacterKey(current.key);} + const handleSelect = (character: Character) => { + setSelectedCharacter(character); }; - const handleSelect = (key: CharacterKey) => { - setMainCharacterKey(key); - setCharacters(prev => prev.map(c => ({...c, isUsed: c.key === key}))); - // await fetch('/user/main-character', { method: 'POST', body: JSON.stringify({ key }) }); + const getCharacterQuantity = (characterId: string) => { + const item = inventory.find(item => item.characterId === characterId); + return item ? item.quantity : 0; }; - useEffect(() => { - fetchCharacters(); - }, []); - return ( - - - + {selectedCharacter && ( + + + + )} ( - item.hasOwned && handleSelect(item.key)} - disabled={!item.hasOwned}> - - {!item.hasOwned && ( - - 未擁有 - - )} - - )} - keyExtractor={item => item.id.toString()} + data={allCharacters} + renderItem={({item: character}) => { + const quantity = getCharacterQuantity(character.id); + const isOwned = quantity > 0; + + return ( + handleSelect(character)}> + + {isOwned ? ( + + x{quantity} + + ) : ( + + 未擁有 + + )} + + ); + }} + keyExtractor={item => item.id} numColumns={3} contentContainerStyle={styles.characterContainer} - scrollEnabled={false} /> @@ -109,27 +108,25 @@ const BagScreen = () => { const styles = StyleSheet.create({ content: { flex: 1, - justifyContent: 'center', - alignItems: 'center', padding: 20, }, characterImg: { - width: 200, - height: 250, + width: '100%', + height: 200, alignItems: 'center', justifyContent: 'center', borderRadius: 20, + marginBottom: 20, }, mainImage: { - width: '100%', - height: '100%', + width: 200, + height: 200, }, characterContainer: { backgroundColor: 'rgba(247, 245, 242, 0.6)', padding: 10, borderRadius: 10, width: '100%', - height: '100%', }, characterItem: { width: 100, @@ -148,20 +145,38 @@ const styles = StyleSheet.create({ borderWidth: 3, borderColor: '#007BFF', }, + unowned: { + opacity: 0.7, + }, + quantityBadge: { + position: 'absolute', + bottom: 0, + right: 0, + backgroundColor: 'rgba(0, 123, 255, 0.8)', + paddingHorizontal: 8, + paddingVertical: 4, + borderTopLeftRadius: 10, + borderBottomRightRadius: 10, + }, + quantityText: { + color: '#fff', + fontSize: 12, + fontWeight: 'bold', + }, lockOverlay: { position: 'absolute', top: 0, left: 0, - width: '100%', - height: '100%', - backgroundColor: 'rgba(0, 0, 0, 0.8)', + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center', borderRadius: 10, }, lockText: { color: '#fff', - fontSize: 16, + fontSize: 12, fontWeight: 'bold', }, }); diff --git a/src/screens/GachaScreen.tsx b/src/screens/GachaScreen.tsx index e0699ee..8a23bfa 100644 --- a/src/screens/GachaScreen.tsx +++ b/src/screens/GachaScreen.tsx @@ -1,89 +1,150 @@ -import React, {useEffect, useState} from 'react'; +import React, {useState} from 'react'; import { + View, + Text, StyleSheet, StatusBar, - FlatList, TouchableOpacity, - Text, - View, + Image, + Animated, + Easing, } from 'react-native'; +import {useDispatch, useSelector} from 'react-redux'; import Layout from '../components/Layout'; -import GachaCard from '../components/GachaCard'; -import {Diamond, ChangeIcon} from '../svg'; - -// image (delete) -const characterImages = [ - require('../assets/characters/blue_1.png'), - require('../assets/characters/blue_2.png'), - require('../assets/characters/green_1.png'), - require('../assets/characters/green_2.png'), - require('../assets/characters/green_3.png'), - require('../assets/characters/pink_1.png'), - require('../assets/characters/yellow_1.png'), - require('../assets/characters/yellow_2.png'), - require('../assets/characters/main_character.png'), - require('../assets/characters/blue_3.png'), - require('../assets/characters/green_4.png'), - require('../assets/characters/green_5.png'), - require('../assets/characters/green_6.png'), - require('../assets/characters/pink_2.png'), - require('../assets/characters/yellow_3.png'), - require('../assets/characters/yellow_4.png'), - require('../assets/characters/main_character2.png'), -]; +import {selectAllCharacters} from '../store/selectors/characterSelectors'; +import {addToInventory} from '../store/slices/characterSlice'; +import {Character} from '../types/character'; + +const characterImages = { + 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'), + main_character: require('../assets/characters/main_character.png'), +}; const GachaScreen = () => { - const [cards, setCards] = useState([]); + const dispatch = useDispatch(); + const allCharacters = useSelector(selectAllCharacters); + const [isSpinning, setIsSpinning] = useState(false); + const [result, setResult] = useState(null); + const [showResult, setShowResult] = useState(false); - const refreshCards = () => { - // Create an array of indices (0 to 17) - const indices = Array.from({length: characterImages.length}, (_, i) => i); + // Animation values + const spinValue = new Animated.Value(0); + const scaleValue = new Animated.Value(1); - // Shuffle the array - for (let i = indices.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [indices[i], indices[j]] = [indices[j], indices[i]]; - } + const spin = spinValue.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'], + }); - // Select the first 9 items - const newCards = indices.slice(0, 9); - setCards(newCards); - }; + const handleGacha = () => { + if (isSpinning) {return;} - const adoptCard = () => { - console.log('領養成功!'); - // fetch('/gacha', { method: 'POST', body: { action: 'adopt' }}) - }; + setIsSpinning(true); + setShowResult(false); + + // Start spinning animation + Animated.loop( + Animated.timing(spinValue, { + toValue: 1, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true, + }), + ).start(); + + // Simulate gacha process + setTimeout(() => { + // Stop spinning + spinValue.stopAnimation(); + spinValue.setValue(0); + + // Randomly select a character + const randomIndex = Math.floor(Math.random() * allCharacters.length); + const selectedCharacter = allCharacters[randomIndex]; - useEffect(() => { - refreshCards(); - }, []); + // Add to inventory + dispatch(addToInventory(selectedCharacter.id)); + + // Show result with animation + setResult(selectedCharacter); + setShowResult(true); + setIsSpinning(false); + + // Pop animation + Animated.sequence([ + Animated.timing(scaleValue, { + toValue: 1.2, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(scaleValue, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + }, 3000); // 3 seconds spinning + }; return ( - - } - keyExtractor={(item, index) => index.toString()} - numColumns={3} - contentContainerStyle={styles.content} - scrollEnabled={false} - /> - - - - 領養 - - 1,000 - - - - - 換一批 20 - + + 抽卡 + + + {isSpinning ? ( + + + + ) : showResult && result ? ( + + + 恭喜獲得! + + ) : ( + + + 點擊下方按鈕開始抽卡 + + )} + + + + {isSpinning ? '抽卡中...' : '抽卡'} + + ); @@ -91,46 +152,78 @@ const GachaScreen = () => { const styles = StyleSheet.create({ content: { - margin: 40, - justifyContent: 'center', + flex: 1, alignItems: 'center', + padding: 20, }, - container: { - flex: 1, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 30, + }, + gachaContainer: { + width: '100%', + height: 300, justifyContent: 'center', alignItems: 'center', + marginBottom: 30, }, - button: { - backgroundColor: '#007BFF', - padding: 10, - borderRadius: 5, - width: 300, - flexDirection: 'row', - alignItems: 'center', + spinningContainer: { + width: 200, + height: 200, justifyContent: 'center', + alignItems: 'center', }, - buttonText: { - color: '#fff', - fontSize: 16, - fontWeight: 'bold', - marginRight: 20, + spinningImage: { + width: '100%', + height: '100%', }, - btnGroup: { - flexDirection: 'column', - alignItems: 'flex-end', + resultContainer: { + width: 200, + height: 200, justifyContent: 'center', + alignItems: 'center', + }, + resultImage: { + width: '100%', + height: '100%', }, - diamond: { - marginRight: 10, + resultText: { + fontSize: 18, + fontWeight: 'bold', + marginTop: 10, + color: '#007BFF', }, - refreshButton: { - flexDirection: 'row', + placeholderContainer: { + width: 200, + height: 200, + justifyContent: 'center', alignItems: 'center', }, - refreshText: { - marginLeft: 6, + placeholderImage: { + width: '100%', + height: '100%', + opacity: 0.5, + }, + placeholderText: { fontSize: 16, - color: '#007BFF', + marginTop: 10, + color: '#666', + }, + gachaButton: { + backgroundColor: '#007BFF', + paddingVertical: 15, + paddingHorizontal: 40, + borderRadius: 10, + marginTop: 20, + }, + disabledButton: { + backgroundColor: '#999', + }, + buttonText: { + color: '#fff', + fontSize: 18, + fontWeight: 'bold', }, }); diff --git a/src/screens/SetUp.tsx b/src/screens/SetUp.tsx index ae879a8..1c0ca18 100644 --- a/src/screens/SetUp.tsx +++ b/src/screens/SetUp.tsx @@ -18,6 +18,8 @@ import {useAuth} from '../contexts/AuthContext'; import {useAppDispatch} from '../store'; import {settingsSlice} from '../store/slices/settingsSlice'; import {colors} from '../theme/colors'; +import {useDispatch} from 'react-redux'; +import {addToInventory} from '../store/slices/characterSlice'; type RootStackParamList = { MainTabs: undefined; @@ -38,6 +40,7 @@ const SetUp = () => { const navigation = useNavigation(); const {user} = useAuth(); const dispatch = useAppDispatch(); + const redDispatch = useDispatch(); const handleNumberInput = ( value: string, @@ -199,6 +202,7 @@ const SetUp = () => { const handleNext = async () => { if (step === 1) { if (selectedDino) { + redDispatch(addToInventory(selectedDino.imageKey)); setStep(2); } } else if (step === 2) { @@ -459,7 +463,7 @@ const styles = StyleSheet.create({ width: '100%', }, inputContainerStep2: { - width: '48%', + width: '100%', }, inputLabel: { fontSize: 16, diff --git a/src/store/index.ts b/src/store/index.ts index a0ac15d..82585f2 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -6,6 +6,7 @@ import settingsReducer from './slices/settingsSlice'; import transactionReducer from './slices/transactionSlice'; import missionReducer from './slices/missionSlice'; import walletReducer from './slices/walletSlice'; +import characterReducer from './slices/characterSlice'; import AsyncStorage from '@react-native-async-storage/async-storage'; // Middleware to save settings to AsyncStorage @@ -36,6 +37,7 @@ export const store = configureStore({ transactions: transactionReducer, missions: missionReducer, wallet: walletReducer, + character: characterReducer, }, middleware: getDefaultMiddleware => getDefaultMiddleware({ diff --git a/src/store/selectors/characterSelectors.ts b/src/store/selectors/characterSelectors.ts new file mode 100644 index 0000000..3a6435e --- /dev/null +++ b/src/store/selectors/characterSelectors.ts @@ -0,0 +1,41 @@ +import {RootState} from '../store'; +import {Character, CharacterInventory} from '../../types/character'; + +export const selectAllCharacters = (state: RootState): Character[] => + state.character.characters; + +export const selectCharacterById = ( + state: RootState, + id: string, +): Character | undefined => + state.character.characters.find( + (character: Character) => character.id === id, + ); + +export const selectInventory = (state: RootState): CharacterInventory[] => + state.character.inventory; + +export const selectInventoryItem = ( + state: RootState, + characterId: string, +): CharacterInventory | undefined => + state.character.inventory.find( + (item: CharacterInventory) => item.characterId === characterId, + ); + +export const selectCharacterWithInventory = ( + state: RootState, +): (Character & {quantity: number})[] => { + return state.character.inventory + .map((inventoryItem: CharacterInventory) => { + const character = state.character.characters.find( + (c: Character) => c.id === inventoryItem.characterId, + ); + if (!character) {return null;} + return { + ...character, + quantity: inventoryItem.quantity, + }; + }) + .filter((item): item is Character & {quantity: number} => item !== null); +}; diff --git a/src/store/slices/characterSlice.ts b/src/store/slices/characterSlice.ts new file mode 100644 index 0000000..9e13a89 --- /dev/null +++ b/src/store/slices/characterSlice.ts @@ -0,0 +1,98 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import {Character, CharacterInventory} from '../../types/character'; + +interface CharacterState { + characters: Character[]; + inventory: CharacterInventory[]; + isLoading: boolean; + error: string | null; +} + +const initialState: CharacterState = { + characters: [ + { + id: 'blue_1', + imageUrl: '/assets/characters/blue_1.png', + }, + { + id: 'blue_2', + imageUrl: '/assets/characters/blue_2.png', + }, + { + id: 'green_1', + imageUrl: '/assets/characters/green_1.png', + }, + { + id: 'green_2', + imageUrl: '/assets/characters/green_2.png', + }, + { + id: 'green_3', + imageUrl: '/assets/characters/green_3.png', + }, + { + id: 'pink_1', + imageUrl: '/assets/characters/pink_1.png', + }, + { + id: 'yellow_1', + imageUrl: '/assets/characters/yellow_1.png', + }, + { + id: 'yellow_2', + imageUrl: '/assets/characters/yellow_2.png', + }, + ], + inventory: [], + isLoading: false, + error: null, +}; + +const characterSlice = createSlice({ + name: 'character', + initialState, + reducers: { + addToInventory: (state, action: PayloadAction) => { + const characterId = action.payload; + const existingItem = state.inventory.find( + item => item.characterId === characterId, + ); + + if (existingItem) { + existingItem.quantity += 1; + } else { + state.inventory.push({ + characterId, + quantity: 1, + obtainedAt: new Date(), + }); + } + }, + removeFromInventory: (state, action: PayloadAction) => { + const characterId = action.payload; + const existingItem = state.inventory.find( + item => item.characterId === characterId, + ); + + if (existingItem) { + if (existingItem.quantity > 1) { + existingItem.quantity -= 1; + } else { + state.inventory = state.inventory.filter( + item => item.characterId !== characterId, + ); + } + } + }, + setLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + }, +}); + +export const {addToInventory, removeFromInventory, setLoading, setError} = + characterSlice.actions; +export default characterSlice.reducer; diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..d70ec49 --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,11 @@ +import {configureStore} from '@reduxjs/toolkit'; +import characterReducer from './slices/characterSlice'; + +export const store = configureStore({ + reducer: { + character: characterReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/types/character.ts b/src/types/character.ts new file mode 100644 index 0000000..dc8986b --- /dev/null +++ b/src/types/character.ts @@ -0,0 +1,10 @@ +export interface Character { + id: string; + imageUrl: string; +} + +export interface CharacterInventory { + characterId: string; + quantity: number; + obtainedAt: Date; +} From 751c30ca8f53e680f6c540551f681716bb215d4f Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 11:07:13 +0800 Subject: [PATCH 12/16] feat: enhance BagScreen with character selection logic; integrate Redux for selected character management and improve UI for character preview --- src/screens/BagScreen.tsx | 169 +++++++++++++++++++++++++------------- 1 file changed, 113 insertions(+), 56 deletions(-) diff --git a/src/screens/BagScreen.tsx b/src/screens/BagScreen.tsx index 4d0d333..ce0e674 100644 --- a/src/screens/BagScreen.tsx +++ b/src/screens/BagScreen.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useEffect} from 'react'; import { View, Text, @@ -8,13 +8,15 @@ import { TouchableOpacity, Image, } from 'react-native'; -import {useSelector} from 'react-redux'; +import {useSelector, useDispatch} from 'react-redux'; import Layout from '../components/Layout'; import { selectAllCharacters, selectInventory, } from '../store/selectors/characterSelectors'; import {Character} from '../types/character'; +import {useAppSelector} from '../store'; +import {setSelectedDino} from '../store/slices/settingsSlice'; const characterImages = { blue_1: require('../assets/characters/blue_1.png'), @@ -29,17 +31,33 @@ const characterImages = { }; const BagScreen = () => { + const dispatch = useDispatch(); const allCharacters = useSelector(selectAllCharacters); const inventory = useSelector(selectInventory); + const {selectedDino} = useAppSelector(state => state.settings); const [selectedCharacter, setSelectedCharacter] = React.useState(null); + useEffect(() => { + // Update selected character when selectedDino changes + if (selectedDino) { + const character = allCharacters.find(char => char.id === selectedDino); + if (character) { + setSelectedCharacter(character); + } + } + }, [selectedDino, allCharacters]); + const handleSelect = (character: Character) => { - setSelectedCharacter(character); + const quantity = getCharacterQuantity(character.id); + if (quantity > 0) { + setSelectedCharacter(character); + dispatch(setSelectedDino(character.id)); + } }; const getCharacterQuantity = (characterId: string) => { - const item = inventory.find(item => item.characterId === characterId); + const item = inventory.find(it => it.characterId === characterId); return item ? item.quantity : 0; }; @@ -47,59 +65,69 @@ const BagScreen = () => { - {selectedCharacter && ( - - - - )} + + 當前夥伴角色 + {selectedCharacter ? ( + + + + ) : ( + + 尚未選擇夥伴角色 + + )} + - { - const quantity = getCharacterQuantity(character.id); - const isOwned = quantity > 0; + + 角色列表 + { + const quantity = getCharacterQuantity(character.id); + const isOwned = quantity > 0; - return ( - handleSelect(character)}> - - {isOwned ? ( - - x{quantity} - - ) : ( - - 未擁有 - - )} - - ); - }} - keyExtractor={item => item.id} - numColumns={3} - contentContainerStyle={styles.characterContainer} - /> + return ( + handleSelect(character)}> + + {isOwned ? ( + + x{quantity} + + ) : ( + + 未擁有 + + )} + + ); + }} + keyExtractor={item => item.id} + numColumns={3} + contentContainerStyle={styles.characterContainer} + /> + ); @@ -110,18 +138,47 @@ const styles = StyleSheet.create({ flex: 1, padding: 20, }, + previewSection: { + marginBottom: 20, + }, + previewTitle: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 10, + textAlign: 'center', + }, characterImg: { width: '100%', height: 200, alignItems: 'center', justifyContent: 'center', borderRadius: 20, - marginBottom: 20, + backgroundColor: 'rgba(247, 245, 242, 0.6)', }, mainImage: { width: 200, height: 200, }, + emptyPreview: { + width: '100%', + height: 200, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 20, + backgroundColor: 'rgba(247, 245, 242, 0.6)', + }, + emptyPreviewText: { + fontSize: 16, + color: '#666', + }, + characterListSection: { + flex: 1, + }, + listTitle: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 10, + }, characterContainer: { backgroundColor: 'rgba(247, 245, 242, 0.6)', padding: 10, From 9ff61beff86b34cc7eb1a33d134c07e8283cb1a2 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 11:14:16 +0800 Subject: [PATCH 13/16] feat: implement diamond cost validation in GachaScreen; alert user for insufficient diamonds and update inventory handling with diamond deduction --- src/screens/BagScreen.tsx | 6 ++++- src/screens/GachaScreen.tsx | 52 +++++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/screens/BagScreen.tsx b/src/screens/BagScreen.tsx index ce0e674..3a65c27 100644 --- a/src/screens/BagScreen.tsx +++ b/src/screens/BagScreen.tsx @@ -62,7 +62,7 @@ const BagScreen = () => { }; return ( - + @@ -126,6 +126,7 @@ const BagScreen = () => { keyExtractor={item => item.id} numColumns={3} contentContainerStyle={styles.characterContainer} + ListFooterComponent={} /> @@ -236,6 +237,9 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: 'bold', }, + footer: { + height: 20, + }, }); export default BagScreen; diff --git a/src/screens/GachaScreen.tsx b/src/screens/GachaScreen.tsx index 8a23bfa..a136be7 100644 --- a/src/screens/GachaScreen.tsx +++ b/src/screens/GachaScreen.tsx @@ -8,12 +8,18 @@ import { Image, Animated, Easing, + Alert, } from 'react-native'; import {useDispatch, useSelector} from 'react-redux'; import Layout from '../components/Layout'; import {selectAllCharacters} from '../store/selectors/characterSelectors'; import {addToInventory} from '../store/slices/characterSlice'; import {Character} from '../types/character'; +import {useAppSelector} from '../store'; +import {setDiamonds} from '../store/slices/settingsSlice'; +import {Diamond} from '../svg'; + +const GACHA_COST = 1000; const characterImages = { blue_1: require('../assets/characters/blue_1.png'), @@ -30,6 +36,7 @@ const characterImages = { const GachaScreen = () => { const dispatch = useDispatch(); const allCharacters = useSelector(selectAllCharacters); + const {diamonds} = useAppSelector(state => state.settings); const [isSpinning, setIsSpinning] = useState(false); const [result, setResult] = useState(null); const [showResult, setShowResult] = useState(false); @@ -44,7 +51,13 @@ const GachaScreen = () => { }); const handleGacha = () => { - if (isSpinning) {return;} + if (isSpinning) { + return; + } + if (diamonds < GACHA_COST) { + Alert.alert('鑽石不足', '需要 100 鑽石才能抽卡'); + return; + } setIsSpinning(true); setShowResult(false); @@ -69,8 +82,9 @@ const GachaScreen = () => { const randomIndex = Math.floor(Math.random() * allCharacters.length); const selectedCharacter = allCharacters[randomIndex]; - // Add to inventory + // Add to inventory and deduct diamonds dispatch(addToInventory(selectedCharacter.id)); + dispatch(setDiamonds(diamonds - GACHA_COST)); // Show result with animation setResult(selectedCharacter); @@ -138,11 +152,20 @@ const GachaScreen = () => { + disabled={isSpinning || diamonds < GACHA_COST}> {isSpinning ? '抽卡中...' : '抽卡'} + {!isSpinning && ( + <> + + 1,000 + + )} @@ -159,7 +182,23 @@ const styles = StyleSheet.create({ title: { fontSize: 24, fontWeight: 'bold', - marginBottom: 30, + marginBottom: 20, + }, + diamondInfo: { + flexDirection: 'row', + justifyContent: 'space-between', + width: '100%', + marginBottom: 20, + paddingHorizontal: 20, + }, + diamondText: { + fontSize: 16, + color: '#007BFF', + fontWeight: 'bold', + }, + costText: { + fontSize: 16, + color: '#666', }, gachaContainer: { width: '100%', @@ -225,6 +264,9 @@ const styles = StyleSheet.create({ fontSize: 18, fontWeight: 'bold', }, + diamond: { + marginRight: 5, + }, }); export default GachaScreen; From dc49a1612ef1ce2bc34246fcddcdf592b74b91e8 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 11:39:58 +0800 Subject: [PATCH 14/16] fix: update diamond cost in GachaScreen alert and UI; change required diamonds from 100 to 1000 and improve button layout for better user experience --- src/screens/GachaScreen.tsx | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/screens/GachaScreen.tsx b/src/screens/GachaScreen.tsx index a136be7..826a244 100644 --- a/src/screens/GachaScreen.tsx +++ b/src/screens/GachaScreen.tsx @@ -55,7 +55,7 @@ const GachaScreen = () => { return; } if (diamonds < GACHA_COST) { - Alert.alert('鑽石不足', '需要 100 鑽石才能抽卡'); + Alert.alert('鑽石不足', '需要 1000 鑽石才能抽卡'); return; } @@ -158,15 +158,17 @@ const GachaScreen = () => { ]} onPress={handleGacha} disabled={isSpinning || diamonds < GACHA_COST}> - - {isSpinning ? '抽卡中...' : '抽卡'} + + + {isSpinning ? '抽卡中...' : '抽卡'} + {!isSpinning && ( - <> + 1,000 - + )} - + @@ -259,11 +261,21 @@ const styles = StyleSheet.create({ disabledButton: { backgroundColor: '#999', }, + buttonContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, buttonText: { color: '#fff', fontSize: 18, fontWeight: 'bold', }, + costContainer: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 5, + }, diamond: { marginRight: 5, }, From a205d9664623fd9807cb485ada3b9cab13f20f33 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 11:42:49 +0800 Subject: [PATCH 15/16] feat: add character rotation animation in GachaScreen; implement currentCharacterIndex state to manage character display during spinning --- src/screens/GachaScreen.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/screens/GachaScreen.tsx b/src/screens/GachaScreen.tsx index 826a244..98f487d 100644 --- a/src/screens/GachaScreen.tsx +++ b/src/screens/GachaScreen.tsx @@ -37,6 +37,7 @@ const GachaScreen = () => { const dispatch = useDispatch(); const allCharacters = useSelector(selectAllCharacters); const {diamonds} = useAppSelector(state => state.settings); + const [currentCharacterIndex, setCurrentCharacterIndex] = useState(0); const [isSpinning, setIsSpinning] = useState(false); const [result, setResult] = useState(null); const [showResult, setShowResult] = useState(false); @@ -61,6 +62,12 @@ const GachaScreen = () => { setIsSpinning(true); setShowResult(false); + setCurrentCharacterIndex(0); + + // Start character rotation animation + const characterInterval = setInterval(() => { + setCurrentCharacterIndex(prev => (prev + 1) % allCharacters.length); + }, 100); // Start spinning animation Animated.loop( @@ -74,7 +81,8 @@ const GachaScreen = () => { // Simulate gacha process setTimeout(() => { - // Stop spinning + // Stop spinning and character rotation + clearInterval(characterInterval); spinValue.stopAnimation(); spinValue.setValue(0); @@ -118,7 +126,12 @@ const GachaScreen = () => { From ea152d509a7684bf2e2b7c98f2276d243a2eca56 Mon Sep 17 00:00:00 2001 From: rice Date: Sat, 26 Apr 2025 11:51:19 +0800 Subject: [PATCH 16/16] feat: add character images and definitions; refactor character image handling in screens to utilize centralized characterImages module --- src/constants/characterImages.ts | 17 ++++++++ src/constants/characters.ts | 66 ++++++++++++++++++++++++++++++ src/screens/BagScreen.tsx | 25 ++--------- src/screens/GachaScreen.tsx | 29 +++---------- src/screens/HomeScreen.tsx | 17 ++------ src/screens/SetUp.tsx | 58 +------------------------- src/store/slices/characterSlice.ts | 39 +++--------------- 7 files changed, 102 insertions(+), 149 deletions(-) create mode 100644 src/constants/characterImages.ts create mode 100644 src/constants/characters.ts diff --git a/src/constants/characterImages.ts b/src/constants/characterImages.ts new file mode 100644 index 0000000..15d21ab --- /dev/null +++ b/src/constants/characterImages.ts @@ -0,0 +1,17 @@ +import {ImageSourcePropType} from 'react-native'; + +export const characterImages: {[key: string]: ImageSourcePropType} = { + 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'), + main_character: require('../assets/characters/main_character.png'), +}; + +export const getCharacterImage = (id: string): ImageSourcePropType => { + return characterImages[id] || characterImages.main_character; +}; diff --git a/src/constants/characters.ts b/src/constants/characters.ts new file mode 100644 index 0000000..2d76130 --- /dev/null +++ b/src/constants/characters.ts @@ -0,0 +1,66 @@ +import {ImageSourcePropType} from 'react-native'; +import {getCharacterImage} from './characterImages'; + +export interface CharacterDefinition { + id: string; + name: string; + imageKey: string; + image: ImageSourcePropType; +} + +export const characters: CharacterDefinition[] = [ + { + id: 'blue_1', + name: '寵物一', + imageKey: 'blue_1', + image: getCharacterImage('blue_1'), + }, + { + id: 'blue_2', + name: '寵物二', + imageKey: 'blue_2', + image: getCharacterImage('blue_2'), + }, + { + id: 'green_1', + name: '寵物三', + imageKey: 'green_1', + image: getCharacterImage('green_1'), + }, + { + id: 'green_2', + name: '寵物四', + imageKey: 'green_2', + image: getCharacterImage('green_2'), + }, + { + id: 'green_3', + name: '寵物五', + imageKey: 'green_3', + image: getCharacterImage('green_3'), + }, + { + id: 'main_character', + name: '寵物六', + imageKey: 'main_character', + image: getCharacterImage('main_character'), + }, + { + id: 'pink_1', + name: '寵物七', + imageKey: 'pink_1', + image: getCharacterImage('pink_1'), + }, + { + id: 'yellow_1', + name: '寵物八', + imageKey: 'yellow_1', + image: getCharacterImage('yellow_1'), + }, + { + id: 'yellow_2', + name: '寵物九', + imageKey: 'yellow_2', + image: getCharacterImage('yellow_2'), + }, +]; diff --git a/src/screens/BagScreen.tsx b/src/screens/BagScreen.tsx index 3a65c27..dbe69b5 100644 --- a/src/screens/BagScreen.tsx +++ b/src/screens/BagScreen.tsx @@ -17,18 +17,7 @@ import { import {Character} from '../types/character'; import {useAppSelector} from '../store'; import {setSelectedDino} from '../store/slices/settingsSlice'; - -const characterImages = { - 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'), - main_character: require('../assets/characters/main_character.png'), -}; +import {getCharacterImage} from '../constants/characterImages'; const BagScreen = () => { const dispatch = useDispatch(); @@ -70,11 +59,7 @@ const BagScreen = () => { {selectedCharacter ? ( @@ -103,11 +88,7 @@ const BagScreen = () => { ]} onPress={() => handleSelect(character)}> diff --git a/src/screens/GachaScreen.tsx b/src/screens/GachaScreen.tsx index 98f487d..3dd9178 100644 --- a/src/screens/GachaScreen.tsx +++ b/src/screens/GachaScreen.tsx @@ -18,21 +18,10 @@ import {Character} from '../types/character'; import {useAppSelector} from '../store'; import {setDiamonds} from '../store/slices/settingsSlice'; import {Diamond} from '../svg'; +import {getCharacterImage} from '../constants/characterImages'; const GACHA_COST = 1000; -const characterImages = { - 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'), - main_character: require('../assets/characters/main_character.png'), -}; - const GachaScreen = () => { const dispatch = useDispatch(); const allCharacters = useSelector(selectAllCharacters); @@ -126,12 +115,9 @@ const GachaScreen = () => { @@ -143,10 +129,7 @@ const GachaScreen = () => { {transform: [{scale: scaleValue}]}, ]}> @@ -155,7 +138,7 @@ const GachaScreen = () => { ) : ( diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 1274919..38e4744 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -19,23 +19,12 @@ import {useUserProfile} from '../api/userService'; import {Dinosaur} from '../svg'; import {useMissions} from '../api/missionService'; import {useAppSelector} from '../store'; +import {getCharacterImage} from '../constants/characterImages'; type RootStackParamList = { TransactionScreen: undefined; }; -const dinoImages: {[key: string]: ImageSourcePropType} = { - 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'), - main_character: require('../assets/characters/main_character.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'), -}; - type NavigationProp = StackNavigationProp; const HomeScreen = () => { @@ -48,8 +37,8 @@ const HomeScreen = () => { const navigation = useNavigation(); useEffect(() => { - if (selectedDino && dinoImages[selectedDino]) { - setDinoImage(dinoImages[selectedDino]); + if (selectedDino && getCharacterImage(selectedDino)) { + setDinoImage(getCharacterImage(selectedDino)); } }, [selectedDino]); diff --git a/src/screens/SetUp.tsx b/src/screens/SetUp.tsx index 1c0ca18..a6e28d4 100644 --- a/src/screens/SetUp.tsx +++ b/src/screens/SetUp.tsx @@ -20,6 +20,7 @@ import {settingsSlice} from '../store/slices/settingsSlice'; import {colors} from '../theme/colors'; import {useDispatch} from 'react-redux'; import {addToInventory} from '../store/slices/characterSlice'; +import {characters} from '../constants/characters'; type RootStackParamList = { MainTabs: undefined; @@ -142,62 +143,7 @@ const SetUp = () => { }; // 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 dinosaurs = characters; const handleNext = async () => { if (step === 1) { diff --git a/src/store/slices/characterSlice.ts b/src/store/slices/characterSlice.ts index 9e13a89..22cf5f3 100644 --- a/src/store/slices/characterSlice.ts +++ b/src/store/slices/characterSlice.ts @@ -1,5 +1,6 @@ import {createSlice, PayloadAction} from '@reduxjs/toolkit'; import {Character, CharacterInventory} from '../../types/character'; +import {characters} from '../../constants/characters'; interface CharacterState { characters: Character[]; @@ -9,40 +10,10 @@ interface CharacterState { } const initialState: CharacterState = { - characters: [ - { - id: 'blue_1', - imageUrl: '/assets/characters/blue_1.png', - }, - { - id: 'blue_2', - imageUrl: '/assets/characters/blue_2.png', - }, - { - id: 'green_1', - imageUrl: '/assets/characters/green_1.png', - }, - { - id: 'green_2', - imageUrl: '/assets/characters/green_2.png', - }, - { - id: 'green_3', - imageUrl: '/assets/characters/green_3.png', - }, - { - id: 'pink_1', - imageUrl: '/assets/characters/pink_1.png', - }, - { - id: 'yellow_1', - imageUrl: '/assets/characters/yellow_1.png', - }, - { - id: 'yellow_2', - imageUrl: '/assets/characters/yellow_2.png', - }, - ], + characters: characters.map(char => ({ + id: char.id, + imageUrl: char.imageKey, + })), inventory: [], isLoading: false, error: null,