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==