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 {children}; +}); +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" /> - setMenuVisible(false)} anchor={ - + } > {members.map((member) => ( - { setPayerId(member.userId); @@ -394,10 +394,10 @@ const AddExpenseScreen = ({ route, navigation }) => { title={member.user.name} /> ))} - + 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