diff --git a/packages/mobile-client/App.tsx b/packages/mobile-client/App.tsx index 4f2af06..c9f607a 100644 --- a/packages/mobile-client/App.tsx +++ b/packages/mobile-client/App.tsx @@ -1,9 +1,9 @@ import React, { useState, useEffect } from 'react'; import { StatusBar } from 'expo-status-bar'; -import { StyleSheet, View, SafeAreaView } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; import { Provider as PaperProvider, BottomNavigation, Badge } from 'react-native-paper'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import * as Haptics from 'expo-haptics'; import type { InjectPromptResponse } from '@codelink/protocol'; // Components @@ -40,6 +40,12 @@ import { useCustomFonts } from './src/design-system/typography/fontLoading'; // Utils import { isInjectPromptResponse, isSyncFullContextMessage } from './src/utils/messageValidation'; +import { + getStatusBarStyle, + registerBackButtonHandler, + isAndroid, + triggerHapticFeedback, +} from './src/utils/platformAdaptations'; /** * Main application content with navigation @@ -94,11 +100,11 @@ const AppContent: React.FC = () => { }); } - // Haptic feedback + // Haptic feedback (Requirements 22.3, 22.4) if (response.payload.success) { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + triggerHapticFeedback('success'); } else { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + triggerHapticFeedback('error'); } // Clear response after 4 seconds @@ -129,17 +135,36 @@ const AppContent: React.FC = () => { } catch (error) { setIsSubmitting(false); setPromptError(error instanceof Error ? error.message : 'Failed to submit prompt'); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + triggerHapticFeedback('error'); } }; // Handle reconnection (currently unused but kept for future use) // eslint-disable-next-line @typescript-eslint/no-unused-vars const _handleReconnect = async () => { - await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + await triggerHapticFeedback('medium'); reconnect(); }; + // Handle Android hardware back button + // Requirement 26.7: Android hardware back button support + useEffect(() => { + if (!isAndroid()) return; + + const backHandler = () => { + // If not on dashboard, go back to dashboard + if (index !== 0) { + setIndex(0); + return true; // Prevent default back behavior + } + // If on dashboard, allow default behavior (exit app) + return false; + }; + + const cleanup = registerBackButtonHandler(backHandler); + return cleanup; + }, [index]); + // Define navigation routes const [routes] = useState([ { @@ -177,7 +202,7 @@ const AppContent: React.FC = () => { onNavigateToCompose={() => setIndex(1)} onNavigateToDiffs={() => setIndex(2)} onRefresh={async () => { - await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + await triggerHapticFeedback('light'); }} /> @@ -247,16 +272,18 @@ const AppContent: React.FC = () => { }; return ( - - - - + + + + + + ); }; diff --git a/packages/mobile-client/metro.config.js b/packages/mobile-client/metro.config.js index a11779f..0a13a92 100644 --- a/packages/mobile-client/metro.config.js +++ b/packages/mobile-client/metro.config.js @@ -18,4 +18,18 @@ config.resolver.nodeModulesPaths = [ // 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths` config.resolver.disableHierarchicalLookup = true; +// 4. Disable HMR for web to avoid errors +config.server = { + ...config.server, + enhanceMiddleware: (middleware) => { + return (req, res, next) => { + // Disable HMR for web platform + if (req.url && req.url.includes('platform=web')) { + req.url = req.url.replace(/&hot=true/g, '&hot=false'); + } + return middleware(req, res, next); + }; + }, +}; + module.exports = config; diff --git a/packages/mobile-client/src/components/AppLoading.tsx b/packages/mobile-client/src/components/AppLoading.tsx index 671d052..a0d6d59 100644 --- a/packages/mobile-client/src/components/AppLoading.tsx +++ b/packages/mobile-client/src/components/AppLoading.tsx @@ -3,11 +3,12 @@ * * Displays a loading screen while the app initializes (e.g., loading fonts). * - * Requirements: 2.5 + * Requirements: 2.5, 26.8, 26.9 */ import React from 'react'; import { View, ActivityIndicator, StyleSheet, Text } from 'react-native'; +import { getActivityIndicatorSize } from '../utils/platformAdaptations'; export interface AppLoadingProps { message?: string; @@ -18,11 +19,15 @@ export interface AppLoadingProps { * * Shows a centered activity indicator with optional message * while the app is initializing. + * + * Uses platform-specific activity indicator styles: + * - iOS: UIActivityIndicatorView style + * - Android: Material Design CircularProgressIndicator style */ export const AppLoading: React.FC = ({ message = 'Loading...' }) => { return ( - + {message} ); diff --git a/packages/mobile-client/src/components/Dashboard.tsx b/packages/mobile-client/src/components/Dashboard.tsx index 2083af6..e09fdbd 100644 --- a/packages/mobile-client/src/components/Dashboard.tsx +++ b/packages/mobile-client/src/components/Dashboard.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; -import { View, StyleSheet, ScrollView, FlatList, Dimensions, RefreshControl } from 'react-native'; +import React, { useState } from 'react'; +import { View, StyleSheet, ScrollView, FlatList, RefreshControl } from 'react-native'; import { useDesignSystem } from '../design-system'; import { Text } from '../design-system/typography/Text'; import { Card } from '../design-system/components/Card'; @@ -9,6 +9,7 @@ import { ProgressBar } from '../design-system/components/ProgressBar'; import { StatusIndicator, ConnectionStatus } from '../design-system/components/StatusIndicator'; import { TopAppBar } from '../navigation/TopAppBar'; import { useLoadingAnnouncement } from '../hooks/useScreenReaderAnnouncement'; +import { useResponsiveLayout } from '../hooks/useResponsiveLayout'; /** * System metrics interface @@ -79,25 +80,16 @@ export const Dashboard: React.FC = ({ onRefresh, }) => { const { theme } = useDesignSystem(); - const [isLargeScreen, setIsLargeScreen] = useState(false); const [refreshing, setRefreshing] = useState(false); + // Get responsive layout configuration + // Requirements 13.1, 13.2, 13.5, 13.6, 13.9 + const layout = useResponsiveLayout(); + // Announce loading state for accessibility // Requirement 14.11: Announce loading states useLoadingAnnouncement(refreshing, 'Refreshing dashboard data', 'Dashboard refreshed'); - // Detect screen size for responsive layout (Requirement 13.5) - useEffect(() => { - const updateLayout = () => { - const { width } = Dimensions.get('window'); - setIsLargeScreen(width >= 768); - }; - - updateLayout(); - const subscription = Dimensions.addEventListener('change', updateLayout); - return () => subscription?.remove(); - }, []); - /** * Handle refresh (Requirement 3.1) */ @@ -239,12 +231,20 @@ export const Dashboard: React.FC = ({ {/* Bento Grid Layout (Requirement 3.8, 13.6) */} - + {/* Large card: Uptime (Requirement 3.3) */} Uptime diff --git a/packages/mobile-client/src/components/PromptComposer.tsx b/packages/mobile-client/src/components/PromptComposer.tsx index 890b9cf..29de409 100644 --- a/packages/mobile-client/src/components/PromptComposer.tsx +++ b/packages/mobile-client/src/components/PromptComposer.tsx @@ -7,7 +7,8 @@ import { TouchableOpacity, Animated, KeyboardAvoidingView, - Platform, + Keyboard, + TouchableWithoutFeedback, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import * as Haptics from 'expo-haptics'; @@ -18,6 +19,7 @@ import { TopAppBar } from '../navigation/TopAppBar'; import { useDraftPrompt } from '../hooks/useDraftPrompt'; import { usePromptHistory } from '../hooks/usePromptHistory'; import { useLoadingAnnouncement } from '../hooks/useScreenReaderAnnouncement'; +import { getKeyboardBehavior, getKeyboardVerticalOffset } from '../utils/platformAdaptations'; /** * Prompt template definition @@ -200,273 +202,304 @@ export const PromptComposer: React.FC = ({ const isAtLimit = charCount >= MAX_CHARS; const canSubmit = prompt.trim().length > 0 && !isLoading && !isAtLimit; + // Dismiss keyboard when tapping outside + // Requirement 25.3: Dismiss keyboard when user taps outside input field + const dismissKeyboard = () => { + Keyboard.dismiss(); + }; + return ( - {/* Requirement 5.1: TopAppBar */} - + + + {/* Requirement 5.1: TopAppBar */} + - - {/* Requirement 5.1, 5.3: Active Context header with "Compose Prompt" title */} - - - Active Context - - - Compose Prompt - - - - {/* Requirement 5.2, 5.3: Horizontal scrolling template chips container */} - - {templates.map((template) => ( - handleSelectTemplate(template)} - activeOpacity={0.7} - accessible={true} - accessibilityLabel={`${template.label} template`} - accessibilityHint="Double tap to insert template into prompt" - accessibilityRole="button" - > - - - {template.label} - - - ))} - - - {/* Requirement 5.3: Main composer container with terminal-like header (colored dots) */} - - {/* Terminal-like header with colored dots */} - - - - - + {/* Requirement 5.1, 5.3: Active Context header with "Compose Prompt" title */} + - New Instruction + Active Context + + + Compose Prompt - - - - - {/* Requirement 5.4, 5.9: Multiline textarea with surfaceContainerLowest background */} - {/* Requirement 25.1, 25.8: Keyboard handling to keep textarea visible */} - - - - {/* Requirement 5.6: Bottom toolbar with Clear and Attach buttons */} - {/* Requirement 5.5, 5.13: Character counter with error state when limit exceeded */} - - + {templates.map((template) => ( handleSelectTemplate(template)} activeOpacity={0.7} accessible={true} - accessibilityLabel="Clear prompt" - accessibilityHint="Double tap to clear all prompt text" + accessibilityLabel={`${template.label} template`} + accessibilityHint="Double tap to insert template into prompt" accessibilityRole="button" - accessibilityState={{ disabled: prompt.length === 0 }} > - + + + {template.label} + + + ))} + + + {/* Requirement 5.3: Main composer container with terminal-like header (colored dots) */} + + {/* Terminal-like header with colored dots */} + + + + + - Clear + New Instruction - + - - - Attach - + + + {/* Requirement 5.4, 5.9: Multiline textarea with surfaceContainerLowest background */} + {/* Requirement 25.1, 25.8: Keyboard handling to keep textarea visible */} - + + {/* Requirement 5.6: Bottom toolbar with Clear and Attach buttons */} + {/* Requirement 5.5, 5.13: Character counter with error state when limit exceeded */} + - {charCount} / {MAX_CHARS} + + + + + Clear + + + + + + Attach + + + + + + {charCount} / {MAX_CHARS} + + + + + + + {/* Requirement 5.8: Pro tip hint section with lightbulb icon */} + + + + + + Pro Tip:{' '} + + Mention specific functions or file names to help the AI understand the scope of + your requested changes more accurately. - - - {/* Requirement 5.8: Pro tip hint section with lightbulb icon */} - - - - - - Pro Tip:{' '} - - Mention specific functions or file names to help the AI understand the scope of your - requested changes more accurately. - - - + {/* Error display */} + {error && ( + + + + {error} + + + )} + - {/* Error display */} - {error && ( - - - - {error} - - - )} - - - {/* Requirement 5.7, 5.10: Floating action button (FAB) with send icon */} - {/* Requirement 12.6: Gradient background (primary to primaryContainer) and scale animation */} - - - - {isLoading ? ( - - ) : ( - - )} - - - + + + {isLoading ? ( + + ) : ( + + )} + + + + + ); }; diff --git a/packages/mobile-client/src/components/ResponsiveContainer.tsx b/packages/mobile-client/src/components/ResponsiveContainer.tsx new file mode 100644 index 0000000..006a5be --- /dev/null +++ b/packages/mobile-client/src/components/ResponsiveContainer.tsx @@ -0,0 +1,55 @@ +/** + * Responsive Container Component + * + * Wraps content with responsive width constraints and centering. + * + * Requirements: + * - 13.3: Use maximum content width of 1024px on large screens + * - 13.4: Center content horizontally on wide screens + */ + +import React, { type ReactNode } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { useResponsiveLayout } from '../hooks/useResponsiveLayout'; + +/** + * Props for ResponsiveContainer + */ +export interface ResponsiveContainerProps { + children: ReactNode; + style?: string | undefined; +} + +/** + * Container that constrains content width and centers on large screens + * + * Automatically applies: + * - Maximum width of 1024px on screens wider than that + * - Horizontal centering when content is constrained + * - Full width on smaller screens + */ +export const ResponsiveContainer: React.FC = ({ children, style }) => { + const layout = useResponsiveLayout(); + + return ( + + {children} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/packages/mobile-client/src/components/Settings.tsx b/packages/mobile-client/src/components/Settings.tsx index ed8d170..4910e23 100644 --- a/packages/mobile-client/src/components/Settings.tsx +++ b/packages/mobile-client/src/components/Settings.tsx @@ -14,7 +14,8 @@ import { ScrollView, StyleSheet, KeyboardAvoidingView, - Platform, + Keyboard, + TouchableWithoutFeedback, Linking, TouchableOpacity, } from 'react-native'; @@ -28,6 +29,7 @@ import { Icon } from '../design-system/components/Icon'; import { TopAppBar } from '../navigation/TopAppBar'; import { useConnection } from '../hooks/useConnection'; import { useConnectionQuality } from '../hooks/useConnectionQuality'; +import { getKeyboardBehavior, getKeyboardVerticalOffset } from '../utils/platformAdaptations'; // AsyncStorage keys const STORAGE_KEYS = { @@ -239,292 +241,297 @@ export const Settings: React.FC = () => { - - {/* Header */} - - - Settings - - Keyboard.dismiss()}> + + - Configure your development environment - - - - {/* Connectivity Bento Cards */} - - - {/* Status Card */} - - - Status + {/* Header */} + + + Settings - {getStatusLabel()} - - - - {/* Latency Card */} - - - Latency + Configure your development environment + + + {/* Connectivity Bento Cards */} + + + {/* Status Card */} + + + Status + + + {getStatusLabel()} + + + + {/* Latency Card */} + + + Latency + + + {latency !== null ? `${latency}ms` : '--'} + + + + {/* Active Instance Card */} + + + Active Instance + + + localhost:8080 + + + + {/* Load Card */} + + + Quality + + + {getQualityLabel()} + + + + + + {/* Infrastructure Section */} + - {latency !== null ? `${latency}ms` : '--'} - - - - {/* Active Instance Card */} - - - Active Instance + Infrastructure + + + + + + {/* Appearance Section */} + - localhost:8080 - - - - {/* Load Card */} - - - Quality + Appearance + + + + + + + + {/* Communication Section */} + - {getQualityLabel()} + Communication - - - - - {/* Infrastructure Section */} - - - Infrastructure - - - - - - - {/* Appearance Section */} - - - Appearance - - - - - - - - - {/* Communication Section */} - - - Communication - - - - - - - - - {/* About Section */} - - - About - - - {/* App Icon and Version */} - - - - - - - CodeLink - - - Version {APP_VERSION} - - + + + + + - {/* Links */} - - openLink('https://github.com/codelink/docs')} - accessible={true} - accessibilityLabel="Open documentation" - accessibilityHint="Opens documentation in browser" - accessibilityRole="link" - > - - - Documentation - - - - openLink('https://github.com/codelink/support')} - accessible={true} - accessibilityLabel="Open support" - accessibilityHint="Opens support page in browser" - accessibilityRole="link" - > - - - Support - - - - openLink('https://github.com/codelink')} - accessible={true} - accessibilityLabel="Open GitHub repository" - accessibilityHint="Opens GitHub repository in browser" - accessibilityRole="link" + {/* About Section */} + + - - - GitHub - - + About + + + {/* App Icon and Version */} + + + + + + + CodeLink + + + Version {APP_VERSION} + + + + + {/* Links */} + + openLink('https://github.com/codelink/docs')} + accessible={true} + accessibilityLabel="Open documentation" + accessibilityHint="Opens documentation in browser" + accessibilityRole="link" + > + + + Documentation + + + + openLink('https://github.com/codelink/support')} + accessible={true} + accessibilityLabel="Open support" + accessibilityHint="Opens support page in browser" + accessibilityRole="link" + > + + + Support + + + + openLink('https://github.com/codelink')} + accessible={true} + accessibilityLabel="Open GitHub repository" + accessibilityHint="Opens GitHub repository in browser" + accessibilityRole="link" + > + + + GitHub + + + + - - - {/* Bottom padding for safe area */} - - + {/* Bottom padding for safe area */} + + + + ); diff --git a/packages/mobile-client/src/components/index.ts b/packages/mobile-client/src/components/index.ts index 2f9dd97..15b6b88 100644 --- a/packages/mobile-client/src/components/index.ts +++ b/packages/mobile-client/src/components/index.ts @@ -17,3 +17,5 @@ export { PromptTemplates } from './PromptTemplates'; export type { PromptTemplate, PromptTemplatesProps } from './PromptTemplates'; export { AppLoading } from './AppLoading'; export type { AppLoadingProps } from './AppLoading'; +export { ResponsiveContainer } from './ResponsiveContainer'; +export type { ResponsiveContainerProps } from './ResponsiveContainer'; diff --git a/packages/mobile-client/src/design-system/components/Button.tsx b/packages/mobile-client/src/design-system/components/Button.tsx index e1ff12a..e1dc58c 100644 --- a/packages/mobile-client/src/design-system/components/Button.tsx +++ b/packages/mobile-client/src/design-system/components/Button.tsx @@ -17,11 +17,10 @@ import { ActivityIndicator, StyleProp, ViewStyle, - Platform, } from 'react-native'; -import * as Haptics from 'expo-haptics'; import { LinearGradient } from 'expo-linear-gradient'; import { useDesignSystem } from '../theme/useDesignSystem'; +import { triggerHapticFeedback } from '../../utils/platformAdaptations'; /** * Button variant types @@ -180,24 +179,13 @@ export const Button: React.FC = ({ /** * Handle press - trigger haptic feedback and callback + * Requirements: 22.1, 22.6, 22.7 */ - const handlePress = () => { + const handlePress = async () => { if (!isInteractive) return; - // Trigger haptic feedback - if (Platform.OS === 'ios' || Platform.OS === 'android') { - switch (hapticFeedback) { - case 'light': - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - break; - case 'medium': - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - break; - case 'heavy': - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); - break; - } - } + // Trigger platform-specific haptic feedback + await triggerHapticFeedback(hapticFeedback); onPress(); }; diff --git a/packages/mobile-client/src/design-system/components/GlassContainer.tsx b/packages/mobile-client/src/design-system/components/GlassContainer.tsx new file mode 100644 index 0000000..289ba73 --- /dev/null +++ b/packages/mobile-client/src/design-system/components/GlassContainer.tsx @@ -0,0 +1,131 @@ +/** + * Glass Container Component + * + * A container with glassmorphism effect using backdrop blur. + * Provides a frosted glass visual effect for floating elements. + * + * Requirements: + * - 11.1: Apply backdrop-blur effect to Bottom_Navigation bar + * - 11.2: Apply backdrop-blur effect to modal overlays + * - 11.3: Apply backdrop-blur effect to floating action buttons + * - 11.4: Use 20px blur radius for glassmorphism effects + * - 11.5: Use 80-90% opacity for glass surfaces + * - 11.6: Layer glass effects over surface-variant background + * - 11.7: Fallback to solid background when backdrop-blur not supported + */ + +import React, { type ReactNode } from 'react'; +import { View, StyleSheet, Platform, StyleProp, ViewStyle } from 'react-native'; +import { BlurView } from 'expo-blur'; +import { useDesignSystem } from '../theme/useDesignSystem'; + +/** + * Glass container props + */ +export interface GlassContainerProps { + /** + * Child components to render inside the glass container + */ + children: ReactNode; + + /** + * Blur intensity (0-100) + * @default 80 + */ + intensity?: number; + + /** + * Tint color for the blur + * @default 'dark' + */ + tint?: 'light' | 'dark' | 'default'; + + /** + * Background opacity (0-1) + * @default 0.8 + */ + opacity?: number; + + /** + * Custom style overrides + */ + style?: StyleProp; + + /** + * Whether to use fallback solid background on unsupported platforms + * @default true + */ + useFallback?: boolean; +} + +/** + * Glass container component with glassmorphism effect + * + * Uses BlurView for backdrop blur on supported platforms (iOS, Android). + * Falls back to semi-transparent solid background on web or when blur is not supported. + */ +export const GlassContainer: React.FC = ({ + children, + intensity = 80, + tint = 'dark', + opacity = 0.8, + style, + useFallback = true, +}) => { + const { theme } = useDesignSystem(); + + // Check if platform supports blur + const supportsBlur = Platform.OS === 'ios' || Platform.OS === 'android'; + + // Fallback background color with opacity + const fallbackBackgroundColor = `${theme.colors.surfaceVariant}${Math.round(opacity * 255) + .toString(16) + .padStart(2, '0')}`; + + if (!supportsBlur && useFallback) { + // Fallback for platforms without blur support (Requirement 11.7) + return ( + + {children} + + ); + } + + // Glass effect with BlurView (Requirements 11.4, 11.5, 11.6) + return ( + + {/* Backdrop blur layer */} + + + {/* Semi-transparent background overlay */} + + + {/* Content layer */} + {children} + + ); +}; + +const styles = StyleSheet.create({ + container: { + overflow: 'hidden', + }, + content: { + flex: 1, + }, +}); diff --git a/packages/mobile-client/src/design-system/components/Toggle.tsx b/packages/mobile-client/src/design-system/components/Toggle.tsx index 05196c8..c988019 100644 --- a/packages/mobile-client/src/design-system/components/Toggle.tsx +++ b/packages/mobile-client/src/design-system/components/Toggle.tsx @@ -8,9 +8,9 @@ */ import React from 'react'; -import { View, Switch, Text, StyleSheet, Platform, StyleProp, ViewStyle } from 'react-native'; -import * as Haptics from 'expo-haptics'; +import { View, Switch, Text, StyleSheet, StyleProp, ViewStyle } from 'react-native'; import { useDesignSystem } from '../theme/useDesignSystem'; +import { triggerHapticFeedback } from '../../utils/platformAdaptations'; export interface ToggleProps { /** @@ -81,13 +81,14 @@ export const Toggle: React.FC = ({ /** * Handle value change with haptic feedback + * Requirements: 22.5, 22.6, 22.7 */ - const handleValueChange = (newValue: boolean) => { + const handleValueChange = async (newValue: boolean) => { if (disabled) return; - // Trigger haptic feedback - if (hapticFeedback && (Platform.OS === 'ios' || Platform.OS === 'android')) { - Haptics.selectionAsync(); + // Trigger platform-specific haptic feedback + if (hapticFeedback) { + await triggerHapticFeedback('selection'); } onValueChange(newValue); diff --git a/packages/mobile-client/src/design-system/components/index.ts b/packages/mobile-client/src/design-system/components/index.ts index 149e745..b24343e 100644 --- a/packages/mobile-client/src/design-system/components/index.ts +++ b/packages/mobile-client/src/design-system/components/index.ts @@ -28,3 +28,5 @@ export { Skeleton } from './Skeleton'; export type { SkeletonProps } from './Skeleton'; export { ToastContainer, showToast } from './Toast'; export type { ToastMessage, ToastVariant } from './Toast'; +export { GlassContainer } from './GlassContainer'; +export type { GlassContainerProps } from './GlassContainer'; diff --git a/packages/mobile-client/src/hooks/index.ts b/packages/mobile-client/src/hooks/index.ts index 6fb49fc..a244e0b 100644 --- a/packages/mobile-client/src/hooks/index.ts +++ b/packages/mobile-client/src/hooks/index.ts @@ -28,3 +28,7 @@ export { useScreenChangeAnnouncement, useLoadingAnnouncement, } from './useScreenReaderAnnouncement'; + +export { usePlatformNavigation } from './usePlatformNavigation'; + +export { useResponsiveLayout, type ResponsiveLayoutConfig } from './useResponsiveLayout'; diff --git a/packages/mobile-client/src/hooks/usePlatformNavigation.tsx b/packages/mobile-client/src/hooks/usePlatformNavigation.tsx new file mode 100644 index 0000000..93e6a3f --- /dev/null +++ b/packages/mobile-client/src/hooks/usePlatformNavigation.tsx @@ -0,0 +1,39 @@ +/** + * Platform-specific navigation configuration hook + * + * Requirements: + * - 26.5: Platform-specific navigation gestures + * - 26.6: iOS swipe-back gesture support + * - 26.7: Android hardware back button support + */ + +import { useMemo } from 'react'; +import type { StackNavigationOptions } from '@react-navigation/stack'; +import { supportsSwipeBack, getNavigationGestureConfig } from '../utils/platformAdaptations'; + +/** + * Hook to get platform-specific navigation options + * + * @returns Navigation options configured for the current platform + */ +export const usePlatformNavigation = (): Partial => { + const navigationOptions = useMemo(() => { + const gestureConfig = getNavigationGestureConfig(); + + return { + // Requirement 26.6: iOS swipe-back gesture support + gestureEnabled: supportsSwipeBack() ? gestureConfig.gestureEnabled : false, + gestureDirection: gestureConfig.gestureDirection, + + // Screen transition animations (Requirements 12.1, 12.2) + animation: 'slide_from_right' as const, + animationDuration: 300, + animationTypeForReplace: 'push' as const, + + // Header configuration + headerShown: false, // We use custom TopAppBar + }; + }, []); + + return navigationOptions; +}; diff --git a/packages/mobile-client/src/hooks/useResponsiveLayout.tsx b/packages/mobile-client/src/hooks/useResponsiveLayout.tsx new file mode 100644 index 0000000..9333bc6 --- /dev/null +++ b/packages/mobile-client/src/hooks/useResponsiveLayout.tsx @@ -0,0 +1,131 @@ +/** + * Responsive Layout Hook + * + * Combines orientation detection with responsive layout utilities + * to provide a complete responsive layout solution. + * + * Requirements: + * - 13.1: Support portrait orientation on all screens + * - 13.2: Support landscape orientation on all screens + * - 13.3: Use maximum content width of 1024px on large screens + * - 13.4: Center content horizontally on wide screens + * - 13.5: Use responsive grid layouts + * - 13.6: Adjust bento grid layout based on orientation + * - 13.7: Adjust typography scales based on screen size + * - 13.9: Re-layout content smoothly on orientation change + */ + +import { useMemo } from 'react'; +import { useOrientation } from './useOrientation'; +import { + getScreenSize, + getGridColumns, + getBentoGridConfig, + getContentWidth, + getTypographyScale, + getResponsivePadding, + isLargeScreen, + isSmallScreen, + type ScreenSize, +} from '../utils/responsiveLayout'; + +/** + * Responsive layout configuration + */ +export interface ResponsiveLayoutConfig { + // Screen information + screenSize: ScreenSize; + isLargeScreen: boolean; + isSmallScreen: boolean; + + // Orientation + orientation: 'portrait' | 'landscape'; + isPortrait: boolean; + isLandscape: boolean; + + // Dimensions + width: number; + height: number; + + // Content width + contentWidth: number; + shouldCenterContent: boolean; + contentMarginHorizontal: number; + + // Grid configuration + gridColumns: number; + + // Bento grid configuration + bentoGrid: { + columns: number; + largeCardSpan: number; + smallCardSpan: number; + gap: number; + padding: number; + }; + + // Typography scale + typographyScale: number; + + // Padding + padding: { + horizontal: number; + vertical: number; + }; +} + +/** + * Hook to get responsive layout configuration + * + * Automatically updates when screen size or orientation changes. + * Requirement 13.9: Re-layout content smoothly on orientation change + * + * @returns Responsive layout configuration + */ +export const useResponsiveLayout = (): ResponsiveLayoutConfig => { + const { orientation, isPortrait, isLandscape, width, height } = useOrientation(); + + const config = useMemo(() => { + const screenSize = getScreenSize(width); + const contentWidthConfig = getContentWidth(width); + const gridColumns = getGridColumns(screenSize, orientation); + const bentoGrid = getBentoGridConfig(screenSize, orientation); + const typographyScale = getTypographyScale(screenSize); + const padding = getResponsivePadding(screenSize); + + return { + // Screen information + screenSize, + isLargeScreen: isLargeScreen(width), + isSmallScreen: isSmallScreen(width), + + // Orientation + orientation, + isPortrait, + isLandscape, + + // Dimensions + width, + height, + + // Content width + contentWidth: contentWidthConfig.width, + shouldCenterContent: contentWidthConfig.shouldCenter, + contentMarginHorizontal: contentWidthConfig.marginHorizontal, + + // Grid configuration + gridColumns, + + // Bento grid configuration + bentoGrid, + + // Typography scale + typographyScale, + + // Padding + padding, + }; + }, [orientation, isPortrait, isLandscape, width, height]); + + return config; +}; diff --git a/packages/mobile-client/src/navigation/BottomNavBar.tsx b/packages/mobile-client/src/navigation/BottomNavBar.tsx index fe6e9ec..b1b8523 100644 --- a/packages/mobile-client/src/navigation/BottomNavBar.tsx +++ b/packages/mobile-client/src/navigation/BottomNavBar.tsx @@ -10,6 +10,7 @@ import React, { useEffect, useRef } from 'react'; import { View, TouchableOpacity, StyleSheet, Platform, Animated } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { BlurView } from 'expo-blur'; import * as Haptics from 'expo-haptics'; import { useDesignSystem } from '../design-system'; import { Text } from '../design-system/typography/Text'; @@ -168,17 +169,8 @@ export const BottomNavBar: React.FC = ({ activeRoute, onNavig }, ]} > - {/* - Glassmorphism backdrop with expo-blur - - To enable: - 1. Install: npm install expo-blur --legacy-peer-deps - 2. Uncomment the import at the top of this file - 3. Uncomment the BlurView below - - See INSTALL_EXPO_BLUR.md for details - */} - {/* */} + {/* Glassmorphism backdrop with expo-blur (Requirements 11.1, 11.4, 11.5) */} + {NAV_ITEMS.map((item) => { diff --git a/packages/mobile-client/src/utils/index.ts b/packages/mobile-client/src/utils/index.ts index 59bb83e..becf0c7 100644 --- a/packages/mobile-client/src/utils/index.ts +++ b/packages/mobile-client/src/utils/index.ts @@ -28,3 +28,44 @@ export { handleError, handleUnknownError, } from './errorHandling'; + +// Export platform adaptation utilities +export { + type PlatformType, + getCurrentPlatform, + isIOS, + isAndroid, + isWeb, + getStatusBarStyle, + getActivityIndicatorSize, + triggerHapticFeedback, + registerBackButtonHandler, + getKeyboardBehavior, + getKeyboardVerticalOffset, + supportsSwipeBack, + hasHardwareBackButton, + getNavigationGestureConfig, +} from './platformAdaptations'; + +// Export responsive layout utilities +export { + BREAKPOINTS, + MAX_CONTENT_WIDTH, + MIN_TOUCH_TARGET_SIZE, + type ScreenSize, + getScreenDimensions, + getScreenSize, + getGridColumns, + getBentoGridConfig, + getContentWidth, + getTypographyScale, + scaleFont, + scaleSpacing, + ensureMinTouchTarget, + getResponsivePadding, + isLargeScreen, + isSmallScreen, + getPixelRatio, + dpToPixels, + pixelsToDp, +} from './responsiveLayout'; diff --git a/packages/mobile-client/src/utils/platformAdaptations.ts b/packages/mobile-client/src/utils/platformAdaptations.ts new file mode 100644 index 0000000..1c5259b --- /dev/null +++ b/packages/mobile-client/src/utils/platformAdaptations.ts @@ -0,0 +1,215 @@ +/** + * Platform-specific adaptations for iOS and Android + * + * Requirements: + * - 26.1: Platform-specific status bar styling + * - 26.2: Platform-specific safe area insets + * - 26.3: Platform-specific keyboard behavior + * - 26.4: Platform-specific haptic feedback patterns + * - 26.5: Platform-specific navigation gestures + * - 26.6: iOS swipe-back gesture support + * - 26.7: Android hardware back button support + * - 26.8: iOS-style activity indicator + * - 26.9: Android Material Design activity indicator + */ + +import { Platform, BackHandler } from 'react-native'; +import * as Haptics from 'expo-haptics'; + +/** + * Platform type + */ +export type PlatformType = 'ios' | 'android' | 'web' | 'other'; + +/** + * Get current platform + */ +export const getCurrentPlatform = (): PlatformType => { + if (Platform.OS === 'ios') return 'ios'; + if (Platform.OS === 'android') return 'android'; + if (Platform.OS === 'web') return 'web'; + return 'other'; +}; + +/** + * Check if running on iOS + */ +export const isIOS = (): boolean => Platform.OS === 'ios'; + +/** + * Check if running on Android + */ +export const isAndroid = (): boolean => Platform.OS === 'android'; + +/** + * Check if running on web + */ +export const isWeb = (): boolean => Platform.OS === 'web'; + +/** + * Get platform-specific status bar style + * Requirement 26.1: Platform-specific status bar styling + */ +export const getStatusBarStyle = (isDark: boolean): 'light' | 'dark' | 'auto' => { + if (isIOS()) { + // iOS uses light content for dark backgrounds, dark content for light backgrounds + return isDark ? 'light' : 'dark'; + } + + if (isAndroid()) { + // Android also follows the same pattern + return isDark ? 'light' : 'dark'; + } + + return 'auto'; +}; + +/** + * Get platform-specific activity indicator size + * Requirements 26.8, 26.9: Platform-specific activity indicators + */ +export const getActivityIndicatorSize = (): 'small' | 'large' => { + // Both platforms use the same sizes, but rendering differs + return 'large'; +}; + +/** + * Haptic feedback patterns for different platforms + * Requirement 26.4: Platform-specific haptic feedback patterns + */ +export const triggerHapticFeedback = async ( + type: 'light' | 'medium' | 'heavy' | 'success' | 'error' | 'selection' +): Promise => { + try { + if (isIOS()) { + // iOS haptic patterns + switch (type) { + case 'light': + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + break; + case 'medium': + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + break; + case 'heavy': + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + break; + case 'success': + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + break; + case 'error': + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + break; + case 'selection': + await Haptics.selectionAsync(); + break; + } + } else if (isAndroid()) { + // Android haptic patterns (more subtle) + switch (type) { + case 'light': + case 'selection': + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + break; + case 'medium': + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + break; + case 'heavy': + case 'success': + case 'error': + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + break; + } + } + } catch (error) { + // Gracefully handle haptic feedback errors + console.warn('Haptic feedback not available:', error); + } +}; + +/** + * Register Android hardware back button handler + * Requirement 26.7: Android hardware back button support + * + * @param handler Function to call when back button is pressed + * @returns Cleanup function to remove the handler + */ +export const registerBackButtonHandler = (handler: () => boolean): (() => void) => { + if (isAndroid()) { + const subscription = BackHandler.addEventListener('hardwareBackPress', handler); + return () => subscription.remove(); + } + + // No-op for other platforms + return () => {}; +}; + +/** + * Get platform-specific keyboard behavior settings + * Requirement 26.3: Platform-specific keyboard behavior + */ +export const getKeyboardBehavior = (): 'padding' | 'height' | 'position' | undefined => { + if (isIOS()) { + return 'padding'; + } + + if (isAndroid()) { + return 'height'; + } + + return undefined; +}; + +/** + * Get platform-specific keyboard vertical offset + * Requirement 26.3: Platform-specific keyboard behavior + */ +export const getKeyboardVerticalOffset = (hasHeader: boolean = true): number => { + if (isIOS()) { + // iOS needs offset for header height + return hasHeader ? 64 : 0; + } + + // Android doesn't need offset + return 0; +}; + +/** + * Check if platform supports swipe-back gesture + * Requirement 26.6: iOS swipe-back gesture support + */ +export const supportsSwipeBack = (): boolean => { + return isIOS(); +}; + +/** + * Check if platform has hardware back button + * Requirement 26.7: Android hardware back button support + */ +export const hasHardwareBackButton = (): boolean => { + return isAndroid(); +}; + +/** + * Get platform-specific navigation gesture config + * Requirement 26.5: Platform-specific navigation gestures + */ +export const getNavigationGestureConfig = () => { + if (isIOS()) { + return { + gestureEnabled: true, + gestureDirection: 'horizontal' as const, + }; + } + + if (isAndroid()) { + return { + gestureEnabled: false, // Android uses hardware back button + gestureDirection: 'horizontal' as const, + }; + } + + return { + gestureEnabled: false, + gestureDirection: 'horizontal' as const, + }; +}; diff --git a/packages/mobile-client/src/utils/responsiveLayout.ts b/packages/mobile-client/src/utils/responsiveLayout.ts new file mode 100644 index 0000000..d332857 --- /dev/null +++ b/packages/mobile-client/src/utils/responsiveLayout.ts @@ -0,0 +1,253 @@ +/** + * Responsive Layout Utilities + * + * Provides utilities for responsive layouts that adapt to different + * screen sizes and orientations. + * + * Requirements: + * - 13.1: Support portrait orientation on all screens + * - 13.2: Support landscape orientation on all screens + * - 13.3: Use maximum content width of 1024px on large screens + * - 13.4: Center content horizontally on wide screens + * - 13.5: Use responsive grid layouts (1 column on small, 2-3 on medium/large) + * - 13.6: Adjust bento grid layout based on orientation + * - 13.7: Adjust typography scales based on screen size + * - 13.8: Maintain minimum touch target size of 44x44pt on all screen sizes + * - 13.9: Re-layout content smoothly on orientation change + */ + +import { Dimensions, PixelRatio } from 'react-native'; +import type { Orientation } from '../hooks/useOrientation'; + +/** + * Screen size breakpoints (in dp/pt) + */ +export const BREAKPOINTS = { + small: 0, // 0-599dp (phones in portrait) + medium: 600, // 600-839dp (large phones, small tablets) + large: 840, // 840-1023dp (tablets) + xlarge: 1024, // 1024dp+ (large tablets, desktops) +} as const; + +/** + * Maximum content width for large screens (in dp/pt) + * Requirement 13.3: Use maximum content width of 1024px on large screens + */ +export const MAX_CONTENT_WIDTH = 1024; + +/** + * Minimum touch target size (in dp/pt) + * Requirement 13.8: Maintain minimum touch target size of 44x44pt + */ +export const MIN_TOUCH_TARGET_SIZE = 44; + +/** + * Screen size category + */ +export type ScreenSize = 'small' | 'medium' | 'large' | 'xlarge'; + +/** + * Get current screen dimensions + */ +export const getScreenDimensions = () => { + return Dimensions.get('window'); +}; + +/** + * Get screen size category based on width + * + * @param width Screen width in dp/pt + * @returns Screen size category + */ +export const getScreenSize = (width: number): ScreenSize => { + if (width >= BREAKPOINTS.xlarge) return 'xlarge'; + if (width >= BREAKPOINTS.large) return 'large'; + if (width >= BREAKPOINTS.medium) return 'medium'; + return 'small'; +}; + +/** + * Get number of grid columns based on screen size + * Requirement 13.5: Use responsive grid layouts (1 column on small, 2-3 on medium/large) + * + * @param screenSize Screen size category + * @param orientation Current orientation + * @returns Number of columns for grid layout + */ +export const getGridColumns = (screenSize: ScreenSize, orientation: Orientation): number => { + if (screenSize === 'small') { + return orientation === 'portrait' ? 1 : 2; + } + + if (screenSize === 'medium') { + return orientation === 'portrait' ? 2 : 3; + } + + // large and xlarge + return orientation === 'portrait' ? 2 : 3; +}; + +/** + * Get bento grid configuration based on screen size and orientation + * Requirement 13.6: Adjust bento grid layout based on orientation + * + * @param screenSize Screen size category + * @param orientation Current orientation + * @returns Bento grid configuration + */ +export const getBentoGridConfig = (screenSize: ScreenSize, orientation: Orientation) => { + const isSmall = screenSize === 'small'; + const isPortrait = orientation === 'portrait'; + + return { + // Use single column on small screens in portrait + columns: isSmall && isPortrait ? 1 : 2, + + // Asymmetrical sizing for bento pattern + largeCardSpan: isSmall && isPortrait ? 1 : 2, + smallCardSpan: 1, + + // Gap between cards + gap: isSmall ? 12 : 16, + + // Padding around grid + padding: isSmall ? 16 : 24, + }; +}; + +/** + * Get content container width with max width constraint + * Requirement 13.3: Use maximum content width of 1024px on large screens + * Requirement 13.4: Center content horizontally on wide screens + * + * @param screenWidth Current screen width + * @returns Content width and whether it should be centered + */ +export const getContentWidth = (screenWidth: number) => { + const shouldConstrain = screenWidth > MAX_CONTENT_WIDTH; + + return { + width: shouldConstrain ? MAX_CONTENT_WIDTH : screenWidth, + shouldCenter: shouldConstrain, + marginHorizontal: shouldConstrain ? (screenWidth - MAX_CONTENT_WIDTH) / 2 : 0, + }; +}; + +/** + * Get typography scale multiplier based on screen size + * Requirement 13.7: Adjust typography scales based on screen size + * + * @param screenSize Screen size category + * @returns Scale multiplier for font sizes + */ +export const getTypographyScale = (screenSize: ScreenSize): number => { + switch (screenSize) { + case 'small': + return 0.9; // Slightly smaller on small screens + case 'medium': + return 1.0; // Base scale + case 'large': + return 1.05; // Slightly larger on large screens + case 'xlarge': + return 1.1; // Larger on extra large screens + } +}; + +/** + * Scale a font size based on screen size + * + * @param baseSize Base font size + * @param screenSize Screen size category + * @returns Scaled font size + */ +export const scaleFont = (baseSize: number, screenSize: ScreenSize): number => { + return Math.round(baseSize * getTypographyScale(screenSize)); +}; + +/** + * Get spacing value based on screen size + * + * @param baseSpacing Base spacing value + * @param screenSize Screen size category + * @returns Scaled spacing value + */ +export const scaleSpacing = (baseSpacing: number, screenSize: ScreenSize): number => { + const scale = screenSize === 'small' ? 0.875 : 1.0; + return Math.round(baseSpacing * scale); +}; + +/** + * Ensure touch target meets minimum size requirement + * Requirement 13.8: Maintain minimum touch target size of 44x44pt + * + * @param size Desired size + * @returns Size that meets minimum requirement + */ +export const ensureMinTouchTarget = (size: number): number => { + return Math.max(size, MIN_TOUCH_TARGET_SIZE); +}; + +/** + * Get responsive padding based on screen size + * + * @param screenSize Screen size category + * @returns Padding values for different screen sizes + */ +export const getResponsivePadding = (screenSize: ScreenSize) => { + switch (screenSize) { + case 'small': + return { horizontal: 16, vertical: 12 }; + case 'medium': + return { horizontal: 24, vertical: 16 }; + case 'large': + case 'xlarge': + return { horizontal: 32, vertical: 20 }; + } +}; + +/** + * Check if screen is considered large + * + * @param width Screen width + * @returns True if screen is large or xlarge + */ +export const isLargeScreen = (width: number): boolean => { + return getScreenSize(width) === 'large' || getScreenSize(width) === 'xlarge'; +}; + +/** + * Check if screen is considered small + * + * @param width Screen width + * @returns True if screen is small + */ +export const isSmallScreen = (width: number): boolean => { + return getScreenSize(width) === 'small'; +}; + +/** + * Get pixel ratio for the current device + */ +export const getPixelRatio = (): number => { + return PixelRatio.get(); +}; + +/** + * Convert dp/pt to pixels + * + * @param dp Value in dp/pt + * @returns Value in pixels + */ +export const dpToPixels = (dp: number): number => { + return PixelRatio.getPixelSizeForLayoutSize(dp); +}; + +/** + * Convert pixels to dp/pt + * + * @param pixels Value in pixels + * @returns Value in dp/pt + */ +export const pixelsToDp = (pixels: number): number => { + return pixels / PixelRatio.get(); +}; diff --git a/packages/mobile-client/webpack.config.js b/packages/mobile-client/webpack.config.js new file mode 100644 index 0000000..91874c6 --- /dev/null +++ b/packages/mobile-client/webpack.config.js @@ -0,0 +1,20 @@ +const createExpoWebpackConfigAsync = require('@expo/webpack-config'); + +module.exports = async function (env, argv) { + const config = await createExpoWebpackConfigAsync( + { + ...env, + // Disable HMR for web to avoid the error + mode: env.mode || 'development', + }, + argv + ); + + // Disable HMR + if (config.devServer) { + config.devServer.hot = false; + config.devServer.liveReload = false; + } + + return config; +}; diff --git a/packages/relay-server/tsconfig.json b/packages/relay-server/tsconfig.json index fd5ac11..b6e4437 100644 --- a/packages/relay-server/tsconfig.json +++ b/packages/relay-server/tsconfig.json @@ -7,5 +7,6 @@ "lib": ["ES2020"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "references": [{ "path": "../protocol" }] } diff --git a/packages/vscode-extension/tsconfig.json b/packages/vscode-extension/tsconfig.json index fd5ac11..b6e4437 100644 --- a/packages/vscode-extension/tsconfig.json +++ b/packages/vscode-extension/tsconfig.json @@ -7,5 +7,6 @@ "lib": ["ES2020"], "types": ["vitest/globals", "node"] }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "references": [{ "path": "../protocol" }] }