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
27 changes: 27 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { dataEvents } from "@/src/core/events/dataEvents";
import { SecurityProvider } from "@/src/core/security/SecurityContext";
import { runMigrations } from "@/src/data/migrations";
import { initializeSeedData } from "@/src/data/seed";
import {
configureNotificationHandler,
initializeNotifications,
} from "@/src/data/services/notificationService";
import { processRecurringRules } from "@/src/data/services/RecurringTransactionEngine";
import { loadSettings } from "@/src/data/services/settingsService";
import { resetCorruptedStore } from "@/src/data/services/storeRecoveryService";
Expand Down Expand Up @@ -39,6 +43,11 @@ Sentry.init({
debug: false,
});

// Module scope so it runs exactly once, at import, before React mounts and before
// any notification can fire. Without a handler, expo-notifications silently drops
// notifications that arrive while the app is foregrounded.
configureNotificationHandler();

export const unstable_settings = {
anchor: "(tabs)",
};
Expand Down Expand Up @@ -131,6 +140,24 @@ function RootLayout() {
// Check if onboarding is needed
setNeedsOnboarding(!settings.onboardingCompleted);

// (Re)assert the daily reminder. Must run AFTER the i18n sync above so the
// notification copy resolves in the user's language, and inside its own
// try/catch: the outer catch flips the app into StoreRecoveryScreen, and a
// reminder that fails to schedule must never present itself as a corrupted
// store. Report-only, but reported - silent failure is the exact thing this
// reminder exists to avoid.
try {
await initializeNotifications();
} catch (notificationErr) {
console.warn(
"[notifications] Startup scheduling failed:",
notificationErr,
);
if (!__DEV__) {
Sentry.captureException(notificationErr);
}
}

// Dev-only, non-blocking, non-fatal balance-integrity assertion.
// Dead-stripped from release builds. Audits every wallet's stored balance
// against its ledger via the authoritative domain use case. Report-only:
Expand Down
69 changes: 22 additions & 47 deletions src/components/ui/SegmentControl.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { useEffect, useRef, useState } from 'react';
import { Animated, Easing, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { radius } from '../../theme/radius';
import { shadows } from '../../theme/shadows';
import { spacing } from '../../theme/spacing';
import { useTheme } from '../../theme/theme';
import { getSelectableA11y } from '../../utils/accessibility';

export interface Segment<T> {
key: string;
Expand All @@ -21,60 +21,40 @@ interface SegmentControlProps<T> {
}

export function SegmentControl<T>({ segments, selectedValue, onSelect, style, testID }: SegmentControlProps<T>) {
const { colors, spacing, typography, radius } = useTheme();

const slideAnim = useRef(new Animated.Value(0)).current;
const [itemWidth, setItemWidth] = useState(0);

useEffect(() => {
const index = segments.findIndex(s => s.value === selectedValue);
Animated.timing(slideAnim, {
toValue: index * itemWidth,
duration: 200,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}).start();
}, [selectedValue, itemWidth]);
const { colors, typography, radius } = useTheme();

return (
<View
testID={testID}
style={[styles.container, { backgroundColor: colors.background }, style]}
onLayout={(e) => {
const innerWidth = e.nativeEvent.layout.width - spacing.xs * 2;
setItemWidth(innerWidth / segments.length);
}}
style={[
styles.container,
{ backgroundColor: colors.background, borderColor: colors.border },
style,
]}
>
{/* Sliding indicator */}
{itemWidth > 0 && (
<Animated.View
style={[
styles.indicator,
{
width: itemWidth,
left: spacing.xs,
backgroundColor: colors.card,
borderRadius: radius.md,
transform: [{ translateX: slideAnim }],
},
]}
/>
)}
{segments.map((seg) => {
const isActive = seg.value === selectedValue;
return (
<TouchableOpacity
key={seg.key}
testID={seg.testID}
style={styles.button}
style={[
styles.button,
{
borderRadius: radius.md,
backgroundColor: isActive ? colors.accent : 'transparent',
borderColor: isActive ? colors.accent : 'transparent',
},
]}
onPress={() => onSelect(seg.value)}
activeOpacity={0.8}
{...getSelectableA11y(seg.label, isActive)}
>
<Text
style={{
color: isActive ? colors.foreground : colors.mutedForeground,
color: isActive ? colors.accentForeground : colors.foreground,
fontSize: typography.sizes.sm,
fontWeight: isActive ? '600' : '500',
fontWeight: isActive ? typography.weights.semibold : typography.weights.regular,
textTransform: 'capitalize',
}}
>
Expand All @@ -92,18 +72,13 @@ const styles = StyleSheet.create({
flexDirection: 'row',
padding: spacing.xs,
borderRadius: radius.lg,
borderWidth: 1,
height: 44,
},
indicator: {
position: 'absolute',
top: spacing.xs,
bottom: spacing.xs,
...shadows.soft,
},
button: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md,
borderWidth: 1,
},
});
122 changes: 122 additions & 0 deletions src/components/ui/__tests__/SegmentControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* SegmentControl Component Tests
*
* Covers F-03: the selected segment must be distinguishable, and must say so
* to assistive technology rather than relying on a visual cue alone.
*
* Assertions target accessibilityState rather than style objects so they
* survive a future restyle.
*/

import { fireEvent, render } from '@testing-library/react-native';
import React from 'react';
import { SegmentControl, type Segment } from '../SegmentControl';

jest.mock('../../../theme/theme', () => ({
useTheme: () => ({
colors: {
background: '#f7f8f7',
foreground: '#2F241F',
accent: '#603b2e',
accentForeground: '#FAFAFA',
border: '#D5DBD8',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 20, xl: 24 },
radius: { sm: 8, md: 12, lg: 16 },
typography: {
sizes: { xs: 11, sm: 14, md: 16 },
weights: { regular: '400', medium: '500', semibold: '600', bold: '700' },
},
}),
}));

const SEGMENTS: Segment<string>[] = [
{ key: 'expense', label: 'Expense', value: 'expense', testID: 'add_tx_type_expense' },
{ key: 'income', label: 'Income', value: 'income', testID: 'add_tx_type_income' },
{ key: 'transfer', label: 'Transfer', value: 'transfer', testID: 'add_tx_type_transfer' },
];

describe('SegmentControl', () => {
it('renders every segment label', () => {
const { getByText } = render(
<SegmentControl segments={SEGMENTS} selectedValue="expense" onSelect={() => { }} />
);

expect(getByText('Expense')).toBeTruthy();
expect(getByText('Income')).toBeTruthy();
expect(getByText('Transfer')).toBeTruthy();
});

it('marks only the selected segment as selected', () => {
const { getByTestId } = render(
<SegmentControl segments={SEGMENTS} selectedValue="income" onSelect={() => { }} />
);

expect(getByTestId('add_tx_type_income').props.accessibilityState).toEqual({ selected: true });
expect(getByTestId('add_tx_type_expense').props.accessibilityState).toEqual({ selected: false });
expect(getByTestId('add_tx_type_transfer').props.accessibilityState).toEqual({ selected: false });
});

it('moves the selected state when selectedValue changes', () => {
const { getByTestId, rerender } = render(
<SegmentControl segments={SEGMENTS} selectedValue="expense" onSelect={() => { }} />
);

expect(getByTestId('add_tx_type_expense').props.accessibilityState).toEqual({ selected: true });

rerender(
<SegmentControl segments={SEGMENTS} selectedValue="transfer" onSelect={() => { }} />
);

expect(getByTestId('add_tx_type_expense').props.accessibilityState).toEqual({ selected: false });
expect(getByTestId('add_tx_type_transfer').props.accessibilityState).toEqual({ selected: true });
});

it('gives every segment a button role and its label', () => {
const { getByTestId } = render(
<SegmentControl segments={SEGMENTS} selectedValue="expense" onSelect={() => { }} />
);

const expense = getByTestId('add_tx_type_expense');
expect(expense.props.accessibilityRole).toBe('button');
expect(expense.props.accessibilityLabel).toBe('Expense');
});

it('fires onSelect with the pressed segment value', () => {
const onSelect = jest.fn();
const { getByTestId } = render(
<SegmentControl segments={SEGMENTS} selectedValue="expense" onSelect={onSelect} />
);

fireEvent.press(getByTestId('add_tx_type_transfer'));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).toHaveBeenCalledWith('transfer');
});

it('distinguishes the selected segment by background and text colour', () => {
const { getByTestId, getByText } = render(
<SegmentControl segments={SEGMENTS} selectedValue="expense" onSelect={() => { }} />
);

const selected = StyleSheetFlatten(getByTestId('add_tx_type_expense').props.style);
const unselected = StyleSheetFlatten(getByTestId('add_tx_type_income').props.style);

expect(selected.backgroundColor).toBe('#603b2e');
expect(unselected.backgroundColor).toBe('transparent');
expect(selected.backgroundColor).not.toBe(unselected.backgroundColor);

expect(getByText('Expense').props.style.color).toBe('#FAFAFA');
expect(getByText('Income').props.style.color).toBe('#2F241F');
});
});

/** Collapses RN's nested style arrays into a single object. */
function StyleSheetFlatten(style: unknown): Record<string, unknown> {
if (Array.isArray(style)) {
return style.reduce<Record<string, unknown>>(
(acc, s) => ({ ...acc, ...StyleSheetFlatten(s) }),
{},
);
}
return (style ?? {}) as Record<string, unknown>;
}
6 changes: 5 additions & 1 deletion src/data/__tests__/settingsFeatures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ jest.mock('expo-notifications', () => ({
getPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
cancelAllScheduledNotificationsAsync: jest.fn().mockResolvedValue(undefined),
cancelScheduledNotificationAsync: jest.fn().mockResolvedValue(undefined),
scheduleNotificationAsync: jest.fn().mockResolvedValue('mock-id'),
SchedulableTriggerInputTypes: { TIME_INTERVAL: 'timeInterval' },
setNotificationChannelAsync: jest.fn().mockResolvedValue(undefined),
setNotificationHandler: jest.fn(),
SchedulableTriggerInputTypes: { TIME_INTERVAL: 'timeInterval', DAILY: 'daily' },
AndroidImportance: { HIGH: 6 },
}));

import AsyncStorage from '@react-native-async-storage/async-storage';
Expand Down
Loading
Loading