Skip to content
Open
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
312 changes: 312 additions & 0 deletions ACCESSIBILITY.md

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions apps/mobile/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import 'react-native-gesture-handler';
import { registerRootComponent } from 'expo';
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';

// registerRootComponent handles mounting and bootstrapping the app
// on both native mobile devices (Expo Go) and web browsers seamlessly.
registerRootComponent(App);
AppRegistry.registerComponent(appName, () => App);
90 changes: 53 additions & 37 deletions apps/mobile/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,49 +1,65 @@
const { getDefaultConfig } = require('expo/metro-config');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const path = require('path');

// Monorepo root
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, '../..');

/**
* Metro configuration for Expo monorepo
* Metro configuration for DevCard React Native Monorepo
*/
const config = getDefaultConfig(projectRoot);
const config = {
watchFolders: [monorepoRoot],
resolver: {
nodeModulesPaths: [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
],
disableHierarchicalLookup: false,
extraNodeModules: {
react: path.resolve(projectRoot, 'node_modules/react'),
'react-native': path.resolve(projectRoot, 'node_modules/react-native'),
'react-native-reanimated': path.resolve(
projectRoot,
'node_modules/react-native-reanimated'
),
'react-native-worklets': path.resolve(
projectRoot,
'node_modules/react-native-worklets'
),
'react-native-gesture-handler': path.resolve(
projectRoot,
'node_modules/react-native-gesture-handler'
),
},
resolveRequest: (context, moduleName, platform) => {
const pinnedModules = {
react: path.resolve(projectRoot, 'node_modules/react'),
'react-native': path.resolve(projectRoot, 'node_modules/react-native'),
'react-native-reanimated': path.resolve(
projectRoot,
'node_modules/react-native-reanimated'
),
'react-native-worklets': path.resolve(
projectRoot,
'node_modules/react-native-worklets'
),
'react-native-gesture-handler': path.resolve(
projectRoot,
'node_modules/react-native-gesture-handler'
),
};

config.watchFolders = [monorepoRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
];
config.resolver.disableHierarchicalLookup = false;
for (const [name, modulePath] of Object.entries(pinnedModules)) {
if (moduleName === name || moduleName.startsWith(`${name}/`)) {
const target = path.join(modulePath, moduleName.slice(name.length));
return context.resolveRequest(context, target, platform);
}
}

const pinnedModules = {
react: path.resolve(projectRoot, 'node_modules/react'),
'react-native': path.resolve(projectRoot, 'node_modules/react-native'),
'react-native-reanimated': path.resolve(
projectRoot,
'node_modules/react-native-reanimated'
),
'react-native-worklets': path.resolve(
projectRoot,
'node_modules/react-native-worklets'
),
'react-native-gesture-handler': path.resolve(
projectRoot,
'node_modules/react-native-gesture-handler'
),
return context.resolveRequest(context, moduleName, platform);
},
},
};

config.resolver.extraNodeModules = pinnedModules;
config.resolver.resolveRequest = (context, moduleName, platform) => {
for (const [name, modulePath] of Object.entries(pinnedModules)) {
if (moduleName === name || moduleName.startsWith(`${name}/`)) {
const target = path.join(modulePath, moduleName.slice(name.length));
return context.resolveRequest(context, target, platform);
}
}

return context.resolveRequest(context, moduleName, platform);
};

module.exports = config;
module.exports = mergeConfig(getDefaultConfig(projectRoot), config);
5 changes: 1 addition & 4 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "expo start",
"start": "react-native start",
"test": "jest"
},
"dependencies": {
Expand All @@ -18,9 +18,6 @@
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"expo": "^54.0.34",
"expo-constants": "^18.0.13",
"expo-linking": "^8.0.12",
"react": "19.1.0",
"react-dom": "^19.1.0",
"react-native": "0.81.5",
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/components/CardPickerSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ const CardPickerSheet = React.forwardRef<BottomSheetModal, Props>(
]}
onPress={() => onSelect(card.id)}
disabled={isSelected}
accessibilityLabel={isSelected ? `${card.title} is currently selected` : `Select card ${card.title}`}
accessibilityRole="button"
accessibilityState={{ disabled: isSelected, selected: isSelected }}
accessibilityHint={isSelected ? undefined : "Switches the active context card shown on your profile and QR code"}
>
<Text
style={[
Expand Down Expand Up @@ -187,7 +191,7 @@ const styles = StyleSheet.create({
borderRadius: 5,
},
morePlatforms: {
fontSize: 10,
fontSize: FONT_SIZE.micro,
color: COLORS.textMuted,
fontWeight: '700',
},
Expand Down
29 changes: 17 additions & 12 deletions apps/mobile/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import Constants from 'expo-constants';
import * as Linking from 'expo-linking';
import { Platform } from 'react-native';

// DevCard API Configuration

// Prefer explicit configuration via Expo/EAS extras. Fallback to sensible defaults
const extras = (Constants as any).manifest?.extra || (Constants as any).expoConfig?.extra;

const DEV_API = extras?.API_BASE_URL || extras?.DEV_API_BASE_URL;
const DEV_APP = extras?.APP_URL;
const getDevServerHost = () => {
// Standard React Native localhost aliases:
// - Android Emulator: 10.0.2.2 (maps to host localhost)
// - iOS Simulator: localhost
// - Physical device / Fallback: '10.155.14.65' (your development machine IP)
return Platform.select({
android: '10.0.2.2',
ios: 'localhost',
default: '10.155.14.65',
});
};

export const API_BASE_URL = __DEV__
? DEV_API ?? `http://10.0.2.2:3000` // 10.0.2.2 is a common emulator host for Android
: extras?.API_BASE_URL ?? 'https://api.devcard.dev';
? `http://${getDevServerHost()}:3000`
: 'https://api.devcard.dev';

export const APP_URL = __DEV__
? DEV_APP ?? `http://localhost:5173`
: extras?.APP_URL ?? 'https://devcard.dev';
? `http://${getDevServerHost()}:5173`
: 'https://devcard.dev';

export const OAUTH_REDIRECT_URI = Linking.createURL('oauth/callback');
export const OAUTH_REDIRECT_URI = 'devcard://oauth/callback';
63 changes: 45 additions & 18 deletions apps/mobile/src/screens/CardsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,10 @@ export default function CardsScreen() {
<Text style={styles.title}>Context Cards</Text>
<TouchableOpacity
style={styles.addButton}
onPress={() => setShowCreate(true)}>
onPress={() => setShowCreate(true)}
accessibilityLabel="Create a new context card"
accessibilityRole="button"
accessibilityHint="Opens a form to create a new context card">
<Text style={styles.addButtonText}>+ New Card</Text>
</TouchableOpacity>
</View>
Expand All @@ -178,7 +181,7 @@ export default function CardsScreen() {
<View style={styles.chip} />
<Text style={styles.brandText}>DevCard</Text>
</View>
<Text style={styles.contactless}>📶</Text>
<Text style={styles.contactless} accessibilityElementsHidden importantForAccessibility="no">📶</Text>
</View>

{/* Card Center: Title */}
Expand All @@ -193,7 +196,7 @@ export default function CardsScreen() {
<Text style={styles.userName}>{user?.displayName || 'Card Holder'}</Text>
<Text style={styles.cardId}>{Math.random().toString(36).substring(2, 6).toUpperCase()} {Math.random().toString(36).substring(2, 6).toUpperCase()}</Text>
</View>
<View style={styles.platformIcons}>
<View style={styles.platformIcons} accessibilityLabel={`Linked platforms: ${item.links.map(l => PLATFORMS[l.platform]?.name || l.platform).join(', ')}`}>
{item.links.slice(0, 3).map(link => (
<View key={link.id} style={[styles.platformDot, { backgroundColor: PLATFORMS[link.platform]?.color || COLORS.primary }]} />
))}
Expand All @@ -210,15 +213,25 @@ export default function CardsScreen() {
{/* Card Actions Below the Card */}
<View style={styles.actionRow}>
{!item.isDefault ? (
<TouchableOpacity onPress={() => setDefault(item.id)} style={styles.actionBtn}>
<TouchableOpacity
onPress={() => setDefault(item.id)}
style={styles.actionBtn}
accessibilityLabel={`Set ${item.title} as primary card`}
accessibilityRole="button"
accessibilityHint="Makes this card the default card shared via your QR code and profile link">
<Text style={styles.actionBtnText}>Set as Primary</Text>
</TouchableOpacity>
) : (
<View style={styles.activeBadge}>
<View style={styles.activeBadge} accessibilityLabel="This is your active card">
<Text style={styles.activeBadgeText}>ACTIVE CARD</Text>
</View>
)}
<TouchableOpacity onPress={() => deleteCard(item.id)} style={styles.deleteBtn}>
<TouchableOpacity
onPress={() => deleteCard(item.id)}
style={styles.deleteBtn}
accessibilityLabel={`Delete card ${item.title}`}
accessibilityRole="button"
accessibilityHint="Removes this card from your list of context cards">
<Text style={styles.deleteBtnText}>Delete</Text>
</TouchableOpacity>
</View>
Expand All @@ -244,6 +257,8 @@ export default function CardsScreen() {
placeholderTextColor={COLORS.textMuted}
value={newTitle}
onChangeText={setNewTitle}
accessibilityLabel="Card title text input"
accessibilityHint="Type a title for your context card"
/>
<Text style={styles.selectLabel}>Select platforms to include:</Text>
{allLinks.length === 0 ? (
Expand All @@ -256,7 +271,11 @@ export default function CardsScreen() {
<TouchableOpacity
key={link.id}
style={[styles.linkOption, selectedLinkIds.includes(link.id) && styles.linkSelected]}
onPress={() => toggleLink(link.id)}>
onPress={() => toggleLink(link.id)}
accessibilityLabel={`Include ${PLATFORMS[link.platform]?.name || link.platform} connection for ${link.username}`}
accessibilityRole="checkbox"
accessibilityState={{ checked: selectedLinkIds.includes(link.id) }}
accessibilityHint="Toggles inclusion of this connection in the context card">
<View style={[styles.dot, { backgroundColor: PLATFORMS[link.platform]?.color || COLORS.primary }]} />
<Text style={styles.linkOptionText}>
{PLATFORMS[link.platform]?.name || link.platform} — {link.username}
Expand All @@ -265,12 +284,20 @@ export default function CardsScreen() {
</TouchableOpacity>
))
)}
<TouchableOpacity style={styles.submitBtn} onPress={createCard}>
<TouchableOpacity
style={styles.submitBtn}
onPress={createCard}
accessibilityLabel="Create card"
accessibilityRole="button"
accessibilityHint="Saves the context card with the selected connections">
<Text style={styles.submitBtnText}>Create Card</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.cancelBtn}
onPress={() => { setShowCreate(false); setNewTitle(''); setSelectedLinkIds([]); }}>
onPress={() => { setShowCreate(false); setNewTitle(''); setSelectedLinkIds([]); }}
accessibilityLabel="Cancel card creation"
accessibilityRole="button"
accessibilityHint="Closes the create card modal without saving">
<Text style={styles.cancelBtnText}>Cancel</Text>
</TouchableOpacity>
</View>
Expand Down Expand Up @@ -407,7 +434,7 @@ const styles = StyleSheet.create({
},
brandText: {
color: 'rgba(255,255,255,0.6)',
fontSize: 12,
fontSize: FONT_SIZE.xs,
fontWeight: '700',
letterSpacing: 1,
textTransform: 'uppercase',
Expand All @@ -420,13 +447,13 @@ const styles = StyleSheet.create({
marginTop: SPACING.md,
},
premiumCardTitle: {
fontSize: 28,
fontSize: FONT_SIZE.xxl,
fontWeight: '800',
color: COLORS.white,
letterSpacing: 0.5,
},
cardType: {
fontSize: 8,
fontSize: FONT_SIZE.nano,
color: 'rgba(255,255,255,0.4)',
fontWeight: '700',
letterSpacing: 2,
Expand All @@ -441,14 +468,14 @@ const styles = StyleSheet.create({
flex: 1,
},
userName: {
fontSize: 14,
fontSize: FONT_SIZE.sm,
color: 'rgba(255,255,255,0.8)',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 1,
},
cardId: {
fontSize: 10,
fontSize: FONT_SIZE.micro,
color: 'rgba(255,255,255,0.3)',
fontFamily: 'monospace',
marginTop: 2,
Expand All @@ -464,7 +491,7 @@ const styles = StyleSheet.create({
borderRadius: 5,
},
morePlatforms: {
fontSize: 10,
fontSize: FONT_SIZE.micro,
color: 'rgba(255,255,255,0.5)',
fontWeight: '700',
},
Expand All @@ -487,7 +514,7 @@ const styles = StyleSheet.create({
},
actionBtnText: {
color: COLORS.primary,
fontSize: 12,
fontSize: FONT_SIZE.xs,
fontWeight: '600',
},
activeBadge: {
Expand All @@ -497,7 +524,7 @@ const styles = StyleSheet.create({
},
activeBadgeText: {
color: COLORS.success,
fontSize: 10,
fontSize: FONT_SIZE.micro,
fontWeight: '800',
letterSpacing: 1,
},
Expand All @@ -507,7 +534,7 @@ const styles = StyleSheet.create({
},
deleteBtnText: {
color: 'rgba(239, 68, 68, 0.6)',
fontSize: 12,
fontSize: FONT_SIZE.xs,
fontWeight: '600',
},
});
Loading