diff --git a/App.tsx b/App.tsx index 91a2242..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'; @@ -8,6 +8,9 @@ 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'; +import {initializationService} from './src/api/initializationService'; const firebaseConfig = { apiKey: Config.FIREBASE_API_KEY || '', @@ -27,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 ; } @@ -45,12 +63,15 @@ const AppContent = () => { }; function App(): React.JSX.Element { + console.log('App rendering...'); return ( - - - - - + + + + + + + ); } diff --git a/package.json b/package.json index a3023c2..a365643 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "@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", + "@reduxjs/toolkit": "^2.7.0", "axios": "^1.8.4", "react": "19.0.0", "react-native": "0.78.0", @@ -37,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": { @@ -50,6 +53,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/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/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/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/missionService.ts b/src/api/missionService.ts new file mode 100644 index 0000000..cfff13e --- /dev/null +++ b/src/api/missionService.ts @@ -0,0 +1,115 @@ +import {useAppDispatch, useAppSelector} from '../store'; +import {missionSlice, Mission} from '../store/slices/missionSlice'; +import {settingsSlice} from '../store/slices/settingsSlice'; + +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 => { + return defaultMissions; + }, + + updateMissionStatus: async ( + missionId: string, + isCompleted: boolean, + currentMissions: Mission[], + ) => { + const mission = currentMissions.find(m => m.id === missionId); + + if (!mission) { + return currentMissions; + } + + // 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; + }, +}; + +export const useMissions = () => { + const dispatch = useAppDispatch(); + 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)); + 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 bf4c819..ac0ce20 100644 --- a/src/api/transactionService.ts +++ b/src/api/transactionService.ts @@ -1,29 +1,40 @@ -import apiClient from './client'; -import {API_ENDPOINTS} from './endpoints'; +import {useAppDispatch, useAppSelector} from '../store'; +import {transactionSlice, Transaction} from '../store/slices/transactionSlice'; -export interface Transaction { - amount: number; - description: string; - date: string; - category: string; - transaction_type: 'Income' | 'Expense'; -} - -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: Date.now().toString(), + }; + return newTransaction; }, getTransactions: async (): Promise => { - const response = await apiClient.get(API_ENDPOINTS.TRANSACTION); - return response.data; + // Get transactions from Redux store + const state = require('../store').store.getState(); + return {transactions: state.transactions.transactions}; }, }; + +export const useTransactions = () => { + 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, + addTransaction, + }; +}; diff --git a/src/api/userService.ts b/src/api/userService.ts index 9231866..63a0b2c 100644 --- a/src/api/userService.ts +++ b/src/api/userService.ts @@ -1,58 +1,58 @@ -import apiClient from './client'; -import {API_ENDPOINTS} from './endpoints'; -import useSWR from 'swr'; -import {useAuth} from '../contexts/AuthContext'; +import {useState, useEffect} from 'react'; +import {useAppDispatch, useAppSelector} from '../store'; +import {settingsSlice} from '../store/slices/settingsSlice'; interface UserProfileResponse { wallet: { diamonds: number; - saving: number; }; } export const userService = { getUserProfile: async (): Promise => { - const response = await apiClient.get(API_ENDPOINTS.USER_ME); - return response.data; + const store = require('../store').store; + const state = store.getState(); + return { + wallet: { + diamonds: state.settings.diamonds, + }, + }; }, - updateUserProfile: async (userData: any) => { - const response = await apiClient.put(API_ENDPOINTS.USER_UPDATE, userData); - return response.data; + updateDiamonds: async (amount: number): Promise => { + const store = require('../store').store; + const state = store.getState(); + return { + wallet: { + diamonds: state.settings.diamonds + amount, + }, + }; }, }; export const useUserProfile = () => { - const {serverToken} = useAuth(); - const {data, error, isLoading, mutate} = useSWR( - serverToken ? API_ENDPOINTS.USER_ME : null, - ); - - return { - user: data, - isLoading, - isError: error, - mutate, - }; -}; + const dispatch = useAppDispatch(); + const diamonds = useAppSelector(state => state.settings.diamonds); + const [user, setUser] = useState(null); -export const useUserProfileManager = () => { - const {user, isLoading, isError, mutate} = useUserProfile(); + useEffect(() => { + const loadUserProfile = async () => { + const profile = await userService.getUserProfile(); + setUser(profile); + }; - const updateProfile = async (userData: any) => { + loadUserProfile(); + }, [diamonds]); + + const updateDiamonds = async (amount: number) => { try { - const updatedUser = await userService.updateUserProfile(userData); - mutate(updatedUser, false); - return updatedUser; + dispatch(settingsSlice.actions.addDiamonds(amount)); + const updatedProfile = await userService.updateDiamonds(amount); + setUser(updatedProfile); } catch (error) { - throw error; + console.error('Error updating diamonds:', error); } }; - return { - user, - isLoading, - isError, - updateProfile, - }; + return {user, setUser, diamonds, updateDiamonds}; }; 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 ff08968..a314a52 100644 --- a/src/components/Mission.tsx +++ b/src/components/Mission.tsx @@ -1,36 +1,40 @@ -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 type {Mission as MissionType} from '../store/slices/missionSlice'; type MissionProps = { - mission: { - title: string; - amount: number; - isCompleted: boolean; - }; + mission: MissionType; + diamonds: number; }; const Mission = ({mission}: MissionProps) => { - // const [isSelected, setSelection] = useState(false); - return ( {mission.isCompleted = isChecked}} + useBuiltInState={true} size={18} disableText={true} + fillColor="#007BFF" + unFillColor="#FFFFFF" + iconStyle={styles.checkboxIcon} + disabled={true} /> - {mission.title} + + {mission.title} + - {mission.amount} + {mission.amount} ); @@ -38,7 +42,6 @@ const Mission = ({mission}: MissionProps) => { const styles = StyleSheet.create({ mission: { - flex: 1, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', @@ -48,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, @@ -58,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/components/TransactionForm.tsx b/src/components/TransactionForm.tsx index de796b7..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,7 +12,10 @@ const categories: string[] = [ '水電', '其他', ]; -type TransactionTypes = 'Income' | 'Expense'; + +const incomeCategories: string[] = ['薪資', '獎金', '投資', '兼職', '其他']; + +type TransactionTypes = 'INCOME' | 'EXPENSE'; interface TransactionFormProps { onSubmit: ( @@ -24,15 +27,15 @@ interface TransactionFormProps { } const TransactionTypeLabels: Record = { - Income: '收入', - Expense: '支出', + INCOME: '收入', + EXPENSE: '支出', }; const TransactionForm: React.FC = ({onSubmit}) => { const [selectedCategory, setSelectedCategory] = useState( - categories[0], + expenseCategories[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); @@ -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 => ( { + 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/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 6fd5947..baf06a5 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,89 @@ 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) { - return false; - } - - setTokenExchangeError(null); - const response = await authService.refreshToken(refreshToken); + useEffect(() => { + GoogleSignin.configure({ + webClientId: Config.GOOGLE_WEB_CLIENT_ID, + }); + }, []); - await AsyncStorage.setItem('userToken', response.access_token); - await AsyncStorage.setItem('refreshToken', response.refresh_token); + const refreshAccessToken = useCallback( + async (_: string) => { + try { + if (!refreshToken) { + return false; + } - 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; - } - }; + 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], + ); - 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 +141,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 currentUser => { + setUser(currentUser); + if (currentUser) { + try { + const firebaseToken = await currentUser.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 +293,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 +313,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 +441,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/mock/data.ts b/src/mock/data.ts new file mode 100644 index 0000000..bda7e9e --- /dev/null +++ b/src/mock/data.ts @@ -0,0 +1,56 @@ +let mockUserProfile = { + wallet: { + diamonds: 5000, + saving: 50000, + }, +}; + +export const updateMockUserProfile = ( + updates: Partial, +) => { + mockUserProfile = { + ...mockUserProfile, + ...updates, + }; + return mockUserProfile; +}; + +export {mockUserProfile}; + +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/BagScreen.tsx b/src/screens/BagScreen.tsx index 461e232..dbe69b5 100644 --- a/src/screens/BagScreen.tsx +++ b/src/screens/BagScreen.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import { View, Text, @@ -8,99 +8,108 @@ import { TouchableOpacity, Image, } from 'react-native'; +import {useSelector, useDispatch} from 'react-redux'; import Layout from '../components/Layout'; -//import Character from '../components/Character'; - -// image (delete) -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'), -}; - -type CharacterKey = keyof typeof characterImages; -interface CharacterType { - id: number; - key: CharacterKey; - hasOwned: boolean; - isUsed: boolean; -} +import { + selectAllCharacters, + selectInventory, +} from '../store/selectors/characterSelectors'; +import {Character} from '../types/character'; +import {useAppSelector} from '../store'; +import {setSelectedDino} from '../store/slices/settingsSlice'; +import {getCharacterImage} from '../constants/characterImages'; const BagScreen = () => { - const [characters, setCharacters] = useState([]); - const [mainCharacterKey, setMainCharacterKey] = - useState('main_character'); + const dispatch = useDispatch(); + const allCharacters = useSelector(selectAllCharacters); + const inventory = useSelector(selectInventory); + const {selectedDino} = useAppSelector(state => state.settings); + 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);} - }; + 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 = (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 handleSelect = (character: Character) => { + const quantity = getCharacterQuantity(character.id); + if (quantity > 0) { + setSelectedCharacter(character); + dispatch(setSelectedDino(character.id)); + } }; - useEffect(() => { - fetchCharacters(); - }, []); + const getCharacterQuantity = (characterId: string) => { + const item = inventory.find(it => it.characterId === characterId); + return item ? item.quantity : 0; + }; return ( - + - - - - - ( - item.hasOwned && handleSelect(item.key)} - disabled={!item.hasOwned}> + + 當前夥伴角色 + {selectedCharacter ? ( + - {!item.hasOwned && ( - - 未擁有 - - )} - + + ) : ( + + 尚未選擇夥伴角色 + )} - keyExtractor={item => item.id.toString()} - numColumns={3} - contentContainerStyle={styles.characterContainer} - scrollEnabled={false} - /> + + + + 角色列表 + { + const quantity = getCharacterQuantity(character.id); + const isOwned = quantity > 0; + + return ( + handleSelect(character)}> + + {isOwned ? ( + + x{quantity} + + ) : ( + + 未擁有 + + )} + + ); + }} + keyExtractor={item => item.id} + numColumns={3} + contentContainerStyle={styles.characterContainer} + ListFooterComponent={} + /> + ); @@ -109,27 +118,54 @@ const BagScreen = () => { const styles = StyleSheet.create({ content: { flex: 1, - justifyContent: 'center', - alignItems: 'center', padding: 20, }, + previewSection: { + marginBottom: 20, + }, + previewTitle: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 10, + textAlign: 'center', + }, characterImg: { - width: 200, - height: 250, + width: '100%', + height: 200, alignItems: 'center', justifyContent: 'center', borderRadius: 20, + backgroundColor: 'rgba(247, 245, 242, 0.6)', }, mainImage: { + width: 200, + height: 200, + }, + emptyPreview: { width: '100%', - height: '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, borderRadius: 10, width: '100%', - height: '100%', }, characterItem: { width: 100, @@ -148,22 +184,43 @@ 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', }, + footer: { + height: 20, + }, }); export default BagScreen; diff --git a/src/screens/GachaScreen.tsx b/src/screens/GachaScreen.tsx index e0699ee..3dd9178 100644 --- a/src/screens/GachaScreen.tsx +++ b/src/screens/GachaScreen.tsx @@ -1,89 +1,171 @@ -import React, {useEffect, useState} from 'react'; +import React, {useState} from 'react'; import { + View, + Text, StyleSheet, StatusBar, - FlatList, TouchableOpacity, - Text, - View, + Image, + Animated, + Easing, + Alert, } 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'; +import {useAppSelector} from '../store'; +import {setDiamonds} from '../store/slices/settingsSlice'; +import {Diamond} from '../svg'; +import {getCharacterImage} from '../constants/characterImages'; + +const GACHA_COST = 1000; const GachaScreen = () => { - const [cards, setCards] = useState([]); + 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); + + // Animation values + const spinValue = new Animated.Value(0); + const scaleValue = new Animated.Value(1); - const refreshCards = () => { - // Create an array of indices (0 to 17) - const indices = Array.from({length: characterImages.length}, (_, i) => i); + const spin = spinValue.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'], + }); - // 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 handleGacha = () => { + if (isSpinning) { + return; + } + if (diamonds < GACHA_COST) { + Alert.alert('鑽石不足', '需要 1000 鑽石才能抽卡'); + return; } - // Select the first 9 items - const newCards = indices.slice(0, 9); - setCards(newCards); - }; + setIsSpinning(true); + setShowResult(false); + setCurrentCharacterIndex(0); - const adoptCard = () => { - console.log('領養成功!'); - // fetch('/gacha', { method: 'POST', body: { action: 'adopt' }}) - }; + // Start character rotation animation + const characterInterval = setInterval(() => { + setCurrentCharacterIndex(prev => (prev + 1) % allCharacters.length); + }, 100); + + // Start spinning animation + Animated.loop( + Animated.timing(spinValue, { + toValue: 1, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true, + }), + ).start(); + + // Simulate gacha process + setTimeout(() => { + // Stop spinning and character rotation + clearInterval(characterInterval); + spinValue.stopAnimation(); + spinValue.setValue(0); + + // Randomly select a character + const randomIndex = Math.floor(Math.random() * allCharacters.length); + const selectedCharacter = allCharacters[randomIndex]; + + // Add to inventory and deduct diamonds + dispatch(addToInventory(selectedCharacter.id)); + dispatch(setDiamonds(diamonds - GACHA_COST)); + + // Show result with animation + setResult(selectedCharacter); + setShowResult(true); + setIsSpinning(false); - useEffect(() => { - refreshCards(); - }, []); + // 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 ? '抽卡中...' : '抽卡'} + + {!isSpinning && ( + + + 1,000 + + )} + + ); @@ -91,46 +173,107 @@ const GachaScreen = () => { const styles = StyleSheet.create({ content: { - margin: 40, + flex: 1, + alignItems: 'center', + padding: 20, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + 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%', + height: 300, justifyContent: 'center', alignItems: 'center', + marginBottom: 30, }, - container: { - flex: 1, + spinningContainer: { + width: 200, + height: 200, + justifyContent: 'center', + alignItems: 'center', + }, + spinningImage: { + width: '100%', + height: '100%', + }, + resultContainer: { + width: 200, + height: 200, + justifyContent: 'center', + alignItems: 'center', + }, + resultImage: { + width: '100%', + height: '100%', + }, + resultText: { + fontSize: 18, + fontWeight: 'bold', + marginTop: 10, + color: '#007BFF', + }, + placeholderContainer: { + width: 200, + height: 200, justifyContent: 'center', alignItems: 'center', }, - button: { + placeholderImage: { + width: '100%', + height: '100%', + opacity: 0.5, + }, + placeholderText: { + fontSize: 16, + marginTop: 10, + color: '#666', + }, + gachaButton: { backgroundColor: '#007BFF', - padding: 10, - borderRadius: 5, - width: 300, + paddingVertical: 15, + paddingHorizontal: 40, + borderRadius: 10, + marginTop: 20, + }, + disabledButton: { + backgroundColor: '#999', + }, + buttonContent: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, buttonText: { color: '#fff', - fontSize: 16, + fontSize: 18, fontWeight: 'bold', - marginRight: 20, }, - btnGroup: { - flexDirection: 'column', - alignItems: 'flex-end', - justifyContent: 'center', - }, - diamond: { - marginRight: 10, - }, - refreshButton: { + costContainer: { flexDirection: 'row', alignItems: 'center', + marginLeft: 5, }, - refreshText: { - marginLeft: 6, - fontSize: 16, - color: '#007BFF', + diamond: { + marginRight: 5, }, }); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 083c979..38e4744 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -7,62 +7,47 @@ import { StatusBar, Image, Platform, -} from 'react-native'; // TouchableOpacity + ImageSourcePropType, +} from 'react-native'; import {useNavigation} 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 {useMissions} from '../api/missionService'; +import {useAppSelector} from '../store'; +import {getCharacterImage} from '../constants/characterImages'; type RootStackParamList = { TransactionScreen: undefined; }; -const dinoImages: {[key: string]: any} = { - 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 = () => { - const {user} = useAuth(); - const [dinoImage, setDinoImage] = useState(null); - 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 {user: userProfile} = useUserProfile(); + const [dinoImage, setDinoImage] = useState(null); + const {monthlySaving, currentSaving, selectedDino} = useAppSelector( + state => state.settings, + ); + const {missions} = useMissions(); const navigation = useNavigation(); useEffect(() => { - const loadDino = async () => { - if (!user?.uid) { - return; - } - const key = `dino-${user.uid}`; - const imageKey = await AsyncStorage.getItem(key); - if (imageKey && dinoImages[imageKey]) { - setDinoImage(dinoImages[imageKey]); - } - }; - loadDino(); - }, [user]); + if (selectedDino && getCharacterImage(selectedDino)) { + setDinoImage(getCharacterImage(selectedDino)); + } + }, [selectedDino]); + + // Calculate progress percentage + const progressPercentage = Math.min( + (parseInt(currentSaving || '0', 10) / parseInt(monthlySaving || '0', 10)) * + 100, + 100, + ); return ( @@ -73,27 +58,33 @@ const HomeScreen = () => { 一起往目標前進吧! {dinoImage ? ( - + ) : ( )} - + - ${currentAmount}/${targetAmount} + NT$ {parseInt(currentSaving || '0', 10).toLocaleString()} / NT${' '} + {parseInt(monthlySaving || '0', 10).toLocaleString()} - 每日任務 - {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, }, @@ -121,17 +112,22 @@ 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', - fontSize: 24, - marginBottom: 20, + marginBottom: 10, + color: '#333', + }, + noMissionsText: { + fontSize: 14, + color: '#666', + textAlign: 'center', + marginTop: 10, }, button: { backgroundColor: '#007BFF', @@ -163,8 +159,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..a6e28d4 100644 --- a/src/screens/SetUp.tsx +++ b/src/screens/SetUp.tsx @@ -7,89 +7,165 @@ import { TouchableOpacity, Image, StatusBar, + ScrollView, + SafeAreaView, + Platform, } from 'react-native'; import * as Progress from 'react-native-progress'; import {useNavigation} from '@react-navigation/native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import {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'; +import {useDispatch} from 'react-redux'; +import {addToInventory} from '../store/slices/characterSlice'; +import {characters} from '../constants/characters'; + +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 dispatch = useAppDispatch(); + const redDispatch = useDispatch(); + + 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 = [ - { - 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) { - if (goal.trim().length > 0) { + if (selectedDino) { + redDispatch(addToInventory(selectedDino.imageKey)); setStep(2); } } else if (step === 2) { - if (selectedDino && user?.uid) { - const key = `setupDone-${user.uid}`; - await AsyncStorage.setItem(key, 'true'); - await AsyncStorage.setItem(`dino-${user.uid}`, selectedDino.imageKey); + if ( + goal.trim().length > 0 && + monthlyIncome.trim().length > 0 && + monthlyExpense.trim().length > 0 && + monthlySaving.trim().length > 0 && + user?.uid + ) { + 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'}]}); } } @@ -105,22 +181,11 @@ const SetUp = () => { const progressValue = step === 1 ? 0.5 : 1.0; return ( - - - - + + + + {step === 1 ? ( - - 設定理財目標 - - - ) : ( 選擇一個萌寵 @@ -138,104 +203,276 @@ const SetUp = () => { ))} + ) : ( + + + + + {petMessage} + + + + + 每月收入 + + handleNumberInput(value, setMonthlyIncome) + } + 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, - padding: 20, - justifyContent: 'space-between', // Distribute top content and bottom container + backgroundColor: colors.background, + paddingTop: Platform.select({ + android: StatusBar.currentHeight, + ios: 44, // iOS 的安全區域高度 + }), }, content: { flex: 1, - justifyContent: 'center', - alignItems: 'center', + padding: 20, }, - stepContent: { + stepContainer: { + flex: 1, width: '100%', - alignItems: 'center', + }, + stepContent: { + 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: { + width: '100%', }, - navButtonsContainer: { + inputContainerStep2: { + width: '100%', + }, + inputLabel: { + fontSize: 16, + marginBottom: 5, + fontWeight: '500', + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 5, + padding: 10, + fontSize: 16, + }, + 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%', + }, + suggestButtonText: { + color: '#2196f3', + textAlign: 'center', + fontWeight: '500', }, - navButtonText: { + 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, + width: '100%', + }, + nextButtonStep2: { + width: '48%', + }, + nextButtonText: { color: '#fff', - fontSize: 16, + textAlign: 'center', + fontWeight: '500', }, }); diff --git a/src/screens/TransactionScreen.tsx b/src/screens/TransactionScreen.tsx index fdffa53..58eb298 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,23 @@ import { } from 'react-native'; import TransactionForm from '../components/TransactionForm'; import Layout from '../components/Layout'; -import {transactionService, Transaction} from '../api/transactionService'; +import {Transaction} from '../store/slices/transactionSlice'; +import {useTransactions} from '../api/transactionService'; +import {useMissions} from '../api/missionService'; const TransactionScreen: React.FC = () => { - const [transactions, setTransactions] = useState([]); const [isModalVisible, setModalVisible] = useState(false); - const [budget, setBudget] = useState(10000); // setting Budget + const [budget, setBudget] = useState(10000); + const {transactions, addTransaction} = useTransactions(); + const {updateMission} = useMissions(); + 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 + const monthlyBalance = totalIncome - totalExpense; const formattedDate = (d: Date) => { return `${d.getFullYear()}-${(d.getMonth() + 1) @@ -33,70 +37,106 @@ const TransactionScreen: React.FC = () => { const handleAddTransaction = async ( amount: number, category: string, - transaction_type: 'Income' | 'Expense', + transaction_type: 'INCOME' | 'EXPENSE', date: Date, ) => { + console.log( + 'Adding transaction:', + amount, + category, + transaction_type, + 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(), - ); - setTransactions(updatedTransactions); //sort by newest - setModalVisible(false); try { - await transactionService.createTransaction(newTransaction); + // Add the transaction + console.log('Dispatching addTransaction'); + await addTransaction(newTransaction); + + // 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 creating transaction:', error); + console.error('Error handling transaction:', error); } }; - 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(); - }, []); + const getCurrentYearMonth = () => { + const now = new Date(); + return `${now.getFullYear()}年${now.getMonth() + 1}月`; + }; return ( - {/* First block:Expense & Revenue */} + {getCurrentYearMonth()} + + {/* First block:Monthly Summary */} - 本月支出 - -${totalExpense} - 本月收入:${totalIncome} + 本月收支 + + + 收入 + +${totalIncome} + + + 支出 + -${totalExpense} + + + 結餘 + = 0 ? styles.positive : styles.negative, + ]}> + {monthlyBalance >= 0 ? '+' : ''} + {monthlyBalance} + + + {/* Second block:Budget & remain balance */} - 本月預算 - - ${budget} - setBudget(budget + 1000)}> - 修改 - - + 剩餘額度 + = 0 ? styles.positive : styles.negative, + ]}> + ${budget - totalExpense} + + + + 本月預算:${budget} + setBudget(budget + 1000)}> + 修改 + - 剩餘額度:${remainingBudget} {/* Third block:details */} @@ -110,7 +150,7 @@ const TransactionScreen: React.FC = () => { {item.date} {item.category} - {item.transaction_type === 'Income' ? '+' : '-'} + {item.type === 'INCOME' ? '+' : '-'} {item.amount} @@ -159,19 +199,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, @@ -184,20 +230,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', @@ -277,6 +324,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; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..82585f2 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,52 @@ +import {configureStore, Middleware} 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'; +import characterReducer from './slices/characterSlice'; +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: { + user: userReducer, + auth: authReducer, + settings: settingsReducer, + transactions: transactionReducer, + missions: missionReducer, + wallet: walletReducer, + character: characterReducer, + }, + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + serializableCheck: false, + }).concat(saveSettingsMiddleware), +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; 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/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/characterSlice.ts b/src/store/slices/characterSlice.ts new file mode 100644 index 0000000..22cf5f3 --- /dev/null +++ b/src/store/slices/characterSlice.ts @@ -0,0 +1,69 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import {Character, CharacterInventory} from '../../types/character'; +import {characters} from '../../constants/characters'; + +interface CharacterState { + characters: Character[]; + inventory: CharacterInventory[]; + isLoading: boolean; + error: string | null; +} + +const initialState: CharacterState = { + characters: characters.map(char => ({ + id: char.id, + imageUrl: char.imageKey, + })), + 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/slices/missionSlice.ts b/src/store/slices/missionSlice.ts new file mode 100644 index 0000000..1ec0afb --- /dev/null +++ b/src/store/slices/missionSlice.ts @@ -0,0 +1,46 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import type {RootState} from '../index'; + +export interface Mission { + id: string; + title: string; + amount: number; + isCompleted: boolean; +} + +interface MissionState { + missions: Mission[]; +} + +const initialState: MissionState = { + missions: [], +}; + +export 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 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 new file mode 100644 index 0000000..1bb8843 --- /dev/null +++ b/src/store/slices/settingsSlice.ts @@ -0,0 +1,89 @@ +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; + diamonds: number; + budget: number; + saving: number; +} + +const initialState: SettingsState = { + setupDone: false, + selectedDino: null, + monthlyIncome: null, + 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; + }, + 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; + }, + setDiamonds: (state, action: PayloadAction) => { + state.diamonds = action.payload; + }, + 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; + state.monthlyIncome = null; + state.monthlySaving = null; + state.currentSaving = null; + state.diamonds = 5000; + state.budget = 0; + state.saving = 0; + }, + }, +}); + +export const { + setSettings, + setSetupDone, + setSelectedDino, + setMonthlyIncome, + setMonthlySaving, + setCurrentSaving, + setDiamonds, + addDiamonds, + setBudget, + setSaving, + 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..9cfac65 --- /dev/null +++ b/src/store/slices/transactionSlice.ts @@ -0,0 +1,43 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import type {RootState} from '../index'; + +export interface Transaction { + id: string; + type: 'INCOME' | 'EXPENSE'; + amount: number; + category: string; + date: string; + description: string; +} + +interface TransactionState { + transactions: Transaction[]; +} + +const initialState: TransactionState = { + transactions: [], +}; + +export const transactionSlice = createSlice({ + name: 'transactions', + initialState, + reducers: { + addTransaction: (state, action: PayloadAction) => { + state.transactions.push(action.payload); + }, + setTransactions: (state, action: PayloadAction) => { + state.transactions = action.payload; + }, + clearTransactions: state => { + state.transactions = []; + }, + }, +}); + +export const {addTransaction, setTransactions, clearTransactions} = + transactionSlice.actions; + +export const selectTransactions = (state: RootState) => + state.transactions.transactions; + +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/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; +} 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..639a602 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" @@ -2311,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" @@ -2347,6 +2374,21 @@ 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" + 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" @@ -2477,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" @@ -4654,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" @@ -6740,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" @@ -6783,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" @@ -6872,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" @@ -7728,7 +7803,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==