From 869f21ee2efaefed6a5aa4fbd417c25df8dec02a Mon Sep 17 00:00:00 2001 From: soryu Date: Sun, 18 Jan 2026 17:44:50 +0000 Subject: Add React Native mobile app for Makima (#3) * [WIP] Heartbeat checkpoint - 2026-01-18 02:58:27 UTC * feat(mobile): complete mobile app integration and verification - Add ThemeColors type export to Colors.ts for type safety - Export SUPABASE_URL from supabase.ts and use environment variables - Update .env.example with correct default URLs - Add comprehensive README.md with setup instructions Verified: - TypeScript compiles without errors - App exports successfully for iOS and Android - All screens accessible (login, dashboard, tasks, settings, task detail) - Auth flow working with Zustand store and Supabase Co-Authored-By: Claude Opus 4.5 * Task completion checkpoint --------- Co-authored-by: Claude Opus 4.5 --- apps/mobile/app/(auth)/_layout.tsx | 31 +++ apps/mobile/app/(auth)/login.tsx | 384 +++++++++++++++++++++++++++++++++++++ 2 files changed, 415 insertions(+) create mode 100644 apps/mobile/app/(auth)/_layout.tsx create mode 100644 apps/mobile/app/(auth)/login.tsx (limited to 'apps/mobile/app/(auth)') diff --git a/apps/mobile/app/(auth)/_layout.tsx b/apps/mobile/app/(auth)/_layout.tsx new file mode 100644 index 0000000..1def062 --- /dev/null +++ b/apps/mobile/app/(auth)/_layout.tsx @@ -0,0 +1,31 @@ +import { Stack } from 'expo-router'; +import { useColorScheme } from 'react-native'; +import { Colors } from '../../constants/Colors'; + +/** + * Auth group layout + * Provides stack navigation for authentication screens + */ +export default function AuthLayout() { + const colorScheme = useColorScheme() ?? 'dark'; + const colors = Colors[colorScheme]; + + return ( + + + + ); +} diff --git a/apps/mobile/app/(auth)/login.tsx b/apps/mobile/app/(auth)/login.tsx new file mode 100644 index 0000000..7e0f14f --- /dev/null +++ b/apps/mobile/app/(auth)/login.tsx @@ -0,0 +1,384 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + KeyboardAvoidingView, + Platform, + ScrollView, + ActivityIndicator, + Linking, + type TextInput as TextInputType, +} from 'react-native'; +import { useColorScheme } from 'react-native'; +import { router } from 'expo-router'; +import { Colors } from '../../constants/Colors'; +import { useAuthStore } from '../../stores/authStore'; +import { config } from '../../lib/config'; + +/** + * Email validation regex + */ +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Login screen component + * Handles user authentication with email and password + */ +export default function LoginScreen() { + const colorScheme = useColorScheme() ?? 'dark'; + const colors = Colors[colorScheme]; + + // Form state + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [emailError, setEmailError] = useState(null); + const [passwordError, setPasswordError] = useState(null); + + // Refs for input focus + const passwordInputRef = useRef(null); + + // Auth store + const signIn = useAuthStore((state) => state.signIn); + const isLoading = useAuthStore((state) => state.isLoading); + const authError = useAuthStore((state) => state.error); + const clearError = useAuthStore((state) => state.clearError); + + /** + * Validate email format + */ + const validateEmail = useCallback((value: string): boolean => { + if (!value.trim()) { + setEmailError('Email is required'); + return false; + } + if (!EMAIL_REGEX.test(value.trim())) { + setEmailError('Please enter a valid email address'); + return false; + } + setEmailError(null); + return true; + }, []); + + /** + * Validate password + */ + const validatePassword = useCallback((value: string): boolean => { + if (!value) { + setPasswordError('Password is required'); + return false; + } + if (value.length < 6) { + setPasswordError('Password must be at least 6 characters'); + return false; + } + setPasswordError(null); + return true; + }, []); + + /** + * Handle email change + */ + const handleEmailChange = useCallback( + (value: string) => { + setEmail(value); + if (emailError) { + setEmailError(null); + } + if (authError) { + clearError(); + } + }, + [emailError, authError, clearError] + ); + + /** + * Handle password change + */ + const handlePasswordChange = useCallback( + (value: string) => { + setPassword(value); + if (passwordError) { + setPasswordError(null); + } + if (authError) { + clearError(); + } + }, + [passwordError, authError, clearError] + ); + + /** + * Handle login button press + */ + const handleLogin = useCallback(async () => { + // Clear previous errors + clearError(); + + // Validate inputs + const isEmailValid = validateEmail(email); + const isPasswordValid = validatePassword(password); + + if (!isEmailValid || !isPasswordValid) { + return; + } + + // Attempt sign in + const success = await signIn(email, password); + + if (success) { + // Navigate to main app + router.replace('/(tabs)'); + } + }, [email, password, signIn, validateEmail, validatePassword, clearError]); + + /** + * Handle forgot password link press + */ + const handleForgotPassword = useCallback(() => { + Linking.openURL(config.forgotPasswordUrl); + }, []); + + /** + * Handle email submit (move focus to password) + */ + const handleEmailSubmit = useCallback(() => { + passwordInputRef.current?.focus(); + }, []); + + /** + * Get combined error message + */ + const displayError = authError || emailError || passwordError; + + return ( + + + + Welcome to Makima + + Sign in to continue + + + + + {/* Email Input */} + + Email + + {emailError && ( + {emailError} + )} + + + {/* Password Input */} + + Password + + {passwordError && ( + {passwordError} + )} + + + {/* Auth Error */} + {authError && ( + + {authError} + + )} + + {/* Login Button */} + + {isLoading ? ( + + ) : ( + + Sign In + + )} + + + {/* Forgot Password Link */} + + + Forgot Password? + + + + + {/* Footer */} + + + By signing in, you agree to our{' '} + Linking.openURL(config.termsOfServiceUrl)} + > + Terms of Service + + {' and '} + Linking.openURL(config.privacyPolicyUrl)} + > + Privacy Policy + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + paddingHorizontal: 24, + paddingVertical: 40, + }, + headerContainer: { + alignItems: 'center', + marginBottom: 40, + }, + title: { + fontSize: 28, + fontWeight: 'bold', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + }, + formContainer: { + width: '100%', + }, + inputContainer: { + marginBottom: 20, + }, + label: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + }, + input: { + height: 50, + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 16, + fontSize: 16, + }, + fieldError: { + fontSize: 12, + marginTop: 4, + }, + errorContainer: { + padding: 12, + borderRadius: 8, + marginBottom: 20, + }, + errorText: { + fontSize: 14, + textAlign: 'center', + }, + button: { + height: 50, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + marginTop: 8, + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + }, + forgotPasswordContainer: { + alignItems: 'center', + marginTop: 20, + }, + forgotPasswordText: { + fontSize: 14, + }, + footerContainer: { + marginTop: 40, + alignItems: 'center', + }, + footerText: { + fontSize: 12, + textAlign: 'center', + lineHeight: 18, + }, + linkText: { + textDecorationLine: 'underline', + }, +}); -- cgit v1.2.3