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 ( - - - + + - setMenuVisible(false)} - anchor={} - > - {members.map(member => ( - { - setPayerId(member.userId); - setMenuVisible(false); - }} - title={member.user.name} - /> - ))} - - - Split Method - + setMenuVisible(false)} + anchor={ + + } + > + {members.map((member) => ( + { + setPayerId(member.userId); + setMenuVisible(false); + }} + title={member.user.name} + /> + ))} + - {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 => ( - ))} @@ -216,7 +260,13 @@ const GroupSettingsScreen = ({ route, navigation }) => { onChangeText={setIcon} /> {isAdmin && ( - )} @@ -225,16 +275,16 @@ const GroupSettingsScreen = ({ route, navigation }) => { - - {members.map(renderMemberItem)} - + {members.map(renderMemberItem)} Join Code: {group?.joinCode} - + @@ -242,11 +292,22 @@ const GroupSettingsScreen = ({ route, navigation }) => { - {isAdmin && ( - )} @@ -259,9 +320,9 @@ const GroupSettingsScreen = ({ route, navigation }) => { const styles = StyleSheet.create({ container: { flex: 1, padding: 16 }, - loaderContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + loaderContainer: { flex: 1, justifyContent: "center", alignItems: "center" }, card: { marginBottom: 16 }, - iconRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 8 }, + iconRow: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 8 }, iconBtn: { marginRight: 8, marginBottom: 8 }, }); diff --git a/frontend/screens/HomeScreen.js b/frontend/screens/HomeScreen.js index e435308a..f6db0010 100644 --- a/frontend/screens/HomeScreen.js +++ b/frontend/screens/HomeScreen.js @@ -1,8 +1,18 @@ -import { useContext, useEffect, useState } from 'react'; -import { Alert, FlatList, StyleSheet, View } from 'react-native'; -import { ActivityIndicator, Appbar, Avatar, Button, Card, Modal, Portal, Text, TextInput } from 'react-native-paper'; -import { createGroup, getGroups, getOptimizedSettlements } from '../api/groups'; -import { AuthContext } from '../context/AuthContext'; +import { useContext, useEffect, useState } from "react"; +import { Alert, FlatList, StyleSheet, View } from "react-native"; +import { + ActivityIndicator, + Appbar, + Avatar, + Button, + Card, + Modal, + Portal, + Text, + TextInput, +} from "react-native-paper"; +import { createGroup, getGroups, getOptimizedSettlements } from "../api/groups"; +import { AuthContext } from "../context/AuthContext"; const HomeScreen = ({ navigation }) => { const { token, logout, user } = useContext(AuthContext); @@ -12,7 +22,7 @@ const HomeScreen = ({ navigation }) => { // State for the Create Group modal const [modalVisible, setModalVisible] = useState(false); - const [newGroupName, setNewGroupName] = useState(''); + const [newGroupName, setNewGroupName] = useState(""); const [isCreatingGroup, setIsCreatingGroup] = useState(false); const showModal = () => setModalVisible(true); @@ -21,29 +31,36 @@ const HomeScreen = ({ navigation }) => { // Calculate settlement status for a group const calculateSettlementStatus = async (groupId, userId) => { try { - const response = await getOptimizedSettlements(token, groupId); + const response = await getOptimizedSettlements(groupId); const settlements = response.data.optimizedSettlements || []; - + // Check if user has any pending settlements - const userOwes = settlements.filter(s => s.fromUserId === userId); - const userIsOwed = settlements.filter(s => s.toUserId === userId); - + const userOwes = settlements.filter((s) => s.fromUserId === userId); + const userIsOwed = settlements.filter((s) => s.toUserId === userId); + const totalOwed = userOwes.reduce((sum, s) => sum + (s.amount || 0), 0); - const totalToReceive = userIsOwed.reduce((sum, s) => sum + (s.amount || 0), 0); - + const totalToReceive = userIsOwed.reduce( + (sum, s) => sum + (s.amount || 0), + 0 + ); + return { isSettled: totalOwed === 0 && totalToReceive === 0, owesAmount: totalOwed, owedAmount: totalToReceive, - netBalance: totalToReceive - totalOwed + netBalance: totalToReceive - totalOwed, }; } catch (error) { - console.error('Failed to fetch settlement status for group:', groupId, error); + console.error( + "Failed to fetch settlement status for group:", + groupId, + error + ); return { isSettled: true, owesAmount: 0, owedAmount: 0, - netBalance: 0 + netBalance: 0, }; } }; @@ -51,17 +68,17 @@ const HomeScreen = ({ navigation }) => { const fetchGroups = async () => { try { setIsLoading(true); - const response = await getGroups(token); + const response = await getGroups(); const groupsList = response.data.groups; setGroups(groupsList); - + // Fetch settlement status for each group if (user?._id) { const settlementPromises = groupsList.map(async (group) => { const status = await calculateSettlementStatus(group._id, user._id); return { groupId: group._id, status }; }); - + const settlementResults = await Promise.all(settlementPromises); const settlementMap = {}; settlementResults.forEach(({ groupId, status }) => { @@ -70,8 +87,8 @@ const HomeScreen = ({ navigation }) => { setGroupSettlements(settlementMap); } } catch (error) { - console.error('Failed to fetch groups:', error); - Alert.alert('Error', 'Failed to fetch groups.'); + console.error("Failed to fetch groups:", error); + Alert.alert("Error", "Failed to fetch groups."); } finally { setIsLoading(false); } @@ -85,18 +102,18 @@ const HomeScreen = ({ navigation }) => { const handleCreateGroup = async () => { if (!newGroupName) { - Alert.alert('Error', 'Please enter a group name.'); + Alert.alert("Error", "Please enter a group name."); return; } setIsCreatingGroup(true); try { - await createGroup(token, newGroupName); + await createGroup(newGroupName); hideModal(); - setNewGroupName(''); + setNewGroupName(""); await fetchGroups(); // Refresh the groups list } catch (error) { - console.error('Failed to create group:', error); - Alert.alert('Error', 'Failed to create group.'); + console.error("Failed to create group:", error); + Alert.alert("Error", "Failed to create group."); } finally { setIsCreatingGroup(false); } @@ -104,52 +121,63 @@ const HomeScreen = ({ navigation }) => { const renderGroup = ({ item }) => { const settlementStatus = groupSettlements[item._id]; - + // Generate settlement status text const getSettlementStatusText = () => { if (!settlementStatus) { return "Calculating balances..."; } - + if (settlementStatus.isSettled) { return "✓ You are settled up."; } - + if (settlementStatus.netBalance > 0) { return `You are owed $${settlementStatus.netBalance.toFixed(2)}.`; } else if (settlementStatus.netBalance < 0) { return `You owe $${Math.abs(settlementStatus.netBalance).toFixed(2)}.`; } - + return "You are settled up."; }; - + // Get text color based on settlement status const getStatusColor = () => { if (!settlementStatus || settlementStatus.isSettled) { - return '#4CAF50'; // Green for settled + return "#4CAF50"; // Green for settled } - + if (settlementStatus.netBalance > 0) { - return '#4CAF50'; // Green for being owed money + return "#4CAF50"; // Green for being owed money } else if (settlementStatus.netBalance < 0) { - return '#F44336'; // Red for owing money + return "#F44336"; // Red for owing money } - - return '#4CAF50'; // Default green + + return "#4CAF50"; // Default green }; const isImage = item.imageUrl && /^(https?:)/.test(item.imageUrl); - const groupIcon = item.imageUrl || (item.name?.charAt(0) || '?'); + const groupIcon = item.imageUrl || item.name?.charAt(0) || "?"; return ( - navigation.navigate('GroupDetails', { groupId: item._id, groupName: item.name, groupIcon })}> + + navigation.navigate("GroupDetails", { + groupId: item._id, + groupName: item.name, + groupIcon, + }) + } + > isImage ? ( - - ) : ( - - )} + left={(props) => + isImage ? ( + + ) : ( + + ) + } /> Join Code: {item.joinCode} @@ -164,7 +192,11 @@ const HomeScreen = ({ navigation }) => { return ( - + Create a New Group { - - - navigation.navigate('JoinGroup', { onGroupJoined: fetchGroups })} /> + + + + navigation.navigate("JoinGroup", { onGroupJoined: fetchGroups }) + } + /> {isLoading ? ( - - - + + + ) : ( - item._id} - contentContainerStyle={styles.list} - ListEmptyComponent={No groups found. Create or join one!} - onRefresh={fetchGroups} - refreshing={isLoading} - /> + item._id} + contentContainerStyle={styles.list} + ListEmptyComponent={ + + No groups found. Create or join one! + + } + onRefresh={fetchGroups} + refreshing={isLoading} + /> )} ); @@ -214,8 +255,8 @@ const styles = StyleSheet.create({ }, loaderContainer: { flex: 1, - justifyContent: 'center', - alignItems: 'center', + justifyContent: "center", + alignItems: "center", }, list: { padding: 16, @@ -224,15 +265,15 @@ const styles = StyleSheet.create({ marginBottom: 16, }, settlementStatus: { - fontWeight: '500', + fontWeight: "500", marginTop: 4, }, emptyText: { - textAlign: 'center', + textAlign: "center", marginTop: 20, }, modalContainer: { - backgroundColor: 'white', + backgroundColor: "white", padding: 20, margin: 20, borderRadius: 8, @@ -240,11 +281,11 @@ const styles = StyleSheet.create({ modalTitle: { fontSize: 20, marginBottom: 20, - textAlign: 'center', + textAlign: "center", }, input: { marginBottom: 20, - } + }, }); export default HomeScreen; diff --git a/frontend/screens/JoinGroupScreen.js b/frontend/screens/JoinGroupScreen.js index 008f10f3..153e4eac 100644 --- a/frontend/screens/JoinGroupScreen.js +++ b/frontend/screens/JoinGroupScreen.js @@ -1,29 +1,32 @@ -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 { joinGroup } from '../api/groups'; +import { useContext, useState } from "react"; +import { Alert, StyleSheet, View } from "react-native"; +import { Appbar, Button, TextInput, Title } from "react-native-paper"; +import { joinGroup } from "../api/groups"; +import { AuthContext } from "../context/AuthContext"; const JoinGroupScreen = ({ navigation, route }) => { const { token } = useContext(AuthContext); - const [joinCode, setJoinCode] = useState(''); + const [joinCode, setJoinCode] = useState(""); const [isJoining, setIsJoining] = useState(false); const { onGroupJoined } = route.params; const handleJoinGroup = async () => { if (!joinCode) { - Alert.alert('Error', 'Please enter a join code.'); + Alert.alert("Error", "Please enter a join code."); return; } setIsJoining(true); try { - await joinGroup(token, joinCode); - Alert.alert('Success', 'Successfully joined the group.'); + await joinGroup(joinCode); + Alert.alert("Success", "Successfully joined the group."); onGroupJoined(); // Call the callback to refresh the groups list navigation.goBack(); } catch (error) { - console.error('Failed to join group:', error); - Alert.alert('Error', 'Failed to join group. Please check the code and try again.'); + console.error("Failed to join group:", error); + Alert.alert( + "Error", + "Failed to join group. Please check the code and try again." + ); } finally { setIsJoining(false); } From e9824a1c7a7264b7746aff1c1e077d6b40add4a7 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Fri, 8 Aug 2025 22:40:42 +0530 Subject: [PATCH 03/14] feat: Implement balance checks for group member removal and leaving group --- backend/app/groups/service.py | 45 +++++++- backend/tests/groups/test_groups_service.py | 116 ++++++++++++++++++++ frontend/screens/GroupSettingsScreen.js | 62 +++++++---- 3 files changed, 196 insertions(+), 27 deletions(-) diff --git a/backend/app/groups/service.py b/backend/app/groups/service.py index 8e8a4274..25652d91 100644 --- a/backend/app/groups/service.py +++ b/backend/app/groups/service.py @@ -312,9 +312,26 @@ async def leave_group(self, group_id: str, user_id: str) -> bool: detail="Cannot leave group when you are the only admin. Delete the group or promote another member to admin first.", ) - # TODO: Check for outstanding balances with expense service - # For now, we'll allow leaving without balance check - # This should be implemented when expense service is ready + # Block leaving when there are unsettled balances involving this user + pending_count = 0 + try: + result = await db.settlements.count_documents( + { + "groupId": group_id, + "status": "pending", + "$or": [{"payerId": user_id}, {"payeeId": user_id}], + } + ) + 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)" + ) + if pending_count > 0: + raise HTTPException( + status_code=400, + detail="Cannot leave group with unsettled balances. Please settle up first.", + ) result = await db.groups.update_one( {"_id": obj_id}, {"$pull": {"members": {"userId": user_id}}} @@ -424,8 +441,26 @@ async def remove_member(self, group_id: str, member_id: str, user_id: str) -> bo detail="Cannot remove yourself. Use leave group instead", ) - # TODO: Check for outstanding balances with expense service - # For now, we'll allow removal without balance check + # Block removal when there are unsettled balances involving the target member + pending_count = 0 + try: + result = await db.settlements.count_documents( + { + "groupId": group_id, + "status": "pending", + "$or": [{"payerId": member_id}, {"payeeId": member_id}], + } + ) + 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)" + ) + if pending_count > 0: + raise HTTPException( + status_code=400, + detail="Cannot remove member with unsettled balances. Please settle up first.", + ) result = await db.groups.update_one( {"_id": obj_id}, {"$pull": {"members": {"userId": member_id}}} diff --git a/backend/tests/groups/test_groups_service.py b/backend/tests/groups/test_groups_service.py index 7548b86a..e84af937 100644 --- a/backend/tests/groups/test_groups_service.py +++ b/backend/tests/groups/test_groups_service.py @@ -188,6 +188,122 @@ async def test_join_group_invalid_code(self): assert exc_info.value.status_code == 404 assert "Invalid join code" in str(exc_info.value.detail) + @pytest.mark.asyncio + async def test_remove_member_blocked_when_unsettled(self): + """Admin cannot remove member if pending settlements exist""" + mock_db = AsyncMock() + groups = AsyncMock() + settlements = AsyncMock() + mock_db.groups = groups + mock_db.settlements = settlements + + group_id = str(ObjectId()) + admin_id = "admin123" + member_id = "member456" + + groups.find_one.return_value = { + "_id": ObjectId(group_id), + "members": [ + {"userId": admin_id, "role": "admin"}, + {"userId": member_id, "role": "member"}, + ], + } + settlements.count_documents.return_value = 2 # pending exists + + with patch.object(self.service, "get_db", return_value=mock_db): + with pytest.raises(HTTPException) as exc: + await self.service.remove_member(group_id, member_id, admin_id) + + assert exc.value.status_code == 400 + assert "unsettled balances" in str(exc.value.detail) + + @pytest.mark.asyncio + async def test_remove_member_allowed_when_settled(self): + """Admin can remove member when no pending settlements""" + mock_db = AsyncMock() + groups = AsyncMock() + settlements = AsyncMock() + mock_db.groups = groups + mock_db.settlements = settlements + + group_id = str(ObjectId()) + admin_id = "admin123" + member_id = "member456" + + groups.find_one.side_effect = [ + { + "_id": ObjectId(group_id), + "members": [ + {"userId": admin_id, "role": "admin"}, + {"userId": member_id, "role": "member"}, + ], + } + ] + settlements.count_documents.return_value = 0 + groups.update_one.return_value = MagicMock(modified_count=1) + + with patch.object(self.service, "get_db", return_value=mock_db): + ok = await self.service.remove_member(group_id, member_id, admin_id) + + assert ok is True + + @pytest.mark.asyncio + async def test_leave_group_blocked_when_unsettled(self): + """Member cannot leave when they have pending settlements""" + mock_db = AsyncMock() + groups = AsyncMock() + settlements = AsyncMock() + mock_db.groups = groups + mock_db.settlements = settlements + + group_id = str(ObjectId()) + user_id = "user123" + + groups.find_one.return_value = { + "_id": ObjectId(group_id), + "members": [ + {"userId": user_id, "role": "member"}, + {"userId": "other", "role": "admin"}, + ], + } + settlements.count_documents.return_value = 1 + + with patch.object(self.service, "get_db", return_value=mock_db): + with pytest.raises(HTTPException) as exc: + await self.service.leave_group(group_id, user_id) + + assert exc.value.status_code == 400 + assert "unsettled balances" in str(exc.value.detail) + + @pytest.mark.asyncio + async def test_leave_group_allowed_when_settled(self): + """Member can leave when no pending settlements and not sole admin""" + mock_db = AsyncMock() + groups = AsyncMock() + settlements = AsyncMock() + mock_db.groups = groups + mock_db.settlements = settlements + + group_id = str(ObjectId()) + user_id = "user123" + + groups.find_one.side_effect = [ + { + "_id": ObjectId(group_id), + "members": [ + {"userId": user_id, "role": "member"}, + {"userId": "admin2", "role": "admin"}, + ], + } + ] + settlements.count_documents.return_value = 0 + groups.update_one.return_value = MagicMock(modified_count=1) + + with patch.object(self.service, "get_db", return_value=mock_db): + ok = await self.service.leave_group(group_id, user_id) + + assert ok is True + @pytest.mark.asyncio async def test_join_group_already_member(self): """Test joining group when already a member""" diff --git a/frontend/screens/GroupSettingsScreen.js b/frontend/screens/GroupSettingsScreen.js index 6e025dad..196ac7b1 100644 --- a/frontend/screens/GroupSettingsScreen.js +++ b/frontend/screens/GroupSettingsScreen.js @@ -1,28 +1,29 @@ import { - useContext, - useEffect, - useLayoutEffect, - useMemo, - useState, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useState, } from "react"; -import { Alert, Share, StyleSheet, View } from "react-native"; +import { Alert, ScrollView, Share, StyleSheet, View } from "react-native"; import { - ActivityIndicator, - Avatar, - Button, - Card, - IconButton, - List, - Text, - TextInput, + 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, + deleteGroup as apiDeleteGroup, + leaveGroup as apiLeaveGroup, + removeMember as apiRemoveMember, + updateGroup as apiUpdateGroup, + getGroupById, + getGroupMembers, + getOptimizedSettlements, } from "../api/groups"; import { AuthContext } from "../context/AuthContext"; @@ -74,8 +75,11 @@ const GroupSettingsScreen = ({ route, navigation }) => { if (!isAdmin) return; const updates = {}; if (name && name !== group?.name) updates.name = name; - if (icon && icon !== (group?.imageUrl || group?.icon)) + // 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; + } if (Object.keys(updates).length === 0) return Alert.alert("Nothing to update"); try { @@ -116,6 +120,14 @@ const GroupSettingsScreen = ({ route, navigation }) => { style: "destructive", onPress: async () => { try { + // Pre-check balances using optimized settlements + const settlementsRes = await getOptimizedSettlements(groupId); + const settlements = settlementsRes?.data?.optimizedSettlements || []; + const hasUnsettled = settlements.some(s => (s.fromUserId === memberId || s.toUserId === memberId) && (s.amount || 0) > 0); + if (hasUnsettled) { + Alert.alert('Cannot remove', 'This member has unsettled balances in the group.'); + return; + } await apiRemoveMember(groupId, memberId); await load(); } catch (e) { @@ -232,6 +244,7 @@ const GroupSettingsScreen = ({ route, navigation }) => { return ( + @@ -239,6 +252,7 @@ const GroupSettingsScreen = ({ route, navigation }) => { label="Group Name" value={name} onChangeText={setName} + editable={!!isAdmin} style={{ marginBottom: 12 }} /> Icon (emoji or image URL) @@ -249,6 +263,7 @@ const GroupSettingsScreen = ({ route, navigation }) => { mode={icon === i ? "contained" : "outlined"} style={styles.iconBtn} onPress={() => setIcon(i)} + disabled={!isAdmin} > {i} @@ -258,6 +273,7 @@ const GroupSettingsScreen = ({ route, navigation }) => { placeholder="Or paste image URL..." value={icon} onChangeText={setIcon} + editable={!!isAdmin} /> {isAdmin && ( ))} - + + + {(pickedImage?.uri || group?.imageUrl) && ( + + )} + {isAdmin && ( + ))} + + + {(pickedImage?.uri || group?.imageUrl) && ( + + )} + + {isAdmin && ( + - ))} - - - - {(pickedImage?.uri || group?.imageUrl) && ( - )} - - {isAdmin && ( - - )} - - + + - - - {members.map(renderMemberItem)} - + + + {members.map(renderMemberItem)} + - - - - Join Code: {group?.joinCode} - - - - - - - - + + + + + Join Code: {group?.joinCode} + - {isAdmin && ( + + + + + + + - )} - - - + {isAdmin && ( + + )} + + + ); @@ -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 && ( + + { @@ -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;