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 } }}
/>
-
+
);
};
@@ -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 } }}
/>
Sign Up
- navigation.navigate('Login')} style={styles.button} disabled={isLoading}>
+
+ Sign up with Google
+
+ navigation.navigate('Login')}
+ style={styles.loginButton}
+ labelStyle={styles.loginButtonLabel}
+ disabled={isLoading}
+ >
Already have an account? Log In
@@ -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,
+};