diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 1d2fef1..720d5ff 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -2,58 +2,64 @@ import { Tabs } from "expo-router";
import { useColorScheme } from "react-native";
import { Ionicons } from "@expo/vector-icons";
-const LIGHT = { bg: "#FFFFFF", border: "#E8E2D5", active: "#C4622D", inactive: "#8A8278" };
-const DARK = { bg: "#161513", border: "#2C2A27", active: "#E07B45", inactive: "#706D67" };
+
+
+const LIGHT_TAB = {
+ bg: "#FFFFFF",
+ border: "#E8E2D5",
+ active: "#C4622D",
+ inactive: "#8A8278",
+};
+const DARK_TAB = {
+ bg: "#161513",
+ border: "#2C2A27",
+ active: "#E07B45",
+ inactive: "#706D67",
+};
export default function TabLayout() {
- const scheme = useColorScheme();
- const dark = scheme === "dark";
- const c = dark ? DARK : LIGHT;
+ const dark = useColorScheme() === "dark";
+ const tab = dark ? DARK_TAB : LIGHT_TAB;
return (
({
headerShown: false,
tabBarStyle: {
- backgroundColor: c.bg,
- borderTopColor: c.border,
- borderTopWidth: 1,
- height: 64,
- paddingBottom: 10,
- paddingTop: 6,
+ backgroundColor: tab.bg,
+ borderTopColor: tab.border,
+ borderTopWidth: 1,
+ height: 60,
+ paddingBottom: 8,
+ paddingTop: 6,
},
- tabBarActiveTintColor: c.active,
- tabBarInactiveTintColor: c.inactive,
- tabBarLabelStyle: { fontSize: 11, fontWeight: "600" },
- }}
+ tabBarActiveTintColor: tab.active,
+ tabBarInactiveTintColor: tab.inactive,
+ tabBarLabelStyle: {
+ fontSize: 10,
+ fontWeight: "600",
+ letterSpacing: 0.3,
+ },
+ tabBarIcon: ({ focused, color, size }) => {
+ const icons: Record = {
+ index: { active: "bookmark", inactive: "bookmark-outline" },
+ all: { active: "compass", inactive: "compass-outline" },
+ profile: { active: "person-circle", inactive: "person-circle-outline" },
+ };
+ const set = icons[route.name] ?? icons.index;
+ return (
+
+ );
+ },
+ })}
>
- (
-
- ),
- }}
- />
- (
-
- ),
- }}
- />
- (
-
- ),
- }}
- />
+
+
+
);
-}
+}
\ No newline at end of file
diff --git a/app/_layout.tsx b/app/_layout.tsx
index dc9bd95..7a41b9b 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,6 +1,14 @@
import "../globals.css";
import { Stack } from "expo-router";
+import {AuthProvider} from "@/context/AuthContext";
+import {SafeAreaProvider} from "react-native-safe-area-context";
export default function RootLayout() {
- return ;
+ return (
+
+
+
+
+
+ );
}
\ No newline at end of file
diff --git a/components/CollapsibleCategory.tsx b/components/CollapsibleCategory.tsx
index a134baf..7204045 100644
--- a/components/CollapsibleCategory.tsx
+++ b/components/CollapsibleCategory.tsx
@@ -1,44 +1,72 @@
-import React, { useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
import {
- View,
- Text,
- TouchableOpacity,
- Animated,
- LayoutAnimation,
- Platform,
- UIManager,
- useColorScheme,
+ View, Text, TouchableOpacity,
+ ActivityIndicator, LayoutAnimation, useColorScheme,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
-import ExpandableItem, { FeedItem } from "./ExpandableItem";
+import { Entry, entriesApi } from "../services/api";
+import ExpandableItem from "./ExpandableItem";
-if (Platform.OS === "android") {
- UIManager.setLayoutAnimationEnabledExperimental?.(true);
-}
+// ─── Props ─────────────────────────────────────────────────────────────────
+// items removed — now fetched from API using groupName
-export interface Category {
- id: string;
- label: string;
- icon: keyof typeof Ionicons.glyphMap;
- color: string; // accent hex for the icon badge
- darkColor: string;
- items: FeedItem[];
-}
-
-interface CollapsibleCategoryProps {
- category: Category;
+export interface CollapsibleCategoryProps {
+ id: string;
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ color: string;
+ darkColor: string;
+ groupName: string; // passed to /entries?groupName=
defaultOpen?: boolean;
}
+// ─── Component ─────────────────────────────────────────────────────────────
+
export default function CollapsibleCategory({
- category,
+ label,
+ icon,
+ color,
+ darkColor,
+ groupName,
defaultOpen = false,
}: CollapsibleCategoryProps) {
- const [open, setOpen] = useState(defaultOpen);
- const scheme = useColorScheme();
- const dark = scheme === "dark";
+ const dark = useColorScheme() === "dark";
+ const accentColor = dark ? darkColor : color;
- const accentColor = dark ? category.darkColor : category.color;
+ const [open, setOpen] = useState(defaultOpen);
+ const [items, setItems] = useState([]);
+ const [page, setPage] = useState(0);
+ const [hasMore, setHasMore] = useState(true);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const initialised = useRef(false);
+
+ const fetchPage = useCallback(async (pageNum: number) => {
+ if (loading) return;
+ setLoading(true);
+ setError("");
+ try {
+ const data = await entriesApi.getEntries({ groupName, page: pageNum });
+ setItems((prev) =>
+ pageNum === 0 ? data.content : [...prev, ...data.content]
+ );
+ setHasMore(!data.last);
+ setPage(pageNum);
+ } catch {
+ setError("Couldn't load entries. Tap to retry.");
+ } finally {
+ setLoading(false);
+ }
+ }, [groupName, loading]);
+
+ // Fetch first page the first time the accordion opens
+ useEffect(() => {
+ if (open && !initialised.current) {
+ initialised.current = true;
+ fetchPage(0);
+ }
+ }, [open]);
const toggle = () => {
LayoutAnimation.configureNext({
@@ -51,23 +79,23 @@ export default function CollapsibleCategory({
return (
+ {/* ── Header — untouched from your original ── */}
- {/* Icon badge */}
-
+
- {category.label}
+ {label}
- {/* Count badge */}
+ {/* Count badge — shows loaded count, spins while first load */}
-
- {category.items.length}
-
+ {loading && items.length === 0 ? (
+
+ ) : (
+
+ {items.length}
+
+ )}
- {/* Items */}
+ {/* ── Body ── */}
{open && (
- {category.items.map((item) => (
+ {/* Error */}
+ {error !== "" && (
+ fetchPage(page)}
+ className="mx-4 py-4 items-center"
+ >
+
+ {error}
+
+
+ )}
+
+ {/* Items — same ExpandableItem you already have */}
+ {items.map((item) => (
))}
+
+ {/* Load more */}
+ {hasMore && items.length > 0 && (
+ fetchPage(page + 1)}
+ disabled={loading}
+ className={`mx-4 mt-1 mb-1 py-3 rounded-2xl border items-center ${
+ dark
+ ? "bg-obsidian-50 border-border-dark"
+ : "bg-parchment-100 border-border-light"
+ }`}
+ activeOpacity={0.7}
+ >
+ {loading ? (
+
+ ) : (
+
+
+
+ Load more
+
+
+ )}
+
+ )}
+
+ {/* End of list */}
+ {!hasMore && items.length > 0 && (
+
+ All {items.length} entries loaded
+
+ )}
)}
diff --git a/components/ExpandableItem.tsx b/components/ExpandableItem.tsx
index 12e5909..792de10 100644
--- a/components/ExpandableItem.tsx
+++ b/components/ExpandableItem.tsx
@@ -1,39 +1,34 @@
-import React, { useState, useRef } from "react";
+import React, { useRef, useState } from "react";
import {
- View,
- Text,
- TouchableOpacity,
- Animated,
- LayoutAnimation,
- Platform,
- UIManager,
- useColorScheme,
+ Animated, LayoutAnimation, Linking,
+ Platform, Text, TouchableOpacity,
+ UIManager, View, useColorScheme,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
+import { Entry } from "../services/api";
-// Enable LayoutAnimation on Android idk honestly probably ok?
if (Platform.OS === "android") {
UIManager.setLayoutAnimationEnabledExperimental?.(true);
}
-export interface FeedItem {
- id: string;
- title: string;
- author: string;
- timestamp: string;
- tag: string;
- paragraphs: string[];
+function formatDate(iso: string): string {
+ try {
+ return new Date(iso).toLocaleDateString("en-GB", {
+ day: "numeric", month: "short", year: "numeric",
+ });
+ } catch {
+ return iso;
+ }
}
-interface ExpandableItemProps {
- item: FeedItem;
+function fileName(path: string): string {
+ return path.split(/[\\/]/).pop() ?? path;
}
-export default function ExpandableItem({ item }: ExpandableItemProps) {
+export default function ExpandableItem({ item }: { item: Entry }) {
const [expanded, setExpanded] = useState(false);
const rotateAnim = useRef(new Animated.Value(0)).current;
- const scheme = useColorScheme();
- const dark = scheme === "dark";
+ const dark = useColorScheme() === "dark";
const toggle = () => {
LayoutAnimation.configureNext({
@@ -50,7 +45,7 @@ export default function ExpandableItem({ item }: ExpandableItemProps) {
};
const chevronRotation = rotateAnim.interpolate({
- inputRange: [0, 1],
+ inputRange: [0, 1],
outputRange: ["0deg", "180deg"],
});
@@ -58,96 +53,146 @@ export default function ExpandableItem({ item }: ExpandableItemProps) {
- {/* Header */}
+ {/* ── Collapsed header ── */}
- {/* Tag + timestamp row */}
-
-
-
- {item.tag}
+
+ {/* Subject tag + date */}
+
+
+
+ {item.subject?.name ?? item.groupName}
-
- {item.timestamp}
+
+
+
+ {item.groupName}
+
+
+
+
+ {formatDate(item.timePublished)}
{/* Title */}
{item.title}
- {/* Author */}
-
- by {item.author}
-
+ {/* info_entry — always visible as subtitle */}
+ {item.infoEntry ? (
+
+ {item.infoEntry}
+
+ ) : null}
-
+
- {/* Expanded body */}
+ {/* ── Expanded body ── */}
{expanded && (
-
- {item.paragraphs.map((para, i) => (
-
+
+ {/* paragraph */}
+ {item.paragraph ? (
+
+
+ Content
+
+
+ {item.paragraph}
+
+
+ ) : null}
+
+ {/* Meta row: subject + group + date */}
+
+
+
+
+
+
+ {/* filepath — clickable attachment */}
+ {item.filepath ? (
+ Linking.openURL(item.filepath!)}
+ activeOpacity={0.75}
+ className={`mx-4 mb-4 flex-row items-center gap-3 px-4 py-3 rounded-xl border ${
+ dark
+ ? "bg-obsidian-50 border-border-dark"
+ : "bg-parchment-100 border-border-light"
}`}
+ // stop the expand/collapse toggle firing
+ onStartShouldSetResponder={() => true}
>
- {para}
-
- ))}
+
+
+
+
+
+ {fileName(item.filepath)}
+
+
+ Tap to open attachment
+
+
+
+
+ ) : null}
)}
);
+}
+
+
+function Row({ label, value, dark, last = false }: {
+ label: string;
+ value?: string;
+ dark: boolean;
+ last?: boolean;
+}) {
+ if (!value) return null;
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
}
\ No newline at end of file
diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx
new file mode 100644
index 0000000..2449079
--- /dev/null
+++ b/context/AuthContext.tsx
@@ -0,0 +1,58 @@
+import React, { createContext, useContext, useEffect, useState } from "react";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { authApi, LoginPayload, RegisterPayload, JwtResponse } from "../services/api";
+
+type User = { email: string; token: string };
+
+type AuthContextValue = {
+ user: User | null;
+ loading: boolean;
+ login: (p: LoginPayload) => Promise;
+ register: (p: RegisterPayload) => Promise;
+ logout: () => Promise;
+};
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ // Restore session on mount
+ useEffect(() => {
+ AsyncStorage.getItem("auth_user")
+ .then((raw) => { if (raw) setUser(JSON.parse(raw)); })
+ .finally(() => setLoading(false));
+ }, []);
+
+ const persist = async (u: User | null) => {
+ if (u) await AsyncStorage.setItem("auth_user", JSON.stringify(u));
+ else await AsyncStorage.removeItem("auth_user");
+ setUser(u);
+ };
+
+ const login = async (payload: LoginPayload) => {
+ const res = await authApi.login(payload);
+ await persist({ email: res.email, token: res.token });
+ };
+
+ const register = async (payload: RegisterPayload) => {
+ await authApi.register(payload);
+ // Auto-login after register
+ await login({ email: payload.email, password: payload.password });
+ };
+
+ const logout = () => persist(null);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
+ return ctx;
+}
\ No newline at end of file
diff --git a/index.tsx b/index.tsx
index d3a17a1..bdcbf54 100644
--- a/index.tsx
+++ b/index.tsx
@@ -13,6 +13,8 @@ import AllFeed from "@/screens/AllFeed";
import Profile from "@/screens/Profile";
import {enableScreens} from "react-native-screens";
import {SafeAreaProvider} from "react-native-safe-area-context";
+import AuthGate from "@/screens/AuthGate";
+import {AuthProvider, useAuth} from "@/context/AuthContext";
// Must be called before any navigator renders
@@ -36,6 +38,11 @@ const DARK_TAB = {
};
+function ProfileTab() {
+ const { user, loading } = useAuth();
+ if (loading) return null;
+ return user ? : ;
+}
export default function App() {
const scheme = useColorScheme();
@@ -47,6 +54,7 @@ export default function App() {
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
return (
+
+
+
);
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 052034a..09c2725 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
+ "@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
@@ -3108,6 +3109,18 @@
}
}
},
+ "node_modules/@react-native-async-storage/async-storage": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+ "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
+ "license": "MIT",
+ "dependencies": {
+ "merge-options": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
+ }
+ },
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -8382,6 +8395,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -9408,6 +9430,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
diff --git a/package.json b/package.json
index 40fe5c8..6a77d57 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
+ "@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
diff --git a/screens/AllFeed.tsx b/screens/AllFeed.tsx
index ef516e2..b2491d9 100644
--- a/screens/AllFeed.tsx
+++ b/screens/AllFeed.tsx
@@ -1,107 +1,114 @@
-import React, { useState, useMemo } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import {
- View,
- Text,
- ScrollView,
- useColorScheme,
+ View, Text, ScrollView,
+ ActivityIndicator, useColorScheme, TouchableOpacity,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import SearchBar from "../components/SearchBar";
-import CollapsibleCategory, { Category } from "../components/CollapsibleCategory";
-import { ALL_CATEGORIES } from "@/data/mockData";
+import CollapsibleCategory, { CollapsibleCategoryProps } from "../components/CollapsibleCategory";
+import { entriesApi } from "../services/api";
+
+// Cycles through as many groups as the API returns
+const PALETTE: Pick[] = [
+ { icon: "book-outline", color: "#3B7DD8", darkColor: "#5E9EF4" },
+ { icon: "book-outline", color: "#2E9E6B", darkColor: "#4EC992" },
+ { icon: "book-outline", color: "#9B4FB8", darkColor: "#C47CE0" },
+ { icon: "book-outline", color: "#C4622D", darkColor: "#E07B45" },
+ { icon: "book-outline", color: "#B8834F", darkColor: "#D4A574" },
+ { icon: "book-outline", color: "#2E7D9E", darkColor: "#4EA8C9" },
+];
export default function AllFeed() {
- const [query, setQuery] = useState("");
- const scheme = useColorScheme();
- const dark = scheme === "dark";
+ const dark = useColorScheme() === "dark";
- const totalCount = ALL_CATEGORIES.reduce(
- (sum, cat) => sum + cat.items.length,
- 0
- );
+ const [groups, setGroups] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [query, setQuery] = useState("");
- // Filter items within each category based on search query
- const filteredCategories = useMemo((): Category[] => {
- if (!query.trim()) return ALL_CATEGORIES;
+ const loadGroups = () => {
+ setLoading(true);
+ setError("");
+ entriesApi.getGroups()
+ .then(setGroups)
+ .catch(() => setError("Couldn't load groups."))
+ .finally(() => setLoading(false));
+ };
+
+ useEffect(() => { loadGroups(); }, []);
+
+ const visibleGroups = useMemo(() => {
+ if (!query.trim()) return groups;
const lower = query.toLowerCase();
- return ALL_CATEGORIES.map((cat) => ({
- ...cat,
- items: cat.items.filter(
- (item) =>
- item.title.toLowerCase().includes(lower) ||
- item.author.toLowerCase().includes(lower) ||
- item.tag.toLowerCase().includes(lower) ||
- cat.label.toLowerCase().includes(lower)
- ),
- })).filter((cat) => cat.items.length > 0);
- }, [query]);
+ return groups.filter((g) => g.toLowerCase().includes(lower));
+ }, [query, groups]);
return (
-
-
-
+
+ {/* Header */}
+
-
+
Discover
-
- {ALL_CATEGORIES.length} categories · {totalCount} articles
+
+ {loading ? "Loading…" : `${groups.length} groups`}
-
-
+
-
- {filteredCategories.length === 0 ? (
-
- 🔍
-
- No results for "{query}"
-
-
- Try searching by category name, author, or topic.
-
-
- ) : (
- filteredCategories.map((cat, index) => (
-
- ))
- )}
-
+ {/* States */}
+ {loading ? (
+
+
+
+
+ ) : error ? (
+
+ ⚠️
+
+ {error}
+
+
+ Retry
+
+
+
+ ) : (
+
+ {visibleGroups.length === 0 ? (
+
+ 🔍
+
+ No groups match "{query}"
+
+
+ Try a different search term.
+
+
+ ) : (
+ visibleGroups.map((groupName, index) => (
+
+ ))
+ )}
+
+ )}
);
}
\ No newline at end of file
diff --git a/screens/AuthGate.tsx b/screens/AuthGate.tsx
new file mode 100644
index 0000000..7fb4720
--- /dev/null
+++ b/screens/AuthGate.tsx
@@ -0,0 +1,185 @@
+import React, { useState } from "react";
+import {
+ View, Text, TextInput, TouchableOpacity,
+ ActivityIndicator, KeyboardAvoidingView,
+ Platform, ScrollView, useColorScheme,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { Ionicons } from "@expo/vector-icons";
+import { useAuth } from "../context/AuthContext";
+
+type Mode = "login" | "register";
+
+export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) { const dark = useColorScheme() === "dark";
+ const { login, register } = useAuth();
+
+ const [mode, setMode] = useState("login");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [name, setName] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [showPass, setShowPass] = useState(false);
+
+ const accent = dark ? "#E07B45" : "#C4622D";
+
+ const submit = async () => {
+ setError("");
+ if (!email || !password) { setError("Please fill in all fields."); return; }
+
+ setLoading(true);
+ try {
+ if (mode === "login") await login({ email, password });
+ else await register({ email, password, name });
+ onSuccess?.(); // ← add this
+ } catch (e: any) {
+ setError(e.message ?? "Something went wrong.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const inputBase = `
+ rounded-xl px-4 py-3.5 text-sm font-sans border
+ ${dark
+ ? "bg-obsidian-100 border-border-dark text-ink-dark"
+ : "bg-parchment-100 border-border-light text-ink-light"}
+ `;
+
+ return (
+
+
+
+ {/* Logo / header */}
+
+
+ 📰
+
+
+ {mode === "login" ? "Welcome back" : "Create account"}
+
+
+ {mode === "login"
+ ? "Sign in to sync your subscriptions"
+ : "Start reading what matters"}
+
+
+
+ {/* Card */}
+
+
+
+
+
+ EMAIL
+
+
+
+
+
+
+ PASSWORD
+
+
+
+ setShowPass((v) => !v)}
+ className="absolute right-3 top-3.5"
+ >
+
+
+
+
+
+ {error !== "" && (
+
+ {error}
+
+ )}
+
+
+ {loading
+ ?
+ :
+ {mode === "login" ? "Sign in" : "Create account"}
+
+ }
+
+
+
+ {/* Toggle mode */}
+
+
+ {mode === "login" ? "Don't have an account? " : "Already have an account? "}
+
+ { setMode(mode === "login" ? "register" : "login"); setError(""); }}>
+
+ {mode === "login" ? "Register" : "Sign in"}
+
+
+
+
+ {/* Continue as guest */}
+
+ You can still browse and save locally without signing in.
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/screens/Profile.tsx b/screens/Profile.tsx
index 9cc4160..5a21708 100644
--- a/screens/Profile.tsx
+++ b/screens/Profile.tsx
@@ -1,17 +1,17 @@
import React from "react";
import {
- View,
- Text,
- ScrollView,
- TouchableOpacity,
- useColorScheme,
- Switch,
+ View, Text, ScrollView, TouchableOpacity,
+ Switch, Modal, useColorScheme,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
+import { useAuth } from "../context/AuthContext";
+import AuthGate from "./AuthGate";
-interface SettingRowProps {
- icon: keyof typeof Ionicons.glyphMap;
+// ─── Sub-components ────────────────────────────────────────────────────────
+
+type SettingRowProps = {
+ icon: any;
label: string;
value?: string;
toggle?: boolean;
@@ -19,34 +19,17 @@ interface SettingRowProps {
onToggle?: (v: boolean) => void;
onPress?: () => void;
accent?: string;
-}
+};
-
-{/*// TODO add logic idk im still missing a home/login screen*/}
-
-
-
-function SettingRow({
- icon,
- label,
- value,
- toggle,
- toggleValue,
- onToggle,
- onPress,
- accent,
- }: SettingRowProps) {
- const scheme = useColorScheme();
- const dark = scheme === "dark";
+function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) {
+ const dark = useColorScheme() === "dark";
const iconColor = accent ?? (dark ? "#706D67" : "#8A8278");
return (
-
+
{label}
{toggle ? (
) : value ? (
-
- {value}
-
+ {value}
) : (
-
+
)}
);
@@ -93,167 +59,197 @@ function SettingRow({
function SectionLabel({ title }: { title: string }) {
const dark = useColorScheme() === "dark";
return (
-
+
{title}
);
}
-export default function Profile() {
- const [notifications, setNotifications] = React.useState(true);
- const [digest, setDigest] = React.useState(false);
- const scheme = useColorScheme();
- const dark = scheme === "dark";
+// ─── Guest profile (logged out) ────────────────────────────────────────────
+
+function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
+ const dark = useColorScheme() === "dark";
+ const accent = dark ? "#E07B45" : "#C4622D";
return (
-
-
-
+ {/* Avatar block */}
+
- {/* Avatar + name block */}
+ {/* Anonymous avatar */}
- {/*// TODO*/}
- {/* Avatar placeholder */}
-
- 📰
-
-
- Get Name here idk
-
-
- {/*// TODO*/}
- aaa@aaaq.com
-
+
+
+
+ Guest
+
+
+ Sign in to sync your reading
+
+
- {/* Stats row */}
-
- {[
- //TODO add stats tracked
- { label: "Subscribed", value: "5" },
- { label: "Read", value: "128" },
- { label: "Saved", value: "34" },
- ].map((stat) => (
-
-
- {stat.value}
-
-
- {stat.label}
-
-
- ))}
-
+ {/* Settings available to guests */}
+
+
+ {}} />
+ {}} />
+
+
+ {/* Sign in / Register CTA */}
+
+
+
+
+ Sign in / Register
+
+
+
+
+
+ You can still browse and save locally without an account.
+
+
+ );
+}
+
+// ─── Authenticated profile (logged in) ────────────────────────────────────
+
+function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
+ const dark = useColorScheme() === "dark";
+ const { user } = useAuth();
+ const [notifications, setNotifications] = React.useState(true);
+ const [digest, setDigest] = React.useState(false);
+
+ return (
+
+ {/* Avatar + name block */}
+
+
+ 📰
+
+
+ {user?.email?.split("@")[0] ?? "Reader"}
+
+
+ {user?.email}
+
+
+ {/* Stats row */}
+
+ {[
+ { label: "Subscribed", value: "5" },
+ { label: "Read", value: "128" },
+ { label: "Saved", value: "34" },
+ ].map((stat) => (
+
+
+ {stat.value}
+
+
+ {stat.label}
+
+
+ ))}
+
+
+
+ {/* Settings sections */}
+
+
+
+
+
+
+ {}} accent={dark ? "#4EC992" : "#2E9E6B"}
+ />
+
+
+ {}} />
+ {}} />
+
+
+ {/* Sign out */}
+
+
+
+ Sign out
+
+
+
+ );
+}
+
+// ─── Root export ───────────────────────────────────────────────────────────
+
+export default function Profile() {
+ const dark = useColorScheme() === "dark";
+ const { user, logout } = useAuth();
+ const [showAuth, setShowAuth] = React.useState(false);
+
+ return (
+
+ {user
+ ?
+ : setShowAuth(true)} />
+ }
+
+ {/* Login / Register modal */}
+ setShowAuth(false)}
+ >
+ {/* Close handle */}
+
+
- {/* Settings sections */}
-
-
-
-
-
-
- {}}
- accent={dark ? "#4EC992" : "#2E9E6B"}
- />
-
-
- {}}
- />
-
- {/*
- {}}
- />
- */}
- {}}
- />
-
-
- {/* Sign out */}
-
-
- Sign out
-
-
-
+ {/* AuthGate auto-closes the modal on success because user becomes non-null */}
+ setShowAuth(false)} />
+
);
}
\ No newline at end of file
diff --git a/services/api.tsx b/services/api.tsx
new file mode 100644
index 0000000..ec1aae5
--- /dev/null
+++ b/services/api.tsx
@@ -0,0 +1,70 @@
+const BASE_URL = "http://192.168.100.6:8080"; // TODO change this
+
+async function post(path: string, body: object): Promise {
+ const res = await fetch(`${BASE_URL}${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
+ return res.json();
+}
+
+async function get(path: string, params?: Record): Promise {
+ const url = new URL(`${BASE_URL}${path}`);
+ if (params) {
+ Object.entries(params).forEach(([k, v]) => {
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
+ });
+ }
+ const res = await fetch(url.toString());
+ if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
+ return res.json();
+}
+
+export type LoginPayload = { email: string; password: string };
+export type RegisterPayload = { email: string; password: string;};
+export type JwtResponse = { token: string; email: string; message: string };
+export type UserDTO = { id: number; email: string; name?: string };
+
+export type Subject = {
+ id: string;
+ name: string;
+};
+
+export type Entry = {
+ id: string;
+ title: string;
+ timePublished: string;
+ groupName: string;
+ infoEntry: string;
+ paragraph: string;
+ filepath?: string;
+ subject: Subject;
+};
+
+export type SpringPage = {
+ content: T[];
+ totalPages: number;
+ totalElements: number;
+ last: boolean;
+ number: number;
+};
+
+
+export const authApi = {
+ login: (p: LoginPayload) => post("/login", p),
+ register: (p: RegisterPayload) => post("/register", p),
+};
+
+
+export const entriesApi = {
+ getGroups: () =>
+ get("/groups"),
+
+ getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
+ get>("/entries", params),
+
+ getEntry: (id: string) =>
+ get(`/entries/${id}`),
+};
\ No newline at end of file
diff --git a/tailwind.config.js b/tailwind.config.js
index 1637420..5369ee2 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,6 +1,5 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
- // NOTE: Update this to include the paths to all files that contain Nativewind classes.
content: [
"./app/**/*.{js,jsx,ts,tsx}",
"./screens/**/*.{js,jsx,ts,tsx}",