Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .Jules/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
## [Unreleased]

### Added
- **Mobile Pull-to-Refresh:** Implemented native pull-to-refresh interactions with haptic feedback for key lists.
- **Features:**
- Integrated `RefreshControl` into `HomeScreen`, `FriendsScreen`, and `GroupDetailsScreen`.
- Added haptic feedback (`Haptics.ImpactFeedbackStyle.Light`) on refresh trigger.
- Separated 'isRefreshing' state from 'isLoading' to prevent full-screen spinner interruptions.
- Themed the refresh spinner using `react-native-paper`'s primary color.
- **Technical:** Installed `expo-haptics`. Refactored data fetching logic to support silent updates.

- **Confirmation Dialog System:** Replaced browser's native `alert`/`confirm` with a custom, accessible, and themed modal system.
- **Features:**
- Dual-theme support (Glassmorphism & Neobrutalism).
Expand Down
14 changes: 8 additions & 6 deletions .Jules/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@

### Mobile

- [ ] **[ux]** Pull-to-refresh with haptic feedback on all list screens
- Files: `mobile/screens/HomeScreen.js`, `mobile/screens/GroupDetailsScreen.js`
- [x] **[ux]** Pull-to-refresh with haptic feedback on all list screens
- Completed: 2026-01-21
- Files: `mobile/screens/HomeScreen.js`, `mobile/screens/GroupDetailsScreen.js`, `mobile/screens/FriendsScreen.js`
- Context: Add RefreshControl + Expo Haptics to main lists
- Impact: Native feel, users can easily refresh data
- Size: ~45 lines
- Added: 2026-01-01
- Size: ~150 lines

- [ ] **[ux]** Complete skeleton loading for HomeScreen groups
- File: `mobile/screens/HomeScreen.js`
Expand Down Expand Up @@ -158,5 +158,7 @@
- Completed: 2026-01-14
- Files modified: `web/components/ErrorBoundary.tsx`, `web/App.tsx`
- Impact: App doesn't crash, users can recover

_No tasks completed yet. Move tasks here after completion._
- [x] **[ux]** Pull-to-refresh with haptic feedback on all list screens
- Completed: 2026-01-21
- Files modified: `mobile/screens/HomeScreen.js`, `mobile/screens/GroupDetailsScreen.js`, `mobile/screens/FriendsScreen.js`
- Impact: Native feel, users can easily refresh data
511 changes: 181 additions & 330 deletions backend/tests/expenses/test_expense_service.py

Large diffs are not rendered by default.

23 changes: 10 additions & 13 deletions mobile/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@react-navigation/native-stack": "^7.3.23",
"axios": "^1.11.0",
"expo": "^54.0.25",
"expo-haptics": "~15.0.8",
"expo-image-picker": "~17.0.8",
"expo-status-bar": "~3.0.8",
"react": "19.1.0",
Expand Down
85 changes: 52 additions & 33 deletions mobile/screens/FriendsScreen.js
Original file line number Diff line number Diff line change
@@ -1,60 +1,71 @@
import { useIsFocused } from "@react-navigation/native";
import { useContext, useEffect, useRef, useState } from "react";
import { Alert, Animated, FlatList, StyleSheet, View } from "react-native";
import { Alert, Animated, FlatList, RefreshControl, StyleSheet, View } from "react-native";
import {
Appbar,
Avatar,
Divider,
IconButton,
List,
Text,
useTheme,
} from "react-native-paper";
import * as Haptics from "expo-haptics";
import { getFriendsBalance, getGroups } from "../api/groups";
import { AuthContext } from "../context/AuthContext";
import { formatCurrency } from "../utils/currency";

const FriendsScreen = () => {
const { token, user } = useContext(AuthContext);
const theme = useTheme();
const [friends, setFriends] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showTooltip, setShowTooltip] = useState(true);
const isFocused = useIsFocused();

useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
// Fetch friends balance + groups concurrently for group icons
const friendsResponse = await getFriendsBalance();
const friendsData = friendsResponse.data.friendsBalance || [];
const groupsResponse = await getGroups();
const groups = groupsResponse?.data?.groups || [];
const groupMeta = new Map(
groups.map((g) => [g._id, { name: g.name, imageUrl: g.imageUrl }])
);
const fetchData = async (showLoading = true) => {
if (showLoading) setIsLoading(true);
try {
// Fetch friends balance + groups concurrently for group icons
const friendsResponse = await getFriendsBalance();
const friendsData = friendsResponse.data.friendsBalance || [];
const groupsResponse = await getGroups();
const groups = groupsResponse?.data?.groups || [];
const groupMeta = new Map(
groups.map((g) => [g._id, { name: g.name, imageUrl: g.imageUrl }])
);

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,
})),
}));
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,
})),
}));

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);
}
};
setFriends(transformedFriends);
} catch (error) {
console.error("Failed to fetch friends balance data:", error);
Alert.alert("Error", "Failed to load friends balance data.");
} finally {
if (showLoading) setIsLoading(false);
}
};

const onRefresh = async () => {
setIsRefreshing(true);
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await fetchData(false);
setIsRefreshing(false);
};

