diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index df7dfb3b..5890a497 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -29,6 +29,14 @@ jobs: run: npm install working-directory: ./frontend + - name: Create .env file + working-directory: ./frontend + run: | + echo "EXPO_PUBLIC_GOOGLE_EXPO_CLIENT_ID=${{ secrets.EXPO_PUBLIC_GOOGLE_EXPO_CLIENT_ID }}" >> .env + echo "EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=${{ secrets.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID }}" >> .env + echo "EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID=${{ secrets.EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID }}" >> .env + echo "EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=${{ secrets.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID }}" >> .env + - name: Create preview uses: expo/expo-github-action/preview@v8 with: diff --git a/.gitignore b/.gitignore index ca92ba2d..f029e89d 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ yarn-error.* # local env files .env*.local +frontend/.env # typescript *.tsbuildinfo diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..a53b63f4 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,4 @@ +EXPO_PUBLIC_GOOGLE_EXPO_CLIENT_ID= +EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID= +EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID= +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID= diff --git a/frontend/api/auth.js b/frontend/api/auth.js index 46e0426b..66fc3952 100644 --- a/frontend/api/auth.js +++ b/frontend/api/auth.js @@ -8,6 +8,10 @@ export const signup = (name, email, password) => { return apiClient.post("/auth/signup/email", { name, email, password }); }; +export const loginWithGoogle = (id_token) => { + return apiClient.post("/auth/login/google", { id_token }); +}; + export const updateUser = (userData) => apiClient.patch("/users/me", userData); export const refresh = (refresh_token) => { diff --git a/frontend/app.json b/frontend/app.json index 169a4caa..dbf77567 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -6,6 +6,7 @@ "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", + "scheme": "splitwiser", "newArchEnabled": true, "splash": { "image": "./assets/splash-icon.png", @@ -14,11 +15,13 @@ }, "ios": { "supportsTablet": true, + "bundleIdentifier": "com.devasy23.splitwiser", "infoPlist": { "NSPhotoLibraryUsageDescription": "Allow Splitwiser to select a group icon from your photo library." } }, "android": { + "package": "com.devasy23.splitwiser", "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" diff --git a/frontend/context/AuthContext.js b/frontend/context/AuthContext.js index 1caf5326..49019510 100644 --- a/frontend/context/AuthContext.js +++ b/frontend/context/AuthContext.js @@ -1,4 +1,6 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useAuthRequest } from "expo-auth-session/providers/google"; +import * as WebBrowser from "expo-web-browser"; import { createContext, useEffect, useState } from "react"; import * as authApi from "../api/auth"; import { @@ -7,6 +9,8 @@ import { setTokenUpdateListener, } from "../api/client"; +WebBrowser.maybeCompleteAuthSession(); + export const AuthContext = createContext(); export const AuthProvider = ({ children }) => { @@ -15,13 +19,64 @@ export const AuthProvider = ({ children }) => { const [refresh, setRefresh] = useState(null); const [isLoading, setIsLoading] = useState(true); + // For Expo Go, we need to use the web-based auth flow + // Force all platforms to use web client ID in Expo Go + const [request, response, promptAsync] = useAuthRequest({ + expoClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, + iosClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, // Force web client for iOS in Expo Go + androidClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, // Force web client for Android in Expo Go + webClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, + redirectUri: 'https://auth.expo.io/@devasy23/frontend', + }); + + // Debug logging + useEffect(() => { + if (request) { + console.log("Auth request details:", { + url: request.url, + params: request.params + }); + } + }, [request]); + + useEffect(() => { + const handleGoogleSignIn = async () => { + if (response?.type === "success") { + const { id_token } = response.params; + try { + const res = await authApi.loginWithGoogle(id_token); + const { access_token, refresh_token, user: userData } = res.data; + setToken(access_token); + setRefresh(refresh_token); + await setAuthTokens({ + newAccessToken: access_token, + newRefreshToken: refresh_token, + }); + const normalizedUser = userData?._id + ? userData + : userData?.id + ? { ...userData, _id: userData.id } + : userData; + setUser(normalizedUser); + } catch (error) { + console.error( + "Google login failed:", + error.response?.data?.detail || error.message + ); + } + } + }; + + handleGoogleSignIn(); + }, [response]); + // Load token and user data from AsyncStorage on app start useEffect(() => { const loadStoredAuth = async () => { try { const storedToken = await AsyncStorage.getItem("auth_token"); const storedRefresh = await AsyncStorage.getItem("refresh_token"); - const storedUser = await AsyncStorage.getItem("user_data"); + const storedUser = await AsyncStorage.getItem("user_data"); if (storedToken && storedUser) { setToken(storedToken); @@ -146,6 +201,10 @@ export const AuthProvider = ({ children }) => { } }; + const loginWithGoogle = async () => { + await promptAsync(); + }; + const logout = async () => { try { // Clear stored authentication data @@ -182,6 +241,7 @@ export const AuthProvider = ({ children }) => { signup, logout, updateUserInContext, + loginWithGoogle, }} > {children} diff --git a/frontend/navigation/GroupsStackNavigator.js b/frontend/navigation/GroupsStackNavigator.js index 5ede954d..dc0fe648 100644 --- a/frontend/navigation/GroupsStackNavigator.js +++ b/frontend/navigation/GroupsStackNavigator.js @@ -12,9 +12,9 @@ const GroupsStackNavigator = () => { - + - + ); }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e1481a1..2955ba51 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,8 @@ "@react-navigation/native-stack": "^7.3.23", "axios": "^1.11.0", "expo": "~53.0.20", + "expo-auth-session": "^6.2.1", + "expo-crypto": "^14.1.5", "expo-image-picker": "~16.0.2", "expo-status-bar": "~2.2.3", "react": "19.0.0", @@ -4249,6 +4251,15 @@ } } }, + "node_modules/expo-application": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-6.1.5.tgz", + "integrity": "sha512-ToImFmzw8luY043pWFJhh2ZMm4IwxXoHXxNoGdlhD4Ym6+CCmkAvCglg0FK8dMLzAb+/XabmOE7Rbm8KZb6NZg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "11.1.7", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-11.1.7.tgz", @@ -4264,6 +4275,24 @@ "react-native": "*" } }, + "node_modules/expo-auth-session": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-6.2.1.tgz", + "integrity": "sha512-9KgqrGpW7PoNOhxJ7toofi/Dz5BU2TE4Q+ktJZsmDXLoFcNOcvBokh2+mkhG58Qvd/xJ9Z5sAt/5QoOFaPb9wA==", + "license": "MIT", + "dependencies": { + "expo-application": "~6.1.5", + "expo-constants": "~17.1.7", + "expo-crypto": "~14.1.5", + "expo-linking": "~7.1.7", + "expo-web-browser": "~14.2.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "17.1.7", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.1.7.tgz", @@ -4278,6 +4307,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "14.1.5", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-14.1.5.tgz", + "integrity": "sha512-ZXJoUMoUeiMNEoSD4itItFFz3cKrit6YJ/BR0hjuwNC+NczbV9rorvhvmeJmrU9O2cFQHhJQQR1fjQnt45Vu4Q==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "18.1.11", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.1.11.tgz", @@ -4330,6 +4371,20 @@ "react": "*" } }, + "node_modules/expo-linking": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.1.7.tgz", + "integrity": "sha512-ZJaH1RIch2G/M3hx2QJdlrKbYFUTOjVVW4g39hfxrE5bPX9xhZUYXqxqQtzMNl1ylAevw9JkgEfWbBWddbZ3UA==", + "license": "MIT", + "dependencies": { + "expo-constants": "~17.1.7", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "2.1.14", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.1.14.tgz", @@ -4371,6 +4426,16 @@ "react-native": "*" } }, + "node_modules/expo-web-browser": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.2.0.tgz", + "integrity": "sha512-6S51d8pVlDRDsgGAp8BPpwnxtyKiMWEFdezNz+5jVIyT+ctReW42uxnjRgtsdn5sXaqzhaX+Tzk/CWaKCyC0hw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/exponential-backoff": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 39a8d506..dd791fbc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,8 @@ "@react-navigation/native-stack": "^7.3.23", "axios": "^1.11.0", "expo": "~53.0.20", + "expo-auth-session": "^6.2.1", + "expo-crypto": "^14.1.5", "expo-image-picker": "~16.0.2", "expo-status-bar": "~2.2.3", "react": "19.0.0", diff --git a/frontend/screens/AccountScreen.js b/frontend/screens/AccountScreen.js index 16ce8392..7bda33b5 100644 --- a/frontend/screens/AccountScreen.js +++ b/frontend/screens/AccountScreen.js @@ -1,7 +1,9 @@ +import { Ionicons } from "@expo/vector-icons"; import { useContext } from "react"; -import { Alert, StyleSheet, View } from "react-native"; -import { Appbar, Avatar, Divider, List, Text } from "react-native-paper"; +import { Alert, StyleSheet, TouchableOpacity, View } from "react-native"; +import { Appbar, Avatar, Divider, Text } from "react-native-paper"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; const AccountScreen = ({ navigation }) => { const { user, logout } = useContext(AuthContext); @@ -14,51 +16,84 @@ const AccountScreen = ({ navigation }) => { Alert.alert("Coming Soon", "This feature is not yet implemented."); }; + const menuItems = [ + { + title: "Edit Profile", + icon: "person-outline", + onPress: () => navigation.navigate("EditProfile"), + }, + { + title: "Email Settings", + icon: "mail-outline", + onPress: handleComingSoon, + }, + { + title: "Send Feedback", + icon: "chatbubble-ellipses-outline", + onPress: handleComingSoon, + }, + { + title: "Logout", + icon: "log-out-outline", + onPress: handleLogout, + color: colors.error, + }, + ]; + return ( - - + + {user?.imageUrl && /^(https?:|data:image)/.test(user.imageUrl) ? ( - + ) : ( - + )} - - {user?.name} - - - {user?.email} - + {user?.name} + {user?.email} - - } - onPress={() => navigation.navigate("EditProfile")} - /> - - } - onPress={handleComingSoon} - /> - - } - onPress={handleComingSoon} - /> - - } - onPress={handleLogout} - /> - + + {menuItems.map((item, index) => ( + + + + + {item.title} + + + + {index < menuItems.length - 1 && } + + ))} + ); @@ -67,20 +102,47 @@ const AccountScreen = ({ navigation }) => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, content: { - padding: 16, + padding: spacing.md, }, profileSection: { alignItems: "center", - marginBottom: 24, + marginBottom: spacing.xl, + backgroundColor: colors.white, + padding: spacing.lg, + borderRadius: spacing.sm, + }, + avatar: { + backgroundColor: colors.primary, }, name: { - marginTop: 16, + ...typography.h2, + marginTop: spacing.md, + color: colors.text, }, email: { - marginTop: 4, - color: "gray", + ...typography.body, + marginTop: spacing.xs, + color: colors.textSecondary, + }, + menuContainer: { + backgroundColor: colors.white, + borderRadius: spacing.sm, + }, + menuItem: { + flexDirection: "row", + alignItems: "center", + paddingVertical: spacing.md, + paddingHorizontal: spacing.md, + }, + menuIcon: { + marginRight: spacing.md, + }, + menuItemText: { + ...typography.body, + flex: 1, }, }); diff --git a/frontend/screens/AddExpenseScreen.js b/frontend/screens/AddExpenseScreen.js index 59cb65ed..2915ea9e 100644 --- a/frontend/screens/AddExpenseScreen.js +++ b/frontend/screens/AddExpenseScreen.js @@ -1,79 +1,107 @@ +import { Ionicons } from "@expo/vector-icons"; import { useContext, useEffect, useState } from "react"; import { Alert, KeyboardAvoidingView, Platform, + ScrollView, StyleSheet, + Text, + TouchableOpacity, View, } from "react-native"; import { ActivityIndicator, + Appbar, Button, - Checkbox, Menu, - Paragraph, SegmentedButtons, - Text, TextInput, - Title, } from "react-native-paper"; import { createExpense, getGroupMembers } from "../api/groups"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; + +const CustomCheckbox = ({ label, status, onPress }) => ( + + + {label} + +); + +const SplitInputRow = ({ + label, + value, + onChangeText, + keyboardType = "numeric", + disabled = false, + isPercentage = false, +}) => ( + + {label} + + + {isPercentage && %} + + +); const AddExpenseScreen = ({ route, navigation }) => { const { groupId } = route.params; - const { token, user } = useContext(AuthContext); + const { user } = useContext(AuthContext); const [description, setDescription] = useState(""); const [amount, setAmount] = useState(""); const [members, setMembers] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [splitMethod, setSplitMethod] = useState("equal"); - const [payerId, setPayerId] = useState(null); // Initialize as null until members are loaded + const [payerId, setPayerId] = useState(null); const [menuVisible, setMenuVisible] = useState(false); - // State for different split methods const [percentages, setPercentages] = useState({}); const [shares, setShares] = useState({}); const [exactAmounts, setExactAmounts] = useState({}); - const [selectedMembers, setSelectedMembers] = useState({}); // For equal split + const [selectedMembers, setSelectedMembers] = useState({}); useEffect(() => { const fetchMembers = async () => { try { const response = await getGroupMembers(groupId); setMembers(response.data); - // Initialize split states const initialShares = {}; const initialPercentages = {}; const initialExactAmounts = {}; const initialSelectedMembers = {}; const numMembers = response.data.length; - - // Calculate percentages using integer math to avoid floating-point errors const basePercentage = Math.floor(100 / numMembers); const remainder = 100 - basePercentage * numMembers; response.data.forEach((member, index) => { initialShares[member.userId] = "1"; - - // Distribute percentages using integer math let memberPercentage = basePercentage; - // Distribute remainder to first members (could also be last, but first is simpler) if (index < remainder) { memberPercentage += 1; } initialPercentages[member.userId] = memberPercentage.toString(); - initialExactAmounts[member.userId] = "0.00"; - initialSelectedMembers[member.userId] = true; // Select all by default + initialSelectedMembers[member.userId] = true; }); setShares(initialShares); setPercentages(initialPercentages); setExactAmounts(initialExactAmounts); setSelectedMembers(initialSelectedMembers); - // Set default payer to current user if they're a member const currentUserMember = response.data.find( (member) => member.userId === user._id ); @@ -89,18 +117,14 @@ const AddExpenseScreen = ({ route, navigation }) => { setIsLoading(false); } }; - if (token && groupId) { + if (groupId) { fetchMembers(); } - }, [token, groupId]); + }, [groupId]); const handleAddExpense = async () => { - if (!description || !amount) { - Alert.alert("Error", "Please fill in all fields."); - return; - } - if (!payerId) { - Alert.alert("Error", "Please select who paid for this expense."); + if (!description || !amount || !payerId) { + Alert.alert("Error", "Please fill in all required fields."); return; } const numericAmount = parseFloat(amount); @@ -110,122 +134,77 @@ const AddExpenseScreen = ({ route, navigation }) => { } setIsSubmitting(true); - let expenseData; - try { let splits = []; - let splitType = splitMethod; - if (splitMethod === "equal") { const includedMembers = Object.keys(selectedMembers).filter( (userId) => selectedMembers[userId] ); - if (includedMembers.length === 0) { - throw new Error("You must select at least one member for the split."); - } + if (includedMembers.length === 0) + throw new Error("Select at least one member for the split."); const splitAmount = Math.round((numericAmount / includedMembers.length) * 100) / 100; - // Calculate remainder to handle rounding - const totalSplitAmount = splitAmount * includedMembers.length; const remainder = - Math.round((numericAmount - totalSplitAmount) * 100) / 100; - + Math.round( + (numericAmount - splitAmount * includedMembers.length) * 100 + ) / 100; splits = includedMembers.map((userId, index) => ({ userId, - amount: index === 0 ? splitAmount + remainder : splitAmount, // Add remainder to first member + amount: index === 0 ? splitAmount + remainder : splitAmount, type: "equal", })); - splitType = "equal"; } else if (splitMethod === "exact") { const total = Object.values(exactAmounts).reduce( (sum, val) => sum + parseFloat(val || "0"), 0 ); - if (Math.abs(total - numericAmount) > 0.01) { - throw new Error( - `The exact amounts must add up to ${numericAmount.toFixed( - 2 - )}. Current total: ${total.toFixed(2)}` - ); - } + if (Math.abs(total - numericAmount) > 0.01) + throw new Error("Exact amounts must add up to the total."); splits = Object.entries(exactAmounts) - .filter(([userId, value]) => parseFloat(value || "0") > 0) + .filter(([, value]) => parseFloat(value || "0") > 0) .map(([userId, value]) => ({ userId, - amount: Math.round(parseFloat(value) * 100) / 100, + amount: parseFloat(value), type: "unequal", })); - splitType = "unequal"; // Backend uses 'unequal' for exact amounts } else if (splitMethod === "percentage") { - const total = Object.values(percentages).reduce( + const totalPercentage = Object.values(percentages).reduce( (sum, val) => sum + parseFloat(val || "0"), 0 ); - if (Math.abs(total - 100) > 0.01) { - throw new Error( - `Percentages must add up to 100%. Current total: ${total.toFixed( - 2 - )}%` - ); + if (Math.abs(totalPercentage - 100) > 0.01) { + throw new Error("Percentages must add up to 100."); } splits = Object.entries(percentages) - .filter(([userId, value]) => parseFloat(value || "0") > 0) + .filter(([, value]) => parseFloat(value || "0") > 0) .map(([userId, value]) => ({ userId, - amount: - Math.round(numericAmount * (parseFloat(value) / 100) * 100) / 100, + amount: (numericAmount * parseFloat(value)) / 100, type: "percentage", })); - splitType = "percentage"; } else if (splitMethod === "shares") { - const nonZeroShares = Object.entries(shares).filter( - ([userId, value]) => parseInt(value || "0", 10) > 0 - ); - const totalShares = nonZeroShares.reduce( - (sum, [, value]) => sum + parseInt(value || "0", 10), + const totalShares = Object.values(shares).reduce( + (sum, val) => sum + parseFloat(val || "0"), 0 ); - if (totalShares === 0) { throw new Error("Total shares cannot be zero."); } - - // Calculate amounts with proper rounding - const amounts = nonZeroShares.map(([userId, value]) => { - const shareRatio = parseInt(value, 10) / totalShares; - return { + splits = Object.entries(shares) + .filter(([, value]) => parseFloat(value || "0") > 0) + .map(([userId, value]) => ({ userId, - amount: Math.round(numericAmount * shareRatio * 100) / 100, - type: "unequal", - }; - }); - - // Adjust for rounding errors - const totalCalculated = amounts.reduce( - (sum, item) => sum + item.amount, - 0 - ); - const difference = - Math.round((numericAmount - totalCalculated) * 100) / 100; - - if (Math.abs(difference) > 0) { - amounts[0].amount = - Math.round((amounts[0].amount + difference) * 100) / 100; - } - - splits = amounts; - splitType = "unequal"; // Backend uses 'unequal' for shares + amount: (numericAmount * parseFloat(value)) / totalShares, + type: "shares", + })); } - - expenseData = { + const expenseData = { description, amount: numericAmount, - paidBy: payerId, // Use the selected payer - splitType, + paidBy: payerId, + splitType: splitMethod, splits, - tags: [], }; - await createExpense(groupId, expenseData); Alert.alert("Success", "Expense added successfully."); navigation.goBack(); @@ -240,44 +219,11 @@ const AddExpenseScreen = ({ route, navigation }) => { setSelectedMembers((prev) => ({ ...prev, [userId]: !prev[userId] })); }; - // Helper function to auto-balance percentages - const balancePercentages = (updatedPercentages) => { - const total = Object.values(updatedPercentages).reduce( - (sum, val) => sum + parseFloat(val || "0"), - 0 - ); - const memberIds = Object.keys(updatedPercentages); - - if (total !== 100 && memberIds.length > 1) { - // Find the last non-zero percentage to adjust - const lastMemberId = memberIds[memberIds.length - 1]; - const otherTotal = Object.entries(updatedPercentages) - .filter(([id]) => id !== lastMemberId) - .reduce((sum, [, val]) => sum + parseFloat(val || "0"), 0); - - const newValue = Math.max(0, 100 - otherTotal); - updatedPercentages[lastMemberId] = newValue.toFixed(2); - } - - return updatedPercentages; - }; - const renderSplitInputs = () => { - const handleSplitChange = (setter, userId, value) => { - if (setter === setPercentages) { - // Auto-balance percentages when one changes - const updatedPercentages = { ...percentages, [userId]: value }; - const balanced = balancePercentages(updatedPercentages); - setter(balanced); - } else { - setter((prev) => ({ ...prev, [userId]: value })); - } - }; - switch (splitMethod) { case "equal": return members.map((member) => ( - { )); case "exact": return members.map((member) => ( - - handleSplitChange(setExactAmounts, member.userId, text) + setExactAmounts({ ...exactAmounts, [member.userId]: text }) } - keyboardType="numeric" - style={styles.splitInput} /> )); case "percentage": return members.map((member) => ( - - handleSplitChange(setPercentages, member.userId, text) + setPercentages({ ...percentages, [member.userId]: text }) } - keyboardType="numeric" - style={styles.splitInput} + isPercentage /> )); case "shares": return members.map((member) => ( - - handleSplitChange(setShares, member.userId, text) + setShares({ ...shares, [member.userId]: text }) } - keyboardType="numeric" - style={styles.splitInput} /> )); default: @@ -331,26 +272,37 @@ const AddExpenseScreen = ({ route, navigation }) => { if (isLoading) { return ( - + ); } - const selectedPayerName = payerId - ? members.find((m) => m.userId === payerId)?.user.name || "Select Payer" - : "Select Payer"; + const selectedPayerName = + members.find((m) => m.userId === payerId)?.user.name || "Select Payer"; return ( - + + navigation.goBack()} + color={colors.white} + /> + + + { onChangeText={setAmount} style={styles.input} keyboardType="numeric" + theme={{ colors: { primary: colors.accent } }} /> - setMenuVisible(false)} - anchor={ - - } - > + + Paid by + setMenuVisible(false)} + anchor={ + setMenuVisible(true)} + > + {selectedPayerName} + + + } + > {members.map((member) => ( { /> ))} + - Split Method + Split Method - {splitMethod === "equal" && ( - - Select members to split the expense equally among them. - - )} - {splitMethod === "exact" && ( - - Enter exact amounts for each member. Total must equal $ - {amount || "0"}. - {amount && ( - - {" "} - Current total: $ - {Object.values(exactAmounts) - .reduce((sum, val) => sum + parseFloat(val || "0"), 0) - .toFixed(2)} - - )} - - )} - {splitMethod === "percentage" && ( - - Enter percentages for each member. Total must equal 100%. - - {" "} - Current total:{" "} - {Object.values(percentages) - .reduce((sum, val) => sum + parseFloat(val || "0"), 0) - .toFixed(2)} - % - - - )} - {splitMethod === "shares" && ( - - Enter shares for each member. Higher shares = larger portion of the - expense. - - )} - {renderSplitInputs()} - + ); }; @@ -453,41 +379,90 @@ const AddExpenseScreen = ({ route, navigation }) => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, content: { - flex: 1, - padding: 16, - paddingBottom: 32, + padding: spacing.lg, + paddingBottom: spacing.xl, }, loaderContainer: { flex: 1, justifyContent: "center", alignItems: "center", + backgroundColor: colors.secondary, }, input: { - marginBottom: 16, + marginBottom: spacing.md, + backgroundColor: colors.white, }, button: { - marginTop: 24, + marginTop: spacing.lg, + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + }, + buttonLabel: { + ...typography.body, + color: colors.white, + fontWeight: "bold", }, splitTitle: { - marginTop: 16, - marginBottom: 8, + ...typography.h3, + color: colors.text, + marginTop: spacing.lg, + marginBottom: spacing.md, }, splitInputsContainer: { - marginTop: 8, + marginTop: spacing.md, }, - splitInput: { - marginBottom: 8, + menuAnchor: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: spacing.md, + backgroundColor: colors.white, + borderRadius: spacing.sm, + borderWidth: 1, + borderColor: colors.textSecondary, }, - helperText: { - fontSize: 12, - marginBottom: 8, - opacity: 0.7, + menuAnchorText: { + ...typography.body, + color: colors.text, }, - totalText: { - fontWeight: "bold", - opacity: 1, + label: { + ...typography.body, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + checkboxContainer: { + flexDirection: "row", + alignItems: "center", + paddingVertical: spacing.sm, + }, + checkboxLabel: { + ...typography.body, + color: colors.text, + marginLeft: spacing.md, + }, + splitRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: spacing.sm, + }, + splitLabel: { + ...typography.body, + color: colors.text, + flex: 1, + }, + splitInput: { + width: 100, + textAlign: "right", + backgroundColor: colors.white, + }, + percentageSymbol: { + ...typography.body, + color: colors.textSecondary, + marginLeft: spacing.sm, }, }); diff --git a/frontend/screens/EditProfileScreen.js b/frontend/screens/EditProfileScreen.js index 8201b708..078d724f 100644 --- a/frontend/screens/EditProfileScreen.js +++ b/frontend/screens/EditProfileScreen.js @@ -1,14 +1,15 @@ import * as ImagePicker from "expo-image-picker"; import { useContext, useState } from "react"; -import { Alert, StyleSheet, View } from "react-native"; -import { Appbar, Avatar, Button, TextInput, Title } from "react-native-paper"; +import { Alert, StyleSheet, View, Text } from "react-native"; +import { Appbar, Avatar, Button, TextInput } from "react-native-paper"; import { updateUser } from "../api/auth"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; const EditProfileScreen = ({ navigation }) => { - const { user, token, updateUserInContext } = useContext(AuthContext); + const { user, updateUserInContext } = useContext(AuthContext); const [name, setName] = useState(user?.name || ""); - const [pickedImage, setPickedImage] = useState(null); // { uri, base64 } + const [pickedImage, setPickedImage] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const handleUpdateProfile = async () => { @@ -19,17 +20,10 @@ const EditProfileScreen = ({ navigation }) => { setIsSubmitting(true); try { const updates = { name }; - - // Add image if picked if (pickedImage?.base64) { - // Dynamically determine MIME type from picker metadata - const mime = - pickedImage.mimeType && /image\//.test(pickedImage.mimeType) - ? pickedImage.mimeType - : "image/jpeg"; // fallback + const mime = pickedImage.mimeType || "image/jpeg"; updates.imageUrl = `data:${mime};base64,${pickedImage.base64}`; } - const response = await updateUser(updates); updateUserInContext(response.data); Alert.alert("Success", "Profile updated successfully."); @@ -43,7 +37,6 @@ const EditProfileScreen = ({ navigation }) => { }; const pickImage = async () => { - // Ask permissions const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== "granted") { Alert.alert( @@ -61,48 +54,47 @@ const EditProfileScreen = ({ navigation }) => { }); if (!result.canceled && result.assets && result.assets.length > 0) { const asset = result.assets[0]; - // Capture mimeType (expo-image-picker provides mimeType on iOS/Android SDK 49+) - let mimeType = asset.mimeType || asset.type; // expo sometimes supplies type like 'image' - if (mimeType && !/image\//.test(mimeType)) { - // if it's just 'image', normalize - if (mimeType === 'image') mimeType = 'image/jpeg'; - } - if (!mimeType || !/image\//.test(mimeType)) { - // Attempt to infer from file extension as a lightweight fallback - const ext = (asset.uri || "").split(".").pop()?.toLowerCase(); - if (ext === "png") mimeType = "image/png"; - else if (ext === "webp") mimeType = "image/webp"; - else if (ext === "gif") mimeType = "image/gif"; - else if (ext === "jpg" || ext === "jpeg") mimeType = "image/jpeg"; - else mimeType = "image/jpeg"; // safe default - } + let mimeType = asset.mimeType || "image/jpeg"; setPickedImage({ uri: asset.uri, base64: asset.base64, mimeType }); } }; return ( - - navigation.goBack()} /> - + + navigation.goBack()} + color={colors.white} + /> + - Edit Your Details + Edit Your Details - {/* Profile Picture Section */} - {pickedImage?.uri ? ( - - ) : user?.imageUrl && /^(https?:|data:image)/.test(user.imageUrl) ? ( - - ) : ( - - )} + @@ -113,6 +105,7 @@ const EditProfileScreen = ({ navigation }) => { value={name} onChangeText={setName} style={styles.input} + theme={{ colors: { primary: colors.accent } }} /> @@ -131,22 +125,43 @@ const EditProfileScreen = ({ navigation }) => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, content: { - padding: 16, + padding: spacing.lg, + }, + title: { + ...typography.h2, + color: colors.text, + marginBottom: spacing.lg, + textAlign: "center", }, profilePictureSection: { alignItems: "center", - marginBottom: 24, + marginBottom: spacing.xl, + }, + avatar: { + backgroundColor: colors.primary, }, imageButton: { - marginTop: 12, + marginTop: spacing.md, + }, + imageButtonLabel: { + color: colors.primary, }, input: { - marginBottom: 16, + marginBottom: spacing.lg, + backgroundColor: colors.white, }, button: { - marginTop: 8, + marginTop: spacing.md, + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + }, + buttonLabel: { + ...typography.body, + color: colors.white, + fontWeight: "bold", }, }); diff --git a/frontend/screens/FriendsScreen.js b/frontend/screens/FriendsScreen.js index 0da27955..3e89e96e 100644 --- a/frontend/screens/FriendsScreen.js +++ b/frontend/screens/FriendsScreen.js @@ -1,237 +1,174 @@ +import { Ionicons } from "@expo/vector-icons"; import { useIsFocused } from "@react-navigation/native"; -import { useContext, useEffect, useRef, useState } from "react"; -import { Alert, Animated, FlatList, StyleSheet, View } from "react-native"; +import { useContext, useEffect, useState } from "react"; import { + Alert, + FlatList, + LayoutAnimation, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { + ActivityIndicator, Appbar, Avatar, Divider, - IconButton, - List, Text, } from "react-native-paper"; import { getFriendsBalance, getGroups } from "../api/groups"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; import { formatCurrency } from "../utils/currency"; +const FriendItem = ({ item, onToggle, isExpanded }) => { + const balanceColor = + item.netBalance < 0 ? colors.error : colors.success; + const balanceText = + item.netBalance < 0 + ? `You owe ${formatCurrency(Math.abs(item.netBalance))}` + : `Owes you ${formatCurrency(item.netBalance)}`; + + return ( + + + + + {item.name} + + {item.netBalance !== 0 ? balanceText : "Settled up"} + + + + + {isExpanded && ( + + + {item.groups.map((group) => ( + + + {group.name} + + {formatCurrency(group.balance)} + + + ))} + + )} + + ); +}; + const FriendsScreen = () => { - const { token, user } = useContext(AuthContext); + const { token } = useContext(AuthContext); const [friends, setFriends] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [showTooltip, setShowTooltip] = useState(true); + const [expandedFriend, setExpandedFriend] = useState(null); const isFocused = useIsFocused(); useEffect(() => { const fetchData = async () => { setIsLoading(true); try { - // Fetch friends balance + groups concurrently for group icons - const friendsResponse = await getFriendsBalance(); + const [friendsResponse, groupsResponse] = await Promise.all([ + getFriendsBalance(), + getGroups(), + ]); const friendsData = friendsResponse.data.friendsBalance || []; - const groupsResponse = await getGroups(); - const groups = groupsResponse?.data?.groups || []; const groupMeta = new Map( - groups.map((g) => [g._id, { name: g.name, imageUrl: g.imageUrl }]) + (groupsResponse.data.groups || []).map((g) => [ + g._id, + { name: g.name, imageUrl: g.imageUrl }, + ]) ); - const transformedFriends = friendsData.map((friend) => ({ id: friend.userId, name: friend.userName, - imageUrl: friend.userImageUrl || null, + imageUrl: friend.userImageUrl, netBalance: friend.netBalance, groups: (friend.breakdown || []).map((group) => ({ id: group.groupId, - name: group.groupName, + name: groupMeta.get(group.groupId)?.name || "Unknown Group", balance: group.balance, - imageUrl: groupMeta.get(group.groupId)?.imageUrl || null, + imageUrl: groupMeta.get(group.groupId)?.imageUrl, })), })); - setFriends(transformedFriends); } catch (error) { - console.error("Failed to fetch friends balance data:", error); Alert.alert("Error", "Failed to load friends balance data."); } finally { setIsLoading(false); } }; - - if (token && isFocused) { - fetchData(); - } + if (token && isFocused) fetchData(); }, [token, isFocused]); - const renderFriend = ({ item }) => { - const balanceColor = item.netBalance < 0 ? "red" : "green"; - const balanceText = - item.netBalance < 0 - ? `You owe ${formatCurrency(Math.abs(item.netBalance))}` - : `Owes you ${formatCurrency(item.netBalance)}`; - - // Determine if we have an image URL or a base64 payload - const hasImage = !!item.imageUrl; - let imageUri = null; - if (hasImage) { - // If it's a raw base64 string without prefix, add a default MIME prefix - if ( - /^data:image/.test(item.imageUrl) || - /^https?:\/\//.test(item.imageUrl) - ) { - imageUri = item.imageUrl; - } else if (/^[A-Za-z0-9+/=]+$/.test(item.imageUrl.substring(0, 50))) { - imageUri = `data:image/jpeg;base64,${item.imageUrl}`; - } - } - - return ( - - imageUri ? ( - - ) : ( - - ) - } - > - {item.groups.map((group) => { - const groupBalanceColor = group.balance < 0 ? "red" : "green"; - const groupBalanceText = - group.balance < 0 - ? `You owe ${formatCurrency(Math.abs(group.balance))}` - : `Owes you ${formatCurrency(group.balance)}`; - // Prepare group icon (imageUrl may be base64 or URL) - let groupImageUri = null; - if (group.imageUrl) { - if ( - /^data:image/.test(group.imageUrl) || - /^https?:\/\//.test(group.imageUrl) - ) { - groupImageUri = group.imageUrl; - } else if ( - /^[A-Za-z0-9+/=]+$/.test(group.imageUrl.substring(0, 50)) - ) { - groupImageUri = `data:image/jpeg;base64,${group.imageUrl}`; - } - } - - return ( - - groupImageUri ? ( - - ) : ( - - ) - } - /> - ); - })} - - ); + const handleToggleFriend = (friendId) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setExpandedFriend(expandedFriend === friendId ? null : friendId); }; - // Shimmer skeleton components - const opacityAnim = useRef(new Animated.Value(0.3)).current; - useEffect(() => { - const loop = Animated.loop( - Animated.sequence([ - Animated.timing(opacityAnim, { - toValue: 1, - duration: 700, - useNativeDriver: true, - }), - Animated.timing(opacityAnim, { - toValue: 0.3, - duration: 700, - useNativeDriver: true, - }), - ]) - ); - loop.start(); - return () => loop.stop(); - }, [opacityAnim]); - - const SkeletonRow = () => ( - - - - - - - - ); - if (isLoading) { return ( - - + + - - {Array.from({ length: 5 }).map((_, i) => ( - - ))} - + ); } return ( - - + + - {showTooltip && ( - - - - 💡 These amounts show your direct balance with each friend across - all shared groups. Check individual group details for optimized - settlement suggestions. - - setShowTooltip(false)} - style={styles.closeButton} - /> - - - )} ( + handleToggleFriend(item.id)} + /> + )} keyExtractor={(item) => item.id} - ItemSeparatorComponent={Divider} + contentContainerStyle={styles.listContent} ListEmptyComponent={ No balances with friends yet. } @@ -243,63 +180,51 @@ const FriendsScreen = () => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, - loaderContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center", + listContent: { + padding: spacing.md, }, - explanationContainer: { - backgroundColor: "#f0f8ff", - margin: 8, - borderRadius: 8, - borderLeftWidth: 4, - borderLeftColor: "#2196f3", + friendCard: { + backgroundColor: colors.white, + borderRadius: spacing.sm, + marginBottom: spacing.md, + padding: spacing.md, }, - explanationContent: { + friendHeader: { flexDirection: "row", - alignItems: "flex-start", - padding: 12, + alignItems: "center", }, - explanationText: { - fontSize: 12, - color: "#555", - lineHeight: 16, + friendInfo: { flex: 1, - paddingRight: 8, + marginLeft: spacing.md, }, - closeButton: { - margin: 0, - marginTop: -4, + friendName: { + ...typography.h3, + color: colors.text, }, - emptyText: { - textAlign: "center", - marginTop: 20, + friendBalance: { + ...typography.body, }, - skeletonContainer: { - padding: 16, + groupBreakdown: { + marginTop: spacing.md, }, - skeletonRow: { + groupItem: { flexDirection: "row", alignItems: "center", - marginBottom: 14, - }, - skeletonAvatar: { - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: "#e0e0e0", + paddingVertical: spacing.sm, }, - skeletonLine: { - height: 14, - backgroundColor: "#e0e0e0", - borderRadius: 6, - marginBottom: 6, + groupName: { + flex: 1, + marginLeft: spacing.md, + ...typography.body, + color: colors.text, }, - skeletonLineSmall: { - height: 12, - backgroundColor: "#e0e0e0", - borderRadius: 6, + emptyText: { + textAlign: "center", + marginTop: spacing.xl, + ...typography.body, + color: colors.textSecondary, }, }); diff --git a/frontend/screens/GroupDetailsScreen.js b/frontend/screens/GroupDetailsScreen.js index a1050b9f..15def482 100644 --- a/frontend/screens/GroupDetailsScreen.js +++ b/frontend/screens/GroupDetailsScreen.js @@ -1,19 +1,23 @@ +import { Ionicons } from "@expo/vector-icons"; import { useContext, useEffect, useState } from "react"; -import { Alert, FlatList, StyleSheet, Text, View } from "react-native"; import { - ActivityIndicator, - Card, - FAB, - IconButton, - Paragraph, - Title, -} from "react-native-paper"; + Alert, + FlatList, + LayoutAnimation, + StyleSheet, + Text, + TouchableOpacity, + View, + Platform, +} from "react-native"; +import { ActivityIndicator, FAB } from "react-native-paper"; import { getGroupExpenses, getGroupMembers, getOptimizedSettlements, } from "../api/groups"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; const GroupDetailsScreen = ({ route, navigation }) => { const { groupId, groupName } = route.params; @@ -22,17 +26,14 @@ const GroupDetailsScreen = ({ route, navigation }) => { const [expenses, setExpenses] = useState([]); const [settlements, setSettlements] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [settlementExpanded, setSettlementExpanded] = useState(false); - // Currency configuration - can be made configurable later - const currency = "₹"; // Default to INR, can be changed to '$' for USD - - // Helper function to format currency amounts + const currency = "₹"; const formatCurrency = (amount) => `${currency}${amount.toFixed(2)}`; const fetchData = async () => { try { setIsLoading(true); - // Fetch members, expenses, and settlements in parallel const [membersResponse, expensesResponse, settlementsResponse] = await Promise.all([ getGroupMembers(groupId), @@ -54,16 +55,25 @@ const GroupDetailsScreen = ({ route, navigation }) => { navigation.setOptions({ title: groupName, headerRight: () => ( - navigation.navigate("GroupSettings", { groupId })} - /> + style={{ marginRight: spacing.md }} + > + + ), + headerStyle: { + backgroundColor: colors.primary, + }, + headerTintColor: colors.white, + headerTitleStyle: { + ...typography.h3, + }, }); if (token && groupId) { fetchData(); } - }, [token, groupId]); + }, [token, groupId, navigation]); const getMemberName = (userId) => { const member = members.find((m) => m.userId === userId); @@ -76,30 +86,36 @@ const GroupDetailsScreen = ({ route, navigation }) => { const paidByMe = (item.paidBy || item.createdBy) === user._id; const net = paidByMe ? item.amount - userShare : -userShare; - let balanceText; - let balanceColor = "black"; - + let balanceText, balanceColor; if (net > 0) { - balanceText = `You are owed ${formatCurrency(net)}`; - balanceColor = "green"; + balanceText = `You get back ${formatCurrency(net)}`; + balanceColor = colors.success; } else if (net < 0) { - balanceText = `You borrowed ${formatCurrency(Math.abs(net))}`; - balanceColor = "red"; + balanceText = `You owe ${formatCurrency(Math.abs(net))}`; + balanceColor = colors.error; } else { - balanceText = "You are settled for this expense."; + balanceText = "You are settled for this expense"; + balanceColor = colors.textSecondary; } return ( - - - {item.description} - Amount: {formatCurrency(item.amount)} - - Paid by: {getMemberName(item.paidBy || item.createdBy)} - - {balanceText} - - + + + + + + {item.description} + + Paid by {getMemberName(item.paidBy || item.createdBy)} + + + {balanceText} + + + + {formatCurrency(item.amount)} + + ); }; @@ -109,91 +125,89 @@ const GroupDetailsScreen = ({ route, navigation }) => { const totalOwed = userOwes.reduce((sum, s) => sum + s.amount, 0); const totalToReceive = userIsOwed.reduce((sum, s) => sum + s.amount, 0); - // If user is all settled up + const toggleExpansion = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setSettlementExpanded(!settlementExpanded); + }; + if (userOwes.length === 0 && userIsOwed.length === 0) { return ( - ✓ You are all settled up! + + You are all settled up! ); } return ( - - {/* You owe section - only show if totalOwed > 0 */} - {totalOwed > 0 && ( - - - You need to pay:{" "} - {formatCurrency(totalOwed)} + + + + You Owe + + {formatCurrency(totalOwed)} + + + + You Are Owed + + {formatCurrency(totalToReceive)} + + + + {settlementExpanded && ( + {userOwes.map((s, index) => ( - - - {getMemberName(s.toUserId)} - - - {formatCurrency(s.amount)} - - + + Pay {getMemberName(s.toUserId)} + + + {formatCurrency(s.amount)} + ))} - - )} - - {/* You receive section - only show if totalToReceive > 0 */} - {totalToReceive > 0 && ( - - - You will receive:{" "} - - {formatCurrency(totalToReceive)} - - {userIsOwed.map((s, index) => ( - - - {getMemberName(s.fromUserId)} - - - {formatCurrency(s.amount)} - - + + Receive from {getMemberName(s.fromUserId)} + + + {formatCurrency(s.amount)} + ))} )} - + ); }; if (isLoading) { return ( - + ); } const renderHeader = () => ( <> - - - Settlement Summary - {renderSettlementSummary()} - - - - Expenses + {renderSettlementSummary()} + Expenses ); return ( item._id} @@ -201,13 +215,13 @@ const GroupDetailsScreen = ({ route, navigation }) => { ListEmptyComponent={ No expenses recorded yet. } - contentContainerStyle={{ paddingBottom: 80 }} // To avoid FAB overlap + contentContainerStyle={styles.contentContainer} /> - navigation.navigate("AddExpense", { groupId: groupId })} + color={colors.white} + onPress={() => navigation.navigate("AddExpense", { groupId })} /> ); @@ -216,99 +230,131 @@ const GroupDetailsScreen = ({ route, navigation }) => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, contentContainer: { - flex: 1, - padding: 16, + padding: spacing.md, + paddingBottom: 80, }, loaderContainer: { flex: 1, justifyContent: "center", alignItems: "center", + backgroundColor: colors.secondary, }, - card: { - marginBottom: 16, + summaryCard: { + backgroundColor: colors.white, + borderRadius: spacing.sm, + padding: spacing.md, + marginBottom: spacing.lg, + ...Platform.select({ + ios: { + shadowColor: colors.black, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + android: { + elevation: 3, + }, + }), }, - expensesTitle: { - marginTop: 16, - marginBottom: 8, - fontSize: 20, - fontWeight: "bold", + summaryTotals: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", }, - memberText: { - fontSize: 16, - lineHeight: 24, + summaryTotal: { + alignItems: "center", }, - fab: { - position: "absolute", - margin: 16, - right: 0, - bottom: 0, + summaryLabel: { + ...typography.caption, + color: colors.textSecondary, + }, + summaryAmount: { + ...typography.h2, + }, + settlementDetails: { + marginTop: spacing.md, + borderTopWidth: 1, + borderTopColor: colors.secondary, + paddingTop: spacing.md, + }, + settlementItem: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: spacing.sm, }, - // Settlement Summary Styles - settlementContainer: { - marginBottom: 16, + settlementText: { + ...typography.body, + color: colors.text, + }, + settlementAmount: { + ...typography.body, + fontWeight: "bold", + color: colors.text, }, settledContainer: { + flexDirection: "row", alignItems: "center", - paddingVertical: 12, + justifyContent: "center", + padding: spacing.md, + backgroundColor: colors.white, + borderRadius: spacing.sm, + marginBottom: spacing.lg, }, settledText: { - fontSize: 16, - color: "#2e7d32", - fontWeight: "500", + ...typography.body, + color: colors.success, + marginLeft: spacing.sm, }, - owedSection: { - backgroundColor: "#ffebee", - borderRadius: 8, - padding: 12, - borderLeftWidth: 4, - borderLeftColor: "#d32f2f", + expensesTitle: { + ...typography.h3, + color: colors.text, + marginBottom: spacing.md, }, - receiveSection: { - backgroundColor: "#e8f5e8", - borderRadius: 8, - padding: 12, - borderLeftWidth: 4, - borderLeftColor: "#2e7d32", + expenseCard: { + backgroundColor: colors.white, + borderRadius: spacing.sm, + padding: spacing.md, + marginBottom: spacing.md, + flexDirection: "row", + alignItems: "center", }, - sectionTitle: { - fontSize: 16, - fontWeight: "600", - marginBottom: 8, - color: "#333", + expenseIcon: { + marginRight: spacing.md, }, - amountOwed: { - color: "#d32f2f", - fontWeight: "bold", + expenseDetails: { + flex: 1, }, - amountReceive: { - color: "#2e7d32", + expenseDescription: { + ...typography.body, fontWeight: "bold", + color: colors.text, }, - settlementItem: { - marginVertical: 4, + expensePaidBy: { + ...typography.caption, + color: colors.textSecondary, }, - personInfo: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 4, + expenseBalance: { + ...typography.body, + marginTop: spacing.xs, }, - personName: { - fontSize: 14, - color: "#555", - flex: 1, + expenseAmount: { + ...typography.h3, + color: colors.text, }, - settlementAmount: { - fontSize: 14, - fontWeight: "600", - color: "#333", + fab: { + position: "absolute", + margin: spacing.md, + right: 0, + bottom: 0, }, emptyText: { - fontSize: 14, - color: "#666", - paddingVertical: 8, + ...typography.body, + color: colors.textSecondary, + textAlign: "center", + marginTop: spacing.lg, }, }); diff --git a/frontend/screens/GroupSettingsScreen.js b/frontend/screens/GroupSettingsScreen.js index 90de5d16..4f49ff29 100644 --- a/frontend/screens/GroupSettingsScreen.js +++ b/frontend/screens/GroupSettingsScreen.js @@ -1,57 +1,53 @@ +import { Ionicons } from "@expo/vector-icons"; import * as ImagePicker from "expo-image-picker"; -import { - useContext, - useEffect, - useLayoutEffect, - useMemo, - useState, -} from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { Alert, - Image, ScrollView, Share, StyleSheet, + TouchableOpacity, View, } from "react-native"; import { ActivityIndicator, + Appbar, Avatar, Button, - Card, + Divider, IconButton, - List, Text, TextInput, } from "react-native-paper"; import { deleteGroup as apiDeleteGroup, - leaveGroup as apiLeaveGroup, - removeMember as apiRemoveMember, - updateGroup as apiUpdateGroup, getGroupById, getGroupMembers, getOptimizedSettlements, + leaveGroup as apiLeaveGroup, + removeMember as apiRemoveMember, + updateGroup as apiUpdateGroup, } from "../api/groups"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; const ICON_CHOICES = ["👥", "🏠", "🎉", "🧳", "🍽️", "🚗", "🏖️", "🎮", "💼"]; const GroupSettingsScreen = ({ route, navigation }) => { const { groupId } = route.params; - const { token, user } = useContext(AuthContext); + const { user } = useContext(AuthContext); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [members, setMembers] = useState([]); const [group, setGroup] = useState(null); const [name, setName] = useState(""); const [icon, setIcon] = useState(""); - const [pickedImage, setPickedImage] = useState(null); // { uri, base64 } + const [pickedImage, setPickedImage] = useState(null); - const isAdmin = useMemo(() => { - const me = members.find((m) => m.userId === user?._id); - return me?.role === "admin"; - }, [members, user?._id]); + const isAdmin = useMemo( + () => members.find((m) => m.userId === user?._id)?.role === "admin", + [members, user?._id] + ); const load = async () => { try { @@ -62,10 +58,9 @@ const GroupSettingsScreen = ({ route, navigation }) => { ]); setGroup(gRes.data); setName(gRes.data.name); - setIcon(gRes.data.imageUrl || gRes.data.icon || ""); + setIcon(gRes.data.imageUrl || ""); setMembers(mRes.data); } catch (e) { - console.error("Failed to load group settings", e); Alert.alert("Error", "Failed to load group settings."); } finally { setLoading(false); @@ -73,132 +68,60 @@ const GroupSettingsScreen = ({ route, navigation }) => { }; useEffect(() => { - if (token && groupId) load(); - }, [token, groupId]); - - useLayoutEffect(() => { - navigation.setOptions({ title: "Group Settings" }); - }, [navigation]); + if (groupId) load(); + }, [groupId]); const onSave = async () => { if (!isAdmin) return; const updates = {}; if (name && name !== group?.name) updates.name = name; - - // Handle different icon types if (pickedImage?.base64) { - // If user picked an image, use it as imageUrl updates.imageUrl = `data:image/jpeg;base64,${pickedImage.base64}`; - } else if (icon && icon !== (group?.imageUrl || group?.icon || "")) { - // If user selected an emoji and it's different from current - // Check if it's an emoji (not a URL) - const isEmoji = ICON_CHOICES.includes(icon); - if (isEmoji) { - updates.imageUrl = icon; // Store emoji as imageUrl for now - } else { - updates.imageUrl = icon; // Store other text/URL as imageUrl - } + } else if (icon !== group?.imageUrl) { + updates.imageUrl = icon; } - if (Object.keys(updates).length === 0) - return Alert.alert("Nothing to update"); + if (Object.keys(updates).length === 0) return; try { setSaving(true); const res = await apiUpdateGroup(groupId, updates); setGroup(res.data); if (pickedImage) setPickedImage(null); - Alert.alert("Updated", "Group updated successfully."); + Alert.alert("Success", "Group updated successfully."); } catch (e) { - console.error("Update failed", e); - Alert.alert( - "Error", - e.response?.data?.detail || "Failed to update group" - ); + Alert.alert("Error", e.response?.data?.detail || "Failed to update."); } finally { setSaving(false); } }; - const pickImage = async () => { - if (!isAdmin) return; - // Ask permissions - const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (status !== "granted") { - Alert.alert( - "Permission required", - "We need media library permission to select an image." - ); - return; - } - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - base64: true, - allowsEditing: true, - aspect: [1, 1], - quality: 0.8, - }); - if (!result.canceled && result.assets && result.assets.length > 0) { - const asset = result.assets[0]; - setPickedImage({ uri: asset.uri, base64: asset.base64 }); - } - }; - - const onShareInvite = async () => { - try { - const code = group?.joinCode; - if (!code) return; - await Share.share({ - message: `Join my group on Splitwiser! Use code ${code}`, - }); - } catch (e) { - console.error("Share failed", e); - } - }; - - const onKick = (memberId, name) => { - if (!isAdmin) return; - if (memberId === user?._id) return; // safeguard - Alert.alert("Remove member", `Are you sure you want to remove ${name}?`, [ - { text: "Cancel", style: "cancel" }, - { - text: "Remove", - style: "destructive", - onPress: async () => { - try { - // Pre-check balances using optimized settlements - const settlementsRes = await getOptimizedSettlements(groupId); - const settlements = - settlementsRes?.data?.optimizedSettlements || []; - const hasUnsettled = settlements.some( - (s) => - (s.fromUserId === memberId || s.toUserId === memberId) && - (s.amount || 0) > 0 - ); - if (hasUnsettled) { - Alert.alert( - "Cannot remove", - "This member has unsettled balances in the group." - ); - return; + const onKick = (memberId, memberName) => { + Alert.alert( + "Kick Member", + `Are you sure you want to kick ${memberName} from the group?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Kick", + style: "destructive", + onPress: async () => { + try { + await apiRemoveMember(groupId, memberId); + setMembers(members.filter((m) => m.userId !== memberId)); + Alert.alert("Success", `${memberName} has been kicked.`); + } catch (error) { + Alert.alert("Error", "Failed to kick member."); } - await apiRemoveMember(groupId, memberId); - await load(); - } catch (e) { - console.error("Remove failed", e); - Alert.alert( - "Error", - e.response?.data?.detail || "Failed to remove member" - ); - } + }, }, - }, - ]); + ] + ); }; const onLeave = () => { Alert.alert( - "Leave group", - "You can leave only when your balances are settled. Continue?", + "Leave Group", + "Are you sure you want to leave this group?", [ { text: "Cancel", style: "cancel" }, { @@ -207,14 +130,9 @@ const GroupSettingsScreen = ({ route, navigation }) => { onPress: async () => { try { await apiLeaveGroup(groupId); - Alert.alert("Left group"); navigation.popToTop(); - } catch (e) { - console.error("Leave failed", e); - Alert.alert( - "Cannot leave", - e.response?.data?.detail || "Please settle balances first" - ); + } catch (error) { + Alert.alert("Error", "Failed to leave group."); } }, }, @@ -222,20 +140,36 @@ const GroupSettingsScreen = ({ route, navigation }) => { ); }; - const onDeleteGroup = () => { + const onShare = async () => { + try { + await Share.share({ + message: `Join my group on MySplitApp! Use this code: ${group?.joinCode}`, + }); + } catch (error) { + Alert.alert("Error", "Failed to share invite code."); + } + }; + + const pickImage = async () => { if (!isAdmin) return; - // Only allow delete if no other members present - const others = members.filter((m) => m.userId !== user?._id); - if (others.length > 0) { - Alert.alert( - "Cannot delete", - "Remove all members first, or transfer admin." - ); - return; + let result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 1, + base64: true, + }); + + if (!result.canceled) { + setPickedImage(result.assets[0]); + setIcon(""); // Clear emoji icon when image is picked } + }; + + const onDeleteGroup = () => { Alert.alert( - "Delete group", - "This will permanently delete the group. Continue?", + "Delete Group", + "Are you sure you want to delete this group? This action is irreversible.", [ { text: "Cancel", style: "cancel" }, { @@ -244,14 +178,9 @@ const GroupSettingsScreen = ({ route, navigation }) => { onPress: async () => { try { await apiDeleteGroup(groupId); - Alert.alert("Group deleted"); navigation.popToTop(); - } catch (e) { - console.error("Delete failed", e); - Alert.alert( - "Error", - e.response?.data?.detail || "Failed to delete group" - ); + } catch (error) { + Alert.alert("Error", "Failed to delete group."); } }, }, @@ -261,166 +190,229 @@ const GroupSettingsScreen = ({ route, navigation }) => { const renderMemberItem = (m) => { const isSelf = m.userId === user?._id; - const displayName = m.user?.name || "Unknown"; - const imageUrl = m.user?.imageUrl; return ( - - imageUrl ? ( - - ) : ( - - ) - } - right={() => - isAdmin && !isSelf ? ( - onKick(m.userId, displayName)} - /> - ) : null - } - /> + + + + {m.user?.name || "Unknown"} + {m.role === "admin" && ( + Admin + )} + + {isAdmin && !isSelf && ( + onKick(m.userId, m.user?.name)} + /> + )} + ); }; if (loading) { return ( - + ); } return ( + + navigation.goBack()} + color={colors.white} + /> + + - - - - - Icon - - {ICON_CHOICES.map((i) => ( - - ))} - - - - {pickedImage?.uri ? ( - - ) : group?.imageUrl && - /^(https?:|data:image)/.test(group.imageUrl) ? ( - - ) : group?.imageUrl ? ( - {group.imageUrl} - ) : null} - - {isAdmin && ( - - )} - - + {i} + + ))} + + + {isAdmin && ( + + )} + - - - {members.map(renderMemberItem)} - + + Members + {members.map(renderMemberItem)} + - - - - - Join Code: {group?.joinCode} - + + Invite + + Join Code: {group?.joinCode} - - + + - - - - - - {isAdmin && ( - - )} - - - + + + Danger Zone + + + {isAdmin && ( + + )} + ); }; const styles = StyleSheet.create({ - container: { flex: 1 }, - scrollContent: { padding: 16 }, - loaderContainer: { flex: 1, justifyContent: "center", alignItems: "center" }, - card: { marginBottom: 16 }, - iconRow: { flexDirection: "row", flexWrap: "wrap", marginBottom: 8 }, - iconBtn: { marginRight: 8, marginBottom: 8 }, + container: { flex: 1, backgroundColor: colors.secondary }, + scrollContent: { padding: spacing.md }, + loaderContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: colors.secondary, + }, + card: { + backgroundColor: colors.white, + borderRadius: spacing.sm, + padding: spacing.md, + marginBottom: spacing.md, + }, + cardTitle: { + ...typography.h3, + marginBottom: spacing.md, + color: colors.text, + }, + input: { + marginBottom: spacing.md, + backgroundColor: colors.white, + }, + label: { + ...typography.body, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + iconRow: { + flexDirection: "row", + flexWrap: "wrap", + marginBottom: spacing.md, + }, + iconBtn: { + padding: spacing.sm, + borderRadius: spacing.sm, + borderWidth: 1, + borderColor: colors.primary, + marginRight: spacing.sm, + marginBottom: spacing.sm, + }, + imageButton: { + borderColor: colors.primary, + }, + saveButton: { + marginTop: spacing.md, + backgroundColor: colors.primary, + }, + memberItem: { + flexDirection: "row", + alignItems: "center", + paddingVertical: spacing.sm, + }, + memberDetails: { + flex: 1, + marginLeft: spacing.md, + }, + memberName: { + ...typography.body, + fontWeight: "bold", + }, + memberRole: { + ...typography.caption, + color: colors.textSecondary, + }, + inviteContent: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + joinCode: { + ...typography.body, + color: colors.text, + }, }); export default GroupSettingsScreen; diff --git a/frontend/screens/HomeScreen.js b/frontend/screens/HomeScreen.js index dfb0eadd..f2481ee4 100644 --- a/frontend/screens/HomeScreen.js +++ b/frontend/screens/HomeScreen.js @@ -1,11 +1,20 @@ -import { useContext, useEffect, useState } from "react"; -import { Alert, FlatList, StyleSheet, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useContext, useEffect, useMemo, useState } from "react"; +import { + Alert, + FlatList, + LayoutAnimation, + Platform, + StyleSheet, + TouchableOpacity, + UIManager, + View, +} from "react-native"; import { ActivityIndicator, Appbar, Avatar, Button, - Card, Modal, Portal, Text, @@ -13,15 +22,23 @@ import { } from "react-native-paper"; import { createGroup, getGroups, getOptimizedSettlements } from "../api/groups"; import { AuthContext } from "../context/AuthContext"; -import { formatCurrency, getCurrencySymbol } from "../utils/currency"; +import { colors, spacing, typography } from "../styles/theme"; +import { formatCurrency } from "../utils/currency"; + +if ( + Platform.OS === "android" && + UIManager.setLayoutAnimationEnabledExperimental +) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} const HomeScreen = ({ navigation }) => { - const { token, logout, user } = useContext(AuthContext); - const [groups, setGroups] = useState([]); + const { token, user } = useContext(AuthContext); + const [activeGroups, setActiveGroups] = useState([]); + const [settledGroups, setSettledGroups] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [groupSettlements, setGroupSettlements] = useState({}); // Track settlement status for each group + const [isSettledExpanded, setIsSettledExpanded] = useState(false); - // State for the Create Group modal const [modalVisible, setModalVisible] = useState(false); const [newGroupName, setNewGroupName] = useState(""); const [isCreatingGroup, setIsCreatingGroup] = useState(false); @@ -29,63 +46,28 @@ const HomeScreen = ({ navigation }) => { const showModal = () => setModalVisible(true); const hideModal = () => setModalVisible(false); - // Calculate settlement status for a group - const calculateSettlementStatus = async (groupId, userId) => { - try { - const response = await getOptimizedSettlements(groupId); - const settlements = response.data.optimizedSettlements || []; - - // Check if user has any pending settlements - const userOwes = settlements.filter((s) => s.fromUserId === userId); - const userIsOwed = settlements.filter((s) => s.toUserId === userId); - - const totalOwed = userOwes.reduce((sum, s) => sum + (s.amount || 0), 0); - const totalToReceive = userIsOwed.reduce( - (sum, s) => sum + (s.amount || 0), - 0 - ); - - return { - isSettled: totalOwed === 0 && totalToReceive === 0, - owesAmount: totalOwed, - owedAmount: totalToReceive, - netBalance: totalToReceive - totalOwed, - }; - } catch (error) { - console.error( - "Failed to fetch settlement status for group:", - groupId, - error - ); - return { - isSettled: true, - owesAmount: 0, - owedAmount: 0, - netBalance: 0, - }; - } - }; - const fetchGroups = async () => { try { setIsLoading(true); const response = await getGroups(); const groupsList = response.data.groups; - setGroups(groupsList); - // Fetch settlement status for each group if (user?._id) { const settlementPromises = groupsList.map(async (group) => { const status = await calculateSettlementStatus(group._id, user._id); - return { groupId: group._id, status }; + return { ...group, settlementStatus: status }; }); + const groupsWithSettlements = await Promise.all(settlementPromises); - const settlementResults = await Promise.all(settlementPromises); - const settlementMap = {}; - settlementResults.forEach(({ groupId, status }) => { - settlementMap[groupId] = status; - }); - setGroupSettlements(settlementMap); + const active = groupsWithSettlements.filter( + (g) => !g.settlementStatus.isSettled + ); + const settled = groupsWithSettlements.filter( + (g) => g.settlementStatus.isSettled + ); + + setActiveGroups(active); + setSettledGroups(settled); } } catch (error) { console.error("Failed to fetch groups:", error); @@ -95,6 +77,23 @@ const HomeScreen = ({ navigation }) => { } }; + const calculateSettlementStatus = async (groupId, userId) => { + try { + const response = await getOptimizedSettlements(groupId); + const settlements = response.data.optimizedSettlements || []; + const userOwes = settlements.filter((s) => s.fromUserId === userId); + const userIsOwed = settlements.filter((s) => s.toUserId === userId); + return { + isSettled: userOwes.length === 0 && userIsOwed.length === 0, + netBalance: + userIsOwed.reduce((sum, s) => sum + s.amount, 0) - + userOwes.reduce((sum, s) => sum + s.amount, 0), + }; + } catch (error) { + return { isSettled: true, netBalance: 0 }; + } + }; + useEffect(() => { if (token) { fetchGroups(); @@ -111,7 +110,7 @@ const HomeScreen = ({ navigation }) => { await createGroup(newGroupName); hideModal(); setNewGroupName(""); - await fetchGroups(); // Refresh the groups list + await fetchGroups(); } catch (error) { console.error("Failed to create group:", error); Alert.alert("Error", "Failed to create group."); @@ -120,77 +119,111 @@ const HomeScreen = ({ navigation }) => { } }; - const currencySymbol = getCurrencySymbol(); - - const renderGroup = ({ item }) => { - const settlementStatus = groupSettlements[item._id]; - - // Generate settlement status text - const getSettlementStatusText = () => { - if (!settlementStatus) { - return "Calculating balances..."; - } - - if (settlementStatus.isSettled) { - return "✓ You are settled up."; - } - - if (settlementStatus.netBalance > 0) { - return `You are owed ${formatCurrency(settlementStatus.netBalance)}.`; - } else if (settlementStatus.netBalance < 0) { - return `You owe ${formatCurrency( - Math.abs(settlementStatus.netBalance) - )}.`; - } + const toggleSettledGroups = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setIsSettledExpanded(!isSettledExpanded); + }; - return "You are settled up."; - }; + const activeGroupRows = useMemo(() => { + const rows = []; + for (let i = 0; i < activeGroups.length; i += 2) { + rows.push(activeGroups.slice(i, i + 2)); + } + return rows; + }, [activeGroups]); - // Get text color based on settlement status - const getStatusColor = () => { - if (!settlementStatus || settlementStatus.isSettled) { - return "#4CAF50"; // Green for settled - } + const renderGroupRow = ({ item: row }) => ( + + {row.map((item) => ( + + navigation.navigate("GroupDetails", { + groupId: item._id, + groupName: item.name, + groupIcon: item.imageUrl || item.name?.charAt(0) || "?", + }) + } + > + {/^(https?:|data:image)/.test(item.imageUrl) ? ( + + ) : ( + + )} + + {item.name} + + 0 + ? colors.success + : colors.error, + }, + ]} + > + {item.settlementStatus.netBalance > 0 + ? `You are owed ${formatCurrency( + item.settlementStatus.netBalance + )}` + : `You owe ${formatCurrency( + Math.abs(item.settlementStatus.netBalance) + )}`} + + + ))} + + ); - if (settlementStatus.netBalance > 0) { - return "#4CAF50"; // Green for being owed money - } else if (settlementStatus.netBalance < 0) { - return "#F44336"; // Red for owing money + const renderSettledGroup = ({ item }) => ( + + navigation.navigate("GroupDetails", { + groupId: item._id, + groupName: item.name, + }) } + > + {item.name} + + + ); - return "#4CAF50"; // Default green - }; + const renderSettledGroupsExpander = () => { + if (settledGroups.length === 0) return null; - const isImage = - item.imageUrl && /^(https?:|data:image)/.test(item.imageUrl); - const groupIcon = item.imageUrl || item.name?.charAt(0) || "?"; return ( - - navigation.navigate("GroupDetails", { - groupId: item._id, - groupName: item.name, - groupIcon, - }) - } - > - - isImage ? ( - - ) : ( - - ) - } - /> - - - {getSettlementStatusText()} - - - + + + Settled Groups + + + {isSettledExpanded && ( + {settledGroups.map((item) => renderSettledGroup({ item }))} + )} + ); }; @@ -208,23 +241,31 @@ const HomeScreen = ({ navigation }) => { value={newGroupName} onChangeText={setNewGroupName} style={styles.input} + theme={{ colors: { primary: colors.accent } }} /> - - - + + + navigation.navigate("JoinGroup", { onGroupJoined: fetchGroups }) } @@ -233,19 +274,24 @@ const HomeScreen = ({ navigation }) => { {isLoading ? ( - + ) : ( item._id} + data={activeGroupRows} + renderItem={renderGroupRow} + keyExtractor={(item, index) => `row-${index}`} contentContainerStyle={styles.list} ListEmptyComponent={ - - No groups found. Create or join one! - + !isLoading && ( + + + No active groups. Create or join one! + + + ) } + ListFooterComponent={renderSettledGroupsExpander} onRefresh={fetchGroups} refreshing={isLoading} /> @@ -257,39 +303,103 @@ const HomeScreen = ({ navigation }) => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, + }, + row: { + flexDirection: "row", + justifyContent: "space-between", }, loaderContainer: { flex: 1, justifyContent: "center", alignItems: "center", + backgroundColor: colors.secondary, }, list: { - padding: 16, + padding: spacing.md, }, card: { - marginBottom: 16, + flex: 1, + backgroundColor: colors.white, + borderRadius: spacing.sm, + padding: spacing.md, + margin: spacing.sm, + alignItems: "center", + shadowColor: colors.black, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + avatar: { + marginBottom: spacing.md, + backgroundColor: colors.primary, + }, + groupName: { + ...typography.h3, + color: colors.text, + textAlign: "center", + marginBottom: spacing.xs, }, settlementStatus: { - fontWeight: "500", - marginTop: 4, + ...typography.body, + textAlign: "center", + }, + settledCard: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: colors.white, + borderRadius: spacing.sm, + padding: spacing.md, + marginBottom: spacing.sm, + }, + settledGroupName: { + ...typography.body, + color: colors.text, + }, + expanderHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: spacing.md, + marginTop: spacing.md, + borderTopWidth: 1, + borderTopColor: colors.secondary, + }, + expanderTitle: { + ...typography.h3, + color: colors.textSecondary, + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + marginTop: 100, + width: "100%", }, emptyText: { - textAlign: "center", - marginTop: 20, + ...typography.body, + color: colors.textSecondary, }, modalContainer: { - backgroundColor: "white", - padding: 20, - margin: 20, - borderRadius: 8, + backgroundColor: colors.white, + padding: spacing.lg, + margin: spacing.lg, + borderRadius: spacing.sm, }, modalTitle: { - fontSize: 20, - marginBottom: 20, + ...typography.h2, + color: colors.text, textAlign: "center", + marginBottom: spacing.lg, }, input: { - marginBottom: 20, + marginBottom: spacing.lg, + backgroundColor: colors.secondary, + }, + createButton: { + backgroundColor: colors.primary, }, }); diff --git a/frontend/screens/JoinGroupScreen.js b/frontend/screens/JoinGroupScreen.js index 153e4eac..49386077 100644 --- a/frontend/screens/JoinGroupScreen.js +++ b/frontend/screens/JoinGroupScreen.js @@ -1,8 +1,9 @@ import { useContext, useState } from "react"; -import { Alert, StyleSheet, View } from "react-native"; -import { Appbar, Button, TextInput, Title } from "react-native-paper"; +import { Alert, StyleSheet, View, Text } from "react-native"; +import { Appbar, Button, TextInput } from "react-native-paper"; import { joinGroup } from "../api/groups"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; const JoinGroupScreen = ({ navigation, route }) => { const { token } = useContext(AuthContext); @@ -19,7 +20,7 @@ const JoinGroupScreen = ({ navigation, route }) => { try { await joinGroup(joinCode); Alert.alert("Success", "Successfully joined the group."); - onGroupJoined(); // Call the callback to refresh the groups list + onGroupJoined(); navigation.goBack(); } catch (error) { console.error("Failed to join group:", error); @@ -34,18 +35,26 @@ const JoinGroupScreen = ({ navigation, route }) => { return ( - - navigation.goBack()} /> - + + navigation.goBack()} + color={colors.white} + /> + - Enter Group Code + Enter Group Code @@ -64,15 +74,30 @@ const JoinGroupScreen = ({ navigation, route }) => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, content: { - padding: 16, + padding: spacing.lg, + }, + title: { + ...typography.h2, + color: colors.text, + marginBottom: spacing.lg, + textAlign: "center", }, input: { - marginBottom: 16, + marginBottom: spacing.lg, + backgroundColor: colors.white, }, button: { - marginTop: 8, + marginTop: spacing.md, + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + }, + buttonLabel: { + ...typography.body, + color: colors.white, + fontWeight: "bold", }, }); diff --git a/frontend/screens/LoginScreen.js b/frontend/screens/LoginScreen.js index 076e9956..729dfeb5 100644 --- a/frontend/screens/LoginScreen.js +++ b/frontend/screens/LoginScreen.js @@ -1,13 +1,14 @@ import React, { useState, useContext } from 'react'; -import { View, StyleSheet, Alert } from 'react-native'; -import { Button, Text, TextInput } from 'react-native-paper'; +import { View, StyleSheet, Alert, Text } from 'react-native'; +import { Button, TextInput } from 'react-native-paper'; import { AuthContext } from '../context/AuthContext'; +import { colors, spacing, typography } from '../styles/theme'; const LoginScreen = ({ navigation }) => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); - const { login } = useContext(AuthContext); + const { login, loginWithGoogle } = useContext(AuthContext); const handleLogin = async () => { if (!email || !password) { @@ -22,9 +23,21 @@ const LoginScreen = ({ navigation }) => { } }; + const handleGoogleSignIn = async () => { + setIsLoading(true); + try { + await loginWithGoogle(); + } catch (error) { + console.error('Google Sign-In error:', error); + Alert.alert('Google Sign-In Failed', 'An error occurred during Google Sign-In. Please try again.'); + } finally { + setIsLoading(false); + } + }; + return ( - Welcome Back! + Welcome Back! { style={styles.input} keyboardType="email-address" autoCapitalize="none" + theme={{ colors: { primary: colors.accent } }} /> { onChangeText={setPassword} style={styles.input} secureTextEntry + theme={{ colors: { primary: colors.accent } }} /> - + @@ -60,17 +90,37 @@ const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', - padding: 16, + padding: spacing.lg, + backgroundColor: colors.secondary, }, title: { + ...typography.h1, + color: colors.primary, textAlign: 'center', - marginBottom: 24, + marginBottom: spacing.xl, }, input: { - marginBottom: 16, + marginBottom: spacing.md, + backgroundColor: colors.white, }, button: { - marginTop: 8, + marginTop: spacing.md, + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + }, + googleButton: { + backgroundColor: '#4285F4', // Google's brand color + }, + buttonLabel: { + ...typography.body, + color: colors.white, + fontWeight: 'bold', + }, + signupButton: { + marginTop: spacing.md, + }, + signupButtonLabel: { + color: colors.primary, }, }); diff --git a/frontend/screens/SignupScreen.js b/frontend/screens/SignupScreen.js index 5594be60..04bca7e2 100644 --- a/frontend/screens/SignupScreen.js +++ b/frontend/screens/SignupScreen.js @@ -1,7 +1,8 @@ import React, { useState, useContext } from 'react'; -import { View, StyleSheet, Alert } from 'react-native'; -import { Button, Text, TextInput } from 'react-native-paper'; +import { View, StyleSheet, Alert, Text } from 'react-native'; +import { Button, TextInput } from 'react-native-paper'; import { AuthContext } from '../context/AuthContext'; +import { colors, spacing, typography } from '../styles/theme'; const SignupScreen = ({ navigation }) => { const [name, setName] = useState(''); @@ -9,7 +10,7 @@ const SignupScreen = ({ navigation }) => { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); - const { signup } = useContext(AuthContext); + const { signup, loginWithGoogle } = useContext(AuthContext); const handleSignup = async () => { if (!name || !email || !password || !confirmPassword) { @@ -34,15 +35,28 @@ const SignupScreen = ({ navigation }) => { } }; + const handleGoogleSignIn = async () => { + setIsLoading(true); + try { + await loginWithGoogle(); + } catch (error) { + console.error('Google Sign-In error:', error); + Alert.alert('Google Sign-In Failed', 'An error occurred during Google Sign-In. Please try again.'); + } finally { + setIsLoading(false); + } + }; + return ( - Create Account + Create Account { style={styles.input} keyboardType="email-address" autoCapitalize="none" + theme={{ colors: { primary: colors.accent } }} /> { onChangeText={setPassword} style={styles.input} secureTextEntry + theme={{ colors: { primary: colors.accent } }} /> { onChangeText={setConfirmPassword} style={styles.input} secureTextEntry + theme={{ colors: { primary: colors.accent } }} /> - + @@ -86,17 +119,37 @@ const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', - padding: 16, + padding: spacing.lg, + backgroundColor: colors.secondary, }, title: { + ...typography.h1, + color: colors.primary, textAlign: 'center', - marginBottom: 24, + marginBottom: spacing.xl, }, input: { - marginBottom: 16, + marginBottom: spacing.md, + backgroundColor: colors.white, }, button: { - marginTop: 8, + marginTop: spacing.md, + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + }, + googleButton: { + backgroundColor: '#4285F4', // Google's brand color + }, + buttonLabel: { + ...typography.body, + color: colors.white, + fontWeight: 'bold', + }, + loginButton: { + marginTop: spacing.md, + }, + loginButtonLabel: { + color: colors.primary, }, }); diff --git a/frontend/styles/theme.js b/frontend/styles/theme.js new file mode 100644 index 00000000..28d28c3b --- /dev/null +++ b/frontend/styles/theme.js @@ -0,0 +1,42 @@ +export const colors = { + primary: "#1E3A8A", + secondary: "#F3F4F6", + accent: "#3B82F6", + text: "#111827", + textSecondary: "#6B7280", + success: "#10B981", + error: "#EF4444", + white: "#FFFFFF", + black: "#000000", +}; + +export const typography = { + h1: { + fontSize: 32, + fontWeight: "bold", + }, + h2: { + fontSize: 24, + fontWeight: "bold", + }, + h3: { + fontSize: 20, + fontWeight: "bold", + }, + body: { + fontSize: 16, + fontWeight: "normal", + }, + caption: { + fontSize: 12, + fontWeight: "normal", + }, +}; + +export const spacing = { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, +};