fix(mobile): Apple App Store compliance fixes

- Remove unused `fetch` from UIBackgroundModes (Guideline 2.5.4)
- Add iOS privacy manifest configuration for required reason APIs
- Add Privacy Policy and Terms of Service links to ProfileScreen
- Add account deletion capability (Guideline 5.1.1(v))
- Remove unused Switch import from ProfileScreen
- Replace emoji icons with Ionicons in UploadScreen and FilesScreen
- Add app version display to ProfileScreen

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-19 11:38:48 +00:00
parent 0aab5bcbf7
commit 5c15a2395a
4 changed files with 164 additions and 31 deletions
+24 -2
View File
@@ -22,7 +22,7 @@
"NSCameraUsageDescription": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload.", "NSCameraUsageDescription": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload.",
"NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.", "NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.",
"NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.", "NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.",
"UIBackgroundModes": ["fetch", "remote-notification"], "UIBackgroundModes": ["remote-notification"],
"ITSAppUsesNonExemptEncryption": false, "ITSAppUsesNonExemptEncryption": false,
"LSSupportsOpeningDocumentsInPlace": true, "LSSupportsOpeningDocumentsInPlace": true,
"CFBundleDocumentTypes": [ "CFBundleDocumentTypes": [
@@ -86,7 +86,29 @@
"expo-build-properties", "expo-build-properties",
{ {
"ios": { "ios": {
"buildReactNativeFromSource": true "buildReactNativeFromSource": true,
"privacyManifests": {
"NSPrivacyAccessedAPITypes": [
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
"NSPrivacyAccessedAPITypeReasons": ["C617.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace",
"NSPrivacyAccessedAPITypeReasons": ["E174.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime",
"NSPrivacyAccessedAPITypeReasons": ["35F9.1"]
}
],
"NSPrivacyCollectedDataTypes": [],
"NSPrivacyTracking": false
}
} }
} }
], ],
+13 -12
View File
@@ -2,6 +2,7 @@
* FilesScreen list of documents processed by DocuElevate. * FilesScreen list of documents processed by DocuElevate.
*/ */
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -34,15 +35,15 @@ function formatDate(iso: string): string {
} }
} }
function statusEmoji(status: string): string { function statusIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } {
const map: Record<string, string> = { const map: Record<string, { name: keyof typeof Ionicons.glyphMap; color: string }> = {
completed: "✅", completed: { name: "checkmark-circle", color: "#059669" },
processing: "⚙️", processing: { name: "sync-circle", color: "#d97706" },
pending: "⏳", pending: { name: "time-outline", color: "#6b7280" },
failed: "❌", failed: { name: "close-circle", color: "#dc2626" },
duplicate: "🔁", duplicate: { name: "copy-outline", color: "#6b7280" },
}; };
return map[status?.toLowerCase()] ?? "📄"; return map[status?.toLowerCase()] ?? { name: "document-outline", color: "#6b7280" };
} }
export default function FilesScreen() { export default function FilesScreen() {
@@ -126,7 +127,7 @@ export default function FilesScreen() {
onEndReachedThreshold={0.4} onEndReachedThreshold={0.4}
ListEmptyComponent={ ListEmptyComponent={
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={styles.emptyEmoji}>📂</Text> <Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}>No documents yet.</Text> <Text style={styles.emptyText}>No documents yet.</Text>
<Text style={styles.emptyHint}> <Text style={styles.emptyHint}>
Upload a document from the Upload tab to get started. Upload a document from the Upload tab to get started.
@@ -144,9 +145,10 @@ export default function FilesScreen() {
function FileRow({ file }: { file: FileRecord }) { function FileRow({ file }: { file: FileRecord }) {
const status = file.processing_status?.status ?? "pending"; const status = file.processing_status?.status ?? "pending";
const icon = statusIcon(status);
return ( return (
<View style={rowStyles.row}> <View style={rowStyles.row}>
<Text style={rowStyles.icon}>{statusEmoji(status)}</Text> <Ionicons name={icon.name} size={22} color={icon.color} style={rowStyles.icon} />
<View style={rowStyles.info}> <View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}> <Text style={rowStyles.filename} numberOfLines={1}>
{file.original_filename} {file.original_filename}
@@ -179,7 +181,6 @@ const styles = StyleSheet.create({
}, },
retryText: { color: "#fff", fontWeight: "600" }, retryText: { color: "#fff", fontWeight: "600" },
emptyState: { alignItems: "center", paddingTop: 60 }, emptyState: { alignItems: "center", paddingTop: 60 },
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 }, emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 },
emptyHint: { emptyHint: {
fontSize: 13, fontSize: 13,
@@ -203,7 +204,7 @@ const rowStyles = StyleSheet.create({
shadowRadius: 4, shadowRadius: 4,
elevation: 2, elevation: 2,
}, },
icon: { fontSize: 22, marginRight: 12 }, icon: { marginRight: 12 },
info: { flex: 1 }, info: { flex: 1 },
filename: { filename: {
fontSize: 14, fontSize: 14,
+113 -3
View File
@@ -2,6 +2,8 @@
* ProfileScreen authenticated user profile and settings. * ProfileScreen authenticated user profile and settings.
*/ */
import Constants from "expo-constants";
import * as Linking from "expo-linking";
import React from "react"; import React from "react";
import { import {
Alert, Alert,
@@ -9,7 +11,6 @@ import {
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Switch,
Text, Text,
View, View,
} from "react-native"; } from "react-native";
@@ -18,6 +19,8 @@ import { useAuth } from "../context/AuthContext";
export default function ProfileScreen() { export default function ProfileScreen() {
const { user, signOut, baseUrl } = useAuth(); const { user, signOut, baseUrl } = useAuth();
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
function handleSignOut() { function handleSignOut() {
Alert.alert("Sign out", "Are you sure you want to sign out?", [ Alert.alert("Sign out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" }, { text: "Cancel", style: "cancel" },
@@ -29,6 +32,37 @@ export default function ProfileScreen() {
]); ]);
} }
function handleDeleteAccount() {
Alert.alert(
"Delete Account",
"This will permanently delete your account and all associated data. This action cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete Account",
style: "destructive",
onPress: () => {
if (baseUrl) {
Linking.openURL(`${baseUrl}/account/delete`);
}
},
},
]
);
}
function openPrivacyPolicy() {
if (baseUrl) {
Linking.openURL(`${baseUrl}/privacy`);
}
}
function openTermsOfService() {
if (baseUrl) {
Linking.openURL(`${baseUrl}/terms`);
}
}
if (!user) { if (!user) {
return ( return (
<View style={styles.center}> <View style={styles.center}>
@@ -76,7 +110,30 @@ export default function ProfileScreen() {
</View> </View>
</View> </View>
{/* Danger zone */} {/* Legal & Privacy */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Legal</Text>
<Pressable
style={styles.linkRow}
onPress={openPrivacyPolicy}
accessibilityRole="link"
accessibilityLabel="Privacy Policy"
>
<Text style={styles.linkText}>Privacy Policy</Text>
<Text style={styles.linkChevron}></Text>
</Pressable>
<Pressable
style={styles.linkRow}
onPress={openTermsOfService}
accessibilityRole="link"
accessibilityLabel="Terms of Service"
>
<Text style={styles.linkText}>Terms of Service</Text>
<Text style={styles.linkChevron}></Text>
</Pressable>
</View>
{/* Sign out */}
<View style={styles.section}> <View style={styles.section}>
<Pressable <Pressable
style={styles.signOutButton} style={styles.signOutButton}
@@ -87,13 +144,28 @@ export default function ProfileScreen() {
<Text style={styles.signOutText}>Sign out</Text> <Text style={styles.signOutText}>Sign out</Text>
</Pressable> </Pressable>
</View> </View>
{/* Account deletion Apple Guideline 5.1.1(v) */}
<View style={styles.section}>
<Pressable
style={styles.deleteAccountButton}
onPress={handleDeleteAccount}
accessibilityRole="button"
accessibilityLabel="Delete account"
>
<Text style={styles.deleteAccountText}>Delete Account</Text>
</Pressable>
</View>
{/* App version */}
<Text style={styles.versionText}>DocuElevate v{appVersion}</Text>
</ScrollView> </ScrollView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#f9fafb" }, scroll: { flex: 1, backgroundColor: "#f9fafb" },
content: { padding: 20 }, content: { padding: 20, paddingBottom: 40 },
center: { center: {
flex: 1, flex: 1,
alignItems: "center", alignItems: "center",
@@ -180,6 +252,24 @@ const styles = StyleSheet.create({
maxWidth: "60%", maxWidth: "60%",
textAlign: "right", textAlign: "right",
}, },
linkRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
minHeight: 44,
},
linkText: {
fontSize: 15,
color: "#1e40af",
},
linkChevron: {
fontSize: 18,
color: "#9ca3af",
fontWeight: "600",
},
signOutButton: { signOutButton: {
backgroundColor: "#fee2e2", backgroundColor: "#fee2e2",
borderRadius: 10, borderRadius: 10,
@@ -192,4 +282,24 @@ const styles = StyleSheet.create({
fontWeight: "700", fontWeight: "700",
fontSize: 15, fontSize: 15,
}, },
deleteAccountButton: {
backgroundColor: "#ffffff",
borderRadius: 10,
borderWidth: 1,
borderColor: "#dc2626",
paddingVertical: 14,
alignItems: "center",
minHeight: 48,
},
deleteAccountText: {
color: "#dc2626",
fontWeight: "600",
fontSize: 14,
},
versionText: {
fontSize: 12,
color: "#9ca3af",
textAlign: "center",
marginTop: 8,
},
}); });
+14 -14
View File
@@ -12,6 +12,7 @@
* track the real-time processing status of each uploaded file. * track the real-time processing status of each uploaded file.
*/ */
import { Ionicons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker"; import * as DocumentPicker from "expo-document-picker";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
@@ -256,7 +257,7 @@ export default function UploadScreen() {
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Capture document with camera" accessibilityLabel="Capture document with camera"
> >
<Text style={styles.actionIcon}>📷</Text> <Ionicons name="camera-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>Camera</Text> <Text style={styles.actionLabel}>Camera</Text>
</Pressable> </Pressable>
@@ -266,7 +267,7 @@ export default function UploadScreen() {
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Select photo from library" accessibilityLabel="Select photo from library"
> >
<Text style={styles.actionIcon}>🖼</Text> <Ionicons name="images-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>Photos</Text> <Text style={styles.actionLabel}>Photos</Text>
</Pressable> </Pressable>
@@ -276,7 +277,7 @@ export default function UploadScreen() {
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Pick file from device" accessibilityLabel="Pick file from device"
> >
<Text style={styles.actionIcon}>📄</Text> <Ionicons name="document-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>Files</Text> <Text style={styles.actionLabel}>Files</Text>
</Pressable> </Pressable>
</View> </View>
@@ -285,7 +286,7 @@ export default function UploadScreen() {
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}> <ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
{uploads.length === 0 ? ( {uploads.length === 0 ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={styles.emptyEmoji}></Text> <Ionicons name="cloud-upload-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}> <Text style={styles.emptyText}>
Tap Camera, Photos, or Files to upload a document. Tap Camera, Photos, or Files to upload a document.
</Text> </Text>
@@ -304,11 +305,11 @@ export default function UploadScreen() {
} }
function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) { function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) {
const uploadIcons: Record<UploadItem["status"], string> = { const uploadIconProps: Record<UploadItem["status"], { name: keyof typeof Ionicons.glyphMap; color: string }> = {
pending: "⏳", pending: { name: "time-outline", color: "#6b7280" },
uploading: "⬆️", uploading: { name: "arrow-up-circle-outline", color: "#1e40af" },
done: "✅", done: { name: "checkmark-circle", color: "#059669" },
error: "❌", error: { name: "close-circle", color: "#dc2626" },
}; };
/** Human-readable label for the server-side processing status. */ /** Human-readable label for the server-side processing status. */
@@ -316,7 +317,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
const labels: Record<string, string> = { const labels: Record<string, string> = {
pending: "Queued for processing…", pending: "Queued for processing…",
processing: "Processing…", processing: "Processing…",
completed: "Processed", completed: "Processed",
failed: "Processing failed", failed: "Processing failed",
duplicate: "Duplicate already processed", duplicate: "Duplicate already processed",
}; };
@@ -342,7 +343,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
accessibilityLabel={canRetry ? `Retry uploading ${item.filename}` : undefined} accessibilityLabel={canRetry ? `Retry uploading ${item.filename}` : undefined}
accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined} accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined}
> >
<Text style={rowStyles.icon}>{uploadIcons[item.status]}</Text> <Ionicons name={uploadIconProps[item.status].name} size={22} color={uploadIconProps[item.status].color} style={rowStyles.icon} />
<View style={rowStyles.info}> <View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}> <Text style={rowStyles.filename} numberOfLines={1}>
{item.filename} {item.filename}
@@ -397,7 +398,7 @@ const styles = StyleSheet.create({
cameraButton: { backgroundColor: "#1e40af" }, cameraButton: { backgroundColor: "#1e40af" },
photoLibraryButton: { backgroundColor: "#7c3aed" }, photoLibraryButton: { backgroundColor: "#7c3aed" },
fileButton: { backgroundColor: "#059669" }, fileButton: { backgroundColor: "#059669" },
actionIcon: { fontSize: 28, marginBottom: 6 }, actionIcon: { marginBottom: 6 },
actionLabel: { actionLabel: {
color: "#fff", color: "#fff",
fontSize: 14, fontSize: 14,
@@ -409,7 +410,6 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
paddingTop: 60, paddingTop: 60,
}, },
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: { emptyText: {
fontSize: 16, fontSize: 16,
color: "#374151", color: "#374151",
@@ -443,7 +443,7 @@ const rowStyles = StyleSheet.create({
shadowRadius: 4, shadowRadius: 4,
elevation: 2, elevation: 2,
}, },
icon: { fontSize: 22, marginRight: 12 }, icon: { marginRight: 12 },
info: { flex: 1 }, info: { flex: 1 },
filename: { filename: {
fontSize: 14, fontSize: 14,