diff --git a/.Jules/changelog.md b/.Jules/changelog.md
index 007d653..ecc0f8a 100644
--- a/.Jules/changelog.md
+++ b/.Jules/changelog.md
@@ -7,6 +7,13 @@
## [Unreleased]
### Added
+- **Mobile Haptics:** Implemented system-wide haptic feedback for all interactive elements.
+ - **Features:**
+ - Created `HapticButton`, `HapticIconButton`, `HapticFAB`, `HapticCard`, `HapticList`, `HapticCheckbox`, `HapticMenu`, `HapticSegmentedButtons`, `HapticAppbar` (including `HapticAppbarAction`, `HapticAppbarBackAction`) wrappers.
+ - Integrated into all screens (`Home`, `GroupDetails`, `AddExpense`, `Friends`, `Account`, `EditProfile`, `Login`, `Signup`, `JoinGroup`, `GroupSettings`, `SplitwiseImport`).
+ - Uses `expo-haptics` with `Light` impact style for subtle feedback.
+ - **Technical:** Centralized haptic logic in `mobile/components/ui/` to ensure consistency and maintainability.
+
- **Mobile Accessibility:** Completed accessibility audit for all mobile screens.
- **Features:**
- Added `accessibilityLabel` to all interactive elements (buttons, inputs, list items).
diff --git a/.Jules/todo.md b/.Jules/todo.md
index de49cb8..ccd795e 100644
--- a/.Jules/todo.md
+++ b/.Jules/todo.md
@@ -94,11 +94,12 @@
- Size: ~55 lines
- Added: 2026-01-01
-- [ ] **[style]** Haptic feedback on all button presses
- - Files: All button interactions across mobile
- - Context: Add Expo.Haptics.impactAsync(Light) to buttons
- - Impact: Tactile feedback makes app feel responsive
- - Size: ~40 lines
+- [x] **[style]** Haptic feedback on all button presses
+ - Completed: 2026-02-07
+ - Files: `mobile/components/ui/Haptic*.js`, `mobile/screens/*.js`
+ - Context: Created comprehensive Haptic UI system wrapping React Native Paper components
+ - Impact: Tactile feedback makes app feel responsive and native
+ - Size: ~400 lines
- Added: 2026-01-01
---
diff --git a/mobile/components/ui/HapticAppbar.js b/mobile/components/ui/HapticAppbar.js
new file mode 100644
index 0000000..b51913b
--- /dev/null
+++ b/mobile/components/ui/HapticAppbar.js
@@ -0,0 +1,7 @@
+import { Appbar } from 'react-native-paper';
+import { withHapticFeedback } from './hapticUtils';
+
+const HapticAppbarAction = withHapticFeedback(Appbar.Action);
+const HapticAppbarBackAction = withHapticFeedback(Appbar.BackAction);
+
+export { HapticAppbarAction, HapticAppbarBackAction };
diff --git a/mobile/components/ui/HapticButton.js b/mobile/components/ui/HapticButton.js
new file mode 100644
index 0000000..f8b821a
--- /dev/null
+++ b/mobile/components/ui/HapticButton.js
@@ -0,0 +1,6 @@
+import { Button } from 'react-native-paper';
+import { withHapticFeedback } from './hapticUtils';
+
+const HapticButton = withHapticFeedback(Button);
+
+export default HapticButton;
diff --git a/mobile/components/ui/HapticCard.js b/mobile/components/ui/HapticCard.js
new file mode 100644
index 0000000..1d58581
--- /dev/null
+++ b/mobile/components/ui/HapticCard.js
@@ -0,0 +1,12 @@
+import { Card } from 'react-native-paper';
+import { withHapticFeedback } from './hapticUtils';
+
+const HapticCard = withHapticFeedback(Card, { onlyWhenHandler: true });
+
+// Attach subcomponents
+HapticCard.Content = Card.Content;
+HapticCard.Actions = Card.Actions;
+HapticCard.Cover = Card.Cover;
+HapticCard.Title = Card.Title;
+
+export default HapticCard;
diff --git a/mobile/components/ui/HapticCheckbox.js b/mobile/components/ui/HapticCheckbox.js
new file mode 100644
index 0000000..bdb2b87
--- /dev/null
+++ b/mobile/components/ui/HapticCheckbox.js
@@ -0,0 +1,6 @@
+import { Checkbox } from 'react-native-paper';
+import { withHapticFeedback } from './hapticUtils';
+
+const HapticCheckboxItem = withHapticFeedback(Checkbox.Item);
+
+export default HapticCheckboxItem;
diff --git a/mobile/components/ui/HapticFAB.js b/mobile/components/ui/HapticFAB.js
new file mode 100644
index 0000000..d4d8f49
--- /dev/null
+++ b/mobile/components/ui/HapticFAB.js
@@ -0,0 +1,6 @@
+import { FAB } from 'react-native-paper';
+import { withHapticFeedback } from './hapticUtils';
+
+const HapticFAB = withHapticFeedback(FAB);
+
+export default HapticFAB;
diff --git a/mobile/components/ui/HapticIconButton.js b/mobile/components/ui/HapticIconButton.js
new file mode 100644
index 0000000..ed0ac2b
--- /dev/null
+++ b/mobile/components/ui/HapticIconButton.js
@@ -0,0 +1,6 @@
+import { IconButton } from 'react-native-paper';
+import { withHapticFeedback } from './hapticUtils';
+
+const HapticIconButton = withHapticFeedback(IconButton);
+
+export default HapticIconButton;
diff --git a/mobile/components/ui/HapticList.js b/mobile/components/ui/HapticList.js
new file mode 100644
index 0000000..b77073b
--- /dev/null
+++ b/mobile/components/ui/HapticList.js
@@ -0,0 +1,7 @@
+import { List } from 'react-native-paper';
+import { withHapticFeedback } from './hapticUtils';
+
+const HapticListItem = withHapticFeedback(List.Item, { onlyWhenHandler: true });
+const HapticListAccordion = withHapticFeedback(List.Accordion, { onlyWhenHandler: true });
+
+export { HapticListItem, HapticListAccordion };
diff --git a/mobile/components/ui/HapticMenu.js b/mobile/components/ui/HapticMenu.js
new file mode 100644
index 0000000..02ffe2a
--- /dev/null
+++ b/mobile/components/ui/HapticMenu.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import { Menu } from 'react-native-paper';
+import { withHapticFeedback } from './hapticUtils';
+
+const HapticMenuItem = withHapticFeedback(Menu.Item);
+
+const HapticMenu = React.forwardRef(({ children, ...props }, ref) => {
+ return
;
+});
+HapticMenu.displayName = 'HapticMenu';
+
+HapticMenu.Item = HapticMenuItem;
+
+export default HapticMenu;
diff --git a/mobile/components/ui/HapticSegmentedButtons.js b/mobile/components/ui/HapticSegmentedButtons.js
new file mode 100644
index 0000000..74542b7
--- /dev/null
+++ b/mobile/components/ui/HapticSegmentedButtons.js
@@ -0,0 +1,8 @@
+import { SegmentedButtons } from 'react-native-paper';
+import { withHapticFeedback } from './hapticUtils';
+
+const HapticSegmentedButtons = withHapticFeedback(SegmentedButtons, {
+ pressProp: 'onValueChange',
+});
+
+export default HapticSegmentedButtons;
diff --git a/mobile/components/ui/hapticUtils.js b/mobile/components/ui/hapticUtils.js
new file mode 100644
index 0000000..bde0a46
--- /dev/null
+++ b/mobile/components/ui/hapticUtils.js
@@ -0,0 +1,56 @@
+import React, { forwardRef, useCallback } from 'react';
+import * as Haptics from 'expo-haptics';
+
+/**
+ * Higher-Order Component to add haptic feedback to pressable components.
+ *
+ * @param {React.Component} WrappedComponent - The component to wrap.
+ * @param {Object} options - Configuration options.
+ * @param {string} options.pressProp - The name of the prop that handles the press event (default: 'onPress').
+ * @param {boolean} options.onlyWhenHandler - If true, haptics only trigger if the handler prop is provided.
+ * @returns {React.Component} - The wrapped component with haptic feedback.
+ */
+export const withHapticFeedback = (WrappedComponent, options = {}) => {
+ const { pressProp = 'onPress', onlyWhenHandler = false } = options;
+
+ const WithHaptic = forwardRef((props, ref) => {
+ const originalHandler = props[pressProp];
+
+ const handlePress = useCallback(
+ (...args) => {
+ if (!onlyWhenHandler || originalHandler) {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }
+ if (originalHandler) {
+ originalHandler(...args);
+ }
+ },
+ [originalHandler]
+ );
+
+ // Only pass the intercepted handler if we're not in "onlyWhenHandler" mode OR if the handler exists.
+ // However, some components might expect the handler prop to always be present or undefined.
+ // If onlyWhenHandler is true and originalHandler is missing, we pass undefined to avoid attaching a no-op handler that might make the component look interactive.
+ const handlerProps = {};
+ if (onlyWhenHandler && !originalHandler) {
+ // Do not attach our handler
+ handlerProps[pressProp] = undefined;
+ } else {
+ handlerProps[pressProp] = handlePress;
+ }
+
+ return ;
+ });
+
+ const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
+ WithHaptic.displayName = `WithHaptic(${displayName})`;
+
+ return WithHaptic;
+};
+
+/**
+ * Triggers a light haptic feedback for pull-to-refresh actions.
+ */
+export const triggerPullRefreshHaptic = async () => {
+ await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+};
diff --git a/mobile/screens/AccountScreen.js b/mobile/screens/AccountScreen.js
index c4ed869..ac130a6 100644
--- a/mobile/screens/AccountScreen.js
+++ b/mobile/screens/AccountScreen.js
@@ -1,6 +1,7 @@
import { useContext } from "react";
import { Alert, StyleSheet, View } from "react-native";
import { Appbar, Avatar, Divider, List, Text } from "react-native-paper";
+import { HapticListItem } from '../components/ui/HapticList';
import { AuthContext } from "../context/AuthContext";
const AccountScreen = ({ navigation }) => {
@@ -35,7 +36,7 @@ const AccountScreen = ({ navigation }) => {
- }
onPress={() => navigation.navigate("EditProfile")}
@@ -43,7 +44,7 @@ const AccountScreen = ({ navigation }) => {
accessibilityRole="button"
/>
- }
onPress={handleComingSoon}
@@ -51,7 +52,7 @@ const AccountScreen = ({ navigation }) => {
accessibilityRole="button"
/>
- }
onPress={handleComingSoon}
@@ -59,7 +60,7 @@ const AccountScreen = ({ navigation }) => {
accessibilityRole="button"
/>
- }
onPress={() => navigation.navigate("SplitwiseImport")}
@@ -67,7 +68,7 @@ const AccountScreen = ({ navigation }) => {
accessibilityRole="button"
/>
- }
onPress={handleLogout}
diff --git a/mobile/screens/AddExpenseScreen.js b/mobile/screens/AddExpenseScreen.js
index f5f58a3..958873f 100644
--- a/mobile/screens/AddExpenseScreen.js
+++ b/mobile/screens/AddExpenseScreen.js
@@ -8,15 +8,15 @@ import {
} from "react-native";
import {
ActivityIndicator,
- Button,
- Checkbox,
- Menu,
Paragraph,
- SegmentedButtons,
Text,
TextInput,
Title,
} from "react-native-paper";
+import HapticButton from '../components/ui/HapticButton';
+import HapticCheckboxItem from '../components/ui/HapticCheckbox';
+import HapticMenu from '../components/ui/HapticMenu';
+import HapticSegmentedButtons from '../components/ui/HapticSegmentedButtons';
import { createExpense, getGroupMembers } from "../api/groups";
import { AuthContext } from "../context/AuthContext";
@@ -277,7 +277,7 @@ const AddExpenseScreen = ({ route, navigation }) => {
switch (splitMethod) {
case "equal":
return members.map((member) => (
- {
accessibilityLabel="Expense Amount"
/>
-
+
Split Method
- {
{renderSplitInputs()}
-
+
);
diff --git a/mobile/screens/EditProfileScreen.js b/mobile/screens/EditProfileScreen.js
index 33a8faf..990b8ae 100644
--- a/mobile/screens/EditProfileScreen.js
+++ b/mobile/screens/EditProfileScreen.js
@@ -1,7 +1,9 @@
import * as ImagePicker from "expo-image-picker";
import { useContext, useState } from "react";
import { Alert, StyleSheet, View } from "react-native";
-import { Appbar, Avatar, Button, TextInput, Title } from "react-native-paper";
+import { Appbar, Avatar, TextInput, Title } from "react-native-paper";
+import HapticButton from '../components/ui/HapticButton';
+import { HapticAppbarBackAction } from '../components/ui/HapticAppbar';
import { updateUser } from "../api/auth";
import { AuthContext } from "../context/AuthContext";
@@ -83,7 +85,7 @@ const EditProfileScreen = ({ navigation }) => {
return (
- navigation.goBack()} />
+ navigation.goBack()} />
@@ -98,7 +100,7 @@ const EditProfileScreen = ({ navigation }) => {
) : (
)}
-
+
{
style={styles.input}
accessibilityLabel="Full Name"
/>
-
+
);
diff --git a/mobile/screens/FriendsScreen.js b/mobile/screens/FriendsScreen.js
index ad9bea2..c778a95 100644
--- a/mobile/screens/FriendsScreen.js
+++ b/mobile/screens/FriendsScreen.js
@@ -5,12 +5,13 @@ import {
Appbar,
Avatar,
Divider,
- IconButton,
List,
Text,
useTheme,
} from "react-native-paper";
-import * as Haptics from "expo-haptics";
+import HapticIconButton from '../components/ui/HapticIconButton';
+import { HapticListAccordion } from '../components/ui/HapticList';
+import { triggerPullRefreshHaptic } from '../components/ui/hapticUtils';
import { getFriendsBalance, getGroups } from "../api/groups";
import { AuthContext } from "../context/AuthContext";
import { formatCurrency } from "../utils/currency";
@@ -60,7 +61,7 @@ const FriendsScreen = () => {
const onRefresh = async () => {
setIsRefreshing(true);
- await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ await triggerPullRefreshHaptic();
await fetchData(false);
setIsRefreshing(false);
};
@@ -94,7 +95,7 @@ const FriendsScreen = () => {
}
return (
- {
/>
);
})}
-
+
);
};
@@ -238,7 +239,7 @@ const FriendsScreen = () => {
all shared groups. Check individual group details for optimized
settlement suggestions.
- setShowTooltip(false)}
diff --git a/mobile/screens/GroupDetailsScreen.js b/mobile/screens/GroupDetailsScreen.js
index bcb0018..7ac1ee8 100644
--- a/mobile/screens/GroupDetailsScreen.js
+++ b/mobile/screens/GroupDetailsScreen.js
@@ -2,13 +2,13 @@ import { useContext, useEffect, useState } from "react";
import { Alert, FlatList, RefreshControl, StyleSheet, Text, View } from "react-native";
import {
ActivityIndicator,
- Card,
- FAB,
- IconButton,
Paragraph,
Title,
useTheme,
} from "react-native-paper";
+import HapticCard from '../components/ui/HapticCard';
+import HapticFAB from '../components/ui/HapticFAB';
+import HapticIconButton from '../components/ui/HapticIconButton';
import * as Haptics from "expo-haptics";
import {
getGroupExpenses,
@@ -65,7 +65,7 @@ const GroupDetailsScreen = ({ route, navigation }) => {
navigation.setOptions({
title: groupName,
headerRight: () => (
- navigation.navigate("GroupSettings", { groupId })}
accessibilityLabel="Group settings"
@@ -103,22 +103,22 @@ const GroupDetailsScreen = ({ route, navigation }) => {
}
return (
-
-
+
{item.description}
Amount: {formatCurrency(item.amount)}
Paid by: {getMemberName(item.paidBy || item.createdBy)}
{balanceText}
-
-
+
+
);
};
@@ -198,12 +198,12 @@ const GroupDetailsScreen = ({ route, navigation }) => {
const renderHeader = () => (
<>
-
-
+
+
Settlement Summary
{renderSettlementSummary()}
-
-
+
+
Expenses
>
@@ -231,7 +231,7 @@ const GroupDetailsScreen = ({ route, navigation }) => {
}
/>
- navigation.navigate("AddExpense", { groupId: groupId })}
diff --git a/mobile/screens/GroupSettingsScreen.js b/mobile/screens/GroupSettingsScreen.js
index f45c099..6dd6111 100644
--- a/mobile/screens/GroupSettingsScreen.js
+++ b/mobile/screens/GroupSettingsScreen.js
@@ -17,13 +17,13 @@ import {
import {
ActivityIndicator,
Avatar,
- Button,
Card,
- IconButton,
- List,
Text,
TextInput,
} from "react-native-paper";
+import HapticButton from '../components/ui/HapticButton';
+import HapticIconButton from '../components/ui/HapticIconButton';
+import { HapticListItem } from '../components/ui/HapticList';
import {
deleteGroup as apiDeleteGroup,
leaveGroup as apiLeaveGroup,
@@ -264,7 +264,7 @@ const GroupSettingsScreen = ({ route, navigation }) => {
const displayName = m.user?.name || "Unknown";
const imageUrl = m.user?.imageUrl;
return (
- {
}
right={() =>
isAdmin && !isSelf ? (
- onKick(m.userId, displayName)}
accessibilityLabel={`Remove ${displayName} from group`}
@@ -315,7 +315,7 @@ const GroupSettingsScreen = ({ route, navigation }) => {
Icon
{ICON_CHOICES.map((i) => (
-
+
))}
-
+
{pickedImage?.uri ? (
{
) : null}
{isAdmin && (
-
+
)}
@@ -382,7 +382,7 @@ const GroupSettingsScreen = ({ route, navigation }) => {
Join Code: {group?.joinCode}
-
+
@@ -398,7 +398,7 @@ const GroupSettingsScreen = ({ route, navigation }) => {
-
+
{isAdmin && (
-
+
)}
diff --git a/mobile/screens/HomeScreen.js b/mobile/screens/HomeScreen.js
index 373bb0a..d2f3c38 100644
--- a/mobile/screens/HomeScreen.js
+++ b/mobile/screens/HomeScreen.js
@@ -4,14 +4,15 @@ import {
ActivityIndicator,
Appbar,
Avatar,
- Button,
- Card,
Modal,
Portal,
Text,
TextInput,
useTheme,
} from "react-native-paper";
+import HapticButton from '../components/ui/HapticButton';
+import HapticCard from '../components/ui/HapticCard';
+import { HapticAppbarAction } from '../components/ui/HapticAppbar';
import * as Haptics from "expo-haptics";
import { createGroup, getGroups, getOptimizedSettlements } from "../api/groups";
import { AuthContext } from "../context/AuthContext";
@@ -176,7 +177,7 @@ const HomeScreen = ({ navigation }) => {
item.imageUrl && /^(https?:|data:image)/.test(item.imageUrl);
const groupIcon = item.imageUrl || item.name?.charAt(0) || "?";
return (
-
navigation.navigate("GroupDetails", {
@@ -189,7 +190,7 @@ const HomeScreen = ({ navigation }) => {
accessibilityLabel={`Group ${item.name}. ${getSettlementStatusText()}`}
accessibilityHint="Double tap to view group details"
>
-
isImage ? (
@@ -199,12 +200,12 @@ const HomeScreen = ({ navigation }) => {
)
}
/>
-
+
{getSettlementStatusText()}
-
-
+
+
);
};
@@ -224,7 +225,7 @@ const HomeScreen = ({ navigation }) => {
style={styles.input}
accessibilityLabel="New group name"
/>
-
+
-
-
navigation.navigate("JoinGroup", { onGroupJoined: fetchGroups })
diff --git a/mobile/screens/JoinGroupScreen.js b/mobile/screens/JoinGroupScreen.js
index a1fc05b..122769d 100644
--- a/mobile/screens/JoinGroupScreen.js
+++ b/mobile/screens/JoinGroupScreen.js
@@ -1,6 +1,8 @@
import { useContext, useState } from "react";
import { Alert, StyleSheet, View } from "react-native";
-import { Appbar, Button, TextInput, Title } from "react-native-paper";
+import { Appbar, TextInput, Title } from "react-native-paper";
+import HapticButton from '../components/ui/HapticButton';
+import { HapticAppbarBackAction } from '../components/ui/HapticAppbar';
import { joinGroup } from "../api/groups";
import { AuthContext } from "../context/AuthContext";
@@ -35,7 +37,7 @@ const JoinGroupScreen = ({ navigation, route }) => {
return (
- navigation.goBack()} />
+ navigation.goBack()} />
@@ -48,7 +50,7 @@ const JoinGroupScreen = ({ navigation, route }) => {
autoCapitalize="characters"
accessibilityLabel="Group Join Code"
/>
-
+
);
diff --git a/mobile/screens/LoginScreen.js b/mobile/screens/LoginScreen.js
index aa5e94a..194db5b 100644
--- a/mobile/screens/LoginScreen.js
+++ b/mobile/screens/LoginScreen.js
@@ -1,6 +1,7 @@
import React, { useState, useContext } from 'react';
import { View, StyleSheet, Alert } from 'react-native';
-import { Button, Text, TextInput } from 'react-native-paper';
+import { Text, TextInput } from 'react-native-paper';
+import HapticButton from '../components/ui/HapticButton';
import { AuthContext } from '../context/AuthContext';
const LoginScreen = ({ navigation }) => {
@@ -42,7 +43,7 @@ const LoginScreen = ({ navigation }) => {
secureTextEntry
accessibilityLabel="Password"
/>
-
-
+
);
};
diff --git a/mobile/screens/SignupScreen.js b/mobile/screens/SignupScreen.js
index c3ba18f..d40f362 100644
--- a/mobile/screens/SignupScreen.js
+++ b/mobile/screens/SignupScreen.js
@@ -1,6 +1,7 @@
import React, { useState, useContext } from 'react';
import { View, StyleSheet, Alert } from 'react-native';
-import { Button, Text, TextInput } from 'react-native-paper';
+import { Text, TextInput } from 'react-native-paper';
+import HapticButton from '../components/ui/HapticButton';
import { AuthContext } from '../context/AuthContext';
const SignupScreen = ({ navigation }) => {
@@ -70,7 +71,7 @@ const SignupScreen = ({ navigation }) => {
secureTextEntry
accessibilityLabel="Confirm Password"
/>
-
-
+
);
};
diff --git a/mobile/screens/SplitwiseImportScreen.js b/mobile/screens/SplitwiseImportScreen.js
index 8abf5cd..d6a65c1 100644
--- a/mobile/screens/SplitwiseImportScreen.js
+++ b/mobile/screens/SplitwiseImportScreen.js
@@ -2,12 +2,13 @@ import { useState } from "react";
import { Alert, Linking, ScrollView, StyleSheet, View } from "react-native";
import {
Appbar,
- Button,
Card,
IconButton,
List,
Text,
} from "react-native-paper";
+import HapticButton from '../components/ui/HapticButton';
+import { HapticAppbarBackAction } from '../components/ui/HapticAppbar';
import { getSplitwiseAuthUrl } from "../api/client";
const SplitwiseImportScreen = ({ navigation }) => {
@@ -45,7 +46,7 @@ const SplitwiseImportScreen = ({ navigation }) => {
return (
- navigation.goBack()} />
+ navigation.goBack()} />
@@ -59,7 +60,7 @@ const SplitwiseImportScreen = ({ navigation }) => {
Import all your friends, groups, and expenses with one click
-
+
You'll be redirected to Splitwise to authorize access