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
7 changes: 7 additions & 0 deletions .Jules/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
11 changes: 6 additions & 5 deletions .Jules/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down
7 changes: 7 additions & 0 deletions mobile/components/ui/HapticAppbar.js
Original file line number Diff line number Diff line change
@@ -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 };
6 changes: 6 additions & 0 deletions mobile/components/ui/HapticButton.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Button } from 'react-native-paper';
import { withHapticFeedback } from './hapticUtils';

const HapticButton = withHapticFeedback(Button);

export default HapticButton;
12 changes: 12 additions & 0 deletions mobile/components/ui/HapticCard.js
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions mobile/components/ui/HapticCheckbox.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Checkbox } from 'react-native-paper';
import { withHapticFeedback } from './hapticUtils';

const HapticCheckboxItem = withHapticFeedback(Checkbox.Item);

export default HapticCheckboxItem;
6 changes: 6 additions & 0 deletions mobile/components/ui/HapticFAB.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { FAB } from 'react-native-paper';
import { withHapticFeedback } from './hapticUtils';

const HapticFAB = withHapticFeedback(FAB);

export default HapticFAB;
6 changes: 6 additions & 0 deletions mobile/components/ui/HapticIconButton.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IconButton } from 'react-native-paper';
import { withHapticFeedback } from './hapticUtils';

const HapticIconButton = withHapticFeedback(IconButton);

export default HapticIconButton;
7 changes: 7 additions & 0 deletions mobile/components/ui/HapticList.js
Original file line number Diff line number Diff line change
@@ -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 };
14 changes: 14 additions & 0 deletions mobile/components/ui/HapticMenu.js
Original file line number Diff line number Diff line change
@@ -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 <Menu ref={ref} {...props}>{children}</Menu>;
});
HapticMenu.displayName = 'HapticMenu';

HapticMenu.Item = HapticMenuItem;

export default HapticMenu;
8 changes: 8 additions & 0 deletions mobile/components/ui/HapticSegmentedButtons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { SegmentedButtons } from 'react-native-paper';
import { withHapticFeedback } from './hapticUtils';

const HapticSegmentedButtons = withHapticFeedback(SegmentedButtons, {
pressProp: 'onValueChange',
});

export default HapticSegmentedButtons;
56 changes: 56 additions & 0 deletions mobile/components/ui/hapticUtils.js
Original file line number Diff line number Diff line change
@@ -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 <WrappedComponent ref={ref} {...props} {...handlerProps} />;
});

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);
};
Comment on lines +54 to +56
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Minor inconsistency: triggerPullRefreshHaptic awaits the haptic call while handlePress does not.

In handlePress (Line 22), Haptics.impactAsync is fire-and-forget, which is fine for UI responsiveness. Here the call is awaited, meaning the caller's await triggerPullRefreshHaptic() will block on the haptic completing. This is unlikely to cause a real problem but is worth noting for consistency — if a caller doesn't await it, the async keyword is just unnecessary overhead.

🤖 Prompt for AI Agents
In `@mobile/components/ui/hapticUtils.js` around lines 54 - 56, The function
triggerPullRefreshHaptic is marked async and awaits Haptics.impactAsync, which
is inconsistent with handlePress that fires Haptics.impactAsync without
awaiting; change triggerPullRefreshHaptic to be fire-and-forget for consistency
by removing the async/await (make it a plain function that calls
Haptics.impactAsync without awaiting) or, alternatively, update handlePress to
await Haptics.impactAsync — pick the fire-and-forget approach and update the
triggerPullRefreshHaptic implementation (referencing triggerPullRefreshHaptic
and handlePress) so both call patterns match.

