Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fa30f365f | ||
|
|
b9c7fb325a | ||
|
|
9eadb0d7fb | ||
|
|
fa42ffb767 | ||
|
|
d2fa050c05 |
@@ -9,6 +9,9 @@ APPLE_TEAM_ID=
|
|||||||
#
|
#
|
||||||
# If export fails with "profile doesn't include signing certificate", regenerate App Store
|
# If export fails with "profile doesn't include signing certificate", regenerate App Store
|
||||||
# profiles at developer.apple.com for com.beenvoice.app and com.beenvoice.app.ExpoWidgetsTarget,
|
# profiles at developer.apple.com for com.beenvoice.app and com.beenvoice.app.ExpoWidgetsTarget,
|
||||||
|
# Optional overrides when those App Store profile names differ from the bundle IDs:
|
||||||
|
# IOS_MAIN_APPSTORE_PROFILE_NAME=com.beenvoice.app
|
||||||
|
# IOS_WIDGET_APPSTORE_PROFILE_NAME=com.beenvoice.app.ExpoWidgetsTarget
|
||||||
# then re-run the full release (not --export-only).
|
# then re-run the full release (not --export-only).
|
||||||
|
|
||||||
# Production API baked into the JS bundle (App Store / TestFlight)
|
# Production API baked into the JS bundle (App Store / TestFlight)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.beenvoice.app",
|
"bundleIdentifier": "com.beenvoice.app",
|
||||||
"buildNumber": "26",
|
"buildNumber": "28",
|
||||||
"icon": "./assets/beenvoice.icon",
|
"icon": "./assets/beenvoice.icon",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import { useState } from "react";
|
|||||||
import Constants from "expo-constants";
|
import Constants from "expo-constants";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { Alert, Platform, Pressable, StyleSheet, Switch, Text, View } from "react-native";
|
import {
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { TabPage } from "@/components/TabPage";
|
import { TabPage } from "@/components/TabPage";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
|
||||||
@@ -20,7 +28,10 @@ import { useAppLock } from "@/contexts/AppLockContext";
|
|||||||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||||
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||||
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
|
import {
|
||||||
|
confirmRemoveAccount,
|
||||||
|
finishAccountRemoval,
|
||||||
|
} from "@/lib/account-actions";
|
||||||
import { performAuthReset } from "@/lib/auth-session";
|
import { performAuthReset } from "@/lib/auth-session";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
@@ -61,6 +72,7 @@ export default function SettingsScreen() {
|
|||||||
lock,
|
lock,
|
||||||
} = useAppLock();
|
} = useAppLock();
|
||||||
const profileQuery = api.settings.getProfile.useQuery();
|
const profileQuery = api.settings.getProfile.useQuery();
|
||||||
|
const deleteAccountMutation = api.settings.deleteAccount.useMutation();
|
||||||
|
|
||||||
const [pinPrompt, setPinPrompt] = useState<
|
const [pinPrompt, setPinPrompt] = useState<
|
||||||
| { mode: "create" }
|
| { mode: "create" }
|
||||||
@@ -110,10 +122,54 @@ export default function SettingsScreen() {
|
|||||||
function confirmSignOut() {
|
function confirmSignOut() {
|
||||||
Alert.alert("Sign out", "Sign out of this account on this device?", [
|
Alert.alert("Sign out", "Sign out of this account on this device?", [
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: "Cancel", style: "cancel" },
|
||||||
{ text: "Sign out", style: "destructive", onPress: () => void handleSignOut() },
|
{
|
||||||
|
text: "Sign out",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => void handleSignOut(),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAccount() {
|
||||||
|
if (!activeAccountId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAccountMutation.mutateAsync({
|
||||||
|
confirmText: "DELETE MY ACCOUNT",
|
||||||
|
});
|
||||||
|
const result = await removeAccount(activeAccountId);
|
||||||
|
await finishAccountRemoval({
|
||||||
|
result,
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
});
|
||||||
|
if (result.remainingCount > 0) {
|
||||||
|
router.replace("/(auth)/select-account");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert(
|
||||||
|
"Could not delete account",
|
||||||
|
error instanceof Error ? error.message : "Please try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteAccount() {
|
||||||
|
Alert.alert(
|
||||||
|
"Permanently delete account?",
|
||||||
|
"This deletes your account, invoices, clients, businesses, expenses, time entries, uploaded files, and sign-in data. This cannot be undone.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete Account",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => void handleDeleteAccount(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function confirmInstanceChange() {
|
function confirmInstanceChange() {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Server updated",
|
"Server updated",
|
||||||
@@ -145,7 +201,10 @@ export default function SettingsScreen() {
|
|||||||
await enableLock(pin);
|
await enableLock(pin);
|
||||||
setPinPrompt(null);
|
setPinPrompt(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Alert.alert("Could not enable lock", err instanceof Error ? err.message : "Try again");
|
Alert.alert(
|
||||||
|
"Could not enable lock",
|
||||||
|
err instanceof Error ? err.message : "Try again",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -169,7 +228,10 @@ export default function SettingsScreen() {
|
|||||||
if (pinPrompt?.mode === "change-next") {
|
if (pinPrompt?.mode === "change-next") {
|
||||||
const success = await changePin(pendingPin, pin);
|
const success = await changePin(pendingPin, pin);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
Alert.alert("Could not change PIN", "Check your current PIN and try again.");
|
Alert.alert(
|
||||||
|
"Could not change PIN",
|
||||||
|
"Check your current PIN and try again.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPendingPin("");
|
setPendingPin("");
|
||||||
@@ -207,9 +269,13 @@ export default function SettingsScreen() {
|
|||||||
: "Enter your current PIN."
|
: "Enter your current PIN."
|
||||||
}
|
}
|
||||||
confirmLabel={
|
confirmLabel={
|
||||||
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next" ? "Save" : "Continue"
|
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
|
||||||
|
? "Save"
|
||||||
|
: "Continue"
|
||||||
|
}
|
||||||
|
requireConfirmation={
|
||||||
|
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
|
||||||
}
|
}
|
||||||
requireConfirmation={pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"}
|
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setPendingPin("");
|
setPendingPin("");
|
||||||
setPinPrompt(null);
|
setPinPrompt(null);
|
||||||
@@ -218,7 +284,10 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
<TabScrollView
|
<TabScrollView
|
||||||
header={
|
header={
|
||||||
<PageHeader title="Settings" subtitle="Account and app preferences" />
|
<PageHeader
|
||||||
|
title="Settings"
|
||||||
|
subtitle="Account and app preferences"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
@@ -253,21 +322,43 @@ export default function SettingsScreen() {
|
|||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={() => void switchAccount(account.id)}
|
onPress={() => void switchAccount(account.id)}
|
||||||
style={({ pressed }) => [styles.accountMain, pressed && styles.pressed]}
|
style={({ pressed }) => [
|
||||||
|
styles.accountMain,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.accountMeta}>
|
<View style={styles.accountMeta}>
|
||||||
<Text style={[styles.accountName, { color: colors.foreground }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.accountName,
|
||||||
|
{ color: colors.foreground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{account.name || account.email}
|
{account.name || account.email}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.accountSub,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{account.email}
|
{account.email}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.accountSub,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{account.instanceUrl.replace(/^https?:\/\//, "")}
|
{account.instanceUrl.replace(/^https?:\/\//, "")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<Text style={[styles.activeBadge, { color: colors.primary }]}>Active</Text>
|
<Text
|
||||||
|
style={[styles.activeBadge, { color: colors.primary }]}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -275,11 +366,21 @@ export default function SettingsScreen() {
|
|||||||
accessibilityLabel={`Remove ${account.name || account.email}`}
|
accessibilityLabel={`Remove ${account.name || account.email}`}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
handleRemoveAccount(account.id, account.name || account.email)
|
handleRemoveAccount(
|
||||||
|
account.id,
|
||||||
|
account.name || account.email,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
style={({ pressed }) => [styles.removeButton, pressed && styles.pressed]}
|
style={({ pressed }) => [
|
||||||
|
styles.removeButton,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
|
<Ionicons
|
||||||
|
name="trash-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.destructive}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -293,10 +394,13 @@ export default function SettingsScreen() {
|
|||||||
<Button
|
<Button
|
||||||
title="Add another account"
|
title="Add another account"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={() => void startAdditionalAccountSignIn(clearActiveAccount)}
|
onPress={() =>
|
||||||
|
void startAdditionalAccountSignIn(clearActiveAccount)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
Tap an account to switch. Refresh updates names from saved sign-in data.
|
Tap an account to switch. Refresh updates names from saved sign-in
|
||||||
|
data.
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -309,7 +413,11 @@ export default function SettingsScreen() {
|
|||||||
<Card title="Security">
|
<Card title="Security">
|
||||||
<View style={styles.settingRow}>
|
<View style={styles.settingRow}>
|
||||||
<View style={styles.settingCopy}>
|
<View style={styles.settingCopy}>
|
||||||
<Text style={[styles.settingTitle, { color: colors.foreground }]}>App lock</Text>
|
<Text
|
||||||
|
style={[styles.settingTitle, { color: colors.foreground }]}
|
||||||
|
>
|
||||||
|
App lock
|
||||||
|
</Text>
|
||||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
Require a PIN when reopening the app
|
Require a PIN when reopening the app
|
||||||
</Text>
|
</Text>
|
||||||
@@ -324,10 +432,14 @@ export default function SettingsScreen() {
|
|||||||
{lockEnabled && biometricAvailable ? (
|
{lockEnabled && biometricAvailable ? (
|
||||||
<View style={styles.settingRow}>
|
<View style={styles.settingRow}>
|
||||||
<View style={styles.settingCopy}>
|
<View style={styles.settingCopy}>
|
||||||
<Text style={[styles.settingTitle, { color: colors.foreground }]}>
|
<Text
|
||||||
|
style={[styles.settingTitle, { color: colors.foreground }]}
|
||||||
|
>
|
||||||
{biometricLabel}
|
{biometricLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
<Text
|
||||||
|
style={[styles.meta, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
Unlock with {biometricLabel.toLowerCase()} when available
|
Unlock with {biometricLabel.toLowerCase()} when available
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -341,7 +453,11 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
{lockEnabled ? (
|
{lockEnabled ? (
|
||||||
<>
|
<>
|
||||||
<Button title="Change PIN" variant="secondary" onPress={handleChangePin} />
|
<Button
|
||||||
|
title="Change PIN"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={handleChangePin}
|
||||||
|
/>
|
||||||
<Button title="Lock now" variant="secondary" onPress={lock} />
|
<Button title="Lock now" variant="secondary" onPress={lock} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -360,14 +476,20 @@ export default function SettingsScreen() {
|
|||||||
styles.themeChip,
|
styles.themeChip,
|
||||||
{
|
{
|
||||||
borderColor: selected ? colors.primary : colors.border,
|
borderColor: selected ? colors.primary : colors.border,
|
||||||
backgroundColor: selected ? colors.muted : "transparent",
|
backgroundColor: selected
|
||||||
|
? colors.muted
|
||||||
|
: "transparent",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.themeChipLabel,
|
styles.themeChipLabel,
|
||||||
{ color: selected ? colors.foreground : colors.mutedForeground },
|
{
|
||||||
|
color: selected
|
||||||
|
? colors.foreground
|
||||||
|
: colors.mutedForeground,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -380,24 +502,52 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
<Card title="App">
|
<Card title="App">
|
||||||
<View style={styles.appRow}>
|
<View style={styles.appRow}>
|
||||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Version</Text>
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
<Text style={[styles.appValue, { color: colors.foreground }]}>{appVersion}</Text>
|
Version
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.appValue, { color: colors.foreground }]}>
|
||||||
|
{appVersion}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.appRow}>
|
<View style={styles.appRow}>
|
||||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Platform</Text>
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Platform
|
||||||
|
</Text>
|
||||||
<Text style={[styles.appValue, { color: colors.foreground }]}>
|
<Text style={[styles.appValue, { color: colors.foreground }]}>
|
||||||
{Constants.platform?.ios ? "iOS" : "Other"}
|
{Constants.platform?.ios ? "iOS" : "Other"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Delete account">
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Permanently delete this account and all of its data from the
|
||||||
|
server. This cannot be undone.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title={
|
||||||
|
deleteAccountMutation.isPending
|
||||||
|
? "Deleting account…"
|
||||||
|
: "Delete Account"
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteAccountMutation.isPending}
|
||||||
|
disabled={deleteAccountMutation.isPending}
|
||||||
|
onPress={confirmDeleteAccount}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityState={{ expanded: showAdvanced }}
|
accessibilityState={{ expanded: showAdvanced }}
|
||||||
onPress={() => setShowAdvanced((open) => !open)}
|
onPress={() => setShowAdvanced((open) => !open)}
|
||||||
style={styles.advancedToggle}
|
style={styles.advancedToggle}
|
||||||
>
|
>
|
||||||
<Text style={[styles.advancedLabel, { color: colors.mutedForeground }]}>Advanced</Text>
|
<Text
|
||||||
|
style={[styles.advancedLabel, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
|
Advanced
|
||||||
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={showAdvanced ? "chevron-up" : "chevron-down"}
|
name={showAdvanced ? "chevron-up" : "chevron-down"}
|
||||||
size={16}
|
size={16}
|
||||||
@@ -408,14 +558,23 @@ export default function SettingsScreen() {
|
|||||||
{showAdvanced ? (
|
{showAdvanced ? (
|
||||||
<Card title="Server instance">
|
<Card title="Server instance">
|
||||||
<InstanceUrlField onSaved={confirmInstanceChange} />
|
<InstanceUrlField onSaved={confirmInstanceChange} />
|
||||||
<Text style={[styles.currentServer, { color: colors.mutedForeground }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.currentServer,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
Connected to {activeAccount?.instanceUrl ?? apiUrl}
|
Connected to {activeAccount?.instanceUrl ?? apiUrl}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Button title="Sign Out" variant="danger" onPress={confirmSignOut} />
|
<Button
|
||||||
|
title="Sign Out"
|
||||||
|
variant="danger"
|
||||||
|
onPress={confirmSignOut}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</TabScrollView>
|
</TabScrollView>
|
||||||
</TabPage>
|
</TabPage>
|
||||||
|
|||||||
@@ -116,12 +116,12 @@ Initial App Store release.
|
|||||||
|
|
||||||
### Demo account (production server)
|
### Demo account (production server)
|
||||||
|
|
||||||
Ensure migration `0014_seed_demo_account` has run on the server reviewers will hit.
|
Ensure migrations through `0028_enable_public_demo_password` have run on the server reviewers will hit.
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
| **Username** | `demo@example.com` |
|
| **Username** | `demo@example.com` |
|
||||||
| **Password** | Provision a private credential with `bun run demo:provision` in the web repository, then enter it only in App Store Connect |
|
| **Password** | `demo123` |
|
||||||
|
|
||||||
### Notes for Review
|
### Notes for Review
|
||||||
|
|
||||||
@@ -139,6 +139,13 @@ WHAT TO TEST
|
|||||||
• Invoices — list includes draft, sent, and paid examples.
|
• Invoices — list includes draft, sent, and paid examples.
|
||||||
• Settings — profile, theme, optional app lock (PIN / Face ID).
|
• Settings — profile, theme, optional app lock (PIN / Face ID).
|
||||||
|
|
||||||
|
ACCOUNT DELETION
|
||||||
|
Settings → Delete account offers permanent in-app account deletion without contacting support.
|
||||||
|
After a destructive confirmation, the server removes the account record, sessions, access keys,
|
||||||
|
invoices, clients, businesses, recurring invoices, expenses and receipts, time entries, templates,
|
||||||
|
audit records, and uploaded files. The app then removes the local account and returns to sign-in.
|
||||||
|
The supplied demo account is shared, so please test deletion last if deletion verification is required.
|
||||||
|
|
||||||
APP LOCK
|
APP LOCK
|
||||||
Optional. Enable in Settings → App Lock. Face ID uses on-device biometrics only; no biometric data is sent to our servers.
|
Optional. Enable in Settings → App Lock. Face ID uses on-device biometrics only; no biometric data is sent to our servers.
|
||||||
|
|
||||||
@@ -276,6 +283,7 @@ Prerequisites:
|
|||||||
- [ ] TestFlight smoke test on device (login, timer, invoices, app lock)
|
- [ ] TestFlight smoke test on device (login, timer, invoices, app lock)
|
||||||
- [ ] Live Activity tested on physical iPhone
|
- [ ] Live Activity tested on physical iPhone
|
||||||
- [ ] App Privacy answers match actual data flows
|
- [ ] App Privacy answers match actual data flows
|
||||||
|
- [ ] In-app account deletion succeeds from Settings and returns to sign-in
|
||||||
- [ ] Screenshots uploaded for required device sizes
|
- [ ] Screenshots uploaded for required device sizes
|
||||||
- [ ] Review notes include demo credentials and server URL
|
- [ ] Review notes include demo credentials and server URL
|
||||||
- [ ] Export compliance answered
|
- [ ] Export compliance answered
|
||||||
|
|||||||
@@ -2,41 +2,63 @@
|
|||||||
const { withXcodeProject } = require("@expo/config-plugins");
|
const { withXcodeProject } = require("@expo/config-plugins");
|
||||||
|
|
||||||
const RELEASE_SIGN_KEY = '"CODE_SIGN_IDENTITY[sdk=iphoneos*]"';
|
const RELEASE_SIGN_KEY = '"CODE_SIGN_IDENTITY[sdk=iphoneos*]"';
|
||||||
|
const MAIN_BUNDLE_ID = "com.beenvoice.app";
|
||||||
|
const WIDGET_BUNDLE_ID = "com.beenvoice.app.ExpoWidgetsTarget";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RN / Expo sets Release CODE_SIGN_IDENTITY to "iPhone Developer", which forces
|
* Keep App Store archives on distribution signing. Automatic signing can select
|
||||||
* development-signed archives. Remove it so automatic signing picks Distribution
|
* an Apple Development identity for the widget target when archiving from the CLI,
|
||||||
* for App Store archives.
|
* so both release targets use their explicit App Store profiles instead.
|
||||||
*/
|
*/
|
||||||
|
function configureReleaseSigning(project) {
|
||||||
|
const configurations = project.pbxXCBuildConfigurationSection();
|
||||||
|
|
||||||
|
for (const key of Object.keys(configurations)) {
|
||||||
|
const buildConfig = configurations[key];
|
||||||
|
if (
|
||||||
|
!buildConfig ||
|
||||||
|
typeof buildConfig !== "object" ||
|
||||||
|
!buildConfig.buildSettings
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buildConfig.name !== "Release") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundleId = String(
|
||||||
|
buildConfig.buildSettings.PRODUCT_BUNDLE_IDENTIFIER ?? "",
|
||||||
|
).replaceAll('"', "");
|
||||||
|
if (bundleId !== MAIN_BUNDLE_ID && bundleId !== WIDGET_BUNDLE_ID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileName =
|
||||||
|
bundleId === WIDGET_BUNDLE_ID
|
||||||
|
? (process.env.IOS_WIDGET_APPSTORE_PROFILE_NAME ?? WIDGET_BUNDLE_ID)
|
||||||
|
: (process.env.IOS_MAIN_APPSTORE_PROFILE_NAME ?? MAIN_BUNDLE_ID);
|
||||||
|
|
||||||
|
buildConfig.buildSettings[RELEASE_SIGN_KEY] = '"Apple Distribution"';
|
||||||
|
buildConfig.buildSettings.CODE_SIGN_STYLE = "Manual";
|
||||||
|
buildConfig.buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${profileName}"`;
|
||||||
|
|
||||||
|
if (process.env.APPLE_TEAM_ID) {
|
||||||
|
buildConfig.buildSettings.DEVELOPMENT_TEAM = process.env.APPLE_TEAM_ID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {import('@expo/config-plugins').ConfigPlugin} */
|
/** @type {import('@expo/config-plugins').ConfigPlugin} */
|
||||||
function withAppStoreSigning(config) {
|
function withAppStoreSigning(config) {
|
||||||
return withXcodeProject(config, (config) => {
|
return withXcodeProject(config, (config) => {
|
||||||
const project = config.modResults;
|
configureReleaseSigning(config.modResults);
|
||||||
const configurations = project.pbxXCBuildConfigurationSection();
|
|
||||||
|
|
||||||
for (const key of Object.keys(configurations)) {
|
|
||||||
const buildConfig = configurations[key];
|
|
||||||
if (!buildConfig || typeof buildConfig !== "object" || !buildConfig.buildSettings) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buildConfig.name !== "Release") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const identity = buildConfig.buildSettings[RELEASE_SIGN_KEY];
|
|
||||||
if (
|
|
||||||
identity === "iPhone Developer" ||
|
|
||||||
identity === '"iPhone Developer"' ||
|
|
||||||
identity === "Apple Distribution" ||
|
|
||||||
identity === '"Apple Distribution"'
|
|
||||||
) {
|
|
||||||
delete buildConfig.buildSettings[RELEASE_SIGN_KEY];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = withAppStoreSigning;
|
module.exports = withAppStoreSigning;
|
||||||
|
module.exports.configureReleaseSigning = configureReleaseSigning;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// @ts-check
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const xcode = require("xcode");
|
||||||
|
const { configureReleaseSigning } = require("../plugins/withAppStoreSigning");
|
||||||
|
|
||||||
|
const projectPath = process.argv[2];
|
||||||
|
if (!projectPath) {
|
||||||
|
throw new Error(
|
||||||
|
"Usage: node scripts/configure-ios-signing.js <project.pbxproj>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = path.resolve(projectPath);
|
||||||
|
const project = xcode.project(resolvedPath);
|
||||||
|
project.parseSync();
|
||||||
|
configureReleaseSigning(project);
|
||||||
|
fs.writeFileSync(resolvedPath, project.writeSync());
|
||||||
|
|
||||||
|
console.log("Configured manual App Store signing for iOS release targets.");
|
||||||
@@ -185,6 +185,10 @@ prepare_native_project() {
|
|||||||
fi
|
fi
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if [[ -f "$ROOT/$PROJECT/project.pbxproj" ]]; then
|
||||||
|
node "$ROOT/scripts/configure-ios-signing.js" "$ROOT/$PROJECT/project.pbxproj"
|
||||||
|
fi
|
||||||
|
|
||||||
resolve_xcode_workspace
|
resolve_xcode_workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +245,6 @@ archive_app() {
|
|||||||
-archivePath "$ARCHIVE_PATH" \
|
-archivePath "$ARCHIVE_PATH" \
|
||||||
-destination "generic/platform=iOS" \
|
-destination "generic/platform=iOS" \
|
||||||
-allowProvisioningUpdates \
|
-allowProvisioningUpdates \
|
||||||
CODE_SIGN_STYLE=Automatic \
|
|
||||||
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
|
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
|
||||||
"${API_AUTH_ARGS[@]}" \
|
"${API_AUTH_ARGS[@]}" \
|
||||||
archive
|
archive
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# App Store screenshots — v1.0
|
||||||
|
|
||||||
|
Generated from the current Expo app using the seeded local review workspace.
|
||||||
|
|
||||||
|
- `final/iphone-69`: five 1320×2868 screenshots uploaded to the App Store Connect `APP_IPHONE_67` set.
|
||||||
|
- `final/ipad-129`: one 2064×2752 screenshot uploaded to the App Store Connect `APP_IPAD_PRO_3GEN_129` set.
|
||||||
|
- `raw`: unframed simulator captures used to create the final artwork.
|
||||||
|
|
||||||
|
The final artwork uses the Black Titanium iPhone 16 Pro Max and Space Black iPad Pro 13-inch (M5) PNG frames supplied in `~/Downloads`. The frame source files are not duplicated in this repository.
|
||||||
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 521 KiB |
|
After Width: | Height: | Size: 712 KiB |
|
After Width: | Height: | Size: 839 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 919 KiB |
|
After Width: | Height: | Size: 846 KiB |