summaryrefslogtreecommitdiff
path: root/apps/mobile/app/(auth)
diff options
context:
space:
mode:
Diffstat (limited to 'apps/mobile/app/(auth)')
-rw-r--r--apps/mobile/app/(auth)/_layout.tsx31
-rw-r--r--apps/mobile/app/(auth)/login.tsx384
2 files changed, 415 insertions, 0 deletions
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 (
+ <Stack
+ screenOptions={{
+ headerShown: false,
+ contentStyle: {
+ backgroundColor: colors.background,
+ },
+ animation: 'fade',
+ }}
+ >
+ <Stack.Screen
+ name="login"
+ options={{
+ title: 'Sign In',
+ }}
+ />
+ </Stack>
+ );
+}
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',
+ },
+});