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;
+}
+
+