Compare commits

...
2 Commits
Author SHA1 Message Date
soconnor fa3b9bf6a0 update app.json 2026-07-06 21:12:22 -04:00
soconnor ae7350dae6 Prevent swipe rows from opening items 2026-07-02 00:52:50 -04:00
5 changed files with 181 additions and 118 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app",
"buildNumber": "22",
"buildNumber": "23",
"icon": "./assets/beenvoice.icon",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
+28 -31
View File
@@ -2,7 +2,6 @@ import { router } from "expo-router";
import { useState } from "react";
import {
Alert,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
@@ -155,23 +154,22 @@ export default function EntitiesScreen() {
onPress: () => confirmDelete(client.id, client.name),
},
]}
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
>
<Pressable onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<Text style={styles.name}>{client.name}</Text>
{client.email ? (
<Text style={styles.meta}>{client.email}</Text>
) : null}
{client.defaultHourlyRate != null ? (
<Text style={styles.meta}>
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
/hr
</Text>
) : null}
</View>
</GlassSurface>
</Pressable>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<Text style={styles.name}>{client.name}</Text>
{client.email ? (
<Text style={styles.meta}>{client.email}</Text>
) : null}
{client.defaultHourlyRate != null ? (
<Text style={styles.meta}>
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
/hr
</Text>
) : null}
</View>
</GlassSurface>
</SwipeableRow>
))
)
@@ -205,23 +203,22 @@ export default function EntitiesScreen() {
onPress: () => confirmDelete(business.id, business.name),
},
]}
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
>
<Pressable onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text>
{business.isDefault ? (
<Text style={styles.badge}>Default</Text>
) : null}
</View>
{business.nickname ? (
<Text style={styles.meta}>{business.nickname}</Text>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text>
{business.isDefault ? (
<Text style={styles.badge}>Default</Text>
) : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
</View>
</GlassSurface>
</Pressable>
{business.nickname ? (
<Text style={styles.meta}>{business.nickname}</Text>
) : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
</View>
</GlassSurface>
</SwipeableRow>
))
)}
+23 -23
View File
@@ -2,7 +2,6 @@ import { router } from "expo-router";
import { useState } from "react";
import {
Alert,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
@@ -205,31 +204,32 @@ export default function InvoicesScreen() {
];
return (
<SwipeableRow key={invoice.id} actions={actions} backgroundColor={colors.cardGlass}>
<Pressable
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
onLongPress={() => promptStatusChange(invoice.id, status)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.cardTop}>
<View style={styles.cardMeta}>
<Text style={styles.invoiceNumber}>{label}</Text>
<Text style={styles.clientName}>
{invoice.client?.name ?? "Client"}
</Text>
</View>
<Text style={styles.amount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
<SwipeableRow
key={invoice.id}
actions={actions}
backgroundColor={colors.cardGlass}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
onLongPress={() => promptStatusChange(invoice.id, status)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.cardTop}>
<View style={styles.cardMeta}>
<Text style={styles.invoiceNumber}>{label}</Text>
<Text style={styles.clientName}>
{invoice.client?.name ?? "Client"}
</Text>
</View>
<View style={styles.cardBottom}>
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
<StatusBadge status={status} />
</View>
<Text style={styles.amount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
</View>
</GlassSurface>
</Pressable>
<View style={styles.cardBottom}>
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
<StatusBadge status={status} />
</View>
</View>
</GlassSurface>
</SwipeableRow>
);
})
+54 -59
View File
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState } from "react";
import {
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
@@ -225,6 +224,12 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
return (
<SwipeableRow
backgroundColor={colors.cardGlass}
contentStyle={({ pressed }) => [
styles.row,
{ borderColor: colors.border },
pressed && styles.rowPressed,
]}
onPress={() => router.push(`/(app)/more/expenses/${expense.id}` as never)}
actions={[
{
key: "open",
@@ -244,67 +249,57 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
},
]}
>
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/(app)/more/expenses/${expense.id}` as never)}
style={({ pressed }) => [
styles.row,
{ borderColor: colors.border },
pressed && styles.rowPressed,
]}
>
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
</View>
<View style={styles.meta}>
<View style={styles.titleRow}>
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
{expense.description}
</Text>
{expense.receiptCount ? (
<View
style={[
styles.receiptPill,
{ borderColor: colors.border, backgroundColor: colors.background },
]}
>
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
{expense.receiptCount}
</Text>
</View>
) : null}
</View>
<View style={styles.meta}>
<View style={styles.titleRow}>
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
{expense.description}
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
{formatDate(expense.date)}
{expense.category ? ` · ${expense.category}` : ""}
{expense.client?.name ? ` · ${expense.client.name}` : ""}
</Text>
<View style={styles.tagRow}>
{expense.billable ? (
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
Billable
</Text>
{expense.receiptCount ? (
<View
style={[
styles.receiptPill,
{ borderColor: colors.border, backgroundColor: colors.background },
]}
>
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
{expense.receiptCount}
</Text>
</View>
) : null}
</View>
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
{formatDate(expense.date)}
{expense.category ? ` · ${expense.category}` : ""}
{expense.client?.name ? ` · ${expense.client.name}` : ""}
</Text>
<View style={styles.tagRow}>
{expense.billable ? (
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
Billable
</Text>
) : null}
{expense.reimbursable ? (
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
Reimbursable
</Text>
) : null}
{expense.taxDeductible ? (
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
Tax
</Text>
) : null}
</View>
) : null}
{expense.reimbursable ? (
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
Reimbursable
</Text>
) : null}
{expense.taxDeductible ? (
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
Tax
</Text>
) : null}
</View>
<View style={styles.amountStack}>
<Text style={[styles.amount, { color: colors.foreground }]}>
{formatCurrency(expense.amount, expense.currency)}
</Text>
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
</View>
</Pressable>
</View>
<View style={styles.amountStack}>
<Text style={[styles.amount, { color: colors.foreground }]}>
{formatCurrency(expense.amount, expense.currency)}
</Text>
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
</View>
</SwipeableRow>
);
}
+75 -4
View File
@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { ReactNode, useRef } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Pressable, type PressableProps, StyleSheet, Text, View } from "react-native";
import Swipeable, {
type SwipeableMethods,
} from "react-native-gesture-handler/ReanimatedSwipeable";
@@ -24,6 +24,9 @@ type SwipeableRowProps = {
actions: SwipeAction[];
enabled?: boolean;
backgroundColor?: string;
onPress?: () => void;
onLongPress?: () => void;
contentStyle?: PressableProps["style"];
};
export function SwipeableRow({
@@ -31,11 +34,63 @@ export function SwipeableRow({
actions,
enabled = true,
backgroundColor,
onPress,
onLongPress,
contentStyle,
}: SwipeableRowProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createSwipeableRowStyles);
const rowBackground = backgroundColor ?? colors.background;
const swipeRef = useRef<SwipeableMethods>(null);
const rowOpenRef = useRef(false);
const suppressPressUntilRef = useRef(0);
function suppressContentPress() {
suppressPressUntilRef.current = Date.now() + 350;
}
function handleContentPress() {
if (!onPress) return;
if (rowOpenRef.current || Date.now() < suppressPressUntilRef.current) {
swipeRef.current?.close();
rowOpenRef.current = false;
return;
}
onPress();
}
function renderContent() {
if (!onPress && !onLongPress) {
return (
<View
style={[
styles.row,
{ backgroundColor: rowBackground },
typeof contentStyle === "function" ? undefined : contentStyle,
]}
>
{children}
</View>
);
}
return (
<Pressable
accessibilityRole="button"
onPress={handleContentPress}
onLongPress={onLongPress}
style={(state) => [
styles.row,
{ backgroundColor: rowBackground },
typeof contentStyle === "function" ? contentStyle(state) : contentStyle,
]}
>
{children}
</Pressable>
);
}
function renderRightActions() {
return (
@@ -45,7 +100,9 @@ export function SwipeableRow({
key={action.key}
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
onPress={() => {
suppressContentPress();
swipeRef.current?.close();
rowOpenRef.current = false;
action.onPress();
}}
>
@@ -58,12 +115,26 @@ export function SwipeableRow({
}
if (!enabled || actions.length === 0) {
return <View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View>;
return renderContent();
}
return (
<Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}>
<View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View>
<Swipeable
ref={swipeRef}
renderRightActions={renderRightActions}
overshootRight={false}
onSwipeableOpenStartDrag={suppressContentPress}
onSwipeableCloseStartDrag={suppressContentPress}
onSwipeableWillOpen={() => {
suppressContentPress();
rowOpenRef.current = true;
}}
onSwipeableWillClose={suppressContentPress}
onSwipeableClose={() => {
rowOpenRef.current = false;
}}
>
{renderContent()}
</Swipeable>
);
}