diff options
| author | soryu <soryu@soryu.co> | 2026-01-18 17:44:50 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-18 17:44:50 +0000 |
| commit | 869f21ee2efaefed6a5aa4fbd417c25df8dec02a (patch) | |
| tree | 2a90820ac817173e5b7154e0ba5e4f5d095f9613 /apps/mobile/app/(auth)/login.tsx | |
| parent | 219bca168508e1ea5e91e8a9ce98338afeddfbd2 (diff) | |
| download | soryu-869f21ee2efaefed6a5aa4fbd417c25df8dec02a.tar.gz soryu-869f21ee2efaefed6a5aa4fbd417c25df8dec02a.zip | |
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 <noreply@anthropic.com>
* Task completion checkpoint
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'apps/mobile/app/(auth)/login.tsx')
| -rw-r--r-- | apps/mobile/app/(auth)/login.tsx | 384 |
1 files changed, 384 insertions, 0 deletions
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<string | null>(null); + const [passwordError, setPasswordError] = useState<string | null>(null); + + // Refs for input focus + const passwordInputRef = useRef<TextInputType>(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 ( + <KeyboardAvoidingView + style={[styles.container, { backgroundColor: colors.background }]} + behavior={Platform.OS === 'ios' ? 'padding' : 'height'} + > + <ScrollView + contentContainerStyle={styles.scrollContent} + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={false} + > + <View style={styles.headerContainer}> + <Text style={[styles.title, { color: colors.text }]}>Welcome to Makima</Text> + <Text style={[styles.subtitle, { color: colors.textSecondary }]}> + Sign in to continue + </Text> + </View> + + <View style={styles.formContainer}> + {/* Email Input */} + <View style={styles.inputContainer}> + <Text style={[styles.label, { color: colors.text }]}>Email</Text> + <TextInput + style={[ + styles.input, + { + backgroundColor: colors.input, + borderColor: emailError ? colors.error : colors.inputBorder, + color: colors.text, + }, + ]} + placeholder="Enter your email" + placeholderTextColor={colors.inputPlaceholder} + value={email} + onChangeText={handleEmailChange} + keyboardType="email-address" + autoCapitalize="none" + autoCorrect={false} + autoComplete="email" + returnKeyType="next" + onSubmitEditing={handleEmailSubmit} + editable={!isLoading} + testID="email-input" + /> + {emailError && ( + <Text style={[styles.fieldError, { color: colors.error }]}>{emailError}</Text> + )} + </View> + + {/* Password Input */} + <View style={styles.inputContainer}> + <Text style={[styles.label, { color: colors.text }]}>Password</Text> + <TextInput + ref={passwordInputRef} + style={[ + styles.input, + { + backgroundColor: colors.input, + borderColor: passwordError ? colors.error : colors.inputBorder, + color: colors.text, + }, + ]} + placeholder="Enter your password" + placeholderTextColor={colors.inputPlaceholder} + value={password} + onChangeText={handlePasswordChange} + secureTextEntry + autoCapitalize="none" + autoCorrect={false} + autoComplete="password" + returnKeyType="done" + onSubmitEditing={handleLogin} + editable={!isLoading} + testID="password-input" + /> + {passwordError && ( + <Text style={[styles.fieldError, { color: colors.error }]}>{passwordError}</Text> + )} + </View> + + {/* Auth Error */} + {authError && ( + <View style={[styles.errorContainer, { backgroundColor: colors.error + '20' }]}> + <Text style={[styles.errorText, { color: colors.error }]}>{authError}</Text> + </View> + )} + + {/* Login Button */} + <TouchableOpacity + style={[ + styles.button, + { + backgroundColor: isLoading ? colors.buttonDisabled : colors.buttonPrimary, + }, + ]} + onPress={handleLogin} + disabled={isLoading} + activeOpacity={0.8} + testID="login-button" + > + {isLoading ? ( + <ActivityIndicator color={colors.buttonPrimaryText} size="small" /> + ) : ( + <Text style={[styles.buttonText, { color: colors.buttonPrimaryText }]}> + Sign In + </Text> + )} + </TouchableOpacity> + + {/* Forgot Password Link */} + <TouchableOpacity + style={styles.forgotPasswordContainer} + onPress={handleForgotPassword} + disabled={isLoading} + testID="forgot-password-link" + > + <Text style={[styles.forgotPasswordText, { color: colors.link }]}> + Forgot Password? + </Text> + </TouchableOpacity> + </View> + + {/* Footer */} + <View style={styles.footerContainer}> + <Text style={[styles.footerText, { color: colors.textSecondary }]}> + By signing in, you agree to our{' '} + <Text + style={[styles.linkText, { color: colors.link }]} + onPress={() => Linking.openURL(config.termsOfServiceUrl)} + > + Terms of Service + </Text> + {' and '} + <Text + style={[styles.linkText, { color: colors.link }]} + onPress={() => Linking.openURL(config.privacyPolicyUrl)} + > + Privacy Policy + </Text> + </Text> + </View> + </ScrollView> + </KeyboardAvoidingView> + ); +} + +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', + }, +}); |
