From 5da289e8b92db2d6e58a2b6a47e376ca86911995 Mon Sep 17 00:00:00 2001
From: Devasy Patel <110348311+Devasy23@users.noreply.github.com>
Date: Fri, 8 Aug 2025 22:19:14 +0530
Subject: [PATCH 01/14] feat: Add group settings functionality and enhance
group details screen
---
frontend/api/groups.js | 49 ++++
frontend/navigation/GroupsStackNavigator.js | 7 +-
frontend/screens/GroupDetailsScreen.js | 36 ++-
frontend/screens/GroupSettingsScreen.js | 268 ++++++++++++++++++++
frontend/screens/HomeScreen.js | 10 +-
5 files changed, 360 insertions(+), 10 deletions(-)
create mode 100644 frontend/screens/GroupSettingsScreen.js
diff --git a/frontend/api/groups.js b/frontend/api/groups.js
index a3dab453..ff74cafe 100644
--- a/frontend/api/groups.js
+++ b/frontend/api/groups.js
@@ -90,3 +90,52 @@ export const getFriendsBalance = (token) => {
},
});
};
+
+// New APIs for Group Settings
+export const getGroupById = (token, groupId) => {
+ return apiClient.get(`/groups/${groupId}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+};
+
+export const updateGroup = (token, groupId, updates) => {
+ return apiClient.patch(`/groups/${groupId}`, updates, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+};
+
+export const deleteGroup = (token, groupId) => {
+ return apiClient.delete(`/groups/${groupId}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+};
+
+export const leaveGroup = (token, groupId) => {
+ return apiClient.post(`/groups/${groupId}/leave`, {}, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+};
+
+export const updateMemberRole = (token, groupId, memberId, role) => {
+ return apiClient.patch(`/groups/${groupId}/members/${memberId}`, { role }, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+};
+
+export const removeMember = (token, groupId, memberId) => {
+ return apiClient.delete(`/groups/${groupId}/members/${memberId}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+};
diff --git a/frontend/navigation/GroupsStackNavigator.js b/frontend/navigation/GroupsStackNavigator.js
index 3851559a..5ede954d 100644
--- a/frontend/navigation/GroupsStackNavigator.js
+++ b/frontend/navigation/GroupsStackNavigator.js
@@ -1,8 +1,8 @@
-import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
-import HomeScreen from '../screens/HomeScreen';
-import GroupDetailsScreen from '../screens/GroupDetailsScreen';
import AddExpenseScreen from '../screens/AddExpenseScreen';
+import GroupDetailsScreen from '../screens/GroupDetailsScreen';
+import GroupSettingsScreen from '../screens/GroupSettingsScreen';
+import HomeScreen from '../screens/HomeScreen';
import JoinGroupScreen from '../screens/JoinGroupScreen';
const Stack = createNativeStackNavigator();
@@ -14,6 +14,7 @@ const GroupsStackNavigator = () => {
+
);
};
diff --git a/frontend/screens/GroupDetailsScreen.js b/frontend/screens/GroupDetailsScreen.js
index 11fcc467..f689bc22 100644
--- a/frontend/screens/GroupDetailsScreen.js
+++ b/frontend/screens/GroupDetailsScreen.js
@@ -1,6 +1,6 @@
import { useContext, useEffect, useState } from 'react';
import { Alert, FlatList, StyleSheet, Text, View } from 'react-native';
-import { ActivityIndicator, Card, FAB, Paragraph, Title } from 'react-native-paper';
+import { ActivityIndicator, Avatar, Card, FAB, IconButton, Paragraph, Title } from 'react-native-paper';
import { getGroupExpenses, getGroupMembers, getOptimizedSettlements } from '../api/groups';
import { AuthContext } from '../context/AuthContext';
@@ -39,7 +39,12 @@ const GroupDetailsScreen = ({ route, navigation }) => {
};
useEffect(() => {
- navigation.setOptions({ title: groupName });
+ navigation.setOptions({
+ title: groupName,
+ headerRight: () => (
+ navigation.navigate('GroupSettings', { groupId })} />
+ ),
+ });
if (token && groupId) {
fetchData();
}
@@ -151,9 +156,18 @@ const GroupDetailsScreen = ({ route, navigation }) => {
Members
- {members.map((item) => (
- • {item.user.name}
- ))}
+
+ {members.map((item) => (
+
+ {item.user?.imageUrl ? (
+
+ ) : (
+
+ )}
+ {item.user?.name}
+
+ ))}
+
@@ -213,6 +227,18 @@ const styles = StyleSheet.create({
fontSize: 16,
lineHeight: 24,
},
+ memberList: {
+ gap: 8,
+ },
+ memberRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingVertical: 2,
+ },
+ memberName: {
+ fontSize: 14,
+ },
fab: {
position: 'absolute',
margin: 16,
diff --git a/frontend/screens/GroupSettingsScreen.js b/frontend/screens/GroupSettingsScreen.js
new file mode 100644
index 00000000..bd33ebf8
--- /dev/null
+++ b/frontend/screens/GroupSettingsScreen.js
@@ -0,0 +1,268 @@
+import { useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react';
+import { Alert, Share, StyleSheet, View } from 'react-native';
+import { ActivityIndicator, Avatar, Button, Card, IconButton, List, Text, TextInput } from 'react-native-paper';
+import {
+ deleteGroup as apiDeleteGroup,
+ leaveGroup as apiLeaveGroup,
+ removeMember as apiRemoveMember,
+ updateGroup as apiUpdateGroup,
+ getGroupById,
+ getGroupMembers,
+} from '../api/groups';
+import { AuthContext } from '../context/AuthContext';
+
+const ICON_CHOICES = ['👥', '🏠', '🎉', '🧳', '🍽️', '🚗', '🏖️', '🎮', '💼'];
+
+const GroupSettingsScreen = ({ route, navigation }) => {
+ const { groupId } = route.params;
+ const { token, 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 isAdmin = useMemo(() => {
+ const me = members.find(m => m.userId === user?._id);
+ return me?.role === 'admin';
+ }, [members, user?._id]);
+
+ const load = async () => {
+ try {
+ setLoading(true);
+ const [gRes, mRes] = await Promise.all([
+ getGroupById(token, groupId),
+ getGroupMembers(token, groupId),
+ ]);
+ setGroup(gRes.data);
+ setName(gRes.data.name);
+ setIcon(gRes.data.imageUrl || gRes.data.icon || '');
+ setMembers(mRes.data);
+ } catch (e) {
+ console.error('Failed to load group settings', e);
+ Alert.alert('Error', 'Failed to load group settings.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (token && groupId) load();
+ }, [token, groupId]);
+
+ useLayoutEffect(() => {
+ navigation.setOptions({ title: 'Group Settings' });
+ }, [navigation]);
+
+ const onSave = async () => {
+ if (!isAdmin) return;
+ const updates = {};
+ if (name && name !== group?.name) updates.name = name;
+ if (icon && icon !== (group?.imageUrl || group?.icon)) updates.imageUrl = icon;
+ if (Object.keys(updates).length === 0) return Alert.alert('Nothing to update');
+ try {
+ setSaving(true);
+ const res = await apiUpdateGroup(token, groupId, updates);
+ setGroup(res.data);
+ Alert.alert('Updated', 'Group updated successfully.');
+ } catch (e) {
+ console.error('Update failed', e);
+ Alert.alert('Error', e.response?.data?.detail || 'Failed to update group');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ 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 {
+ await apiRemoveMember(token, 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?',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Leave', style: 'destructive', onPress: async () => {
+ try {
+ await apiLeaveGroup(token, 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');
+ }
+ }
+ }
+ ]
+ );
+ };
+
+ const onDeleteGroup = () => {
+ 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;
+ }
+ Alert.alert(
+ 'Delete group',
+ 'This will permanently delete the group. Continue?',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete', style: 'destructive', onPress: async () => {
+ try {
+ await apiDeleteGroup(token, 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');
+ }
+ }
+ }
+ ]
+ );
+ };
+
+ 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
+ )}
+ />
+ );
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Icon (emoji or image URL)
+
+ {ICON_CHOICES.map(i => (
+
+ ))}
+
+
+ {isAdmin && (
+
+ )}
+
+
+
+
+
+
+ {members.map(renderMemberItem)}
+
+
+
+
+
+
+ Join Code: {group?.joinCode}
+
+
+
+
+
+
+
+
+
+ {isAdmin && (
+
+ )}
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, padding: 16 },
+ loaderContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
+ card: { marginBottom: 16 },
+ iconRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 8 },
+ iconBtn: { marginRight: 8, marginBottom: 8 },
+});
+
+export default GroupSettingsScreen;
diff --git a/frontend/screens/HomeScreen.js b/frontend/screens/HomeScreen.js
index 226aea44..e435308a 100644
--- a/frontend/screens/HomeScreen.js
+++ b/frontend/screens/HomeScreen.js
@@ -139,11 +139,17 @@ const HomeScreen = ({ navigation }) => {
return '#4CAF50'; // Default green
};
+ const isImage = item.imageUrl && /^(https?:)/.test(item.imageUrl);
+ const groupIcon = item.imageUrl || (item.name?.charAt(0) || '?');
return (
- navigation.navigate('GroupDetails', { groupId: item._id, groupName: item.name, groupIcon: item.icon })}>
+ navigation.navigate('GroupDetails', { groupId: item._id, groupName: item.name, groupIcon })}>
}
+ left={(props) => isImage ? (
+
+ ) : (
+
+ )}
/>
Join Code: {item.joinCode}
From df20f0321fbed15894d41326ea9584030d1222d7 Mon Sep 17 00:00:00 2001
From: Devasy Patel <110348311+Devasy23@users.noreply.github.com>
Date: Fri, 8 Aug 2025 22:29:11 +0530
Subject: [PATCH 02/14] Refactor screens for improved readability and
consistency
- Updated import statements to use consistent formatting across GroupDetailsScreen, GroupSettingsScreen, HomeScreen, and JoinGroupScreen.
- Changed string literals to use double quotes for consistency.
- Simplified API calls by removing unnecessary token parameters in group-related API functions.
- Enhanced error handling and user feedback with clearer alert messages.
- Improved layout and styling for better user experience in various components.
- Refactored settlement status calculations and rendering logic for clarity.
---
frontend/api/auth.js | 25 +-
frontend/api/client.js | 98 +++++
frontend/api/groups.js | 154 ++-----
frontend/context/AuthContext.js | 103 ++++-
frontend/screens/AddExpenseScreen.js | 527 ++++++++++++++----------
frontend/screens/FriendsScreen.js | 223 +++++-----
frontend/screens/GroupDetailsScreen.js | 214 ++++++----
frontend/screens/GroupSettingsScreen.js | 239 +++++++----
frontend/screens/HomeScreen.js | 175 +++++---
frontend/screens/JoinGroupScreen.js | 25 +-
10 files changed, 1035 insertions(+), 748 deletions(-)
create mode 100644 frontend/api/client.js
diff --git a/frontend/api/auth.js b/frontend/api/auth.js
index c1ffc58a..7524c3e7 100644
--- a/frontend/api/auth.js
+++ b/frontend/api/auth.js
@@ -1,26 +1,17 @@
-import axios from 'axios';
-
-const API_URL = 'https://splitwiser-production.up.railway.app';
-
-const apiClient = axios.create({
- baseURL: API_URL,
- headers: {
- 'Content-Type': 'application/json',
- },
-});
+import { apiClient } from "./client";
export const login = (email, password) => {
- return apiClient.post('/auth/login/email', { email, password });
+ return apiClient.post("/auth/login/email", { email, password });
};
export const signup = (name, email, password) => {
- return apiClient.post('/auth/signup/email', { name, email, password });
+ return apiClient.post("/auth/signup/email", { name, email, password });
};
export const updateUser = (token, userData) => {
- return apiClient.patch('/user/', userData, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
+ return apiClient.patch("/user/", userData);
+};
+
+export const refresh = (refresh_token) => {
+ return apiClient.post("/auth/refresh", { refresh_token });
};
diff --git a/frontend/api/client.js b/frontend/api/client.js
new file mode 100644
index 00000000..b8468c08
--- /dev/null
+++ b/frontend/api/client.js
@@ -0,0 +1,98 @@
+import axios from "axios";
+
+const API_URL = "https://splitwiser-production.up.railway.app";
+
+let accessToken = null;
+let refreshToken = null;
+let isRefreshing = false;
+let refreshPromise = null;
+let tokenUpdateListener = null; // function({ accessToken, refreshToken })
+
+export const setTokenUpdateListener = (fn) => {
+ tokenUpdateListener = fn;
+};
+
+export const setAuthTokens = async ({ newAccessToken, newRefreshToken }) => {
+ if (newAccessToken) accessToken = newAccessToken;
+ if (newRefreshToken) refreshToken = newRefreshToken;
+};
+
+export const clearAuthTokens = async () => {
+ accessToken = null;
+ refreshToken = null;
+};
+
+export const getAccessToken = () => accessToken;
+export const getRefreshToken = () => refreshToken;
+
+export const apiClient = axios.create({
+ baseURL: API_URL,
+ headers: { "Content-Type": "application/json" },
+});
+
+// Attach Authorization header
+apiClient.interceptors.request.use((config) => {
+ if (accessToken && !config.headers?.Authorization) {
+ config.headers = config.headers || {};
+ config.headers.Authorization = `Bearer ${accessToken}`;
+ }
+ return config;
+});
+
+async function performRefresh() {
+ // Avoid multiple refresh calls
+ if (isRefreshing && refreshPromise) return refreshPromise;
+ isRefreshing = true;
+ refreshPromise = (async () => {
+ try {
+ if (!refreshToken) throw new Error("No refresh token");
+ const resp = await axios.post(
+ `${API_URL}/auth/refresh`,
+ { refresh_token: refreshToken },
+ { headers: { "Content-Type": "application/json" } }
+ );
+ const { access_token, refresh_token } = resp.data || {};
+ accessToken = access_token || accessToken;
+ refreshToken = refresh_token || refreshToken;
+ // Notify listener (AuthContext) so it can persist & update state
+ if (tokenUpdateListener) {
+ tokenUpdateListener({ accessToken, refreshToken });
+ }
+ return accessToken;
+ } finally {
+ isRefreshing = false;
+ const p = refreshPromise; // preserve for awaiting callers
+ refreshPromise = null;
+ return p; // not used
+ }
+ })();
+ return refreshPromise;
+}
+
+// Retry logic on 401
+apiClient.interceptors.response.use(
+ (response) => response,
+ async (error) => {
+ const originalRequest = error.config || {};
+ const status = error.response?.status;
+
+ // Avoid refresh loop
+ const isAuthRefreshCall = originalRequest.url?.includes("/auth/refresh");
+
+ if (status === 401 && !originalRequest._retry && !isAuthRefreshCall) {
+ originalRequest._retry = true;
+ try {
+ await performRefresh();
+ // Set new Authorization and retry
+ originalRequest.headers = originalRequest.headers || {};
+ if (accessToken)
+ originalRequest.headers.Authorization = `Bearer ${accessToken}`;
+ return apiClient(originalRequest);
+ } catch (e) {
+ // Propagate original error; caller should handle logout
+ return Promise.reject(error);
+ }
+ }
+ return Promise.reject(error);
+ }
+);
diff --git a/frontend/api/groups.js b/frontend/api/groups.js
index ff74cafe..8cf9cdba 100644
--- a/frontend/api/groups.js
+++ b/frontend/api/groups.js
@@ -1,141 +1,47 @@
-import axios from 'axios';
-
-const API_URL = 'https://splitwiser-production.up.railway.app';
-
-// This creates a new axios instance.
-// It's better to have a single instance that can be configured with interceptors.
-// I will create a single apiClient in a separate file later if needed.
-const apiClient = axios.create({
- baseURL: API_URL,
- headers: {
- 'Content-Type': 'application/json',
- },
-});
-
-export const getGroups = (token) => {
- return apiClient.get('/groups', {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+import { apiClient } from "./client";
-export const getOptimizedSettlements = (token, groupId) => {
- return apiClient.post(`/groups/${groupId}/settlements/optimize`, {}, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const getGroups = () => apiClient.get("/groups");
-export const createExpense = (token, groupId, expenseData) => {
- return apiClient.post(`/groups/${groupId}/expenses`, expenseData, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const getOptimizedSettlements = (groupId) =>
+ apiClient.post(`/groups/${groupId}/settlements/optimize`, {});
-export const getGroupDetails = (token, groupId) => {
- return Promise.all([
- getGroupMembers(token, groupId),
- getGroupExpenses(token, groupId),
- ]);
-};
+export const createExpense = (groupId, expenseData) =>
+ apiClient.post(`/groups/${groupId}/expenses`, expenseData);
-export const getGroupMembers = (token, groupId) => {
- return apiClient.get(`/groups/${groupId}/members`, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
+export const getGroupDetails = (groupId) => {
+ return Promise.all([getGroupMembers(groupId), getGroupExpenses(groupId)]);
};
-export const getGroupExpenses = (token, groupId) => {
- return apiClient.get(`/groups/${groupId}/expenses`, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const getGroupMembers = (groupId) =>
+ apiClient.get(`/groups/${groupId}/members`);
-export const createGroup = (token, name) => {
- return apiClient.post('/groups', { name }, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const getGroupExpenses = (groupId) =>
+ apiClient.get(`/groups/${groupId}/expenses`);
-export const joinGroup = (token, joinCode) => {
- return apiClient.post('/groups/join', { joinCode }, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const createGroup = (name) => apiClient.post("/groups", { name });
-export const getUserBalanceSummary = (token) => {
- return apiClient.get('/users/me/balance-summary', {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const joinGroup = (joinCode) =>
+ apiClient.post("/groups/join", { joinCode });
-export const getFriendsBalance = (token) => {
- return apiClient.get('/users/me/friends-balance', {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const getUserBalanceSummary = () =>
+ apiClient.get("/users/me/balance-summary");
+
+export const getFriendsBalance = () =>
+ apiClient.get("/users/me/friends-balance");
// New APIs for Group Settings
-export const getGroupById = (token, groupId) => {
- return apiClient.get(`/groups/${groupId}`, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const getGroupById = (groupId) => apiClient.get(`/groups/${groupId}`);
-export const updateGroup = (token, groupId, updates) => {
- return apiClient.patch(`/groups/${groupId}`, updates, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const updateGroup = (groupId, updates) =>
+ apiClient.patch(`/groups/${groupId}`, updates);
-export const deleteGroup = (token, groupId) => {
- return apiClient.delete(`/groups/${groupId}`, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const deleteGroup = (groupId) => apiClient.delete(`/groups/${groupId}`);
-export const leaveGroup = (token, groupId) => {
- return apiClient.post(`/groups/${groupId}/leave`, {}, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const leaveGroup = (groupId) =>
+ apiClient.post(`/groups/${groupId}/leave`, {});
-export const updateMemberRole = (token, groupId, memberId, role) => {
- return apiClient.patch(`/groups/${groupId}/members/${memberId}`, { role }, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const updateMemberRole = (groupId, memberId, role) =>
+ apiClient.patch(`/groups/${groupId}/members/${memberId}`, { role });
-export const removeMember = (token, groupId, memberId) => {
- return apiClient.delete(`/groups/${groupId}/members/${memberId}`, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-};
+export const removeMember = (groupId, memberId) =>
+ apiClient.delete(`/groups/${groupId}/members/${memberId}`);
diff --git a/frontend/context/AuthContext.js b/frontend/context/AuthContext.js
index aa24688b..f7d0e933 100644
--- a/frontend/context/AuthContext.js
+++ b/frontend/context/AuthContext.js
@@ -1,27 +1,39 @@
-import AsyncStorage from '@react-native-async-storage/async-storage';
-import { createContext, useEffect, useState } from 'react';
-import * as authApi from '../api/auth';
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { createContext, useEffect, useState } from "react";
+import * as authApi from "../api/auth";
+import {
+ clearAuthTokens,
+ setAuthTokens,
+ setTokenUpdateListener,
+} from "../api/client";
export const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
+ const [refresh, setRefresh] = useState(null);
const [isLoading, setIsLoading] = useState(true);
// Load token and user data from AsyncStorage on app start
useEffect(() => {
const loadStoredAuth = async () => {
try {
- const storedToken = await AsyncStorage.getItem('auth_token');
- const storedUser = await AsyncStorage.getItem('user_data');
-
+ const storedToken = await AsyncStorage.getItem("auth_token");
+ const storedRefresh = await AsyncStorage.getItem("refresh_token");
+ const storedUser = await AsyncStorage.getItem("user_data");
+
if (storedToken && storedUser) {
setToken(storedToken);
+ setRefresh(storedRefresh);
+ await setAuthTokens({
+ newAccessToken: storedToken,
+ newRefreshToken: storedRefresh,
+ });
setUser(JSON.parse(storedUser));
}
} catch (error) {
- console.error('Failed to load stored authentication:', error);
+ console.error("Failed to load stored authentication:", error);
} finally {
setIsLoading(false);
}
@@ -30,34 +42,57 @@ export const AuthProvider = ({ children }) => {
loadStoredAuth();
}, []);
- // Save token to AsyncStorage whenever it changes
+ // Subscribe to token updates from the api client (refresh flow)
+ useEffect(() => {
+ setTokenUpdateListener(async ({ accessToken, refreshToken }) => {
+ if (accessToken && accessToken !== token) setToken(accessToken);
+ if (refreshToken && refreshToken !== refresh) setRefresh(refreshToken);
+ });
+ }, [token, refresh]);
+
+ // Save tokens to AsyncStorage whenever they change
useEffect(() => {
const saveToken = async () => {
try {
if (token) {
- await AsyncStorage.setItem('auth_token', token);
+ await AsyncStorage.setItem("auth_token", token);
} else {
- await AsyncStorage.removeItem('auth_token');
+ await AsyncStorage.removeItem("auth_token");
}
} catch (error) {
- console.error('Failed to save token to storage:', error);
+ console.error("Failed to save token to storage:", error);
}
};
saveToken();
}, [token]);
+ useEffect(() => {
+ const saveRefresh = async () => {
+ try {
+ if (refresh) {
+ await AsyncStorage.setItem("refresh_token", refresh);
+ } else {
+ await AsyncStorage.removeItem("refresh_token");
+ }
+ } catch (error) {
+ console.error("Failed to save refresh token to storage:", error);
+ }
+ };
+ saveRefresh();
+ }, [refresh]);
+
// Save user data to AsyncStorage whenever it changes
useEffect(() => {
const saveUser = async () => {
try {
if (user) {
- await AsyncStorage.setItem('user_data', JSON.stringify(user));
+ await AsyncStorage.setItem("user_data", JSON.stringify(user));
} else {
- await AsyncStorage.removeItem('user_data');
+ await AsyncStorage.removeItem("user_data");
}
} catch (error) {
- console.error('Failed to save user data to storage:', error);
+ console.error("Failed to save user data to storage:", error);
}
};
@@ -67,12 +102,20 @@ export const AuthProvider = ({ children }) => {
const login = async (email, password) => {
try {
const response = await authApi.login(email, password);
- const { access_token, user: userData } = response.data;
+ const { access_token, refresh_token, user: userData } = response.data;
setToken(access_token);
+ setRefresh(refresh_token);
+ await setAuthTokens({
+ newAccessToken: access_token,
+ newRefreshToken: refresh_token,
+ });
setUser(userData);
return true;
} catch (error) {
- console.error('Login failed:', error.response?.data?.detail || error.message);
+ console.error(
+ "Login failed:",
+ error.response?.data?.detail || error.message
+ );
return false;
}
};
@@ -82,7 +125,10 @@ export const AuthProvider = ({ children }) => {
await authApi.signup(name, email, password);
return true;
} catch (error) {
- console.error('Signup failed:', error.response?.data?.detail || error.message);
+ console.error(
+ "Signup failed:",
+ error.response?.data?.detail || error.message
+ );
return false;
}
};
@@ -90,14 +136,17 @@ export const AuthProvider = ({ children }) => {
const logout = async () => {
try {
// Clear stored authentication data
- await AsyncStorage.removeItem('auth_token');
- await AsyncStorage.removeItem('user_data');
+ await AsyncStorage.removeItem("auth_token");
+ await AsyncStorage.removeItem("refresh_token");
+ await AsyncStorage.removeItem("user_data");
} catch (error) {
- console.error('Failed to clear stored authentication:', error);
+ console.error("Failed to clear stored authentication:", error);
}
-
+
setToken(null);
+ setRefresh(null);
setUser(null);
+ await clearAuthTokens();
};
const updateUserInContext = (updatedUser) => {
@@ -105,7 +154,17 @@ export const AuthProvider = ({ children }) => {
};
return (
-
+
{children}
);
diff --git a/frontend/screens/AddExpenseScreen.js b/frontend/screens/AddExpenseScreen.js
index 2d8c6eba..59cb65ed 100644
--- a/frontend/screens/AddExpenseScreen.js
+++ b/frontend/screens/AddExpenseScreen.js
@@ -1,18 +1,34 @@
-import { useContext, useEffect, useState } from 'react';
-import { Alert, KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native';
-import { ActivityIndicator, Button, Checkbox, Menu, Paragraph, SegmentedButtons, Text, TextInput, Title } from 'react-native-paper';
-import { createExpense, getGroupMembers } from '../api/groups';
-import { AuthContext } from '../context/AuthContext';
+import { useContext, useEffect, useState } from "react";
+import {
+ Alert,
+ KeyboardAvoidingView,
+ Platform,
+ StyleSheet,
+ View,
+} from "react-native";
+import {
+ ActivityIndicator,
+ Button,
+ Checkbox,
+ Menu,
+ Paragraph,
+ SegmentedButtons,
+ Text,
+ TextInput,
+ Title,
+} from "react-native-paper";
+import { createExpense, getGroupMembers } from "../api/groups";
+import { AuthContext } from "../context/AuthContext";
const AddExpenseScreen = ({ route, navigation }) => {
const { groupId } = route.params;
const { token, user } = useContext(AuthContext);
- const [description, setDescription] = useState('');
- const [amount, setAmount] = useState('');
+ 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 [splitMethod, setSplitMethod] = useState("equal");
const [payerId, setPayerId] = useState(null); // Initialize as null until members are loaded
const [menuVisible, setMenuVisible] = useState(false);
@@ -25,7 +41,7 @@ const AddExpenseScreen = ({ route, navigation }) => {
useEffect(() => {
const fetchMembers = async () => {
try {
- const response = await getGroupMembers(token, groupId);
+ const response = await getGroupMembers(groupId);
setMembers(response.data);
// Initialize split states
const initialShares = {};
@@ -33,14 +49,14 @@ const AddExpenseScreen = ({ route, navigation }) => {
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);
-
+ const remainder = 100 - basePercentage * numMembers;
+
response.data.forEach((member, index) => {
- initialShares[member.userId] = '1';
-
+ 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)
@@ -48,25 +64,27 @@ const AddExpenseScreen = ({ route, navigation }) => {
memberPercentage += 1;
}
initialPercentages[member.userId] = memberPercentage.toString();
-
- initialExactAmounts[member.userId] = '0.00';
+
+ initialExactAmounts[member.userId] = "0.00";
initialSelectedMembers[member.userId] = true; // Select all by default
});
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);
+ const currentUserMember = response.data.find(
+ (member) => member.userId === user._id
+ );
if (currentUserMember) {
setPayerId(user._id);
} else if (response.data.length > 0) {
setPayerId(response.data[0].userId);
}
} catch (error) {
- console.error('Failed to fetch members:', error);
- Alert.alert('Error', 'Failed to fetch group members.');
+ console.error("Failed to fetch members:", error);
+ Alert.alert("Error", "Failed to fetch group members.");
} finally {
setIsLoading(false);
}
@@ -78,16 +96,16 @@ const AddExpenseScreen = ({ route, navigation }) => {
const handleAddExpense = async () => {
if (!description || !amount) {
- Alert.alert('Error', 'Please fill in all fields.');
+ Alert.alert("Error", "Please fill in all fields.");
return;
}
if (!payerId) {
- Alert.alert('Error', 'Please select who paid for this expense.');
+ Alert.alert("Error", "Please select who paid for this expense.");
return;
}
const numericAmount = parseFloat(amount);
if (isNaN(numericAmount) || numericAmount <= 0) {
- Alert.alert('Error', 'Please enter a valid amount.');
+ Alert.alert("Error", "Please enter a valid amount.");
return;
}
@@ -95,174 +113,212 @@ const AddExpenseScreen = ({ route, navigation }) => {
let expenseData;
try {
- let splits = [];
- let splitType = splitMethod;
+ 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.');
- }
- 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;
-
- splits = includedMembers.map((userId, index) => ({
- userId,
- amount: index === 0 ? splitAmount + remainder : splitAmount, // Add remainder to first member
- 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)}`);
- }
- splits = Object.entries(exactAmounts)
- .filter(([userId, value]) => parseFloat(value || '0') > 0)
- .map(([userId, value]) => ({
- userId,
- amount: Math.round(parseFloat(value) * 100) / 100,
- type: 'unequal'
- }));
- splitType = 'unequal'; // Backend uses 'unequal' for exact amounts
- } else if (splitMethod === 'percentage') {
- const total = 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)}%`);
- }
- splits = Object.entries(percentages)
- .filter(([userId, value]) => parseFloat(value || '0') > 0)
- .map(([userId, value]) => ({
- userId,
- amount: Math.round((numericAmount * (parseFloat(value) / 100)) * 100) / 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), 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 {
- 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
+ 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.");
+ }
+ 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;
+
+ splits = includedMembers.map((userId, index) => ({
+ userId,
+ amount: index === 0 ? splitAmount + remainder : splitAmount, // Add remainder to first member
+ 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)}`
+ );
+ }
+ splits = Object.entries(exactAmounts)
+ .filter(([userId, value]) => parseFloat(value || "0") > 0)
+ .map(([userId, value]) => ({
+ userId,
+ amount: Math.round(parseFloat(value) * 100) / 100,
+ type: "unequal",
+ }));
+ splitType = "unequal"; // Backend uses 'unequal' for exact amounts
+ } else if (splitMethod === "percentage") {
+ const total = 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
+ )}%`
+ );
}
+ splits = Object.entries(percentages)
+ .filter(([userId, value]) => parseFloat(value || "0") > 0)
+ .map(([userId, value]) => ({
+ userId,
+ amount:
+ Math.round(numericAmount * (parseFloat(value) / 100) * 100) / 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),
+ 0
+ );
- expenseData = {
- description,
- amount: numericAmount,
- paidBy: payerId, // Use the selected payer
- splitType,
- splits,
- tags: []
- };
-
- await createExpense(token, groupId, expenseData);
- Alert.alert('Success', 'Expense added successfully.');
- navigation.goBack();
+ 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 {
+ 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
+ }
+
+ expenseData = {
+ description,
+ amount: numericAmount,
+ paidBy: payerId, // Use the selected payer
+ splitType,
+ splits,
+ tags: [],
+ };
+
+ await createExpense(groupId, expenseData);
+ Alert.alert("Success", "Expense added successfully.");
+ navigation.goBack();
} catch (error) {
- Alert.alert('Error', error.message || 'Failed to create expense.');
+ Alert.alert("Error", error.message || "Failed to create expense.");
} finally {
- setIsSubmitting(false);
+ setIsSubmitting(false);
}
};
const handleMemberSelect = (userId) => {
- setSelectedMembers(prev => ({...prev, [userId]: !prev[userId]}));
+ 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 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);
-
+ .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 }));
- }
+ 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 => (
- handleMemberSelect(member.userId)}
- />
+ case "equal":
+ return members.map((member) => (
+ handleMemberSelect(member.userId)}
+ />
));
- case 'exact':
- return members.map(member => (
+ case "exact":
+ return members.map((member) => (
handleSplitChange(setExactAmounts, member.userId, text)}
+ onChangeText={(text) =>
+ handleSplitChange(setExactAmounts, member.userId, text)
+ }
keyboardType="numeric"
style={styles.splitInput}
/>
));
- case 'percentage':
- return members.map(member => (
+ case "percentage":
+ return members.map((member) => (
handleSplitChange(setPercentages, member.userId, text)}
+ onChangeText={(text) =>
+ handleSplitChange(setPercentages, member.userId, text)
+ }
keyboardType="numeric"
style={styles.splitInput}
/>
));
- case 'shares':
- return members.map(member => (
+ case "shares":
+ return members.map((member) => (
handleSplitChange(setShares, member.userId, text)}
+ onChangeText={(text) =>
+ handleSplitChange(setShares, member.userId, text)
+ }
keyboardType="numeric"
style={styles.splitInput}
/>
@@ -280,97 +336,116 @@ const AddExpenseScreen = ({ route, navigation }) => {
);
}
- const selectedPayerName = payerId ? (members.find(m => m.userId === payerId)?.user.name || 'Select Payer') : 'Select Payer';
+ const selectedPayerName = payerId
+ ? members.find((m) => m.userId === payerId)?.user.name || "Select Payer"
+ : "Select Payer";
return (
-
-
-
+
+
-
-
- 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%.
+ 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(percentages).reduce((sum, val) => sum + parseFloat(val || '0'), 0).toFixed(2)}%
+ {" "}
+ Current total: $
+ {Object.values(exactAmounts)
+ .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()}
-
-
-
-
+ )}
+
+ )}
+ {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()}
+
+
+
);
};
@@ -386,8 +461,8 @@ const styles = StyleSheet.create({
},
loaderContainer: {
flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
+ justifyContent: "center",
+ alignItems: "center",
},
input: {
marginBottom: 16,
@@ -411,9 +486,9 @@ const styles = StyleSheet.create({
opacity: 0.7,
},
totalText: {
- fontWeight: 'bold',
+ fontWeight: "bold",
opacity: 1,
- }
+ },
});
export default AddExpenseScreen;
diff --git a/frontend/screens/FriendsScreen.js b/frontend/screens/FriendsScreen.js
index 3c190ae3..0a03bcb9 100644
--- a/frontend/screens/FriendsScreen.js
+++ b/frontend/screens/FriendsScreen.js
@@ -1,91 +1,101 @@
-import { useIsFocused } from '@react-navigation/native';
-import { useContext, useEffect, useState } from 'react';
-import { Alert, FlatList, StyleSheet, View } from 'react-native';
-import { ActivityIndicator, Appbar, Divider, IconButton, List, Text } from 'react-native-paper';
-import { getFriendsBalance } from '../api/groups';
-import { AuthContext } from '../context/AuthContext';
+import { useIsFocused } from "@react-navigation/native";
+import { useContext, useEffect, useState } from "react";
+import { Alert, FlatList, StyleSheet, View } from "react-native";
+import {
+ ActivityIndicator,
+ Appbar,
+ Divider,
+ IconButton,
+ List,
+ Text,
+} from "react-native-paper";
+import { getFriendsBalance } from "../api/groups";
+import { AuthContext } from "../context/AuthContext";
const FriendsScreen = () => {
- const { token, user } = useContext(AuthContext);
- const [friends, setFriends] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [showTooltip, setShowTooltip] = useState(true);
- const isFocused = useIsFocused();
+ const { token, user } = useContext(AuthContext);
+ const [friends, setFriends] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [showTooltip, setShowTooltip] = useState(true);
+ const isFocused = useIsFocused();
- useEffect(() => {
- const fetchData = async () => {
- setIsLoading(true);
- try {
- const friendsResponse = await getFriendsBalance(token);
- const friendsData = friendsResponse.data.friendsBalance || [];
-
- // Transform the backend data to match the expected frontend format
- const transformedFriends = friendsData.map(friend => ({
- id: friend.userId,
- name: friend.userName,
- netBalance: friend.netBalance,
- groups: friend.breakdown.map(group => ({
- id: group.groupId,
- name: group.groupName,
- balance: group.balance
- }))
- }));
-
- setFriends(transformedFriends);
+ useEffect(() => {
+ const fetchData = async () => {
+ setIsLoading(true);
+ try {
+ const friendsResponse = await getFriendsBalance();
+ const friendsData = friendsResponse.data.friendsBalance || [];
- } catch (error) {
- console.error('Failed to fetch friends balance data:', error);
- Alert.alert('Error', 'Failed to load friends balance data.');
- } finally {
- setIsLoading(false);
- }
- };
+ // Transform the backend data to match the expected frontend format
+ const transformedFriends = friendsData.map((friend) => ({
+ id: friend.userId,
+ name: friend.userName,
+ netBalance: friend.netBalance,
+ groups: friend.breakdown.map((group) => ({
+ id: group.groupId,
+ name: group.groupName,
+ balance: group.balance,
+ })),
+ }));
- if (token && isFocused) {
- fetchData();
- }
- }, [token, isFocused]);
-
- const renderFriend = ({ item }) => {
- const balanceColor = item.netBalance < 0 ? 'red' : 'green';
- const balanceText = item.netBalance < 0
- ? `You owe $${Math.abs(item.netBalance).toFixed(2)}`
- : `Owes you $${item.netBalance.toFixed(2)}`;
-
- return (
- }
- >
- {item.groups.map(group => {
- const groupBalanceColor = group.balance < 0 ? 'red' : 'green';
- const groupBalanceText = group.balance < 0
- ? `You owe $${Math.abs(group.balance).toFixed(2)}`
- : `Owes you $${group.balance.toFixed(2)}`;
-
- return (
- }
- />
- );
- })}
-
- );
+ 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 (isLoading) {
- return (
-
-
-
- );
+ if (token && isFocused) {
+ fetchData();
}
+ }, [token, isFocused]);
+
+ const renderFriend = ({ item }) => {
+ const balanceColor = item.netBalance < 0 ? "red" : "green";
+ const balanceText =
+ item.netBalance < 0
+ ? `You owe $${Math.abs(item.netBalance).toFixed(2)}`
+ : `Owes you $${item.netBalance.toFixed(2)}`;
+
+ return (
+ }
+ >
+ {item.groups.map((group) => {
+ const groupBalanceColor = group.balance < 0 ? "red" : "green";
+ const groupBalanceText =
+ group.balance < 0
+ ? `You owe $${Math.abs(group.balance).toFixed(2)}`
+ : `Owes you $${group.balance.toFixed(2)}`;
+
+ return (
+ }
+ />
+ );
+ })}
+
+ );
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
return (
@@ -96,8 +106,9 @@ const FriendsScreen = () => {
- 💡 These amounts show your direct balance with each friend across all shared groups.
- Check individual group details for optimized settlement suggestions.
+ 💡 These amounts show your direct balance with each friend across
+ all shared groups. Check individual group details for optimized
+ settlement suggestions.
{
item.id}
+ keyExtractor={(item) => item.id}
ItemSeparatorComponent={Divider}
- ListEmptyComponent={No balances with friends yet.}
+ ListEmptyComponent={
+ No balances with friends yet.
+ }
/>
);
@@ -124,37 +137,37 @@ const styles = StyleSheet.create({
flex: 1,
},
loaderContainer: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
},
explanationContainer: {
- backgroundColor: '#f0f8ff',
- margin: 8,
- borderRadius: 8,
- borderLeftWidth: 4,
- borderLeftColor: '#2196f3',
+ backgroundColor: "#f0f8ff",
+ margin: 8,
+ borderRadius: 8,
+ borderLeftWidth: 4,
+ borderLeftColor: "#2196f3",
},
explanationContent: {
- flexDirection: 'row',
- alignItems: 'flex-start',
- padding: 12,
+ flexDirection: "row",
+ alignItems: "flex-start",
+ padding: 12,
},
explanationText: {
- fontSize: 12,
- color: '#555',
- lineHeight: 16,
- flex: 1,
- paddingRight: 8,
+ fontSize: 12,
+ color: "#555",
+ lineHeight: 16,
+ flex: 1,
+ paddingRight: 8,
},
closeButton: {
- margin: 0,
- marginTop: -4,
+ margin: 0,
+ marginTop: -4,
},
emptyText: {
- textAlign: 'center',
- marginTop: 20,
- }
+ textAlign: "center",
+ marginTop: 20,
+ },
});
export default FriendsScreen;
diff --git a/frontend/screens/GroupDetailsScreen.js b/frontend/screens/GroupDetailsScreen.js
index f689bc22..7b4f5cca 100644
--- a/frontend/screens/GroupDetailsScreen.js
+++ b/frontend/screens/GroupDetailsScreen.js
@@ -1,8 +1,20 @@
-import { useContext, useEffect, useState } from 'react';
-import { Alert, FlatList, StyleSheet, Text, View } from 'react-native';
-import { ActivityIndicator, Avatar, Card, FAB, IconButton, Paragraph, Title } from 'react-native-paper';
-import { getGroupExpenses, getGroupMembers, getOptimizedSettlements } from '../api/groups';
-import { AuthContext } from '../context/AuthContext';
+import { useContext, useEffect, useState } from "react";
+import { Alert, FlatList, StyleSheet, Text, View } from "react-native";
+import {
+ ActivityIndicator,
+ Avatar,
+ Card,
+ FAB,
+ IconButton,
+ Paragraph,
+ Title,
+} from "react-native-paper";
+import {
+ getGroupExpenses,
+ getGroupMembers,
+ getOptimizedSettlements,
+} from "../api/groups";
+import { AuthContext } from "../context/AuthContext";
const GroupDetailsScreen = ({ route, navigation }) => {
const { groupId, groupName, groupIcon } = route.params;
@@ -13,7 +25,7 @@ const GroupDetailsScreen = ({ route, navigation }) => {
const [isLoading, setIsLoading] = useState(true);
// Currency configuration - can be made configurable later
- const currency = '₹'; // Default to INR, can be changed to '$' for USD
+ const currency = "₹"; // Default to INR, can be changed to '$' for USD
// Helper function to format currency amounts
const formatCurrency = (amount) => `${currency}${amount.toFixed(2)}`;
@@ -22,17 +34,18 @@ const GroupDetailsScreen = ({ route, navigation }) => {
try {
setIsLoading(true);
// Fetch members, expenses, and settlements in parallel
- const [membersResponse, expensesResponse, settlementsResponse] = await Promise.all([
- getGroupMembers(token, groupId),
- getGroupExpenses(token, groupId),
- getOptimizedSettlements(token, groupId),
- ]);
+ const [membersResponse, expensesResponse, settlementsResponse] =
+ await Promise.all([
+ getGroupMembers(groupId),
+ getGroupExpenses(groupId),
+ getOptimizedSettlements(groupId),
+ ]);
setMembers(membersResponse.data);
setExpenses(expensesResponse.data.expenses);
setSettlements(settlementsResponse.data.optimizedSettlements || []);
} catch (error) {
- console.error('Failed to fetch group details:', error);
- Alert.alert('Error', 'Failed to fetch group details.');
+ console.error("Failed to fetch group details:", error);
+ Alert.alert("Error", "Failed to fetch group details.");
} finally {
setIsLoading(false);
}
@@ -42,7 +55,10 @@ const GroupDetailsScreen = ({ route, navigation }) => {
navigation.setOptions({
title: groupName,
headerRight: () => (
- navigation.navigate('GroupSettings', { groupId })} />
+ navigation.navigate("GroupSettings", { groupId })}
+ />
),
});
if (token && groupId) {
@@ -51,44 +67,46 @@ const GroupDetailsScreen = ({ route, navigation }) => {
}, [token, groupId]);
const getMemberName = (userId) => {
- const member = members.find(m => m.userId === userId);
- return member ? member.user.name : 'Unknown';
+ const member = members.find((m) => m.userId === userId);
+ return member ? member.user.name : "Unknown";
};
const renderExpense = ({ item }) => {
- const userSplit = item.splits.find(s => s.userId === user._id);
+ const userSplit = item.splits.find((s) => s.userId === user._id);
const userShare = userSplit ? userSplit.amount : 0;
const paidByMe = (item.paidBy || item.createdBy) === user._id;
const net = paidByMe ? item.amount - userShare : -userShare;
let balanceText;
- let balanceColor = 'black';
+ let balanceColor = "black";
if (net > 0) {
balanceText = `You are owed ${formatCurrency(net)}`;
- balanceColor = 'green';
+ balanceColor = "green";
} else if (net < 0) {
balanceText = `You borrowed ${formatCurrency(Math.abs(net))}`;
- balanceColor = 'red';
+ balanceColor = "red";
} else {
balanceText = "You are settled for this expense.";
}
return (
-
+
- {item.description}
- Amount: {formatCurrency(item.amount)}
- Paid by: {getMemberName(item.paidBy || item.createdBy)}
- {balanceText}
+ {item.description}
+ Amount: {formatCurrency(item.amount)}
+
+ Paid by: {getMemberName(item.paidBy || item.createdBy)}
+
+ {balanceText}
-
+
);
};
const renderSettlementSummary = () => {
- const userOwes = settlements.filter(s => s.fromUserId === user._id);
- const userIsOwed = settlements.filter(s => s.toUserId === user._id);
+ const userOwes = settlements.filter((s) => s.fromUserId === user._id);
+ const userIsOwed = settlements.filter((s) => s.toUserId === user._id);
const totalOwed = userOwes.reduce((sum, s) => sum + s.amount, 0);
const totalToReceive = userIsOwed.reduce((sum, s) => sum + s.amount, 0);
@@ -106,12 +124,19 @@ const GroupDetailsScreen = ({ route, navigation }) => {
{/* You owe section - only show if totalOwed > 0 */}
{totalOwed > 0 && (
- You need to pay: {formatCurrency(totalOwed)}
+
+ You need to pay:{" "}
+ {formatCurrency(totalOwed)}
+
{userOwes.map((s, index) => (
- {getMemberName(s.toUserId)}
- {formatCurrency(s.amount)}
+
+ {getMemberName(s.toUserId)}
+
+
+ {formatCurrency(s.amount)}
+
))}
@@ -121,12 +146,21 @@ const GroupDetailsScreen = ({ route, navigation }) => {
{/* You receive section - only show if totalToReceive > 0 */}
{totalToReceive > 0 && (
- You will receive: {formatCurrency(totalToReceive)}
+
+ You will receive:{" "}
+
+ {formatCurrency(totalToReceive)}
+
+
{userIsOwed.map((s, index) => (
- {getMemberName(s.fromUserId)}
- {formatCurrency(s.amount)}
+
+ {getMemberName(s.fromUserId)}
+
+
+ {formatCurrency(s.amount)}
+
))}
@@ -160,9 +194,15 @@ const GroupDetailsScreen = ({ route, navigation }) => {
{members.map((item) => (
{item.user?.imageUrl ? (
-
+
) : (
-
+
)}
{item.user?.name}
@@ -177,26 +217,26 @@ const GroupDetailsScreen = ({ route, navigation }) => {
return (
- item._id}
- ListHeaderComponent={renderHeader}
- ListEmptyComponent={
-
- {renderHeader()}
- No expenses recorded yet.
-
- }
- contentContainerStyle={{ paddingBottom: 80 }} // To avoid FAB overlap
- />
+ item._id}
+ ListHeaderComponent={renderHeader}
+ ListEmptyComponent={
+
+ {renderHeader()}
+ No expenses recorded yet.
+
+ }
+ contentContainerStyle={{ paddingBottom: 80 }} // To avoid FAB overlap
+ />
- navigation.navigate('AddExpense', { groupId: groupId })}
- />
+ navigation.navigate("AddExpense", { groupId: groupId })}
+ />
);
};
@@ -206,33 +246,33 @@ const styles = StyleSheet.create({
flex: 1,
},
contentContainer: {
- flex: 1,
- padding: 16,
+ flex: 1,
+ padding: 16,
},
loaderContainer: {
flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
+ justifyContent: "center",
+ alignItems: "center",
},
card: {
marginBottom: 16,
},
expensesTitle: {
- marginTop: 16,
- marginBottom: 8,
- fontSize: 20,
- fontWeight: 'bold',
+ marginTop: 16,
+ marginBottom: 8,
+ fontSize: 20,
+ fontWeight: "bold",
},
memberText: {
- fontSize: 16,
- lineHeight: 24,
+ fontSize: 16,
+ lineHeight: 24,
},
memberList: {
gap: 8,
},
memberRow: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
gap: 8,
paddingVertical: 2,
},
@@ -240,7 +280,7 @@ const styles = StyleSheet.create({
fontSize: 14,
},
fab: {
- position: 'absolute',
+ position: "absolute",
margin: 16,
right: 0,
bottom: 0,
@@ -250,60 +290,60 @@ const styles = StyleSheet.create({
gap: 16,
},
settledContainer: {
- alignItems: 'center',
+ alignItems: "center",
paddingVertical: 12,
},
settledText: {
fontSize: 16,
- color: '#2e7d32',
- fontWeight: '500',
+ color: "#2e7d32",
+ fontWeight: "500",
},
owedSection: {
- backgroundColor: '#ffebee',
+ backgroundColor: "#ffebee",
borderRadius: 8,
padding: 12,
borderLeftWidth: 4,
- borderLeftColor: '#d32f2f',
+ borderLeftColor: "#d32f2f",
},
receiveSection: {
- backgroundColor: '#e8f5e8',
+ backgroundColor: "#e8f5e8",
borderRadius: 8,
padding: 12,
borderLeftWidth: 4,
- borderLeftColor: '#2e7d32',
+ borderLeftColor: "#2e7d32",
},
sectionTitle: {
fontSize: 16,
- fontWeight: '600',
+ fontWeight: "600",
marginBottom: 8,
- color: '#333',
+ color: "#333",
},
amountOwed: {
- color: '#d32f2f',
- fontWeight: 'bold',
+ color: "#d32f2f",
+ fontWeight: "bold",
},
amountReceive: {
- color: '#2e7d32',
- fontWeight: 'bold',
+ color: "#2e7d32",
+ fontWeight: "bold",
},
settlementItem: {
marginVertical: 4,
},
personInfo: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
paddingVertical: 4,
},
personName: {
fontSize: 14,
- color: '#555',
+ color: "#555",
flex: 1,
},
settlementAmount: {
fontSize: 14,
- fontWeight: '600',
- color: '#333',
+ fontWeight: "600",
+ color: "#333",
},
});
diff --git a/frontend/screens/GroupSettingsScreen.js b/frontend/screens/GroupSettingsScreen.js
index bd33ebf8..6e025dad 100644
--- a/frontend/screens/GroupSettingsScreen.js
+++ b/frontend/screens/GroupSettingsScreen.js
@@ -1,17 +1,32 @@
-import { useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react';
-import { Alert, Share, StyleSheet, View } from 'react-native';
-import { ActivityIndicator, Avatar, Button, Card, IconButton, List, Text, TextInput } from 'react-native-paper';
import {
- deleteGroup as apiDeleteGroup,
- leaveGroup as apiLeaveGroup,
- removeMember as apiRemoveMember,
- updateGroup as apiUpdateGroup,
- getGroupById,
- getGroupMembers,
-} from '../api/groups';
-import { AuthContext } from '../context/AuthContext';
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useState,
+} from "react";
+import { Alert, Share, StyleSheet, View } from "react-native";
+import {
+ ActivityIndicator,
+ Avatar,
+ Button,
+ Card,
+ IconButton,
+ List,
+ Text,
+ TextInput,
+} from "react-native-paper";
+import {
+ deleteGroup as apiDeleteGroup,
+ leaveGroup as apiLeaveGroup,
+ removeMember as apiRemoveMember,
+ updateGroup as apiUpdateGroup,
+ getGroupById,
+ getGroupMembers,
+} from "../api/groups";
+import { AuthContext } from "../context/AuthContext";
-const ICON_CHOICES = ['👥', '🏠', '🎉', '🧳', '🍽️', '🚗', '🏖️', '🎮', '💼'];
+const ICON_CHOICES = ["👥", "🏠", "🎉", "🧳", "🍽️", "🚗", "🏖️", "🎮", "💼"];
const GroupSettingsScreen = ({ route, navigation }) => {
const { groupId } = route.params;
@@ -20,28 +35,28 @@ const GroupSettingsScreen = ({ route, navigation }) => {
const [saving, setSaving] = useState(false);
const [members, setMembers] = useState([]);
const [group, setGroup] = useState(null);
- const [name, setName] = useState('');
- const [icon, setIcon] = useState('');
+ const [name, setName] = useState("");
+ const [icon, setIcon] = useState("");
const isAdmin = useMemo(() => {
- const me = members.find(m => m.userId === user?._id);
- return me?.role === 'admin';
+ const me = members.find((m) => m.userId === user?._id);
+ return me?.role === "admin";
}, [members, user?._id]);
const load = async () => {
try {
setLoading(true);
const [gRes, mRes] = await Promise.all([
- getGroupById(token, groupId),
- getGroupMembers(token, groupId),
+ getGroupById(groupId),
+ getGroupMembers(groupId),
]);
setGroup(gRes.data);
setName(gRes.data.name);
- setIcon(gRes.data.imageUrl || gRes.data.icon || '');
+ setIcon(gRes.data.imageUrl || gRes.data.icon || "");
setMembers(mRes.data);
} catch (e) {
- console.error('Failed to load group settings', e);
- Alert.alert('Error', 'Failed to load group settings.');
+ console.error("Failed to load group settings", e);
+ Alert.alert("Error", "Failed to load group settings.");
} finally {
setLoading(false);
}
@@ -52,23 +67,28 @@ const GroupSettingsScreen = ({ route, navigation }) => {
}, [token, groupId]);
useLayoutEffect(() => {
- navigation.setOptions({ title: 'Group Settings' });
+ navigation.setOptions({ title: "Group Settings" });
}, [navigation]);
const onSave = async () => {
if (!isAdmin) return;
const updates = {};
if (name && name !== group?.name) updates.name = name;
- if (icon && icon !== (group?.imageUrl || group?.icon)) updates.imageUrl = icon;
- if (Object.keys(updates).length === 0) return Alert.alert('Nothing to update');
+ if (icon && icon !== (group?.imageUrl || group?.icon))
+ updates.imageUrl = icon;
+ if (Object.keys(updates).length === 0)
+ return Alert.alert("Nothing to update");
try {
setSaving(true);
- const res = await apiUpdateGroup(token, groupId, updates);
+ const res = await apiUpdateGroup(groupId, updates);
setGroup(res.data);
- Alert.alert('Updated', 'Group updated successfully.');
+ Alert.alert("Updated", "Group updated successfully.");
} catch (e) {
- console.error('Update failed', e);
- Alert.alert('Error', e.response?.data?.detail || 'Failed to update group');
+ console.error("Update failed", e);
+ Alert.alert(
+ "Error",
+ e.response?.data?.detail || "Failed to update group"
+ );
} finally {
setSaving(false);
}
@@ -82,51 +102,57 @@ const GroupSettingsScreen = ({ route, navigation }) => {
message: `Join my group on Splitwiser! Use code ${code}`,
});
} catch (e) {
- console.error('Share failed', 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 {
- await apiRemoveMember(token, groupId, memberId);
- await load();
- } catch (e) {
- console.error('Remove failed', e);
- Alert.alert('Error', e.response?.data?.detail || 'Failed to remove member');
- }
+ Alert.alert("Remove member", `Are you sure you want to remove ${name}?`, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Remove",
+ style: "destructive",
+ onPress: async () => {
+ try {
+ 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",
+ "You can leave only when your balances are settled. Continue?",
[
- { text: 'Cancel', style: 'cancel' },
+ { text: "Cancel", style: "cancel" },
{
- text: 'Leave', style: 'destructive', onPress: async () => {
+ text: "Leave",
+ style: "destructive",
+ onPress: async () => {
try {
- await apiLeaveGroup(token, groupId);
- Alert.alert('Left group');
+ 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');
+ console.error("Leave failed", e);
+ Alert.alert(
+ "Cannot leave",
+ e.response?.data?.detail || "Please settle balances first"
+ );
}
- }
- }
+ },
+ },
]
);
};
@@ -134,51 +160,64 @@ const GroupSettingsScreen = ({ route, navigation }) => {
const onDeleteGroup = () => {
if (!isAdmin) return;
// Only allow delete if no other members present
- const others = members.filter(m => m.userId !== user?._id);
+ const others = members.filter((m) => m.userId !== user?._id);
if (others.length > 0) {
- Alert.alert('Cannot delete', 'Remove all members first, or transfer admin.');
+ Alert.alert(
+ "Cannot delete",
+ "Remove all members first, or transfer admin."
+ );
return;
}
Alert.alert(
- 'Delete group',
- 'This will permanently delete the group. Continue?',
+ "Delete group",
+ "This will permanently delete the group. Continue?",
[
- { text: 'Cancel', style: 'cancel' },
+ { text: "Cancel", style: "cancel" },
{
- text: 'Delete', style: 'destructive', onPress: async () => {
+ text: "Delete",
+ style: "destructive",
+ onPress: async () => {
try {
- await apiDeleteGroup(token, groupId);
- Alert.alert('Group deleted');
+ 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');
+ console.error("Delete failed", e);
+ Alert.alert(
+ "Error",
+ e.response?.data?.detail || "Failed to delete group"
+ );
}
- }
- }
+ },
+ },
]
);
};
const renderMemberItem = (m) => {
const isSelf = m.userId === user?._id;
- const displayName = m.user?.name || 'Unknown';
+ const displayName = m.user?.name || "Unknown";
const imageUrl = m.user?.imageUrl;
return (
imageUrl ? (
-
- ) : (
-
- )}
- right={() => (
+ description={m.role === "admin" ? "Admin" : undefined}
+ left={() =>
+ imageUrl ? (
+
+ ) : (
+
+ )
+ }
+ right={() =>
isAdmin && !isSelf ? (
- onKick(m.userId, displayName)} />
+ onKick(m.userId, displayName)}
+ />
) : null
- )}
+ }
/>
);
};
@@ -204,8 +243,13 @@ const GroupSettingsScreen = ({ route, navigation }) => {
/>
Icon (emoji or image URL)
- {ICON_CHOICES.map(i => (
-
+
);
};
const styles = StyleSheet.create({
- container: { flex: 1, padding: 16 },
+ container: { flex: 1 },
+ scrollContent: { padding: 16 },
loaderContainer: { flex: 1, justifyContent: "center", alignItems: "center" },
card: { marginBottom: 16 },
iconRow: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 8 },
From a05c920f9a19f2e7a51c5d619cdc3effa16d5203 Mon Sep 17 00:00:00 2001
From: Devasy Patel <110348311+Devasy23@users.noreply.github.com>
Date: Fri, 8 Aug 2025 22:51:23 +0530
Subject: [PATCH 04/14] feat: Enhance group settings with image upload
functionality and update group icon handling
---
frontend/app.json | 8 +++-
frontend/package.json | 1 +
frontend/screens/GroupDetailsScreen.js | 39 +------------------
frontend/screens/GroupSettingsScreen.js | 52 +++++++++++++++++++------
4 files changed, 48 insertions(+), 52 deletions(-)
diff --git a/frontend/app.json b/frontend/app.json
index 35a24265..169a4caa 100644
--- a/frontend/app.json
+++ b/frontend/app.json
@@ -13,14 +13,18 @@
"backgroundColor": "#ffffff"
},
"ios": {
- "supportsTablet": true
+ "supportsTablet": true,
+ "infoPlist": {
+ "NSPhotoLibraryUsageDescription": "Allow Splitwiser to select a group icon from your photo library."
+ }
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
- "edgeToEdgeEnabled": true
+ "edgeToEdgeEnabled": true,
+ "permissions": ["READ_MEDIA_IMAGES"]
},
"web": {
"favicon": "./assets/favicon.png"
diff --git a/frontend/package.json b/frontend/package.json
index 3e33c375..8cc31737 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -16,6 +16,7 @@
"@react-navigation/native-stack": "^7.3.23",
"axios": "^1.11.0",
"expo": "~53.0.20",
+ "expo-image-picker": "~16.0.2",
"expo-status-bar": "~2.2.3",
"react": "19.0.0",
"react-dom": "19.0.0",
diff --git a/frontend/screens/GroupDetailsScreen.js b/frontend/screens/GroupDetailsScreen.js
index 7b4f5cca..3cd77b90 100644
--- a/frontend/screens/GroupDetailsScreen.js
+++ b/frontend/screens/GroupDetailsScreen.js
@@ -2,7 +2,6 @@ import { useContext, useEffect, useState } from "react";
import { Alert, FlatList, StyleSheet, Text, View } from "react-native";
import {
ActivityIndicator,
- Avatar,
Card,
FAB,
IconButton,
@@ -17,7 +16,7 @@ import {
import { AuthContext } from "../context/AuthContext";
const GroupDetailsScreen = ({ route, navigation }) => {
- const { groupId, groupName, groupIcon } = route.params;
+ const { groupId, groupName } = route.params;
const { token, user } = useContext(AuthContext);
const [members, setMembers] = useState([]);
const [expenses, setExpenses] = useState([]);
@@ -187,30 +186,6 @@ const GroupDetailsScreen = ({ route, navigation }) => {
-
-
- Members
-
- {members.map((item) => (
-
- {item.user?.imageUrl ? (
-
- ) : (
-
- )}
- {item.user?.name}
-
- ))}
-
-
-
-
Expenses
>
);
@@ -267,18 +242,6 @@ const styles = StyleSheet.create({
fontSize: 16,
lineHeight: 24,
},
- memberList: {
- gap: 8,
- },
- memberRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: 8,
- paddingVertical: 2,
- },
- memberName: {
- fontSize: 14,
- },
fab: {
position: "absolute",
margin: 16,
diff --git a/frontend/screens/GroupSettingsScreen.js b/frontend/screens/GroupSettingsScreen.js
index 196ac7b1..59d16ee9 100644
--- a/frontend/screens/GroupSettingsScreen.js
+++ b/frontend/screens/GroupSettingsScreen.js
@@ -1,3 +1,4 @@
+import * as ImagePicker from 'expo-image-picker';
import {
useContext,
useEffect,
@@ -5,7 +6,7 @@ import {
useMemo,
useState,
} from "react";
-import { Alert, ScrollView, Share, StyleSheet, View } from "react-native";
+import { Alert, Image, ScrollView, Share, StyleSheet, View } from "react-native";
import {
ActivityIndicator,
Avatar,
@@ -38,6 +39,7 @@ const GroupSettingsScreen = ({ route, navigation }) => {
const [group, setGroup] = useState(null);
const [name, setName] = useState("");
const [icon, setIcon] = useState("");
+ const [pickedImage, setPickedImage] = useState(null); // { uri, base64 }
const isAdmin = useMemo(() => {
const me = members.find((m) => m.userId === user?._id);
@@ -75,10 +77,9 @@ const GroupSettingsScreen = ({ route, navigation }) => {
if (!isAdmin) return;
const updates = {};
if (name && name !== group?.name) updates.name = name;
- // Only set imageUrl if it looks like a valid URL; ignore emoji-only selections
- const isUrl = typeof icon === 'string' && /^(https?:)\/\//i.test(icon);
- if (isUrl && icon !== (group?.imageUrl || '')) {
- updates.imageUrl = icon;
+ // Prefer picked image (base64 -> data URL). If none, ignore unless we had previous URL change via choices.
+ if (pickedImage?.base64) {
+ updates.imageUrl = `data:image/jpeg;base64,${pickedImage.base64}`;
}
if (Object.keys(updates).length === 0)
return Alert.alert("Nothing to update");
@@ -86,6 +87,7 @@ const GroupSettingsScreen = ({ route, navigation }) => {
setSaving(true);
const res = await apiUpdateGroup(groupId, updates);
setGroup(res.data);
+ if (pickedImage) setPickedImage(null);
Alert.alert("Updated", "Group updated successfully.");
} catch (e) {
console.error("Update failed", e);
@@ -98,6 +100,27 @@ const GroupSettingsScreen = ({ route, navigation }) => {
}
};
+ 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;
@@ -255,7 +278,7 @@ const GroupSettingsScreen = ({ route, navigation }) => {
editable={!!isAdmin}
style={{ marginBottom: 12 }}
/>
- Icon (emoji or image URL)
+ Icon
{ICON_CHOICES.map((i) => (
{
))}
-
+
+
+ {pickedImage ? 'Change Image' : 'Upload Image'}
+
+ {(pickedImage?.uri || group?.imageUrl) && (
+
+ )}
+
{isAdmin && (
Date: Fri, 8 Aug 2025 23:23:14 +0530
Subject: [PATCH 05/14] feat: Improve balance verification on group leave and
member removal, update error handling
---
backend/app/groups/service.py | 38 ++--
backend/tests/groups/test_groups_service.py | 14 +-
frontend/api/auth.js | 4 +-
frontend/api/client.js | 3 +-
frontend/screens/EditProfileScreen.js | 22 +-
frontend/screens/GroupDetailsScreen.js | 2 +-
frontend/screens/GroupSettingsScreen.js | 239 +++++++++++---------
7 files changed, 181 insertions(+), 141 deletions(-)
diff --git a/backend/app/groups/service.py b/backend/app/groups/service.py
index 25652d91..c70086e9 100644
--- a/backend/app/groups/service.py
+++ b/backend/app/groups/service.py
@@ -313,21 +313,24 @@ async def leave_group(self, group_id: str, user_id: str) -> bool:
)
# Block leaving when there are unsettled balances involving this user
- pending_count = 0
try:
- result = await db.settlements.count_documents(
+ pending = await db.settlements.find_one(
{
- "groupId": group_id,
+ "groupId": group_id, # settlements store string groupId
"status": "pending",
"$or": [{"payerId": user_id}, {"payeeId": user_id}],
- }
+ },
+ {"_id": 1},
)
- pending_count = result if isinstance(result, int) else 0
except Exception as e:
- logger.warning(
- f"Skipping unsettled check on leave due to error: {e} (defaulting to 0)"
+ logger.error(
+ f"Failed to verify unsettled balances for group {group_id}: {e}"
)
- if pending_count > 0:
+ raise HTTPException(
+ status_code=503,
+ detail="Unable to verify unsettled balances right now. Please try again later.",
+ )
+ if pending:
raise HTTPException(
status_code=400,
detail="Cannot leave group with unsettled balances. Please settle up first.",
@@ -442,21 +445,24 @@ async def remove_member(self, group_id: str, member_id: str, user_id: str) -> bo
)
# Block removal when there are unsettled balances involving the target member
- pending_count = 0
try:
- result = await db.settlements.count_documents(
+ pending = await db.settlements.find_one(
{
- "groupId": group_id,
+ "groupId": group_id, # settlements store string groupId
"status": "pending",
"$or": [{"payerId": member_id}, {"payeeId": member_id}],
- }
+ },
+ {"_id": 1},
)
- pending_count = result if isinstance(result, int) else 0
except Exception as e:
- logger.warning(
- f"Skipping unsettled check on removal due to error: {e} (defaulting to 0)"
+ logger.error(
+ f"Failed to verify unsettled balances on removal for group {group_id}: {e}"
+ )
+ raise HTTPException(
+ status_code=503,
+ detail="Unable to verify unsettled balances right now. Please try again later.",
)
- if pending_count > 0:
+ if pending:
raise HTTPException(
status_code=400,
detail="Cannot remove member with unsettled balances. Please settle up first.",
diff --git a/backend/tests/groups/test_groups_service.py b/backend/tests/groups/test_groups_service.py
index e84af937..8c0f53e6 100644
--- a/backend/tests/groups/test_groups_service.py
+++ b/backend/tests/groups/test_groups_service.py
@@ -208,7 +208,10 @@ async def test_remove_member_blocked_when_unsettled(self):
{"userId": member_id, "role": "member"},
],
}
- settlements.count_documents.return_value = 2 # pending exists
+ settlements.find_one.return_value = {
+ "_id": ObjectId(),
+ "status": "pending",
+ } # Has pending settlements
with patch.object(self.service, "get_db", return_value=mock_db):
with pytest.raises(HTTPException) as exc:
@@ -239,7 +242,7 @@ async def test_remove_member_allowed_when_settled(self):
],
}
]
- settlements.count_documents.return_value = 0
+ settlements.find_one.return_value = None # No pending settlements
groups.update_one.return_value = MagicMock(modified_count=1)
with patch.object(self.service, "get_db", return_value=mock_db):
@@ -266,7 +269,7 @@ async def test_leave_group_blocked_when_unsettled(self):
{"userId": "other", "role": "admin"},
],
}
- settlements.count_documents.return_value = 1
+ settlements.find_one.return_value = {"_id": ObjectId(), "status": "pending"}
with patch.object(self.service, "get_db", return_value=mock_db):
with pytest.raises(HTTPException) as exc:
@@ -296,7 +299,7 @@ async def test_leave_group_allowed_when_settled(self):
],
}
]
- settlements.count_documents.return_value = 0
+ settlements.find_one.return_value = None # No pending settlements
groups.update_one.return_value = MagicMock(modified_count=1)
with patch.object(self.service, "get_db", return_value=mock_db):
@@ -527,7 +530,9 @@ async def test_leave_group_allow_member_to_leave(self):
"""Test allowing regular members to leave"""
mock_db = AsyncMock()
mock_collection = AsyncMock()
+ mock_settlements = AsyncMock()
mock_db.groups = mock_collection
+ mock_db.settlements = mock_settlements
group = {
"_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"),
@@ -547,6 +552,7 @@ async def test_leave_group_allow_member_to_leave(self):
}
mock_collection.find_one.return_value = group
+ mock_settlements.find_one.return_value = None # No pending settlements
mock_result = MagicMock()
mock_result.modified_count = 1
mock_collection.update_one.return_value = mock_result
diff --git a/frontend/api/auth.js b/frontend/api/auth.js
index 7524c3e7..56da7255 100644
--- a/frontend/api/auth.js
+++ b/frontend/api/auth.js
@@ -8,9 +8,7 @@ export const signup = (name, email, password) => {
return apiClient.post("/auth/signup/email", { name, email, password });
};
-export const updateUser = (token, userData) => {
- return apiClient.patch("/user/", userData);
-};
+export const updateUser = (userData) => apiClient.patch("/user/", userData);
export const refresh = (refresh_token) => {
return apiClient.post("/auth/refresh", { refresh_token });
diff --git a/frontend/api/client.js b/frontend/api/client.js
index b8468c08..0125850a 100644
--- a/frontend/api/client.js
+++ b/frontend/api/client.js
@@ -61,9 +61,8 @@ async function performRefresh() {
return accessToken;
} finally {
isRefreshing = false;
- const p = refreshPromise; // preserve for awaiting callers
+ // allow GC of the completed promise
refreshPromise = null;
- return p; // not used
}
})();
return refreshPromise;
diff --git a/frontend/screens/EditProfileScreen.js b/frontend/screens/EditProfileScreen.js
index f8eae899..d7b5df97 100644
--- a/frontend/screens/EditProfileScreen.js
+++ b/frontend/screens/EditProfileScreen.js
@@ -1,28 +1,28 @@
-import React, { useState, useContext } from 'react';
-import { View, StyleSheet, Alert } from 'react-native';
-import { Button, TextInput, Appbar, Title } from 'react-native-paper';
-import { AuthContext } from '../context/AuthContext';
-import { updateUser } from '../api/auth';
+import { useContext, useState } from "react";
+import { Alert, StyleSheet, View } from "react-native";
+import { Appbar, Button, TextInput, Title } from "react-native-paper";
+import { updateUser } from "../api/auth";
+import { AuthContext } from "../context/AuthContext";
const EditProfileScreen = ({ navigation }) => {
const { user, token, updateUserInContext } = useContext(AuthContext);
- const [name, setName] = useState(user?.name || '');
+ const [name, setName] = useState(user?.name || "");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleUpdateProfile = async () => {
if (!name) {
- Alert.alert('Error', 'Name cannot be empty.');
+ Alert.alert("Error", "Name cannot be empty.");
return;
}
setIsSubmitting(true);
try {
- const response = await updateUser(token, { name });
+ const response = await updateUser({ name });
updateUserInContext(response.data);
- Alert.alert('Success', 'Profile updated successfully.');
+ Alert.alert("Success", "Profile updated successfully.");
navigation.goBack();
} catch (error) {
- console.error('Failed to update profile:', error);
- Alert.alert('Error', 'Failed to update profile.');
+ console.error("Failed to update profile:", error);
+ Alert.alert("Error", "Failed to update profile.");
} finally {
setIsSubmitting(false);
}
diff --git a/frontend/screens/GroupDetailsScreen.js b/frontend/screens/GroupDetailsScreen.js
index 3cd77b90..4e89f385 100644
--- a/frontend/screens/GroupDetailsScreen.js
+++ b/frontend/screens/GroupDetailsScreen.js
@@ -250,7 +250,7 @@ const styles = StyleSheet.create({
},
// Settlement Summary Styles
settlementContainer: {
- gap: 16,
+ marginBottom: 16,
},
settledContainer: {
alignItems: "center",
diff --git a/frontend/screens/GroupSettingsScreen.js b/frontend/screens/GroupSettingsScreen.js
index 59d16ee9..0bc3af86 100644
--- a/frontend/screens/GroupSettingsScreen.js
+++ b/frontend/screens/GroupSettingsScreen.js
@@ -1,30 +1,37 @@
-import * as ImagePicker from 'expo-image-picker';
+import * as ImagePicker from "expo-image-picker";
import {
- useContext,
- useEffect,
- useLayoutEffect,
- useMemo,
- useState,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useState,
} from "react";
-import { Alert, Image, ScrollView, Share, StyleSheet, View } from "react-native";
import {
- ActivityIndicator,
- Avatar,
- Button,
- Card,
- IconButton,
- List,
- Text,
- TextInput,
+ Alert,
+ Image,
+ ScrollView,
+ Share,
+ StyleSheet,
+ View,
+} from "react-native";
+import {
+ ActivityIndicator,
+ Avatar,
+ Button,
+ Card,
+ IconButton,
+ List,
+ Text,
+ TextInput,
} from "react-native-paper";
import {
- deleteGroup as apiDeleteGroup,
- leaveGroup as apiLeaveGroup,
- removeMember as apiRemoveMember,
- updateGroup as apiUpdateGroup,
- getGroupById,
- getGroupMembers,
- getOptimizedSettlements,
+ deleteGroup as apiDeleteGroup,
+ leaveGroup as apiLeaveGroup,
+ removeMember as apiRemoveMember,
+ updateGroup as apiUpdateGroup,
+ getGroupById,
+ getGroupMembers,
+ getOptimizedSettlements,
} from "../api/groups";
import { AuthContext } from "../context/AuthContext";
@@ -104,8 +111,11 @@ const GroupSettingsScreen = ({ route, navigation }) => {
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.');
+ if (status !== "granted") {
+ Alert.alert(
+ "Permission required",
+ "We need media library permission to select an image."
+ );
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
@@ -145,10 +155,18 @@ const GroupSettingsScreen = ({ route, navigation }) => {
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);
+ 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.');
+ Alert.alert(
+ "Cannot remove",
+ "This member has unsettled balances in the group."
+ );
return;
}
await apiRemoveMember(groupId, memberId);
@@ -268,96 +286,109 @@ const GroupSettingsScreen = ({ route, navigation }) => {
return (
-
-
-
-
- Icon
-
- {ICON_CHOICES.map((i) => (
+
+
+
+
+ Icon
+
+ {ICON_CHOICES.map((i) => (
+ setIcon(i)}
+ disabled={!isAdmin}
+ >
+ {i}
+
+ ))}
+
+
setIcon(i)}
+ mode="outlined"
+ onPress={pickImage}
disabled={!isAdmin}
+ icon="image"
+ style={{ marginRight: 12 }}
>
- {i}
+ {pickedImage ? "Change Image" : "Upload Image"}
+
+ {(pickedImage?.uri || group?.imageUrl) && (
+
+ )}
+
+ {isAdmin && (
+
+ Save Changes
- ))}
-
-
-
- {pickedImage ? 'Change Image' : 'Upload Image'}
-
- {(pickedImage?.uri || group?.imageUrl) && (
-
)}
-
- {isAdmin && (
-
- Save Changes
-
- )}
-
-
+
+
-
-
- {members.map(renderMemberItem)}
-
+
+
+ {members.map(renderMemberItem)}
+
-
-
-
- Join Code: {group?.joinCode}
-
- Share invite
-
-
-
-
-
-
-
-
+
+
+
+
+ Join Code: {group?.joinCode}
+
- Leave Group
+ Share invite
- {isAdmin && (
+
+
+
+
+
+
+
- Delete Group
+ Leave Group
- )}
-
-
-
+ {isAdmin && (
+
+ Delete Group
+
+ )}
+
+
+
);
@@ -368,7 +399,7 @@ const styles = StyleSheet.create({
scrollContent: { padding: 16 },
loaderContainer: { flex: 1, justifyContent: "center", alignItems: "center" },
card: { marginBottom: 16 },
- iconRow: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 8 },
+ iconRow: { flexDirection: "row", flexWrap: "wrap", marginBottom: 8 },
iconBtn: { marginRight: 8, marginBottom: 8 },
});
From bcd7fe7e7952e905408940765577dbc2fb79812a Mon Sep 17 00:00:00 2001
From: "patel.devasy.23"
Date: Fri, 8 Aug 2025 18:14:48 +0000
Subject: [PATCH 06/14] updates the package_lock.json
---
frontend/package-lock.json | 20 ++++++++++++++++++++
frontend/package.json | 2 +-
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 06236370..7e1481a1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -15,6 +15,7 @@
"@react-navigation/native-stack": "^7.3.23",
"axios": "^1.11.0",
"expo": "~53.0.20",
+ "expo-image-picker": "~16.0.2",
"expo-status-bar": "~2.2.3",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -4300,6 +4301,25 @@
"react": "*"
}
},
+ "node_modules/expo-image-loader": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.0.0.tgz",
+ "integrity": "sha512-Eg+5FHtyzv3Jjw9dHwu2pWy4xjf8fu3V0Asyy42kO+t/FbvW/vjUixpTjPtgKQLQh+2/9Nk4JjFDV6FwCnF2ZA==",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
+ "node_modules/expo-image-picker": {
+ "version": "16.0.6",
+ "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-16.0.6.tgz",
+ "integrity": "sha512-HN4xZirFjsFDIsWFb12AZh19fRzuvZjj2ll17cGr19VNRP06S/VPQU3Tdccn5vwUzQhOBlLu704CnNm278boiQ==",
+ "dependencies": {
+ "expo-image-loader": "~5.0.0"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-keep-awake": {
"version": "14.1.4",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.1.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 8cc31737..39a8d506 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -16,7 +16,7 @@
"@react-navigation/native-stack": "^7.3.23",
"axios": "^1.11.0",
"expo": "~53.0.20",
- "expo-image-picker": "~16.0.2",
+ "expo-image-picker": "~16.0.2",
"expo-status-bar": "~2.2.3",
"react": "19.0.0",
"react-dom": "19.0.0",
From 1b142c1ec99861a13ca205dff3dc159258243acc Mon Sep 17 00:00:00 2001
From: Devasy Patel <110348311+Devasy23@users.noreply.github.com>
Date: Fri, 8 Aug 2025 23:54:42 +0530
Subject: [PATCH 07/14] feat: Enhance image handling in group settings and home
screen
---
frontend/screens/GroupSettingsScreen.js | 28 +++++++++++++++++++++----
frontend/screens/HomeScreen.js | 4 ++--
2 files changed, 26 insertions(+), 6 deletions(-)
diff --git a/frontend/screens/GroupSettingsScreen.js b/frontend/screens/GroupSettingsScreen.js
index 0bc3af86..90de5d16 100644
--- a/frontend/screens/GroupSettingsScreen.js
+++ b/frontend/screens/GroupSettingsScreen.js
@@ -84,10 +84,22 @@ const GroupSettingsScreen = ({ route, navigation }) => {
if (!isAdmin) return;
const updates = {};
if (name && name !== group?.name) updates.name = name;
- // Prefer picked image (base64 -> data URL). If none, ignore unless we had previous URL change via choices.
+
+ // 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
+ }
}
+
if (Object.keys(updates).length === 0)
return Alert.alert("Nothing to update");
try {
@@ -320,12 +332,20 @@ const GroupSettingsScreen = ({ route, navigation }) => {
>
{pickedImage ? "Change Image" : "Upload Image"}
- {(pickedImage?.uri || group?.imageUrl) && (
+ {pickedImage?.uri ? (
- )}
+ ) : group?.imageUrl &&
+ /^(https?:|data:image)/.test(group.imageUrl) ? (
+
+ ) : group?.imageUrl ? (
+ {group.imageUrl}
+ ) : null}
{isAdmin && (
{
return "#4CAF50"; // Default green
};
- const isImage = item.imageUrl && /^(https?:)/.test(item.imageUrl);
+ const isImage =
+ item.imageUrl && /^(https?:|data:image)/.test(item.imageUrl);
const groupIcon = item.imageUrl || item.name?.charAt(0) || "?";
return (
{
}
/>
- Join Code: {item.joinCode}
{getSettlementStatusText()}
From 6f9e0160c10b4049b131619ff29f7ba896f42251 Mon Sep 17 00:00:00 2001
From: Devasy Patel <110348311+Devasy23@users.noreply.github.com>
Date: Sat, 9 Aug 2025 00:06:55 +0530
Subject: [PATCH 08/14] feat: Enhance user profile management with image upload
and display in account and friends screens
---
frontend/api/auth.js | 2 +-
frontend/screens/AccountScreen.js | 94 +++++++++++++++------------
frontend/screens/EditProfileScreen.js | 63 +++++++++++++++++-
frontend/screens/FriendsScreen.js | 75 +++++++++++++++++----
4 files changed, 175 insertions(+), 59 deletions(-)
diff --git a/frontend/api/auth.js b/frontend/api/auth.js
index 56da7255..46e0426b 100644
--- a/frontend/api/auth.js
+++ b/frontend/api/auth.js
@@ -8,7 +8,7 @@ export const signup = (name, email, password) => {
return apiClient.post("/auth/signup/email", { name, email, password });
};
-export const updateUser = (userData) => apiClient.patch("/user/", userData);
+export const updateUser = (userData) => apiClient.patch("/users/me", userData);
export const refresh = (refresh_token) => {
return apiClient.post("/auth/refresh", { refresh_token });
diff --git a/frontend/screens/AccountScreen.js b/frontend/screens/AccountScreen.js
index 840d9d08..74436dae 100644
--- a/frontend/screens/AccountScreen.js
+++ b/frontend/screens/AccountScreen.js
@@ -1,18 +1,18 @@
-import React, { useContext } from 'react';
-import { View, StyleSheet, Alert } from 'react-native';
-import { Text, Appbar, Avatar, List, Divider } from 'react-native-paper';
-import { AuthContext } from '../context/AuthContext';
+import { useContext } from "react";
+import { Alert, StyleSheet, View } from "react-native";
+import { Appbar, Avatar, Divider, List, Text } from "react-native-paper";
+import { AuthContext } from "../context/AuthContext";
const AccountScreen = ({ navigation }) => {
- const { user, logout } = useContext(AuthContext);
+ const { user, logout } = useContext(AuthContext);
- const handleLogout = () => {
- logout();
- };
+ const handleLogout = () => {
+ logout();
+ };
- const handleComingSoon = () => {
- Alert.alert('Coming Soon', 'This feature is not yet implemented.');
- };
+ const handleComingSoon = () => {
+ Alert.alert("Coming Soon", "This feature is not yet implemented.");
+ };
return (
@@ -21,35 +21,43 @@ const AccountScreen = ({ navigation }) => {
-
- {user?.name}
- {user?.email}
+ {user?.imageUrl && /^(https?:|data:image)/.test(user.imageUrl) ? (
+
+ ) : (
+
+ )}
+
+ {user?.name}
+
+
+ {user?.email}
+
- }
- onPress={() => navigation.navigate('EditProfile')}
- />
-
- }
- onPress={handleComingSoon}
- />
-
- }
- onPress={handleComingSoon}
- />
-
- }
- onPress={handleLogout}
- />
+ }
+ onPress={() => navigation.navigate("EditProfile")}
+ />
+
+ }
+ onPress={handleComingSoon}
+ />
+
+ }
+ onPress={handleComingSoon}
+ />
+
+ }
+ onPress={handleLogout}
+ />
@@ -64,16 +72,16 @@ const styles = StyleSheet.create({
padding: 16,
},
profileSection: {
- alignItems: 'center',
+ alignItems: "center",
marginBottom: 24,
},
name: {
- marginTop: 16,
+ marginTop: 16,
+ },
+ email: {
+ marginTop: 4,
+ color: "gray",
},
- email: {
- marginTop: 4,
- color: 'gray',
- }
});
export default AccountScreen;
diff --git a/frontend/screens/EditProfileScreen.js b/frontend/screens/EditProfileScreen.js
index d7b5df97..fd7e1660 100644
--- a/frontend/screens/EditProfileScreen.js
+++ b/frontend/screens/EditProfileScreen.js
@@ -1,12 +1,14 @@
+import * as ImagePicker from "expo-image-picker";
import { useContext, useState } from "react";
import { Alert, StyleSheet, View } from "react-native";
-import { Appbar, Button, TextInput, Title } from "react-native-paper";
+import { Appbar, Avatar, Button, TextInput, Title } from "react-native-paper";
import { updateUser } from "../api/auth";
import { AuthContext } from "../context/AuthContext";
const EditProfileScreen = ({ navigation }) => {
const { user, token, updateUserInContext } = useContext(AuthContext);
const [name, setName] = useState(user?.name || "");
+ const [pickedImage, setPickedImage] = useState(null); // { uri, base64 }
const [isSubmitting, setIsSubmitting] = useState(false);
const handleUpdateProfile = async () => {
@@ -16,7 +18,14 @@ const EditProfileScreen = ({ navigation }) => {
}
setIsSubmitting(true);
try {
- const response = await updateUser({ name });
+ const updates = { name };
+
+ // Add image if picked
+ if (pickedImage?.base64) {
+ updates.imageUrl = `data:image/jpeg;base64,${pickedImage.base64}`;
+ }
+
+ const response = await updateUser(updates);
updateUserInContext(response.data);
Alert.alert("Success", "Profile updated successfully.");
navigation.goBack();
@@ -28,6 +37,29 @@ const EditProfileScreen = ({ navigation }) => {
}
};
+ const pickImage = async () => {
+ // 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 });
+ }
+ };
+
return (
@@ -36,6 +68,26 @@ const EditProfileScreen = ({ navigation }) => {
Edit Your Details
+
+ {/* Profile Picture Section */}
+
+ {pickedImage?.uri ? (
+
+ ) : user?.imageUrl && /^(https?:|data:image)/.test(user.imageUrl) ? (
+
+ ) : (
+
+ )}
+
+ {pickedImage ? "Change Photo" : "Add Photo"}
+
+
+
{
@@ -23,20 +24,51 @@ const FriendsScreen = () => {
const fetchData = async () => {
setIsLoading(true);
try {
+ // Fetch friends balance data
const friendsResponse = await getFriendsBalance();
const friendsData = friendsResponse.data.friendsBalance || [];
- // Transform the backend data to match the expected frontend format
- const transformedFriends = friendsData.map((friend) => ({
- id: friend.userId,
- name: friend.userName,
- netBalance: friend.netBalance,
- groups: friend.breakdown.map((group) => ({
- id: group.groupId,
- name: group.groupName,
- balance: group.balance,
- })),
- }));
+ // Fetch all groups to get member details with user images
+ const groupsResponse = await getGroups();
+ const groups = groupsResponse.data.groups || [];
+
+ // Create a map of userId to user details by fetching all group members
+ const userDetailsMap = new Map();
+
+ for (const group of groups) {
+ try {
+ const membersResponse = await getGroupMembers(group.id);
+ const members = membersResponse.data || [];
+
+ members.forEach((member) => {
+ if (member.user && member.userId) {
+ userDetailsMap.set(member.userId, member.user);
+ }
+ });
+ } catch (error) {
+ console.warn(
+ `Failed to fetch members for group ${group.id}:`,
+ error
+ );
+ }
+ }
+
+ // Transform the backend data and enrich with user details
+ const transformedFriends = friendsData.map((friend) => {
+ const userDetails = userDetailsMap.get(friend.userId);
+
+ return {
+ id: friend.userId,
+ name: friend.userName,
+ imageUrl: userDetails?.imageUrl || null,
+ netBalance: friend.netBalance,
+ groups: friend.breakdown.map((group) => ({
+ id: group.groupId,
+ name: group.groupName,
+ balance: group.balance,
+ })),
+ };
+ });
setFriends(transformedFriends);
} catch (error) {
@@ -59,6 +91,9 @@ const FriendsScreen = () => {
? `You owe $${Math.abs(item.netBalance).toFixed(2)}`
: `Owes you $${item.netBalance.toFixed(2)}`;
+ const isImage =
+ item.imageUrl && /^(https?:|data:image)/.test(item.imageUrl);
+
return (
{
descriptionStyle={{
color: item.netBalance !== 0 ? balanceColor : "gray",
}}
- left={(props) => }
+ left={(props) =>
+ isImage ? (
+
+ ) : (
+
+ )
+ }
>
{item.groups.map((group) => {
const groupBalanceColor = group.balance < 0 ? "red" : "green";
From 2cc7991f13814569253b1997182fcde0f106880c Mon Sep 17 00:00:00 2001
From: Devasy Patel <110348311+Devasy23@users.noreply.github.com>
Date: Sat, 9 Aug 2025 00:31:40 +0530
Subject: [PATCH 09/14] feat: Normalize user ID shape in AuthContext and
FriendsScreen for consistent handling
---
frontend/context/AuthContext.js | 27 +++++++++++++++++----
frontend/screens/FriendsScreen.js | 40 ++++++++++++++++++-------------
2 files changed, 46 insertions(+), 21 deletions(-)
diff --git a/frontend/context/AuthContext.js b/frontend/context/AuthContext.js
index f7d0e933..1caf5326 100644
--- a/frontend/context/AuthContext.js
+++ b/frontend/context/AuthContext.js
@@ -21,7 +21,7 @@ export const AuthProvider = ({ children }) => {
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);
@@ -30,7 +30,14 @@ export const AuthProvider = ({ children }) => {
newAccessToken: storedToken,
newRefreshToken: storedRefresh,
});
- setUser(JSON.parse(storedUser));
+ // Normalize user id shape: ensure `_id` exists even if API stored `id`
+ const parsed = JSON.parse(storedUser);
+ const normalized = parsed?._id
+ ? parsed
+ : parsed?.id
+ ? { ...parsed, _id: parsed.id }
+ : parsed;
+ setUser(normalized);
}
} catch (error) {
console.error("Failed to load stored authentication:", error);
@@ -109,7 +116,13 @@ export const AuthProvider = ({ children }) => {
newAccessToken: access_token,
newRefreshToken: refresh_token,
});
- setUser(userData);
+ // Normalize user id shape: ensure `_id` exists even if backend returns `id`
+ const normalizedUser = userData?._id
+ ? userData
+ : userData?.id
+ ? { ...userData, _id: userData.id }
+ : userData;
+ setUser(normalizedUser);
return true;
} catch (error) {
console.error(
@@ -150,7 +163,13 @@ export const AuthProvider = ({ children }) => {
};
const updateUserInContext = (updatedUser) => {
- setUser(updatedUser);
+ // Normalize on updates too
+ const normalizedUser = updatedUser?._id
+ ? updatedUser
+ : updatedUser?.id
+ ? { ...updatedUser, _id: updatedUser.id }
+ : updatedUser;
+ setUser(normalizedUser);
};
return (
diff --git a/frontend/screens/FriendsScreen.js b/frontend/screens/FriendsScreen.js
index acf1cf57..b6aca212 100644
--- a/frontend/screens/FriendsScreen.js
+++ b/frontend/screens/FriendsScreen.js
@@ -2,13 +2,13 @@ import { useIsFocused } from "@react-navigation/native";
import { useContext, useEffect, useState } from "react";
import { Alert, FlatList, StyleSheet, View } from "react-native";
import {
- ActivityIndicator,
- Appbar,
- Avatar,
- Divider,
- IconButton,
- List,
- Text,
+ ActivityIndicator,
+ Appbar,
+ Avatar,
+ Divider,
+ IconButton,
+ List,
+ Text,
} from "react-native-paper";
import { getFriendsBalance, getGroupMembers, getGroups } from "../api/groups";
import { AuthContext } from "../context/AuthContext";
@@ -35,9 +35,10 @@ const FriendsScreen = () => {
// Create a map of userId to user details by fetching all group members
const userDetailsMap = new Map();
- for (const group of groups) {
+ for (const group of groups) {
try {
- const membersResponse = await getGroupMembers(group.id);
+ // Use backend group id key `_id` when fetching members
+ const membersResponse = await getGroupMembers(group._id || group.id);
const members = membersResponse.data || [];
members.forEach((member) => {
@@ -91,8 +92,17 @@ const FriendsScreen = () => {
? `You owe $${Math.abs(item.netBalance).toFixed(2)}`
: `Owes you $${item.netBalance.toFixed(2)}`;
- const isImage =
- item.imageUrl && /^(https?:|data:image)/.test(item.imageUrl);
+ // 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 (
{
color: item.netBalance !== 0 ? balanceColor : "gray",
}}
left={(props) =>
- isImage ? (
-
+ imageUri ? (
+
) : (
Date: Sat, 9 Aug 2025 10:30:46 +0530
Subject: [PATCH 10/14] feat: Enhance FriendsScreen with user images and
loading skeletons for improved UI experience
---
backend/app/expenses/service.py | 6 +-
frontend/screens/FriendsScreen.js | 153 ++++++++++++++++++++----------
2 files changed, 105 insertions(+), 54 deletions(-)
diff --git a/backend/app/expenses/service.py b/backend/app/expenses/service.py
index b9a1b025..80cbcf31 100644
--- a/backend/app/expenses/service.py
+++ b/backend/app/expenses/service.py
@@ -970,17 +970,19 @@ async def get_friends_balance_summary(self, user_id: str) -> Dict[str, Any]:
if member["userId"] != user_id:
friend_ids.add(member["userId"])
- # Get user names
+ # Get user names & images
users = await self.users_collection.find(
{"_id": {"$in": [ObjectId(uid) for uid in friend_ids]}}
).to_list(None)
user_names = {str(user["_id"]): user.get("name", "Unknown") for user in users}
+ user_images = {str(user["_id"]): user.get("imageUrl") for user in users}
for friend_id in friend_ids:
friend_balance_data = {
"userId": friend_id,
"userName": user_names.get(friend_id, "Unknown"),
- "userImageUrl": None, # Would need to be fetched from user profile
+ # Populate image directly from users collection to avoid extra client round-trips
+ "userImageUrl": user_images.get(friend_id),
"netBalance": 0,
"owesYou": False,
"breakdown": [],
diff --git a/frontend/screens/FriendsScreen.js b/frontend/screens/FriendsScreen.js
index b6aca212..cee52a98 100644
--- a/frontend/screens/FriendsScreen.js
+++ b/frontend/screens/FriendsScreen.js
@@ -1,16 +1,8 @@
import { useIsFocused } from "@react-navigation/native";
-import { useContext, useEffect, useState } from "react";
-import { Alert, FlatList, StyleSheet, View } from "react-native";
-import {
- ActivityIndicator,
- Appbar,
- Avatar,
- Divider,
- IconButton,
- List,
- Text,
-} from "react-native-paper";
-import { getFriendsBalance, getGroupMembers, getGroups } from "../api/groups";
+import { useContext, useEffect, useRef, useState } from "react";
+import { Alert, Animated, FlatList, StyleSheet, View } from "react-native";
+import { Appbar, Avatar, Divider, IconButton, List, Text } from "react-native-paper";
+import { getFriendsBalance, getGroups } from "../api/groups";
import { AuthContext } from "../context/AuthContext";
const FriendsScreen = () => {
@@ -24,52 +16,27 @@ const FriendsScreen = () => {
const fetchData = async () => {
setIsLoading(true);
try {
- // Fetch friends balance data
+ // Fetch friends balance + groups concurrently for group icons
const friendsResponse = await getFriendsBalance();
const friendsData = friendsResponse.data.friendsBalance || [];
-
- // Fetch all groups to get member details with user images
const groupsResponse = await getGroups();
- const groups = groupsResponse.data.groups || [];
-
- // Create a map of userId to user details by fetching all group members
- const userDetailsMap = new Map();
-
- for (const group of groups) {
- try {
- // Use backend group id key `_id` when fetching members
- const membersResponse = await getGroupMembers(group._id || group.id);
- const members = membersResponse.data || [];
+ const groups = groupsResponse?.data?.groups || [];
+ const groupMeta = new Map(
+ groups.map((g) => [g._id, { name: g.name, imageUrl: g.imageUrl }])
+ );
- members.forEach((member) => {
- if (member.user && member.userId) {
- userDetailsMap.set(member.userId, member.user);
- }
- });
- } catch (error) {
- console.warn(
- `Failed to fetch members for group ${group.id}:`,
- error
- );
- }
- }
-
- // Transform the backend data and enrich with user details
- const transformedFriends = friendsData.map((friend) => {
- const userDetails = userDetailsMap.get(friend.userId);
-
- return {
- id: friend.userId,
+ const transformedFriends = friendsData.map((friend) => ({
+ id: friend.userId,
name: friend.userName,
- imageUrl: userDetails?.imageUrl || null,
+ imageUrl: friend.userImageUrl || null,
netBalance: friend.netBalance,
- groups: friend.breakdown.map((group) => ({
+ groups: (friend.breakdown || []).map((group) => ({
id: group.groupId,
name: group.groupName,
- balance: group.balance,
+ balance: group.balance,
+ imageUrl: groupMeta.get(group.groupId)?.imageUrl || null,
})),
- };
- });
+ }));
setFriends(transformedFriends);
} catch (error) {
@@ -129,6 +96,15 @@ const FriendsScreen = () => {
group.balance < 0
? `You owe $${Math.abs(group.balance).toFixed(2)}`
: `Owes you $${group.balance.toFixed(2)}`;
+ // 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 (
{
title={group.name}
description={groupBalanceText}
descriptionStyle={{ color: groupBalanceColor }}
- left={(props) => }
+ left={(props) =>
+ groupImageUri ? (
+
+ ) : (
+
+ )
+ }
/>
);
})}
@@ -144,10 +130,48 @@ const FriendsScreen = () => {
);
};
+ // 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) => (
+
+ ))}
+
);
}
@@ -223,6 +247,31 @@ const styles = StyleSheet.create({
textAlign: "center",
marginTop: 20,
},
+ skeletonContainer: {
+ padding: 16,
+ },
+ skeletonRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 14,
+ },
+ skeletonAvatar: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ backgroundColor: '#e0e0e0',
+ },
+ skeletonLine: {
+ height: 14,
+ backgroundColor: '#e0e0e0',
+ borderRadius: 6,
+ marginBottom: 6,
+ },
+ skeletonLineSmall: {
+ height: 12,
+ backgroundColor: '#e0e0e0',
+ borderRadius: 6,
+ },
});
export default FriendsScreen;
From a2ea88504454f2e124df2af29f636f709c9e8f2f Mon Sep 17 00:00:00 2001
From: Devasy Patel <110348311+Devasy23@users.noreply.github.com>
Date: Sat, 9 Aug 2025 10:37:28 +0530
Subject: [PATCH 11/14] feat: Implement currency formatting utility and update
screens to use formatted currency
---
frontend/screens/AccountScreen.js | 4 +-
frontend/screens/FriendsScreen.js | 121 ++++++++++++++++++------------
frontend/screens/HomeScreen.js | 9 ++-
frontend/utils/currency.js | 18 +++++
4 files changed, 102 insertions(+), 50 deletions(-)
create mode 100644 frontend/utils/currency.js
diff --git a/frontend/screens/AccountScreen.js b/frontend/screens/AccountScreen.js
index 74436dae..16ce8392 100644
--- a/frontend/screens/AccountScreen.js
+++ b/frontend/screens/AccountScreen.js
@@ -43,13 +43,13 @@ const AccountScreen = ({ navigation }) => {
}
+ left={() => }
onPress={handleComingSoon}
/>
}
+ left={() => }
onPress={handleComingSoon}
/>
diff --git a/frontend/screens/FriendsScreen.js b/frontend/screens/FriendsScreen.js
index cee52a98..0da27955 100644
--- a/frontend/screens/FriendsScreen.js
+++ b/frontend/screens/FriendsScreen.js
@@ -1,9 +1,17 @@
import { useIsFocused } from "@react-navigation/native";
import { useContext, useEffect, useRef, useState } from "react";
import { Alert, Animated, FlatList, StyleSheet, View } from "react-native";
-import { Appbar, Avatar, Divider, IconButton, List, Text } from "react-native-paper";
+import {
+ Appbar,
+ Avatar,
+ Divider,
+ IconButton,
+ List,
+ Text,
+} from "react-native-paper";
import { getFriendsBalance, getGroups } from "../api/groups";
import { AuthContext } from "../context/AuthContext";
+import { formatCurrency } from "../utils/currency";
const FriendsScreen = () => {
const { token, user } = useContext(AuthContext);
@@ -27,15 +35,15 @@ const FriendsScreen = () => {
const transformedFriends = friendsData.map((friend) => ({
id: friend.userId,
- name: friend.userName,
- imageUrl: friend.userImageUrl || null,
- netBalance: friend.netBalance,
- groups: (friend.breakdown || []).map((group) => ({
- id: group.groupId,
- name: group.groupName,
- balance: group.balance,
- imageUrl: groupMeta.get(group.groupId)?.imageUrl || null,
- })),
+ name: friend.userName,
+ imageUrl: friend.userImageUrl || null,
+ netBalance: friend.netBalance,
+ groups: (friend.breakdown || []).map((group) => ({
+ id: group.groupId,
+ name: group.groupName,
+ balance: group.balance,
+ imageUrl: groupMeta.get(group.groupId)?.imageUrl || null,
+ })),
}));
setFriends(transformedFriends);
@@ -56,15 +64,18 @@ const FriendsScreen = () => {
const balanceColor = item.netBalance < 0 ? "red" : "green";
const balanceText =
item.netBalance < 0
- ? `You owe $${Math.abs(item.netBalance).toFixed(2)}`
- : `Owes you $${item.netBalance.toFixed(2)}`;
+ ? `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)) {
+ 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}`;
@@ -94,14 +105,19 @@ const FriendsScreen = () => {
const groupBalanceColor = group.balance < 0 ? "red" : "green";
const groupBalanceText =
group.balance < 0
- ? `You owe $${Math.abs(group.balance).toFixed(2)}`
- : `Owes you $${group.balance.toFixed(2)}`;
+ ? `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)) {
+ 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))) {
+ } else if (
+ /^[A-Za-z0-9+/=]+$/.test(group.imageUrl.substring(0, 50))
+ ) {
groupImageUri = `data:image/jpeg;base64,${group.imageUrl}`;
}
}
@@ -114,7 +130,11 @@ const FriendsScreen = () => {
descriptionStyle={{ color: groupBalanceColor }}
left={(props) =>
groupImageUri ? (
-
+
) : (
{
const SkeletonRow = () => (
-
+
-
-
+
+
);
@@ -247,31 +276,31 @@ const styles = StyleSheet.create({
textAlign: "center",
marginTop: 20,
},
- skeletonContainer: {
- padding: 16,
- },
- skeletonRow: {
- flexDirection: 'row',
- alignItems: 'center',
- marginBottom: 14,
- },
- skeletonAvatar: {
- width: 48,
- height: 48,
- borderRadius: 24,
- backgroundColor: '#e0e0e0',
- },
- skeletonLine: {
- height: 14,
- backgroundColor: '#e0e0e0',
- borderRadius: 6,
- marginBottom: 6,
- },
- skeletonLineSmall: {
- height: 12,
- backgroundColor: '#e0e0e0',
- borderRadius: 6,
- },
+ skeletonContainer: {
+ padding: 16,
+ },
+ skeletonRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ marginBottom: 14,
+ },
+ skeletonAvatar: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ backgroundColor: "#e0e0e0",
+ },
+ skeletonLine: {
+ height: 14,
+ backgroundColor: "#e0e0e0",
+ borderRadius: 6,
+ marginBottom: 6,
+ },
+ skeletonLineSmall: {
+ height: 12,
+ backgroundColor: "#e0e0e0",
+ borderRadius: 6,
+ },
});
export default FriendsScreen;
diff --git a/frontend/screens/HomeScreen.js b/frontend/screens/HomeScreen.js
index fbad3e5d..dfb0eadd 100644
--- a/frontend/screens/HomeScreen.js
+++ b/frontend/screens/HomeScreen.js
@@ -13,6 +13,7 @@ import {
} from "react-native-paper";
import { createGroup, getGroups, getOptimizedSettlements } from "../api/groups";
import { AuthContext } from "../context/AuthContext";
+import { formatCurrency, getCurrencySymbol } from "../utils/currency";
const HomeScreen = ({ navigation }) => {
const { token, logout, user } = useContext(AuthContext);
@@ -119,6 +120,8 @@ const HomeScreen = ({ navigation }) => {
}
};
+ const currencySymbol = getCurrencySymbol();
+
const renderGroup = ({ item }) => {
const settlementStatus = groupSettlements[item._id];
@@ -133,9 +136,11 @@ const HomeScreen = ({ navigation }) => {
}
if (settlementStatus.netBalance > 0) {
- return `You are owed $${settlementStatus.netBalance.toFixed(2)}.`;
+ return `You are owed ${formatCurrency(settlementStatus.netBalance)}.`;
} else if (settlementStatus.netBalance < 0) {
- return `You owe $${Math.abs(settlementStatus.netBalance).toFixed(2)}.`;
+ return `You owe ${formatCurrency(
+ Math.abs(settlementStatus.netBalance)
+ )}.`;
}
return "You are settled up.";
diff --git a/frontend/utils/currency.js b/frontend/utils/currency.js
new file mode 100644
index 00000000..c5fff644
--- /dev/null
+++ b/frontend/utils/currency.js
@@ -0,0 +1,18 @@
+// Centralized currency formatting utility
+// Future enhancement: make currency code/user preference dynamic (user.profile.currency)
+
+const DEFAULT_CURRENCY_SYMBOL = "₹"; // INR default
+
+export const getCurrencySymbol = () => DEFAULT_CURRENCY_SYMBOL;
+
+export const formatCurrency = (amount) => {
+ if (amount == null || isNaN(Number(amount)))
+ return `${DEFAULT_CURRENCY_SYMBOL}0.00`;
+ const num = Number(amount);
+ return `${DEFAULT_CURRENCY_SYMBOL}${num.toFixed(2)}`;
+};
+
+export default {
+ getCurrencySymbol,
+ formatCurrency,
+};
From 6177cebb04fa57adec6019d2c6e51ad5f137c73b Mon Sep 17 00:00:00 2001
From: Devasy Patel <110348311+Devasy23@users.noreply.github.com>
Date: Sat, 9 Aug 2025 11:27:55 +0530
Subject: [PATCH 12/14] feat: Enhance API client with retry logic for transient
errors and improve image MIME type handling in profile updates
---
frontend/api/client.js | 24 ++++++++++++++++++++++--
frontend/screens/EditProfileScreen.js | 24 ++++++++++++++++++++++--
frontend/utils/currency.js | 3 +++
3 files changed, 47 insertions(+), 4 deletions(-)
diff --git a/frontend/api/client.js b/frontend/api/client.js
index 0125850a..958a06b9 100644
--- a/frontend/api/client.js
+++ b/frontend/api/client.js
@@ -30,6 +30,14 @@ export const apiClient = axios.create({
headers: { "Content-Type": "application/json" },
});
+// Basic retry configuration
+const MAX_RETRIES = 3;
+const BASE_DELAY_MS = 300; // base backoff
+
+function sleep(ms) {
+ return new Promise((res) => setTimeout(res, ms));
+}
+
// Attach Authorization header
apiClient.interceptors.request.use((config) => {
if (accessToken && !config.headers?.Authorization) {
@@ -78,20 +86,32 @@ apiClient.interceptors.response.use(
// Avoid refresh loop
const isAuthRefreshCall = originalRequest.url?.includes("/auth/refresh");
+ // 1. Handle 401 with refresh
if (status === 401 && !originalRequest._retry && !isAuthRefreshCall) {
originalRequest._retry = true;
try {
await performRefresh();
- // Set new Authorization and retry
originalRequest.headers = originalRequest.headers || {};
if (accessToken)
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest);
} catch (e) {
- // Propagate original error; caller should handle logout
return Promise.reject(error);
}
}
+
+ // 2. Retry on network errors or 5xx (all methods; caller should ensure idempotency where needed)
+ const transientError = !status || (status >= 500 && status < 600);
+ if (transientError) {
+ originalRequest._retryCount = originalRequest._retryCount || 0;
+ if (originalRequest._retryCount < MAX_RETRIES) {
+ originalRequest._retryCount += 1;
+ const delay = BASE_DELAY_MS * 2 ** (originalRequest._retryCount - 1);
+ await sleep(delay + Math.random() * 100); // jitter
+ return apiClient(originalRequest);
+ }
+ }
+
return Promise.reject(error);
}
);
diff --git a/frontend/screens/EditProfileScreen.js b/frontend/screens/EditProfileScreen.js
index fd7e1660..8201b708 100644
--- a/frontend/screens/EditProfileScreen.js
+++ b/frontend/screens/EditProfileScreen.js
@@ -22,7 +22,12 @@ const EditProfileScreen = ({ navigation }) => {
// Add image if picked
if (pickedImage?.base64) {
- updates.imageUrl = `data:image/jpeg;base64,${pickedImage.base64}`;
+ // Dynamically determine MIME type from picker metadata
+ const mime =
+ pickedImage.mimeType && /image\//.test(pickedImage.mimeType)
+ ? pickedImage.mimeType
+ : "image/jpeg"; // fallback
+ updates.imageUrl = `data:${mime};base64,${pickedImage.base64}`;
}
const response = await updateUser(updates);
@@ -56,7 +61,22 @@ const EditProfileScreen = ({ navigation }) => {
});
if (!result.canceled && result.assets && result.assets.length > 0) {
const asset = result.assets[0];
- setPickedImage({ uri: asset.uri, base64: asset.base64 });
+ // 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
+ }
+ setPickedImage({ uri: asset.uri, base64: asset.base64, mimeType });
}
};
diff --git a/frontend/utils/currency.js b/frontend/utils/currency.js
index c5fff644..436059f9 100644
--- a/frontend/utils/currency.js
+++ b/frontend/utils/currency.js
@@ -9,6 +9,9 @@ export const formatCurrency = (amount) => {
if (amount == null || isNaN(Number(amount)))
return `${DEFAULT_CURRENCY_SYMBOL}0.00`;
const num = Number(amount);
+ if (!Number.isFinite(num)) {
+ return `${DEFAULT_CURRENCY_SYMBOL}0.00`;
+ }
return `${DEFAULT_CURRENCY_SYMBOL}${num.toFixed(2)}`;
};
From 42723b8e18cd2679571f083126741a42284adc6d Mon Sep 17 00:00:00 2001
From: Devasy Patel <110348311+Devasy23@users.noreply.github.com>
Date: Sat, 9 Aug 2025 11:37:36 +0530
Subject: [PATCH 13/14] feat: Add tests for group leave and member removal with
unsettled balances and exception handling
---
backend/tests/groups/test_groups_service.py | 141 ++++++++++++++++++++
1 file changed, 141 insertions(+)
diff --git a/backend/tests/groups/test_groups_service.py b/backend/tests/groups/test_groups_service.py
index 8c0f53e6..df70c82f 100644
--- a/backend/tests/groups/test_groups_service.py
+++ b/backend/tests/groups/test_groups_service.py
@@ -599,3 +599,144 @@ def test_transform_group_document_partial_input(self):
assert result["name"] == "Partial Group"
assert result["currency"] == "USD" # default fallback
assert result["members"] == [] # default fallback
+
+ # --- New tests for unsettled balance checks & exception handling (coverage additions) ---
+ @pytest.mark.asyncio
+ async def test_leave_group_pending_settlement_blocks(self):
+ """Member can't leave when a pending settlement exists (covers pending branch)."""
+ mock_db = AsyncMock()
+ groups = AsyncMock()
+ settlements = AsyncMock()
+ mock_db.groups = groups
+ mock_db.settlements = settlements
+
+ group = {
+ "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"),
+ "name": "Test Group",
+ "members": [
+ {
+ "userId": "admin1",
+ "role": "admin",
+ "joinedAt": "2023-01-01T00:00:00Z",
+ },
+ {
+ "userId": "member1",
+ "role": "member",
+ "joinedAt": "2023-01-01T00:00:00Z",
+ },
+ ],
+ }
+ groups.find_one.return_value = group
+ settlements.find_one.return_value = {"_id": ObjectId()}
+
+ with patch.object(self.service, "get_db", return_value=mock_db):
+ with pytest.raises(HTTPException) as exc:
+ await self.service.leave_group(str(group["_id"]), "member1")
+
+ assert exc.value.status_code == 400
+ assert "Cannot leave group with unsettled balances" in exc.value.detail
+
+ @pytest.mark.asyncio
+ async def test_leave_group_settlement_lookup_failure(self):
+ """Service returns 503 when settlement lookup errors (covers except block)."""
+ mock_db = AsyncMock()
+ groups = AsyncMock()
+ settlements = AsyncMock()
+ mock_db.groups = groups
+ mock_db.settlements = settlements
+
+ group = {
+ "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"),
+ "name": "Test Group",
+ "members": [
+ {
+ "userId": "admin1",
+ "role": "admin",
+ "joinedAt": "2023-01-01T00:00:00Z",
+ },
+ {
+ "userId": "member1",
+ "role": "member",
+ "joinedAt": "2023-01-01T00:00:00Z",
+ },
+ ],
+ }
+ groups.find_one.return_value = group
+ settlements.find_one.side_effect = Exception("db down")
+
+ with patch.object(self.service, "get_db", return_value=mock_db):
+ with pytest.raises(HTTPException) as exc:
+ await self.service.leave_group(str(group["_id"]), "member1")
+
+ assert exc.value.status_code == 503
+ assert "Unable to verify unsettled balances" in exc.value.detail
+
+ @pytest.mark.asyncio
+ async def test_remove_member_pending_settlement_blocks(self):
+ """Admin can't remove member with pending settlement (covers pending branch)."""
+ mock_db = AsyncMock()
+ groups = AsyncMock()
+ settlements = AsyncMock()
+ mock_db.groups = groups
+ mock_db.settlements = settlements
+
+ group = {
+ "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"),
+ "name": "Test Group",
+ "members": [
+ {
+ "userId": "admin1",
+ "role": "admin",
+ "joinedAt": "2023-01-01T00:00:00Z",
+ },
+ {
+ "userId": "member1",
+ "role": "member",
+ "joinedAt": "2023-01-01T00:00:00Z",
+ },
+ ],
+ }
+ groups.find_one.return_value = group # Admin check passes
+ settlements.find_one.return_value = {"_id": ObjectId()}
+
+ with patch.object(self.service, "get_db", return_value=mock_db):
+ with pytest.raises(HTTPException) as exc:
+ await self.service.remove_member(str(group["_id"]), "member1", "admin1")
+
+ assert exc.value.status_code == 400
+ assert "Cannot remove member with unsettled balances" in exc.value.detail
+
+ @pytest.mark.asyncio
+ async def test_remove_member_settlement_lookup_failure(self):
+ """Service returns 503 when settlement lookup fails during removal (covers except block)."""
+ mock_db = AsyncMock()
+ groups = AsyncMock()
+ settlements = AsyncMock()
+ mock_db.groups = groups
+ mock_db.settlements = settlements
+
+ group = {
+ "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"),
+ "name": "Test Group",
+ "members": [
+ {
+ "userId": "admin1",
+ "role": "admin",
+ "joinedAt": "2023-01-01T00:00:00Z",
+ },
+ {
+ "userId": "member1",
+ "role": "member",
+ "joinedAt": "2023-01-01T00:00:00Z",
+ },
+ ],
+ }
+ groups.find_one.return_value = group # Admin check passes
+ settlements.find_one.side_effect = Exception("db error")
+
+ with patch.object(self.service, "get_db", return_value=mock_db):
+ with pytest.raises(HTTPException) as exc:
+ await self.service.remove_member(str(group["_id"]), "member1", "admin1")
+
+ assert exc.value.status_code == 503
+ assert "Unable to verify unsettled balances" in exc.value.detail
From a1843099aba9d6146df4513329bf0452232bf99b Mon Sep 17 00:00:00 2001
From: Devasy Patel <110348311+Devasy23@users.noreply.github.com>
Date: Sat, 9 Aug 2025 11:48:40 +0530
Subject: [PATCH 14/14] feat: Simplify empty expenses message display in
GroupDetailsScreen
---
frontend/screens/GroupDetailsScreen.js | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/frontend/screens/GroupDetailsScreen.js b/frontend/screens/GroupDetailsScreen.js
index 4e89f385..a1050b9f 100644
--- a/frontend/screens/GroupDetailsScreen.js
+++ b/frontend/screens/GroupDetailsScreen.js
@@ -199,10 +199,7 @@ const GroupDetailsScreen = ({ route, navigation }) => {
keyExtractor={(item) => item._id}
ListHeaderComponent={renderHeader}
ListEmptyComponent={
-
- {renderHeader()}
- No expenses recorded yet.
-
+ No expenses recorded yet.
}
contentContainerStyle={{ paddingBottom: 80 }} // To avoid FAB overlap
/>
@@ -308,6 +305,11 @@ const styles = StyleSheet.create({
fontWeight: "600",
color: "#333",
},
+ emptyText: {
+ fontSize: 14,
+ color: "#666",
+ paddingVertical: 8,
+ },
});
export default GroupDetailsScreen;