useEffect(() => {
if (token && isFocused) {
fetchData();
}
Expand Down Expand Up @@ -235,6 +246,14 @@ const FriendsScreen = () => {
ListEmptyComponent={
<Text style={styles.emptyText}>No balances with friends yet.</Text>
}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
colors={[theme.colors.primary]}
tintColor={theme.colors.primary}
/>
}
/>
</View>
);
Expand Down
30 changes: 24 additions & 6 deletions mobile/screens/GroupDetailsScreen.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useContext, useEffect, useState } from "react";
import { Alert, FlatList, StyleSheet, Text, View } from "react-native";
import { Alert, FlatList, RefreshControl, StyleSheet, Text, View } from "react-native";
import {
ActivityIndicator,
Card,
FAB,
IconButton,
Paragraph,
Title,
useTheme,
} from "react-native-paper";
import * as Haptics from "expo-haptics";
import {
getGroupExpenses,
getGroupMembers,
Expand All @@ -18,20 +20,22 @@ import { AuthContext } from "../context/AuthContext";
const GroupDetailsScreen = ({ route, navigation }) => {
const { groupId, groupName } = route.params;
const { token, user } = useContext(AuthContext);
const theme = useTheme();
const [members, setMembers] = useState([]);
const [expenses, setExpenses] = useState([]);
const [settlements, setSettlements] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);

// Currency configuration - can be made configurable later
const currency = "₹"; // Default to INR, can be changed to '$' for USD

// Helper function to format currency amounts
const formatCurrency = (amount) => `${currency}${amount.toFixed(2)}`;

const fetchData = async () => {
const fetchData = async (showLoading = true) => {
try {
setIsLoading(true);
if (showLoading) setIsLoading(true);
// Fetch members, expenses, and settlements in parallel
const [membersResponse, expensesResponse, settlementsResponse] =
await Promise.all([
Expand All @@ -46,10 +50,17 @@ const GroupDetailsScreen = ({ route, navigation }) => {
console.error("Failed to fetch group details:", error);
Alert.alert("Error", "Failed to fetch group details.");
} finally {
setIsLoading(false);
if (showLoading) setIsLoading(false);
}
};

const onRefresh = async () => {
setIsRefreshing(true);
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await fetchData(false);
setIsRefreshing(false);
};
Comment on lines +57 to +62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

wc -l mobile/screens/GroupDetailsScreen.js

Repository: Devasy/splitwiser

Length of output: 101


🏁 Script executed:

sed -n '50,70p' mobile/screens/GroupDetailsScreen.js

Repository: Devasy/splitwiser

Length of output: 661


🏁 Script executed:

sed -n '1,30p' mobile/screens/GroupDetailsScreen.js

Repository: Devasy/splitwiser

Length of output: 1025


🏁 Script executed:

rg -n "onRefresh" mobile/screens/GroupDetailsScreen.js

Repository: Devasy/splitwiser

Length of output: 135


🏁 Script executed:

rg -n "Haptics" mobile/screens/ -A 2 -B 2

Repository: Devasy/splitwiser

Length of output: 2227


🏁 Script executed:

sed -n '40,55p' mobile/screens/GroupDetailsScreen.js

Repository: Devasy/splitwiser

Length of output: 671


🏁 Script executed:

web_search
expo-haptics impactAsync error handling rejection behavior

Repository: Devasy/splitwiser

Length of output: 162


🌐 Web query:

expo-haptics impactAsync can it reject throw error

💡 Result:

Yes. expo-haptics’s Haptics.impactAsync() returns a Promise<void> and it can reject (throw) in at least these cases:

  • API unavailable on the current platform/runtime: the JS wrapper explicitly checks for the native method and throws UnavailabilityError if it doesn’t exist, which rejects the promise. (In current source it checks ExpoHaptics.impactAsync and throws if missing.) [2]
  • Native call fails: after the availability check it awaits the native module call, so any native-side error would also reject the promise. [2]

The official docs only state that it “fulfills once native haptics functionality is triggered” and don’t enumerate errors, but it is still a Promise and (per the implementation) can reject as above. [1][2]

Practical usage: wrap it in try/catch if you need to be safe on all platforms/builds. [1][2]

Sources: Expo docs for impactAsync() [1], expo-haptics implementation (Haptics.ts) [2].


Harden refresh flow against haptics failures.

Haptics.impactAsync can reject if haptics is unavailable on the platform or if the native call fails. If it rejects, fetchData is never called and isRefreshing remains true, leaving the UI in a stuck state. Wrap in try/finally and use .catch() to suppress haptics errors so the refresh flow always completes.

✅ Suggested fix
 const onRefresh = async () => {
   setIsRefreshing(true);
-  await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
-  await fetchData(false);
-  setIsRefreshing(false);
+  try {
+    await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
+    await fetchData(false);
+  } finally {
+    setIsRefreshing(false);
+  }
 };
🤖 Prompt for AI Agents
In `@mobile/screens/GroupDetailsScreen.js` around lines 57 - 62, The onRefresh
handler can get stuck if Haptics.impactAsync rejects; update the onRefresh
function so you call setIsRefreshing(true) then attempt Haptics.impactAsync but
suppress any haptics errors (use .catch or try/catch) and ensure
fetchData(false) is always awaited and setIsRefreshing(false) is executed in a
finally block; reference the onRefresh function, Haptics.impactAsync, fetchData,
and setIsRefreshing when making this change.


useEffect(() => {
navigation.setOptions({
title: groupName,
Expand Down Expand Up @@ -202,6 +213,14 @@ const GroupDetailsScreen = ({ route, navigation }) => {
<Text style={styles.emptyText}>No expenses recorded yet.</Text>
}
contentContainerStyle={{ paddingBottom: 80 }} // To avoid FAB overlap
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
colors={[theme.colors.primary]}
tintColor={theme.colors.primary}
/>
}
/>

<FAB
Expand Down Expand Up @@ -232,8 +251,7 @@ const styles = StyleSheet.create({
expensesTitle: {
marginTop: 16,
marginBottom: 8,
fontSize: 20,
fontWeight: "bold",
fontSize: 20, fontWeight: "bold",
},
memberText: {
fontSize: 16,
Expand Down
Loading
Loading