11 changes: 6 additions & 5 deletions mobile/screens/AccountScreen.js
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down Expand Up @@ -35,39 +36,39 @@ const AccountScreen = ({ navigation }) => {
</View>

<List.Section>
<List.Item
<HapticListItem
title="Edit Profile"
left={() => <List.Icon icon="account-edit" />}
onPress={() => navigation.navigate("EditProfile")}
accessibilityLabel="Edit Profile"
accessibilityRole="button"
/>
<Divider />
<List.Item
<HapticListItem
title="Email Settings"
left={() => <List.Icon icon="email-edit-outline" />}
onPress={handleComingSoon}
accessibilityLabel="Email Settings"
accessibilityRole="button"
/>
<Divider />
<List.Item
<HapticListItem
title="Send Feedback"
left={() => <List.Icon icon="message-alert-outline" />}
onPress={handleComingSoon}
accessibilityLabel="Send Feedback"
accessibilityRole="button"
/>
<Divider />
<List.Item
<HapticListItem
title="Import from Splitwise"
left={() => <List.Icon icon="import" />}
onPress={() => navigation.navigate("SplitwiseImport")}
accessibilityLabel="Import from Splitwise"
accessibilityRole="button"
/>
<Divider />
<List.Item
<HapticListItem
title="Logout"
left={() => <List.Icon icon="logout" />}
onPress={handleLogout}
Expand Down
26 changes: 13 additions & 13 deletions mobile/screens/AddExpenseScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -277,7 +277,7 @@ const AddExpenseScreen = ({ route, navigation }) => {
switch (splitMethod) {
case "equal":
return members.map((member) => (
<Checkbox.Item
<HapticCheckboxItem
key={member.userId}
label={member.user.name}
status={selectedMembers[member.userId] ? "checked" : "unchecked"}
Expand Down Expand Up @@ -370,22 +370,22 @@ const AddExpenseScreen = ({ route, navigation }) => {
accessibilityLabel="Expense Amount"
/>

<Menu
<HapticMenu
visible={menuVisible}
onDismiss={() => setMenuVisible(false)}
anchor={
<Button
<HapticButton
onPress={() => setMenuVisible(true)}
accessibilityLabel={`Paid by ${selectedPayerName}`}
accessibilityRole="button"
accessibilityHint="Double tap to change payer"
>
Paid by: {selectedPayerName}
</Button>
</HapticButton>
}
>
{members.map((member) => (
<Menu.Item
<HapticMenu.Item
key={member.userId}
onPress={() => {
setPayerId(member.userId);
Expand All @@ -394,10 +394,10 @@ const AddExpenseScreen = ({ route, navigation }) => {
title={member.user.name}
/>
))}
</Menu>
</HapticMenu>

<Title style={styles.splitTitle}>Split Method</Title>
<SegmentedButtons
<HapticSegmentedButtons
value={splitMethod}
onValueChange={setSplitMethod}
buttons={[
Expand Down Expand Up @@ -451,7 +451,7 @@ const AddExpenseScreen = ({ route, navigation }) => {

<View style={styles.splitInputsContainer}>{renderSplitInputs()}</View>

<Button
<HapticButton
mode="contained"
onPress={handleAddExpense}
style={styles.button}
Expand All @@ -461,7 +461,7 @@ const AddExpenseScreen = ({ route, navigation }) => {
accessibilityRole="button"
>
Add Expense
</Button>
</HapticButton>
</View>
</KeyboardAvoidingView>
);
Expand Down
14 changes: 8 additions & 6 deletions mobile/screens/EditProfileScreen.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -83,7 +85,7 @@ const EditProfileScreen = ({ navigation }) => {
return (
<View style={styles.container}>
<Appbar.Header>
<Appbar.BackAction onPress={() => navigation.goBack()} />
<HapticAppbarBackAction onPress={() => navigation.goBack()} />
<Appbar.Content title="Edit Profile" />
</Appbar.Header>
<View style={styles.content}>
Expand All @@ -98,7 +100,7 @@ const EditProfileScreen = ({ navigation }) => {
) : (
<Avatar.Text size={100} label={(user?.name || "?").charAt(0)} />
)}
<Button
<HapticButton
mode="outlined"
onPress={pickImage}
icon="camera"
Expand All @@ -108,7 +110,7 @@ const EditProfileScreen = ({ navigation }) => {
accessibilityHint="Opens your media library to select a new photo"
>
{pickedImage ? "Change Photo" : "Add Photo"}
</Button>
</HapticButton>
</View>

<TextInput
Expand All @@ -118,7 +120,7 @@ const EditProfileScreen = ({ navigation }) => {
style={styles.input}
accessibilityLabel="Full Name"
/>
<Button
<HapticButton
mode="contained"
onPress={handleUpdateProfile}
loading={isSubmitting}
Expand All @@ -128,7 +130,7 @@ const EditProfileScreen = ({ navigation }) => {
accessibilityRole="button"
>
Save Changes
</Button>
</HapticButton>
</View>
</View>
);
Expand Down
Loading
Loading