diff --git a/.gitea/workflows/build-apk.yml b/.gitea/workflows/build-apk.yml index 36fa843..6aa972b 100644 --- a/.gitea/workflows/build-apk.yml +++ b/.gitea/workflows/build-apk.yml @@ -44,21 +44,8 @@ jobs: working-directory: ./apk/android run: | ./gradlew assembleRelease - - name: 📦 Zip APK - working-directory: . - run: | - mkdir -p dist - cp apk/android/app/build/outputs/apk/release/app-release.apk dist/ - zip -j dist/build.zip dist/app-release.apk - - - name: Create Release - uses: https://gitea.com/actions/gitea-release-action@v1 - working-directory: . + - name: 📤 Upload APK Artifact + uses: actions/upload-artifact@v3 with: - tag_name: latest - name: Latest Build - overwrite_files: true - files: | - - dist/build.zip - env: - GITEA_TOKEN: ${{ secrets.GITEA }} \ No newline at end of file + name: app-release-apk + path: apk/android/app/build/outputs/apk/release/app-release.apk \ No newline at end of file diff --git a/apk/app/(auth)/_layout.tsx b/apk/app/(auth)/_layout.tsx new file mode 100644 index 0000000..5b9eb40 --- /dev/null +++ b/apk/app/(auth)/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from "expo-router"; + +export default function AuthLayout() { + return ; +} diff --git a/apk/app/(auth)/login.tsx b/apk/app/(auth)/login.tsx new file mode 100644 index 0000000..589c9b0 --- /dev/null +++ b/apk/app/(auth)/login.tsx @@ -0,0 +1,149 @@ +import React, { useState } from "react"; +import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native"; +import { useAuth } from "@/context/auth-context"; +import { router } from "expo-router"; + +export default function AuthScreen() { + const [isLogin, setIsLogin] = useState(true); + + return isLogin ? ( + setIsLogin(false)} /> + ) : ( + setIsLogin(true)} /> + ); +} + +function LoginScreen({ onSwitch }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const { login } = useAuth(); + + const handleLogin = async () => { + const loginResult = await login(email, password); + if (loginResult) { + console.log("Login successful"); + router.replace("/(tabs)"); + } + }; + + return ( + + Welcome Back + + + + + + + Login + + + + Don't have an account? Register + + + ); +} + +function RegisterScreen({ onSwitch }) { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const handleRegister = () => { + console.log("Register ->", { name, email, password }); + }; + + return ( + + Create Account + + + + + + + + + Register + + + + Already have an account? Login + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: "center", + padding: 20, + backgroundColor: "#f5f5f5", + }, + title: { + fontSize: 28, + fontWeight: "bold", + marginBottom: 30, + textAlign: "center", + }, + input: { + height: 50, + borderColor: "#ccc", + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 15, + marginBottom: 15, + backgroundColor: "#fff", + }, + button: { + height: 50, + backgroundColor: "#4CAF50", + borderRadius: 10, + justifyContent: "center", + alignItems: "center", + marginTop: 10, + }, + buttonText: { + color: "#fff", + fontSize: 16, + fontWeight: "bold", + }, + link: { + marginTop: 15, + textAlign: "center", + color: "#007BFF", + }, +}); diff --git a/apk/app/(tabs)/_layout.tsx b/apk/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..f0bd0af --- /dev/null +++ b/apk/app/(tabs)/_layout.tsx @@ -0,0 +1,25 @@ +import { Tabs } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; + +export default function TabsLayout() { + return ( + + ( + + ), + }} /> + ( + + ), + }} /> + + ); +} \ No newline at end of file diff --git a/apk/app/index.tsx b/apk/app/(tabs)/index.tsx similarity index 94% rename from apk/app/index.tsx rename to apk/app/(tabs)/index.tsx index 417f39e..5819aeb 100644 --- a/apk/app/index.tsx +++ b/apk/app/(tabs)/index.tsx @@ -14,7 +14,11 @@ export default function HomeScreen() { Upcoming Birthdays router.push("/add")} + onPress={() => { + router.push("/add"); + } + } + style={styles.addButton} > + diff --git a/apk/app/(tabs)/settings.tsx b/apk/app/(tabs)/settings.tsx new file mode 100644 index 0000000..12f06be --- /dev/null +++ b/apk/app/(tabs)/settings.tsx @@ -0,0 +1,148 @@ +import React, { useState } from "react"; +import { StyleSheet, Text, View, TouchableOpacity, Alert } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { useRouter } from "expo-router"; +import { useAuth } from "@/context/auth-context"; + +export default function SettingsScreen() { + const router = useRouter() + const { logout } = useAuth(); + const [theme, setTheme] = useState("light"); // light | dark | system + + const handleLogout = () => { + Alert.alert("Logout", "Are you sure you want to logout?", [ + { text: "Cancel", style: "cancel" }, + { + text: "Logout", + style: "destructive", + onPress: () => { + // TODO: clear auth state here + router.replace("/login"); + logout(); + }, + }, + ]); + }; + + const toggleTheme = (value) => { + setTheme(value); + // TODO: persist theme (AsyncStorage / context / zustand) + }; + + return ( + + + + Settings + + {/* Theme Selector */} + + Theme + + + toggleTheme("light")} + /> + toggleTheme("dark")} + /> + toggleTheme("system")} + /> + + + + {/* Logout */} + + Account + + + Log Out + + + + + + ); +} + +function ThemeButton({ label, active, onPress }) { + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: "#fff", + }, + content: { + flex: 1, + paddingHorizontal: 16, + paddingTop: 12, + }, + title: { + fontSize: 24, + fontWeight: "bold", + marginBottom: 20, + }, + + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 16, + fontWeight: "600", + marginBottom: 10, + }, + + row: { + flexDirection: "row", + gap: 10, + }, + + themeButton: { + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 8, + borderWidth: 1, + borderColor: "#ccc", + }, + themeButtonActive: { + backgroundColor: "#111", + borderColor: "#111", + }, + themeText: { + color: "#333", + paddingHorizontal: 5, + }, + themeTextActive: { + color: "#fff", + }, + + logoutButton: { + padding: 14, + borderRadius: 10, + backgroundColor: "#ff3b30", + alignItems: "center", + }, + logoutText: { + color: "#fff", + fontWeight: "600", + }, +}); \ No newline at end of file diff --git a/apk/app/_layout.tsx b/apk/app/_layout.tsx index efab7b9..b42d716 100644 --- a/apk/app/_layout.tsx +++ b/apk/app/_layout.tsx @@ -1,31 +1,11 @@ -import { - DarkTheme, - DefaultTheme, - ThemeProvider, -} from "@react-navigation/native"; -import { Stack } from "expo-router"; -import { StatusBar } from "expo-status-bar"; -import "react-native-reanimated"; +import RootNavigation from "@/components/views/root-navigation"; +import { AuthProvider } from "@/context/auth-context"; -import { BirthdaysProvider } from "@/context/birthdays-context"; -import { useColorScheme } from "@/hooks/use-color-scheme"; - -export const unstable_settings = { - anchor: "(tabs)", -}; export default function RootLayout() { - const colorScheme = useColorScheme(); - return ( - - - - - - - - - + + + ); -} +} \ No newline at end of file diff --git a/apk/components/scroll-view.tsx b/apk/components/scroll-view.tsx index e7ec710..0535e3b 100644 --- a/apk/components/scroll-view.tsx +++ b/apk/components/scroll-view.tsx @@ -1,10 +1,8 @@ -import { StyleSheet } from "react-native"; +import { StyleSheet, View } from "react-native"; import Animated, { useAnimatedRef } from "react-native-reanimated"; -import { useColorScheme } from "@/hooks/use-color-scheme"; import { useThemeColor } from "@/hooks/use-theme-color"; import { PropsWithChildren } from "react"; -import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; type Props = PropsWithChildren<{ @@ -14,7 +12,6 @@ type Props = PropsWithChildren<{ export default function ScrollView({ children, headerBackgroundColor }: Props) { const scrollRef = useAnimatedRef(); const insets = useSafeAreaInsets(); - const colorScheme = useColorScheme() ?? "light"; const backgroundColor = useThemeColor({}, "background"); return ( diff --git a/apk/components/views/loading-screen.tsx b/apk/components/views/loading-screen.tsx new file mode 100644 index 0000000..3a134f9 --- /dev/null +++ b/apk/components/views/loading-screen.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { View, Text, ActivityIndicator, StyleSheet } from "react-native"; + +export default function LoadingScreen({ message = "Loading..." }) { + return ( + + + {message} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + text: { + marginTop: 10, + fontSize: 16, + }, +}); \ No newline at end of file diff --git a/apk/components/views/root-navigation.tsx b/apk/components/views/root-navigation.tsx new file mode 100644 index 0000000..5951315 --- /dev/null +++ b/apk/components/views/root-navigation.tsx @@ -0,0 +1,36 @@ +import { BirthdaysProvider } from "@/context/birthdays-context"; +import { useAuth } from "@/context/auth-context"; +import { Stack, router, useRootNavigationState, useSegments } from "expo-router"; +import React, { useEffect } from "react"; +import LoadingScreen from "./loading-screen"; + +export default function RootLayout() { + const { user, isHydrated } = useAuth(); + const navigationState = useRootNavigationState(); + const segments = useSegments(); + const [loaded, setLoaded] = React.useState(false); + + useEffect(() => { + if (!isHydrated || !navigationState?.key) return; + + const inAuthGroup = segments.length === 0; + + if (!user) { + router.replace("/(auth)/login"); + } else if (inAuthGroup) { + router.replace("/(tabs)"); + } + + setLoaded(true); // 👈 ALWAYS run this +}, [isHydrated, navigationState?.key, segments, user]); + + if(!loaded) { + return (); // or a loading spinner + } + + return ( + + + + ); +} diff --git a/apk/context/auth-context.tsx b/apk/context/auth-context.tsx new file mode 100644 index 0000000..a86e0bc --- /dev/null +++ b/apk/context/auth-context.tsx @@ -0,0 +1,69 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as React from "react"; + +interface User { + email: string; +} + +interface AuthContextValue { + user: User | null; + isHydrated: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; +} + +const authContext = React.createContext(undefined); +const STORAGE_KEY = "user"; + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = React.useState(null); + const [isHydrated, setIsHydrated] = React.useState(false); + + React.useEffect(() => { + const restoreUser = async () => { + try { + const storedUser = await AsyncStorage.getItem(STORAGE_KEY); + if (storedUser) { + setUser(JSON.parse(storedUser) as User); + } + } catch (error) { + console.log("Error restoring user", error); + } finally { + setIsHydrated(true); + } + }; + + restoreUser(); + }, []); + + const login = async (email: string, password: string) => { + const fakeUser = { email }; + + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(fakeUser)); + setUser(fakeUser); + console.log(fakeUser) + await new Promise((resolve) => setTimeout(resolve, 1000)); + return true; + } + + const logout = async () => { + await AsyncStorage.removeItem(STORAGE_KEY); + setUser(null); + } + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = React.useContext(authContext); + if(!context) { + throw new Error("useAuth must be inside AuthProvider"); + } + return context; +} + +