-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Feature: Unequal Bill Split Support
🎯 Overview
Add support for custom/unequal bill splitting alongside the existing equal split functionality. Users should be able to allocate bills using hybrid allocation methods (fixed amounts, percentages, and remainder splitting) through a dedicated screen interface.
💡 Motivation
Currently, TipMate only supports equal bill splitting where the total is divided evenly among people. Many real-world scenarios require unequal splits:
- Different people ordering different priced items
- Someone covering a larger portion of the bill
- Splitting based on individual consumption rather than equal division
🏗️ Architecture Decisions
1. Allocation Strategy: Hybrid
Support three allocation types per person:
- Fixed Amount: Assign specific dollar amount to a person
- Percentage: Assign percentage of total bill to a person
- Remainder: Split remaining amount equally among remainder persons
2. Rounding Distribution: Largest Decimal Remainder
When rounding creates penny differences, distribute extra cents by:
- Sort persons by decimal remainder (descending)
- Add $0.01 sequentially to persons with largest remainders
- Continue until all penny differences are allocated
3. Validation: Require 100% Allocation
- Users must allocate exactly 100% of the bill before saving
- UI shows real-time validation with visual indicators
- Save button disabled until allocation is complete
4. UX Approach: Dedicated Screen
Use a full-screen interface instead of modal for better mobile UX:
- More space for multiple person entries
- Better scrolling experience
- Clearer navigation flow with back button
5. Backward Compatibility: Default to Equal
Existing SavedTip records without splitType field default to 'equal' split type
📋 Implementation Plan
Step 1: Data Model Extension
File: app/context/types.ts
Add new interfaces and extend existing types:
// New interface for individual split configuration
export interface IndividualSplit {
id: string;
name: string;
allocationType: 'fixed' | 'percentage' | 'remainder';
value?: number; // Dollar amount for 'fixed', percentage for 'percentage', undefined for 'remainder'
calculatedAmount?: number; // Computed final amount after calculation
}
// Extend SavedTip interface
export interface SavedTip {
id: string;
timestamp: number;
amount: number;
tip: number;
total: number;
tipPercentage: number;
numberOfPeople: number;
splitType?: 'equal' | 'custom'; // Add split type (default 'equal' when undefined)
perPerson?: {
amount: number;
tip: number;
total: number;
};
individualSplits?: IndividualSplit[]; // Array of individual split details for custom splits
currencySymbol: string;
currencyCode: string;
}
// Extend AppState
export interface AppState {
tips: TipOptionState[];
splits: SplitOptionState[];
tipSliderConfig: TipSliderConfigValues;
splitSliderConfig: SplitSliderConfigValues;
currencyConfig: CurrencyType;
savedTips: SavedTip[];
activeSplitConfig?: {
type: 'equal' | 'custom';
customSplits?: IndividualSplit[];
};
}Step 2: Create CustomSplitScreen
File: app/screens/TipScreens/CustomSplitScreen.tsx
Create dedicated full-screen component with:
Header Section:
- Use
StyledHeaderwith back button and title "Custom Split" - Subtitle showing total bill amount
Person List Section (Scrollable):
- FlatList/ScrollView of person cards
- Each card contains:
- Text input for person name (default: "Person 1", "Person 2", etc.)
- Picker/Selector for allocation type (Fixed/Percentage/Remainder)
- Numeric input for value (hidden for remainder type)
- Currency/percentage formatting based on type
- Delete button for removing person
- Add Person button at bottom of list
Validation Footer (Sticky):
- Display allocated amount vs total bill amount
- Visual indicators:
- Green checkmark + "100% Allocated - Ready to save" when complete
- Red warning + "X% Allocated - Need Y% more" when incomplete
- Orange warning + "Over-allocated by X%" when exceeds 100%
- Breakdown: "Fixed: $X | Percentage: Y% | Remainder: Z people"
Save Button:
- Disabled (grayed out) until 100% allocated
- On press: Save to app state and navigate back to HomeTipScreen
Constraints:
- Minimum 2 persons, maximum 15 persons
- Validation prevents invalid inputs (negative values, >100% total percentage, etc.)
Step 3: Hybrid Calculation Logic
File: app/hooks/calculateBill.ts
Create new calculation function:
export type CustomSplitCalculationType = {
overall: {
total: string;
tip: string;
subtotal: string;
};
individuals: IndividualSplit[]; // Array with calculated amounts per person
disabledRoundingMethods: DisabledRoundingMethodsType;
};
export const calculateBillValuesCustomSplit = (
tipPercentage: number,
billAmount: number,
roundingMethod: RoundingMethodType,
individualSplits: IndividualSplit[],
): CustomSplitCalculationType => {
// 1. Calculate overall tip and total
const tipTotal = (tipPercentage / 100) * billAmount;
const totalBill = billAmount + tipTotal;
// 2. Apply rounding to overall amounts
const roundedOverallTotal = applyRoundingMethod(totalBill, roundingMethod);
const roundedOverallTip = applyRoundingMethod(tipTotal, roundingMethod);
const roundedOverallSubtotal = applyRoundingMethod(billAmount, roundingMethod);
// 3. Process allocations by type
let remainingAmount = roundedOverallTotal;
const processedSplits: IndividualSplit[] = [];
// 3a. Process FIXED amounts first
const fixedSplits = individualSplits.filter(s => s.allocationType === 'fixed');
fixedSplits.forEach(split => {
const amount = split.value || 0;
remainingAmount -= amount;
processedSplits.push({ ...split, calculatedAmount: amount });
});
// 3b. Process PERCENTAGE allocations second
const percentageSplits = individualSplits.filter(s => s.allocationType === 'percentage');
percentageSplits.forEach(split => {
const percentage = split.value || 0;
const amount = (percentage / 100) * roundedOverallTotal;
remainingAmount -= amount;
processedSplits.push({ ...split, calculatedAmount: amount });
});
// 3c. Process REMAINDER splits (divide remaining equally)
const remainderSplits = individualSplits.filter(s => s.allocationType === 'remainder');
if (remainderSplits.length > 0) {
const amountPerRemainder = remainingAmount / remainderSplits.length;
remainderSplits.forEach(split => {
processedSplits.push({ ...split, calculatedAmount: amountPerRemainder });
});
}
// 4. Distribute penny differences using largest decimal remainder method
const splitsWithDecimals = processedSplits.map(split => ({
...split,
decimalPart: (split.calculatedAmount || 0) % 1,
}));
// Sort by decimal remainder descending
splitsWithDecimals.sort((a, b) => b.decimalPart - a.decimalPart);
// Calculate total after flooring all amounts
const totalFloored = splitsWithDecimals.reduce(
(sum, split) => sum + Math.floor(split.calculatedAmount || 0),
0
);
const pennyDifference = roundedOverallTotal - totalFloored;
// Add $0.01 to persons with largest decimal remainders
for (let i = 0; i < pennyDifference && i < splitsWithDecimals.length; i++) {
splitsWithDecimals[i].calculatedAmount =
Math.floor(splitsWithDecimals[i].calculatedAmount || 0) + 1;
}
// 5. Format and return
return {
overall: {
total: toFixedWithoutRounding(roundedOverallTotal, 2),
tip: toFixedWithoutRounding(roundedOverallTip, 2),
subtotal: toFixedWithoutRounding(roundedOverallSubtotal, 2),
},
individuals: splitsWithDecimals.map(split => ({
id: split.id,
name: split.name,
allocationType: split.allocationType,
value: split.value,
calculatedAmount: split.calculatedAmount,
})),
disabledRoundingMethods: {
UP: totalBill === Math.ceil(totalBill),
DOWN: totalBill === Math.floor(totalBill),
NO: false,
},
};
};Step 4: Update StyledSplitOptions Navigation
File: app/components/StyledSplitOptions/index.tsx
Add navigation to custom split screen with "Custom Split" button below existing equal split options. Show active indicator badge when custom split is configured.
Step 5: Modify StyledBillBox for Custom Display
File: app/components/StyledBillBox/index.tsx
Add conditional rendering based on split type. For custom splits, create expandable accordion section showing individual person breakdowns with name, subtotal, tip, and total amounts.
Step 6: State Management & Reducers
Files:
app/context/reducers.ts- AddSET_ACTIVE_SPLIT_CONFIGandCLEAR_ACTIVE_SPLIT_CONFIGactionsapp/context/AppContext.tsx- Add migration logic to defaultsplitType: 'equal'for existing records
Step 7: Update HomeTipScreen Logic
File: app/screens/TipScreens/HomeTipScreen.tsx
Integrate custom split calculation alongside existing equal split logic based on activeSplitConfig state.
Step 8: Update Share/Export Hooks
Files:
app/hooks/useShareTipDetailsText.ts- Update text template for custom splitsapp/hooks/useShareTipDetailsPDF.ts- Update PDF template for custom splits
Step 9: Register CustomSplitScreen in Navigation
File: app/navigation/StackNavigation.tsx
Add CustomSplitScreen to stack navigator.
🎨 UI/UX Specifications
CustomSplitScreen Layout
┌─────────────────────────────┐
│ ← Custom Split │ Header
│ Total Bill: $150.00 │
├─────────────────────────────┤
│ │
│ ┌─────────────────────────┐ │
│ │ Person 1 [×] │ │ Person Card
│ │ ○ Fixed ○ % ● Remain │ │
│ │ $50.00 │ │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ Person 2 [×] │ │
│ │ ● Fixed ○ % ○ Remain │ │
│ │ $60.00 │ │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ Person 3 [×] │ │
│ │ ○ Fixed ● % ○ Remain │ │
│ │ 30% │ │
│ └─────────────────────────┘ │
│ │
│ [+ Add Person] │
│ │
├─────────────────────────────┤
│ ✓ 100% Allocated - Ready! │ Validation Footer
│ Fixed: $110 | %: 30% │
│ Remainder: 1 person │
│ │
│ [ SAVE SPLIT ] │ Save Button
└─────────────────────────────┘
Validation States
- Incomplete (<100%): Red warning icon + "Need X% more"
- Complete (=100%): Green checkmark + "Ready to save"
- Over-allocated (>100%): Orange warning + "Over by X%"
Person Card Components
- Name Input: Text field with placeholder "Person 1", "Person 2", etc.
- Allocation Type: Segmented control or radio buttons (Fixed / % / Remainder)
- Value Input:
- Numeric keyboard for Fixed (show currency symbol)
- Numeric keyboard for % (show % symbol)
- Hidden/disabled for Remainder
- Delete Button: Icon button (X or trash) on right side
🔧 Technical Considerations
Person Limits
- Minimum: 2 persons (validation error if trying to go below)
- Maximum: 15 persons (matching current equal split slider max)
- Default: Start with 2 persons when screen opens
Default Person Names
Use editable defaults to reduce friction:
- Person 1, Person 2, Person 3, etc.
- Users can edit inline by tapping name field
- Consider saving frequently used names for future use (optional enhancement)
Validation Rules
- At least one person must exist
- All person names must be non-empty
- Fixed amounts must be ≥ $0.01
- Percentages must be between 0.01% and 100%
- Total allocation must equal 100% (with 0.01% tolerance for floating point)
- At least one person must have allocation type selected
Error Handling
- Show inline validation errors on person cards
- Disable save button until all validations pass
- Toast notifications for critical errors
- Preserve form state when navigating away (warn user about unsaved changes)
✅ Acceptance Criteria
- User can navigate to CustomSplitScreen from HomeTipScreen
- User can add/remove persons (2-15 limit enforced)
- User can set allocation type and value for each person
- Real-time validation shows allocation status
- Save button disabled until 100% allocated
- Custom split calculations match expected results (±$0.01 tolerance)
- Penny differences distributed using largest remainder method
- Custom splits display correctly in StyledBillBox with expandable list
- Share/export includes individual person breakdowns
- Saved tips with custom splits persist across app restarts
- Existing saved tips without splitType default to 'equal'
- User can switch between equal and custom split modes seamlessly
- UI is responsive and performant on various screen sizes
🧪 Testing Requirements
Unit Tests
-
calculateBillValuesCustomSplitfunction with various allocation combinations - Penny distribution logic with edge cases (1¢, 2¢, 10¢ differences)
- Validation logic for allocation totals
- Data migration for existing SavedTip records
Integration Tests
- Navigation flow: HomeTipScreen → CustomSplitScreen → Save → Back
- State persistence across app restarts
- Share/export functionality with custom splits
- Saved tips with custom splits displayed correctly
E2E Test Scenarios
- Create custom split with 3 people (1 fixed, 1 percentage, 1 remainder)
- Edit existing custom split configuration
- Switch between equal and custom split modes
- Save and view custom split tip in history
- Share custom split via text and PDF
🚀 Future Enhancements (Out of Scope)
- Item-based splitting (assign specific items to people)
- Saved custom split templates/presets
- Import contacts for person names
- Split history analytics
- Venmo/PayPal integration for payment requests
- QR code generation for split details
📁 Related Files
app/context/types.ts- Data modelsapp/hooks/calculateBill.ts- Calculation logicapp/screens/TipScreens/CustomSplitScreen.tsx- New screen (create)app/screens/TipScreens/HomeTipScreen.tsx- Integration pointapp/components/StyledSplitOptions/index.tsx- Navigation triggerapp/components/StyledBillBox/index.tsx- Display componentapp/context/reducers.ts- State actionsapp/context/AppContext.tsx- Migration logicapp/navigation/StackNavigation.tsx- Screen registrationapp/hooks/useShareTipDetailsText.ts- Share formattingapp/hooks/useShareTipDetailsPDF.ts- PDF formatting
🎨 Design Assets Needed
- Icon for "Custom Split" button
- Icon for person cards (user icon)
- Icons for allocation types (dollar, percent, users)
- Success/warning/error icons for validation states
📚 Documentation Updates
- Update README.md with custom split feature
- Add code comments explaining calculation logic
- Create user guide/help screen explaining custom splits
- Update app store description with new feature
Estimated Effort: 5-7 days