Skip to content

Commit 7211eba

Browse files
This commit introduces several improvements to the expense creation and group details screens based on your feedback.
- **Equal Split Member Selection:** You can now select which members to include when splitting an expense equally. - **Optimized Settlements Summary:** The group details page now displays a summary of optimized settlements for you, showing who you need to pay and how much. - **Transaction-level Balance Display:** Each expense in the group details now shows your financial relationship to it (e.g., "You borrowed $30").
1 parent 7b76a62 commit 7211eba

File tree

3 files changed

+105
-23
lines changed

3 files changed

+105
-23
lines changed

frontend/api/groups.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ export const getGroups = (token) => {
2020
});
2121
};
2222

23+
export const getOptimizedSettlements = (token, groupId) => {
24+
return apiClient.post(`/groups/${groupId}/settlements/optimize`, {}, {
25+
headers: {
26+
Authorization: `Bearer ${token}`,
27+
},
28+
});
29+
};
30+
2331
export const createExpense = (token, groupId, expenseData) => {
2432
return apiClient.post(`/groups/${groupId}/expenses`, expenseData, {
2533
headers: {

frontend/screens/AddExpenseScreen.js

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useEffect, useContext } from 'react';
22
import { View, StyleSheet, Alert, ScrollView } from 'react-native';
3-
import { Button, TextInput, ActivityIndicator, Appbar, Title, SegmentedButtons, Text, Paragraph } from 'react-native-paper';
3+
import { Button, TextInput, ActivityIndicator, Appbar, Title, SegmentedButtons, Text, Paragraph, Checkbox } from 'react-native-paper';
44
import { AuthContext } from '../context/AuthContext';
55
import { getGroupMembers, createExpense } from '../api/groups';
66

@@ -18,6 +18,7 @@ const AddExpenseScreen = ({ route, navigation }) => {
1818
const [percentages, setPercentages] = useState({});
1919
const [shares, setShares] = useState({});
2020
const [exactAmounts, setExactAmounts] = useState({});
21+
const [selectedMembers, setSelectedMembers] = useState({}); // For equal split
2122

2223
useEffect(() => {
2324
const fetchMembers = async () => {
@@ -28,15 +29,18 @@ const AddExpenseScreen = ({ route, navigation }) => {
2829
const initialShares = {};
2930
const initialPercentages = {};
3031
const initialExactAmounts = {};
32+
const initialSelectedMembers = {};
3133
const numMembers = response.data.length;
3234
response.data.forEach(member => {
3335
initialShares[member.userId] = '1';
3436
initialPercentages[member.userId] = numMembers > 0 ? (100 / numMembers).toFixed(2) : '0';
3537
initialExactAmounts[member.userId] = '0.00';
38+
initialSelectedMembers[member.userId] = true; // Select all by default
3639
});
3740
setShares(initialShares);
3841
setPercentages(initialPercentages);
3942
setExactAmounts(initialExactAmounts);
43+
setSelectedMembers(initialSelectedMembers);
4044
} catch (error) {
4145
console.error('Failed to fetch members:', error);
4246
Alert.alert('Error', 'Failed to fetch group members.');
@@ -68,8 +72,12 @@ const AddExpenseScreen = ({ route, navigation }) => {
6872
let splitType = splitMethod;
6973

7074
if (splitMethod === 'equal') {
71-
const splitAmount = numericAmount / members.length;
72-
splits = members.map(member => ({ userId: member.userId, amount: splitAmount }));
75+
const includedMembers = Object.keys(selectedMembers).filter(userId => selectedMembers[userId]);
76+
if (includedMembers.length === 0) {
77+
throw new Error('You must select at least one member for the split.');
78+
}
79+
const splitAmount = numericAmount / includedMembers.length;
80+
splits = includedMembers.map(userId => ({ userId, amount: splitAmount }));
7381
} else if (splitMethod === 'exact') {
7482
const total = Object.values(exactAmounts).reduce((sum, val) => sum + parseFloat(val || '0'), 0);
7583
if (Math.abs(total - numericAmount) > 0.01) {
@@ -114,12 +122,25 @@ const AddExpenseScreen = ({ route, navigation }) => {
114122
}
115123
};
116124

125+
const handleMemberSelect = (userId) => {
126+
setSelectedMembers(prev => ({...prev, [userId]: !prev[userId]}));
127+
};
128+
117129
const renderSplitInputs = () => {
118130
const handleSplitChange = (setter, userId, value) => {
119131
setter(prev => ({ ...prev, [userId]: value }));
120132
};
121133

122134
switch (splitMethod) {
135+
case 'equal':
136+
return members.map(member => (
137+
<Checkbox.Item
138+
key={member.userId}
139+
label={member.user.name}
140+
status={selectedMembers[member.userId] ? 'checked' : 'unchecked'}
141+
onPress={() => handleMemberSelect(member.userId)}
142+
/>
143+
));
123144
case 'exact':
124145
return members.map(member => (
125146
<TextInput
@@ -153,9 +174,8 @@ const AddExpenseScreen = ({ route, navigation }) => {
153174
style={styles.splitInput}
154175
/>
155176
));
156-
case 'equal':
157177
default:
158-
return <Paragraph>The expense will be split equally among all {members.length} members.</Paragraph>;
178+
return null;
159179
}
160180
};
161181

frontend/screens/GroupDetailsScreen.js

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,29 @@
11
import React, { useState, useEffect, useContext } from 'react';
2-
import { View, StyleSheet, FlatList, Alert } from 'react-native';
2+
import { View, StyleSheet, FlatList, Alert, ScrollView } from 'react-native';
33
import { Button, Text, Card, ActivityIndicator, Appbar, FAB, Title, Paragraph } from 'react-native-paper';
44
import { AuthContext } from '../context/AuthContext';
5-
import { getGroupMembers, getGroupExpenses } from '../api/groups';
5+
import { getGroupMembers, getGroupExpenses, getOptimizedSettlements } from '../api/groups';
66

77
const GroupDetailsScreen = ({ route, navigation }) => {
88
const { groupId, groupName } = route.params;
9-
const { token } = useContext(AuthContext);
9+
const { token, user } = useContext(AuthContext);
1010
const [members, setMembers] = useState([]);
1111
const [expenses, setExpenses] = useState([]);
12+
const [settlements, setSettlements] = useState([]);
1213
const [isLoading, setIsLoading] = useState(true);
1314

1415
const fetchData = async () => {
1516
try {
1617
setIsLoading(true);
17-
// Fetch members and expenses in parallel
18-
const [membersResponse, expensesResponse] = await Promise.all([
18+
// Fetch members, expenses, and settlements in parallel
19+
const [membersResponse, expensesResponse, settlementsResponse] = await Promise.all([
1920
getGroupMembers(token, groupId),
2021
getGroupExpenses(token, groupId),
22+
getOptimizedSettlements(token, groupId),
2123
]);
2224
setMembers(membersResponse.data);
2325
setExpenses(expensesResponse.data.expenses);
26+
setSettlements(settlementsResponse.data.optimizedSettlements || []);
2427
} catch (error) {
2528
console.error('Failed to fetch group details:', error);
2629
Alert.alert('Error', 'Failed to fetch group details.');
@@ -36,22 +39,66 @@ const GroupDetailsScreen = ({ route, navigation }) => {
3639
}
3740
}, [token, groupId]);
3841

39-
const renderExpense = ({ item }) => (
40-
<Card style={styles.card}>
41-
<Card.Content>
42-
<Title>{item.description}</Title>
43-
<Paragraph>Amount: ${item.amount.toFixed(2)}</Paragraph>
44-
{/* The API doesn't provide the payer's name directly in the expense object */}
45-
{/* We would need to match the createdBy id with the members list */}
46-
{/* <Paragraph>Paid by: {getMemberName(item.createdBy)}</Paragraph> */}
47-
</Card.Content>
48-
</Card>
49-
);
42+
const getMemberName = (userId) => {
43+
const member = members.find(m => m.userId === userId);
44+
return member ? member.user.name : 'Unknown';
45+
};
46+
47+
const renderExpense = ({ item }) => {
48+
const userSplit = item.splits.find(s => s.userId === user._id);
49+
const userShare = userSplit ? userSplit.amount : 0;
50+
const paidByMe = item.createdBy === user._id;
51+
const net = paidByMe ? item.amount - userShare : -userShare;
52+
53+
let balanceText;
54+
let balanceColor = 'black';
55+
56+
if (net > 0) {
57+
balanceText = `You are owed $${net.toFixed(2)}`;
58+
balanceColor = 'green';
59+
} else if (net < 0) {
60+
balanceText = `You borrowed $${Math.abs(net).toFixed(2)}`;
61+
balanceColor = 'red';
62+
} else {
63+
balanceText = "You are settled for this expense.";
64+
}
65+
66+
return (
67+
<Card style={styles.card}>
68+
<Card.Content>
69+
<Title>{item.description}</Title>
70+
<Paragraph>Amount: ${item.amount.toFixed(2)}</Paragraph>
71+
<Paragraph>Paid by: {getMemberName(item.createdBy)}</Paragraph>
72+
<Paragraph style={{ color: balanceColor }}>{balanceText}</Paragraph>
73+
</Card.Content>
74+
</Card>
75+
);
76+
};
5077

5178
const renderMember = ({ item }) => (
5279
<Paragraph style={styles.memberText}>{item.user.name}</Paragraph>
5380
);
5481

82+
const renderSettlementSummary = () => {
83+
const userSettlements = settlements.filter(s => s.fromUserId === user._id);
84+
const totalOwed = userSettlements.reduce((sum, s) => sum + s.amount, 0);
85+
86+
if (userSettlements.length === 0) {
87+
return <Paragraph>You are all settled up in this group!</Paragraph>;
88+
}
89+
90+
return (
91+
<>
92+
<Title>You owe ${totalOwed.toFixed(2)} overall</Title>
93+
{userSettlements.map((s, index) => (
94+
<Paragraph key={index}>
95+
- You owe {getMemberName(s.toUserId)} ${s.amount.toFixed(2)}
96+
</Paragraph>
97+
))}
98+
</>
99+
);
100+
};
101+
55102
if (isLoading) {
56103
return (
57104
<View style={styles.loaderContainer}>
@@ -67,7 +114,14 @@ const GroupDetailsScreen = ({ route, navigation }) => {
67114
<Appbar.Content title={groupName} />
68115
</Appbar.Header>
69116

70-
<View style={styles.contentContainer}>
117+
<ScrollView style={styles.contentContainer}>
118+
<Card style={styles.card}>
119+
<Card.Content>
120+
<Title>Settlement Summary</Title>
121+
{renderSettlementSummary()}
122+
</Card.Content>
123+
</Card>
124+
71125
<Card style={styles.card}>
72126
<Card.Content>
73127
<Title>Members</Title>
@@ -87,7 +141,7 @@ const GroupDetailsScreen = ({ route, navigation }) => {
87141
ListEmptyComponent={<Text>No expenses recorded yet.</Text>}
88142
contentContainerStyle={{ paddingBottom: 80 }} // To avoid FAB overlap
89143
/>
90-
</View>
144+
</ScrollView>
91145

92146
<FAB
93147
style={styles.fab}

0 commit comments

Comments
 (0)