diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml
index 0f3b3d6..9bb22cf 100644
--- a/.github/workflows/release-apk.yml
+++ b/.github/workflows/release-apk.yml
@@ -52,8 +52,12 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
+ # Anchor the capture on a trailing digit so the period that ends the
+ # sentence ("runtimeVersion: 1.3.0. JS updates…") isn't swallowed into
+ # the version — "1.3.0." never equals "1.3.0", which used to make the
+ # gate think every JS-only release was native and rebuild needlessly.
prev=$(gh release view apk-latest --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null \
- | sed -n 's/.*runtimeVersion: \([0-9][0-9.]*\).*/\1/p' | head -1)
+ | sed -n 's/.*runtimeVersion: \([0-9][0-9.]*[0-9]\).*/\1/p' | head -1)
echo "value=$prev" >> "$GITHUB_OUTPUT"
- name: Decide whether to rebuild
@@ -115,7 +119,7 @@ jobs:
run: |
set -euo pipefail
curl -fL -o one-concept.apk "${{ steps.build.outputs.url }}"
- notes="Sideload build for the current native runtime. runtimeVersion: ${RV}. JS updates arrive over the air on release; reinstall from here only when a native release changes this."
+ notes="Sideload build for the current native runtime. runtimeVersion: ${RV} — JS updates arrive over the air on release; reinstall from here only when a native release changes this."
if gh release view apk-latest --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload apk-latest one-concept.apk --repo "$GITHUB_REPOSITORY" --clobber
gh release edit apk-latest --repo "$GITHUB_REPOSITORY" --notes "$notes"
diff --git a/backend/.env.example b/backend/.env.example
index e8216cc..75f8176 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -15,6 +15,9 @@ DIRECT_URL=postgresql://postgres.PROJECT:PASSWORD@aws-0-REGION.pooler.supabase.c
# Settings → API
SUPABASE_URL=https://PROJECT.supabase.co
+# Public anon key (Settings → API). Safe to expose — it's already in the mobile
+# bundle. Used by the /reset-password landing page to call Supabase auth.
+SUPABASE_ANON_KEY=
# Full RLS bypass. Treat like a root password: never log it, never ship it.
SUPABASE_SERVICE_ROLE_KEY=
# Legacy HS256 secret. Unused when the project signs asymmetrically (ES256),
diff --git a/backend/app/api/v1/concepts.py b/backend/app/api/v1/concepts.py
index 47cdcff..a7487ea 100644
--- a/backend/app/api/v1/concepts.py
+++ b/backend/app/api/v1/concepts.py
@@ -1,8 +1,10 @@
-from fastapi import APIRouter, Depends, Response, status
+from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.deps import CurrentUser, get_current_user
+from app.schemas.daily import ConceptOut
+from app.services.concepts import get_concept_out
from app.services.interactions import set_interaction
router = APIRouter(prefix="/concepts", tags=["concepts"])
@@ -12,6 +14,19 @@
_NO_CONTENT = Response(status_code=status.HTTP_204_NO_CONTENT)
+@router.get("/{slug}", response_model=ConceptOut)
+async def get_concept(
+ slug: str,
+ user: CurrentUser = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+) -> ConceptOut:
+ """Full concept by slug, for reopening a History/Saved card's details."""
+ concept = await get_concept_out(db, user.id, slug)
+ if concept is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concept not found")
+ return concept
+
+
@router.put("/{slug}/like", status_code=status.HTTP_204_NO_CONTENT)
async def like(slug: str, user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db)) -> Response:
diff --git a/backend/app/api/v1/pages.py b/backend/app/api/v1/pages.py
index 153e203..13f30b1 100644
--- a/backend/app/api/v1/pages.py
+++ b/backend/app/api/v1/pages.py
@@ -5,9 +5,13 @@
loop with a clear instruction instead of a dead localhost tab.
"""
+import json
+
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
+from app.config import get_settings
+
router = APIRouter(tags=["pages"])
_CONFIRMED = """
@@ -38,3 +42,173 @@
@router.get("/confirmed", response_class=HTMLResponse, include_in_schema=False)
async def confirmed() -> str:
return _CONFIRMED
+
+
+# The password-recovery link Supabase emails lands here (its Site URL / the
+# redirectTo the app passes). Supabase verifies the token and appends the
+# recovery session to the URL *fragment* (#access_token=...&type=recovery),
+# which the browser never sends to us — so the token stays client-side. The
+# page reads it and calls Supabase's auth REST endpoint directly to set the new
+# password; the anon key it needs is public (already in the app bundle).
+_RESET_PASSWORD = """
+
+
+
+
+ One Concept — reset password
+
+
+
+
+ Reset your password
+ Choose a new password for your One Concept account.
+
+
+
+
+
+
+
+
+"""
+
+
+_RESET_UNCONFIGURED = """
+
+
+
+
+ One Concept — reset password
+
+
+
+
+ Reset unavailable
+ Password reset isn't configured on the server yet. Please try again
+ later.
+
+
+"""
+
+
+@router.get("/reset-password", response_class=HTMLResponse, include_in_schema=False)
+async def reset_password() -> str:
+ settings = get_settings()
+ if not settings.supabase_anon_key:
+ # No public key configured — the page can't call Supabase, so fail
+ # clearly instead of rendering a form that silently can't submit.
+ return _RESET_UNCONFIGURED
+ config = json.dumps(
+ {"url": settings.supabase_url.rstrip("/"), "anonKey": settings.supabase_anon_key}
+ )
+ return _RESET_PASSWORD.replace("__CONFIG__", config)
diff --git a/backend/app/config.py b/backend/app/config.py
index ac810da..46654c3 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -22,6 +22,10 @@ class Settings(BaseSettings):
# Present for legacy HS256 projects; this project signs with ES256 via JWKS.
supabase_jwt_secret: str | None = None
supabase_service_role_key: str | None = None
+ # Public anon key. Safe to expose — it's already shipped in the mobile
+ # bundle. The /reset-password landing page uses it (client-side) to call
+ # Supabase's auth REST endpoint; the page is inert without it.
+ supabase_anon_key: str | None = None
# Generation. The key lives here and only here — never in the app bundle.
gemini_api_key: str = ""
diff --git a/backend/app/services/concepts.py b/backend/app/services/concepts.py
new file mode 100644
index 0000000..9003059
--- /dev/null
+++ b/backend/app/services/concepts.py
@@ -0,0 +1,49 @@
+"""Read a single concept for the detail view.
+
+History and Saved lists carry only a concept's name/topic, not its body, so the
+app fetches the full concept (summary + example) by slug when a card is opened.
+"""
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import Concept, ConceptInteraction, Topic
+from app.schemas.daily import ConceptOut
+
+
+async def get_concept_out(db: AsyncSession, user_id, slug: str) -> ConceptOut | None:
+ """The published concept with the given slug, or None if there isn't one.
+
+ like_count is other users' likes only — the client adds the viewer's own,
+ exactly as the daily and state endpoints do, so the number matches the card.
+ """
+ like_count = (
+ select(func.count())
+ .select_from(ConceptInteraction)
+ .where(
+ ConceptInteraction.concept_id == Concept.id,
+ ConceptInteraction.liked_at.is_not(None),
+ ConceptInteraction.user_id != user_id,
+ )
+ .correlate(Concept)
+ .scalar_subquery()
+ )
+ stmt = (
+ select(Concept, Topic.slug, Topic.name, like_count)
+ .join(Topic, Topic.id == Concept.topic_id)
+ .where(Concept.slug == slug, Concept.status == "published")
+ )
+ row = (await db.execute(stmt)).first()
+ if row is None:
+ return None
+ concept, topic_slug, topic_name, likes = row
+ return ConceptOut(
+ id=concept.id,
+ slug=concept.slug,
+ title=concept.title,
+ summary=concept.summary,
+ example=concept.example,
+ topic_slug=topic_slug,
+ topic_name=topic_name,
+ like_count=likes,
+ )
diff --git a/backend/tests/test_writes.py b/backend/tests/test_writes.py
index e7c3af6..1b79856 100644
--- a/backend/tests/test_writes.py
+++ b/backend/tests/test_writes.py
@@ -1,11 +1,13 @@
"""Write endpoints: completion, streaks, follows, likes, saves."""
+import uuid
from datetime import timedelta
import pytest
from fastapi import HTTPException
from sqlalchemy import text
+from app.services.concepts import get_concept_out
from app.services.interactions import complete_today, set_followed_topics, set_interaction
from app.services.selection import get_or_create_daily
from app.services.state import load_state
@@ -13,6 +15,35 @@
from tests.test_selection import DAY
+async def _make_user(session) -> uuid.UUID:
+ """A second bootstrapped user (the auth.users insert fires the profile trigger)."""
+ uid = uuid.uuid4()
+ await session.execute(
+ text("insert into auth.users (id, email) values (:id, :email)"),
+ {"id": uid, "email": f"{uid}@example.invalid"},
+ )
+ await session.commit()
+ return uid
+
+
+async def test_get_concept_returns_body_and_only_others_likes(session, user):
+ other = await _make_user(session)
+ # Another user likes it, and so does the viewer — the returned count must
+ # exclude the viewer's own like (the client adds it back).
+ await set_interaction(session, other, "hash-tables", "liked_at", True)
+ await set_interaction(session, user, "hash-tables", "liked_at", True)
+
+ concept = await get_concept_out(session, user, "hash-tables")
+ assert concept is not None
+ assert concept.slug == "hash-tables"
+ assert concept.title and concept.summary and concept.topic_name
+ assert concept.like_count == 1, "only other users' likes, not the viewer's own"
+
+
+async def test_get_concept_unknown_slug_returns_none(session, user):
+ assert await get_concept_out(session, user, "not-a-real-concept") is None
+
+
async def _assign_and_complete(session, user, day):
await get_or_create_daily(session, user, today=day)
await complete_today(session, user, day)
diff --git a/mobile/App.tsx b/mobile/App.tsx
index ce1df68..7de71a8 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -21,10 +21,12 @@ import { useWhatsNew } from './src/hooks/useWhatsNew';
import { AuthScreen } from './src/screens/AuthScreen';
import { HistoryScreen } from './src/screens/HistoryScreen';
import { AboutScreen } from './src/screens/AboutScreen';
+import { ConceptDetailScreen } from './src/screens/ConceptDetailScreen';
import { PersonalizationScreen } from './src/screens/PersonalizationScreen';
import { ProfileScreen, ProfileStackParamList } from './src/screens/ProfileScreen';
import { StatsScreen } from './src/screens/StatsScreen';
import { TodayScreen } from './src/screens/TodayScreen';
+import { RootStackParamList } from './src/navigation';
// Hold the native splash up until we're ready to paint, instead of hiding it
// automatically and flashing a blank screen while the font loads (issue #93).
@@ -33,6 +35,7 @@ SplashScreen.preventAutoHideAsync().catch(() => {});
const Tab = createBottomTabNavigator();
const ProfileStack = createNativeStackNavigator();
+const RootStack = createNativeStackNavigator();
function ProfileStackScreen() {
return (
@@ -106,39 +109,14 @@ function ThemedApp() {
return (
<>
-
-
+
+
-
-
-
-
+
{whatsNew.entry && (
@@ -148,6 +126,47 @@ function ThemedApp() {
);
}
+/** The bottom tabs — nested under the root stack so a concept-detail modal can
+ * be presented above them from any tab. */
+function Tabs() {
+ const { colors } = useTheme();
+ return (
+
+
+
+
+
+
+ );
+}
+
export default function App() {
const [fontsLoaded, fontError] = useFonts({ SpaceGrotesk_700Bold });
// Proceed even if the font fails to load: falling back to the system font is
diff --git a/mobile/app.config.js b/mobile/app.config.js
index 68a8a46..4af2d57 100644
--- a/mobile/app.config.js
+++ b/mobile/app.config.js
@@ -18,7 +18,7 @@ module.exports = {
name: 'One Concept',
slug: 'one-concept',
owner: 'coding-moves',
- version: '1.4.1',
+ version: '1.5.0',
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'automatic',
diff --git a/mobile/src/components/CategoryChip.tsx b/mobile/src/components/CategoryChip.tsx
index 910078b..ea4d44f 100644
--- a/mobile/src/components/CategoryChip.tsx
+++ b/mobile/src/components/CategoryChip.tsx
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../context/ThemeContext';
-import { radius, spacing, ThemeColors } from '../theme';
+import { scaleFont, radius, spacing, ThemeColors } from '../theme';
// Accepts any label string: the app's Category values and the server's topic
// names (which match those values) both render the same way.
@@ -27,7 +27,7 @@ const createStyles = (colors: ThemeColors) =>
},
label: {
color: colors.categoryChipText,
- fontSize: 11.5,
+ fontSize: scaleFont(11.5),
fontWeight: '700',
letterSpacing: 1,
textTransform: 'uppercase',
diff --git a/mobile/src/components/ConceptActions.tsx b/mobile/src/components/ConceptActions.tsx
index 6ca86d9..2712398 100644
--- a/mobile/src/components/ConceptActions.tsx
+++ b/mobile/src/components/ConceptActions.tsx
@@ -3,7 +3,7 @@ import { useMemo, useRef } from 'react';
import { Animated, Pressable, Share, StyleSheet, Text, View } from 'react-native';
import { useProgress } from '../context/ProgressContext';
import { useTheme } from '../context/ThemeContext';
-import { radius, spacing, ThemeColors } from '../theme';
+import { scaleIcon, scaleFont, radius, spacing, ThemeColors } from '../theme';
import { Concept } from '../types';
function usePop() {
@@ -59,7 +59,7 @@ export function ConceptActions({ concept }: { concept: Concept }) {
@@ -80,7 +80,7 @@ export function ConceptActions({ concept }: { concept: Concept }) {
@@ -94,7 +94,7 @@ export function ConceptActions({ concept }: { concept: Concept }) {
accessibilityRole="button"
accessibilityLabel="Share"
>
-
+
);
@@ -122,7 +122,7 @@ const createStyles = (colors: ThemeColors) =>
gap: spacing.xs + 2,
},
likeCount: {
- fontSize: 13,
+ fontSize: scaleFont(13),
fontWeight: '600',
color: colors.textSecondary,
},
diff --git a/mobile/src/components/ConceptCard.tsx b/mobile/src/components/ConceptCard.tsx
index 98717a8..67d1dd3 100644
--- a/mobile/src/components/ConceptCard.tsx
+++ b/mobile/src/components/ConceptCard.tsx
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../context/ThemeContext';
-import { radius, shadows, spacing, ThemeColors } from '../theme';
+import { scaleFont, radius, shadows, spacing, ThemeColors } from '../theme';
import { Concept } from '../types';
import { CategoryChip } from './CategoryChip';
@@ -36,14 +36,14 @@ const createStyles = (colors: ThemeColors) =>
...shadows.card,
},
title: {
- fontSize: 24,
- lineHeight: 30,
+ fontSize: scaleFont(24),
+ lineHeight: scaleFont(30),
fontFamily: 'SpaceGrotesk_700Bold',
color: colors.text,
},
summary: {
- fontSize: 16,
- lineHeight: 26,
+ fontSize: scaleFont(16),
+ lineHeight: scaleFont(26),
color: colors.textSecondary,
},
exampleBox: {
@@ -55,15 +55,15 @@ const createStyles = (colors: ThemeColors) =>
gap: spacing.xs,
},
exampleLabel: {
- fontSize: 11,
+ fontSize: scaleFont(11),
fontWeight: '700',
letterSpacing: 1.2,
textTransform: 'uppercase',
color: colors.categoryChipText,
},
exampleText: {
- fontSize: 14.5,
- lineHeight: 22,
+ fontSize: scaleFont(14.5),
+ lineHeight: scaleFont(22),
color: colors.textSecondary,
},
});
diff --git a/mobile/src/components/FollowPill.tsx b/mobile/src/components/FollowPill.tsx
index bae8a5e..6d4f640 100644
--- a/mobile/src/components/FollowPill.tsx
+++ b/mobile/src/components/FollowPill.tsx
@@ -2,7 +2,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useMemo, useRef } from 'react';
import { Animated, Pressable, StyleSheet, Text } from 'react-native';
import { useTheme } from '../context/ThemeContext';
-import { radius, spacing, ThemeColors } from '../theme';
+import { scaleIcon, scaleFont, radius, spacing, ThemeColors } from '../theme';
interface Props {
following: boolean;
@@ -40,7 +40,7 @@ export function FollowPill({ following, onPress }: Props) {
>
@@ -74,7 +74,7 @@ const createStyles = (colors: ThemeColors) =>
opacity: 0.75,
},
label: {
- fontSize: 14,
+ fontSize: scaleFont(14),
fontWeight: '600',
},
followLabel: {
diff --git a/mobile/src/components/LikeCount.tsx b/mobile/src/components/LikeCount.tsx
index 906346c..9be3fad 100644
--- a/mobile/src/components/LikeCount.tsx
+++ b/mobile/src/components/LikeCount.tsx
@@ -2,7 +2,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../context/ThemeContext';
-import { ThemeColors } from '../theme';
+import { scaleIcon, scaleFont, ThemeColors } from '../theme';
/**
* A small "♥ N" badge showing how many people liked a concept. Renders nothing
@@ -15,7 +15,7 @@ export function LikeCount({ count }: { count: number }) {
if (count <= 0) return null;
return (
-
+
{count}
);
@@ -24,5 +24,5 @@ export function LikeCount({ count }: { count: number }) {
const createStyles = (colors: ThemeColors) =>
StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 3 },
- text: { fontSize: 12, fontWeight: '600', color: colors.textMuted },
+ text: { fontSize: scaleFont(12), fontWeight: '600', color: colors.textMuted },
});
diff --git a/mobile/src/components/PrimaryButton.tsx b/mobile/src/components/PrimaryButton.tsx
index 928082a..8dcbe3e 100644
--- a/mobile/src/components/PrimaryButton.tsx
+++ b/mobile/src/components/PrimaryButton.tsx
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import { useTheme } from '../context/ThemeContext';
-import { radius, shadows, spacing, ThemeColors } from '../theme';
+import { scaleFont, radius, shadows, spacing, ThemeColors } from '../theme';
interface Props {
label: string;
@@ -50,7 +50,7 @@ const createStyles = (colors: ThemeColors) =>
},
label: {
color: colors.onPrimary,
- fontSize: 16,
+ fontSize: scaleFont(16),
fontWeight: '700',
letterSpacing: 0.2,
},
diff --git a/mobile/src/components/StreakBadge.tsx b/mobile/src/components/StreakBadge.tsx
index 9426d7a..f5d14fd 100644
--- a/mobile/src/components/StreakBadge.tsx
+++ b/mobile/src/components/StreakBadge.tsx
@@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { AnimatedFlame } from './AnimatedFlame';
import { useTheme } from '../context/ThemeContext';
-import { radius, shadows, spacing, ThemeColors } from '../theme';
+import { scaleIcon, scaleFont, radius, shadows, spacing, ThemeColors } from '../theme';
import { StreakStats } from '../services/streak';
export function StreakBadge({ streaks }: { streaks: StreakStats }) {
@@ -15,7 +15,7 @@ export function StreakBadge({ streaks }: { streaks: StreakStats }) {
0 ? colors.streak : colors.textMuted}
active={streaks.current > 0}
/>
@@ -25,14 +25,14 @@ export function StreakBadge({ streaks }: { streaks: StreakStats }) {
-
+
{streaks.longest}
longest
-
+
{streaks.totalLearned}
learned
@@ -63,12 +63,12 @@ const createStyles = (colors: ThemeColors) =>
gap: spacing.xs,
},
value: {
- fontSize: 19,
+ fontSize: scaleFont(19),
fontFamily: 'SpaceGrotesk_700Bold',
color: colors.text,
},
label: {
- fontSize: 11.5,
+ fontSize: scaleFont(11.5),
letterSpacing: 0.3,
color: colors.textMuted,
},
diff --git a/mobile/src/components/WhatsNewCard.tsx b/mobile/src/components/WhatsNewCard.tsx
index a77641c..fd12a82 100644
--- a/mobile/src/components/WhatsNewCard.tsx
+++ b/mobile/src/components/WhatsNewCard.tsx
@@ -11,7 +11,7 @@ import {
} from 'react-native';
import { useTheme } from '../context/ThemeContext';
import { WhatsNewEntry } from '../data/whatsNew';
-import { radius, shadows, spacing, ThemeColors, typography } from '../theme';
+import { scaleIcon, scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme';
interface Props {
entry: WhatsNewEntry;
@@ -61,11 +61,11 @@ export function WhatsNewCard({ entry, onDismiss }: Props) {
accessibilityLabel="Close"
hitSlop={8}
>
-
+
-
+
What's new
@@ -76,7 +76,7 @@ export function WhatsNewCard({ entry, onDismiss }: Props) {
@@ -139,11 +139,11 @@ const createStyles = (colors: ThemeColors) =>
},
title: {
...typography.title,
- fontSize: 24,
+ fontSize: scaleFont(24),
color: colors.text,
},
version: {
- fontSize: 13,
+ fontSize: scaleFont(13),
fontWeight: '600',
color: colors.textMuted,
marginTop: 2,
@@ -164,7 +164,7 @@ const createStyles = (colors: ThemeColors) =>
itemText: {
flex: 1,
...typography.body,
- fontSize: 15,
+ fontSize: scaleFont(15),
color: colors.textSecondary,
},
button: {
@@ -175,7 +175,7 @@ const createStyles = (colors: ThemeColors) =>
},
buttonText: {
color: colors.onPrimary,
- fontSize: 16,
+ fontSize: scaleFont(16),
fontWeight: '700',
},
});
diff --git a/mobile/src/context/AuthContext.tsx b/mobile/src/context/AuthContext.tsx
index c7120b1..89c4306 100644
--- a/mobile/src/context/AuthContext.tsx
+++ b/mobile/src/context/AuthContext.tsx
@@ -8,7 +8,7 @@ import {
useMemo,
useState,
} from 'react';
-import { setTokenProvider } from '../api/client';
+import { API_BASE_URL, setTokenProvider } from '../api/client';
import { supabase } from '../lib/supabase';
import { clearAccountCaches } from '../services/accountCaches';
import {
@@ -23,6 +23,7 @@ export interface AuthContextValue {
email: string | null;
signIn: (email: string, password: string) => Promise;
signUp: (email: string, password: string) => Promise<{ needsConfirmation: boolean }>;
+ resetPassword: (email: string) => Promise;
signOut: () => Promise;
}
@@ -113,6 +114,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return { needsConfirmation: !data.session };
}, []);
+ const resetPassword = useCallback(async (email: string) => {
+ // Sends a recovery link to /reset-password (a backend web page), where the
+ // user sets a new password. Supabase returns success whether or not the
+ // email is registered, so the UI must stay deliberately neutral — never
+ // confirm an account exists.
+ //
+ // Only pass redirectTo when we have an absolute base URL. An empty
+ // API_BASE_URL would make it the relative '/reset-password', which is not a
+ // valid redirect — better to fall back to the project's Site URL.
+ const options = API_BASE_URL
+ ? { redirectTo: `${API_BASE_URL}/reset-password` }
+ : undefined;
+ const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), options);
+ if (error) throw new Error(describe(error));
+ }, []);
+
const signOut = useCallback(async () => {
// Deregister the push token first — it is an authenticated call, so it
// must happen while the session is still valid. Best-effort: reminders
@@ -136,9 +153,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
email: session?.user?.email ?? null,
signIn,
signUp,
+ resetPassword,
signOut,
}),
- [loading, session, signIn, signUp, signOut]
+ [loading, session, signIn, signUp, resetPassword, signOut]
);
return {children};
diff --git a/mobile/src/data/concepts.ts b/mobile/src/data/concepts.ts
index a452244..588a54a 100644
--- a/mobile/src/data/concepts.ts
+++ b/mobile/src/data/concepts.ts
@@ -186,3 +186,7 @@ export const CONCEPTS: Concept[] = [
'"systemctl status nginx" shows whether the web server is running and its recent logs; "systemctl enable nginx" makes it start on every boot; Restart=on-failure brings it back if it crashes.',
},
];
+
+/** id -> concept lookup for the bundled catalog (signed-out demo + offline
+ * fallback). Defined once here rather than rebuilt in each screen. */
+export const CONCEPTS_BY_ID = new Map(CONCEPTS.map((c) => [c.id, c]));
diff --git a/mobile/src/data/whatsNew.ts b/mobile/src/data/whatsNew.ts
index a0ed954..5b75202 100644
--- a/mobile/src/data/whatsNew.ts
+++ b/mobile/src/data/whatsNew.ts
@@ -21,6 +21,14 @@ export interface WhatsNewEntry {
}
export const WHATS_NEW: WhatsNewEntry[] = [
+ {
+ version: '1.5.0',
+ highlights: [
+ 'Forgot your password? You can now reset it right from the sign-in screen.',
+ 'Tap any concept in History or your Saved list to open it again in full.',
+ 'The About screen now links to our GitHub and a quick way to send feedback.',
+ ],
+ },
{
version: '1.4.1',
highlights: [
diff --git a/mobile/src/navigation.ts b/mobile/src/navigation.ts
new file mode 100644
index 0000000..7e13218
--- /dev/null
+++ b/mobile/src/navigation.ts
@@ -0,0 +1,13 @@
+/** Root navigator params. The concept-detail modal sits above the tabs so it
+ * can be opened from any tab (History, Profile) — see App.tsx. */
+export type RootStackParamList = {
+ Tabs: undefined;
+ ConceptDetail: {
+ conceptId: string;
+ /** Optional bits the opener already has, so the header paints instantly
+ * while the full body loads. */
+ title?: string;
+ topicName?: string;
+ likeCount?: number;
+ };
+};
diff --git a/mobile/src/screens/AboutScreen.tsx b/mobile/src/screens/AboutScreen.tsx
index 4d53924..6ce6b13 100644
--- a/mobile/src/screens/AboutScreen.tsx
+++ b/mobile/src/screens/AboutScreen.tsx
@@ -3,9 +3,9 @@ import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Constants from 'expo-constants';
import { useMemo } from 'react';
-import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Alert, Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../context/ThemeContext';
-import { radius, spacing, ThemeColors, typography } from '../theme';
+import { scaleIcon, scaleFont, radius, spacing, ThemeColors, typography } from '../theme';
import { ProfileStackParamList } from './ProfileScreen';
const HOW_IT_WORKS = [
@@ -14,12 +14,33 @@ const HOW_IT_WORKS = [
'Mark it learned to keep your streak, and like or save the ones you love.',
];
+const GITHUB_ORG = 'https://github.com/Coding-Moves';
+const GITHUB_DEV = 'https://github.com/Muawiya-contact';
+const ISSUES_URL = 'https://github.com/Coding-Moves/one-concept/issues/new';
+const FEEDBACK_EMAIL = 'contactmuawia@gmail.com';
+
+/**
+ * Open a URL, falling back to an alert if nothing can handle it — most likely
+ * a mailto: on a device with no mail app. The fallback shows the destination
+ * (email or link) so the tap is never a dead end.
+ */
+function openURL(url: string, fallback: string) {
+ Linking.openURL(url).catch(() => {
+ Alert.alert("Couldn't open that", fallback);
+ });
+}
+
export function AboutScreen() {
const navigation = useNavigation>();
const { colors } = useTheme();
const styles = useMemo(() => createStyles(colors), [colors]);
const version = Constants.expoConfig?.version ?? '?';
+ const contact = () => {
+ const subject = encodeURIComponent(`One Concept App Feedback (v${version})`);
+ openURL(`mailto:${FEEDBACK_EMAIL}?subject=${subject}`, `Reach us at ${FEEDBACK_EMAIL}`);
+ };
+
return (
@@ -30,13 +51,13 @@ export function AboutScreen() {
accessibilityRole="button"
accessibilityLabel="Close"
>
-
+
-
+
One Concept
Version {version}
@@ -51,13 +72,50 @@ export function AboutScreen() {
{HOW_IT_WORKS.map((line, i) => (
-
+
{line}
))}
- Made by Coding Moves.
+ Feedback & support
+
+ [styles.actionRow, pressed && styles.pressed]}
+ accessibilityRole="button"
+ accessibilityLabel="Contact us by email"
+ >
+
+ Have feedback or found a bug? Contact us
+
+
+ openURL(ISSUES_URL, ISSUES_URL)}
+ style={({ pressed }) => [styles.actionRow, pressed && styles.pressed]}
+ accessibilityRole="link"
+ accessibilityLabel="Report an issue on GitHub"
+ >
+
+ Report an issue on GitHub
+
+
+
+
+
+
+ Built with ❤️ by{' '}
+ openURL(GITHUB_ORG, GITHUB_ORG)} accessibilityRole="link">
+ Coding Moves
+
+
+
+ Developed by{' '}
+ openURL(GITHUB_DEV, GITHUB_DEV)} accessibilityRole="link">
+ @Muawiya-contact
+
+
+
);
}
@@ -72,7 +130,7 @@ const createStyles = (colors: ThemeColors) =>
justifyContent: 'space-between',
marginBottom: spacing.lg,
},
- title: { ...typography.title, fontSize: 24, color: colors.text },
+ title: { ...typography.title, fontSize: scaleFont(24), color: colors.text },
closeButton: { padding: spacing.xs },
hero: { alignItems: 'center', gap: spacing.xs, marginBottom: spacing.lg },
badge: {
@@ -84,24 +142,39 @@ const createStyles = (colors: ThemeColors) =>
justifyContent: 'center',
marginBottom: spacing.sm,
},
- appName: { ...typography.title, fontSize: 26, color: colors.text },
- version: { fontSize: 13, fontWeight: '600', color: colors.textMuted },
+ appName: { ...typography.title, fontSize: scaleFont(26), color: colors.text },
+ version: { fontSize: scaleFont(13), fontWeight: '600', color: colors.textMuted },
tagline: {
- fontSize: 15,
- lineHeight: 22,
+ fontSize: scaleFont(15),
+ lineHeight: scaleFont(22),
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
},
sectionTitle: {
...typography.title,
- fontSize: 18,
+ fontSize: scaleFont(18),
color: colors.text,
marginBottom: spacing.md,
},
list: { gap: spacing.md, marginBottom: spacing.xl },
item: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm },
itemIcon: { marginTop: 1 },
- itemText: { flex: 1, fontSize: 15, lineHeight: 21, color: colors.textSecondary },
- credit: { fontSize: 13, color: colors.textMuted, textAlign: 'center' },
+ itemText: { flex: 1, fontSize: scaleFont(15), lineHeight: scaleFont(21), color: colors.textSecondary },
+ actionRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ backgroundColor: colors.surface,
+ borderRadius: radius.lg,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.border,
+ padding: spacing.md,
+ },
+ pressed: { opacity: 0.7 },
+ actionText: { flex: 1, fontSize: scaleFont(15), fontWeight: '600', color: colors.text },
+ footer: { alignItems: 'center', gap: spacing.xs, marginTop: spacing.sm },
+ footerText: { fontSize: scaleFont(13), color: colors.textMuted, textAlign: 'center' },
+ footerSub: { fontSize: scaleFont(12), color: colors.textMuted, textAlign: 'center' },
+ link: { color: colors.primary, fontWeight: '700' },
});
diff --git a/mobile/src/screens/AuthScreen.tsx b/mobile/src/screens/AuthScreen.tsx
index 32fed57..b7fb7a7 100644
--- a/mobile/src/screens/AuthScreen.tsx
+++ b/mobile/src/screens/AuthScreen.tsx
@@ -15,7 +15,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { PrimaryButton } from '../components/PrimaryButton';
import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext';
-import { radius, shadows, spacing, ThemeColors, typography } from '../theme';
+import { scaleIcon, scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme';
type Mode = 'signIn' | 'signUp';
@@ -23,7 +23,7 @@ export function AuthScreen() {
const insets = useSafeAreaInsets();
const { colors, mode: themeMode, toggle } = useTheme();
const styles = useMemo(() => createStyles(colors), [colors]);
- const { signIn, signUp } = useAuth();
+ const { signIn, signUp, resetPassword } = useAuth();
const [mode, setMode] = useState('signIn');
const [email, setEmail] = useState('');
@@ -55,6 +55,29 @@ export function AuthScreen() {
}
};
+ const forgotPassword = async () => {
+ const trimmed = email.trim();
+ if (trimmed.length <= 3 || !trimmed.includes('@')) {
+ setNotice(null);
+ setError('Enter your email above first, then tap “Forgot password?”.');
+ return;
+ }
+ setError(null);
+ setNotice(null);
+ setBusy(true);
+ try {
+ await resetPassword(trimmed);
+ // Deliberately neutral: never reveal whether an account exists.
+ setNotice(
+ 'If an account exists for that email, a password reset link is on its way. Check your inbox (and spam).'
+ );
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Could not send the reset email. Try again.');
+ } finally {
+ setBusy(false);
+ }
+ };
+
return (
@@ -119,18 +142,28 @@ export function AuthScreen() {
secureTextEntry
editable={!busy}
/>
+ {mode === 'signIn' ? (
+
+ Forgot password?
+
+ ) : null}
{error ? (
-
+
{error}
) : null}
{notice ? (
-
+
{notice}
) : null}
@@ -186,12 +219,12 @@ const createStyles = (colors: ThemeColors) =>
justifyContent: 'center',
},
header: { gap: spacing.sm },
- title: { ...typography.title, fontSize: 38, color: colors.text },
- tagline: { fontSize: 15, color: colors.textMuted, lineHeight: 22 },
+ title: { ...typography.title, fontSize: scaleFont(38), color: colors.text },
+ tagline: { fontSize: scaleFont(15), color: colors.textMuted, lineHeight: scaleFont(22) },
form: { gap: spacing.md },
field: { gap: spacing.sm },
label: {
- fontSize: 12,
+ fontSize: scaleFont(12),
fontWeight: '700',
letterSpacing: 0.8,
textTransform: 'uppercase',
@@ -204,7 +237,7 @@ const createStyles = (colors: ThemeColors) =>
borderRadius: radius.lg,
paddingHorizontal: spacing.md + 2,
paddingVertical: spacing.md,
- fontSize: 16,
+ fontSize: scaleFont(16),
color: colors.text,
...shadows.card,
},
@@ -217,13 +250,19 @@ const createStyles = (colors: ThemeColors) =>
},
errorBanner: { backgroundColor: colors.categoryChip },
noticeBanner: { backgroundColor: colors.successSurface },
- bannerText: { flex: 1, fontSize: 14, lineHeight: 20 },
+ bannerText: { flex: 1, fontSize: scaleFont(14), lineHeight: scaleFont(20) },
busy: { paddingVertical: spacing.md, alignItems: 'center' },
switchText: {
textAlign: 'center',
color: colors.primary,
- fontSize: 14,
+ fontSize: scaleFont(14),
fontWeight: '600',
paddingVertical: spacing.sm,
},
+ forgot: { alignSelf: 'flex-end', paddingTop: spacing.xs },
+ forgotText: {
+ color: colors.primary,
+ fontSize: scaleFont(13),
+ fontWeight: '600',
+ },
});
diff --git a/mobile/src/screens/ConceptDetailScreen.tsx b/mobile/src/screens/ConceptDetailScreen.tsx
new file mode 100644
index 0000000..823f89e
--- /dev/null
+++ b/mobile/src/screens/ConceptDetailScreen.tsx
@@ -0,0 +1,106 @@
+import { Ionicons } from '@expo/vector-icons';
+import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
+import { useEffect, useMemo, useState } from 'react';
+import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { ConceptActions } from '../components/ConceptActions';
+import { ConceptCard } from '../components/ConceptCard';
+import { useTheme } from '../context/ThemeContext';
+import { CONCEPTS_BY_ID } from '../data/concepts';
+import { fetchConcept } from '../services/conceptApi';
+import { RootStackParamList } from '../navigation';
+import { scaleFont, scaleIcon, spacing, ThemeColors, typography } from '../theme';
+import { Concept } from '../types';
+
+type Status = 'loading' | 'ready' | 'error';
+
+/** Full-concept modal opened from a History or Saved card. */
+export function ConceptDetailScreen() {
+ const navigation = useNavigation();
+ const { params } = useRoute>();
+ const { conceptId, title } = params;
+ const { colors } = useTheme();
+ const styles = useMemo(() => createStyles(colors), [colors]);
+
+ const [concept, setConcept] = useState(null);
+ const [status, setStatus] = useState('loading');
+
+ useEffect(() => {
+ let active = true;
+ setStatus('loading');
+ fetchConcept(conceptId)
+ .then((c) => {
+ if (active) {
+ setConcept(c);
+ setStatus('ready');
+ }
+ })
+ .catch(() => {
+ // Offline or not found: the bundled catalog covers the signed-out demo
+ // set; anything else we can't show, so say so rather than hang.
+ const local = CONCEPTS_BY_ID.get(conceptId);
+ if (!active) return;
+ if (local) {
+ setConcept(local);
+ setStatus('ready');
+ } else {
+ setStatus('error');
+ }
+ });
+ return () => {
+ active = false;
+ };
+ }, [conceptId]);
+
+ return (
+
+
+
+ {title ?? 'Concept'}
+
+ navigation.goBack()}
+ style={({ pressed }) => [styles.closeButton, pressed && { opacity: 0.6 }]}
+ accessibilityRole="button"
+ accessibilityLabel="Close"
+ >
+
+
+
+
+ {status === 'loading' ? (
+
+
+
+ ) : status === 'error' || !concept ? (
+
+
+ Couldn’t load this concept
+ Check your connection and try again.
+
+ ) : (
+
+
+
+
+ )}
+
+ );
+}
+
+const createStyles = (colors: ThemeColors) =>
+ StyleSheet.create({
+ screen: { flex: 1, backgroundColor: colors.background },
+ topBar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: spacing.md,
+ padding: spacing.lg,
+ },
+ heading: { ...typography.title, fontSize: scaleFont(22), color: colors.text, flexShrink: 1 },
+ closeButton: { padding: spacing.xs },
+ content: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xl, gap: spacing.md },
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.xl },
+ errorTitle: { fontSize: scaleFont(17), fontWeight: '700', color: colors.text },
+ errorText: { fontSize: scaleFont(14), color: colors.textMuted, textAlign: 'center' },
+ });
diff --git a/mobile/src/screens/HistoryScreen.tsx b/mobile/src/screens/HistoryScreen.tsx
index e38fd99..327f572 100644
--- a/mobile/src/screens/HistoryScreen.tsx
+++ b/mobile/src/screens/HistoryScreen.tsx
@@ -1,17 +1,22 @@
import { Ionicons } from '@expo/vector-icons';
+import { CompositeNavigationProp, ParamListBase, useNavigation } from '@react-navigation/native';
+import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
+import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useMemo } from 'react';
-import { FlatList, StyleSheet, Text, View } from 'react-native';
+import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { CategoryChip } from '../components/CategoryChip';
import { LikeCount } from '../components/LikeCount';
import { SkeletonRow } from '../components/Skeleton';
import { useProgress } from '../context/ProgressContext';
import { useTheme } from '../context/ThemeContext';
-import { CONCEPTS } from '../data/concepts';
+import { CONCEPTS_BY_ID } from '../data/concepts';
+import { RootStackParamList } from '../navigation';
import { formatDateKey } from '../services/dates';
-import { radius, shadows, spacing, ThemeColors, typography } from '../theme';
+import { scaleIcon, scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme';
import { Category, LearnedRecord } from '../types';
-const CONCEPTS_BY_ID = new Map(CONCEPTS.map((c) => [c.id, c]));
+// Keep the feed focused on recent activity (issue #124).
+const HISTORY_LIMIT = 10;
function prettify(slug: string): string {
return slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
@@ -21,10 +26,12 @@ function HistoryRow({
record,
liked,
styles,
+ onOpen,
}: {
record: LearnedRecord;
liked: boolean;
styles: Styles;
+ onOpen: (conceptId: string, title: string) => void;
}) {
// Server records carry their own names; the bundled catalog is only the
// signed-out fallback, and a prettified slug beats a silently missing row.
@@ -33,16 +40,21 @@ function HistoryRow({
const category = (record.topicName as Category | undefined) ?? local?.category;
const likeTotal = (record.likeCount ?? 0) + (liked ? 1 : 0);
return (
-
+ onOpen(record.conceptId, title)}
+ style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
+ accessibilityRole="button"
+ accessibilityLabel={`Open ${title}`}
+ >
{title}
-
- {category ? : null}
-
-
+ {/* Category and likes each get their own row, so the heart never
+ wraps to a different line depending on chip width (issue #121). */}
+ {category ? : null}
+
{formatDateKey(record.date)}
-
+
);
}
@@ -50,23 +62,43 @@ export function HistoryScreen() {
const { loading, progress } = useProgress();
const { colors } = useTheme();
const styles = useMemo(() => createStyles(colors), [colors]);
+ // Composite: History is a tab screen that reaches up to the root stack's
+ // concept-detail modal (#124).
+ const navigation =
+ useNavigation<
+ CompositeNavigationProp<
+ BottomTabNavigationProp,
+ NativeStackNavigationProp
+ >
+ >();
- const records = [...progress.learned].sort((a, b) => (a.date < b.date ? 1 : -1));
+ // Newest first, capped at the last HISTORY_LIMIT to keep the feed focused.
+ const records = [...progress.learned]
+ .sort((a, b) => (a.date < b.date ? 1 : -1))
+ .slice(0, HISTORY_LIMIT);
const likedIds = useMemo(() => new Set(progress.likes), [progress.likes]);
+ const open = (conceptId: string, title: string) =>
+ navigation.navigate('ConceptDetail', { conceptId, title });
+
return (
`${r.date}-${r.conceptId}`}
renderItem={({ item }) => (
-
+
)}
contentContainerStyle={styles.content}
ListHeaderComponent={
History
- Everything you’ve learned so far.
+ Your last {HISTORY_LIMIT} concepts.
}
ListEmptyComponent={
@@ -78,7 +110,7 @@ export function HistoryScreen() {
) : (
-
+
Nothing here yet
Learn today’s concept and it will show up here.
@@ -113,7 +145,7 @@ const createStyles = (colors: ThemeColors) =>
color: colors.text,
},
subtitle: {
- fontSize: 14,
+ fontSize: scaleFont(14),
color: colors.textMuted,
},
list: {
@@ -131,23 +163,19 @@ const createStyles = (colors: ThemeColors) =>
gap: spacing.md,
...shadows.card,
},
+ rowPressed: { opacity: 0.7 },
rowText: {
gap: spacing.sm,
flexShrink: 1,
- },
- metaRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm,
- flexWrap: 'wrap',
+ alignItems: 'flex-start',
},
rowTitle: {
- fontSize: 16,
+ fontSize: scaleFont(16),
fontWeight: '600',
color: colors.text,
},
rowDate: {
- fontSize: 13,
+ fontSize: scaleFont(13),
color: colors.textMuted,
},
empty: {
@@ -156,12 +184,12 @@ const createStyles = (colors: ThemeColors) =>
paddingVertical: spacing.xl * 2,
},
emptyTitle: {
- fontSize: 17,
+ fontSize: scaleFont(17),
fontWeight: '700',
color: colors.text,
},
emptyText: {
- fontSize: 14,
+ fontSize: scaleFont(14),
color: colors.textMuted,
textAlign: 'center',
},
diff --git a/mobile/src/screens/PersonalizationScreen.tsx b/mobile/src/screens/PersonalizationScreen.tsx
index 8d56363..81c9c1f 100644
--- a/mobile/src/screens/PersonalizationScreen.tsx
+++ b/mobile/src/screens/PersonalizationScreen.tsx
@@ -5,7 +5,7 @@ import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { FollowPill } from '../components/FollowPill';
import { useTheme } from '../context/ThemeContext';
import { useTopics } from '../hooks/useTopics';
-import { spacing, ThemeColors, typography } from '../theme';
+import { scaleIcon, scaleFont, spacing, ThemeColors, typography } from '../theme';
export function PersonalizationScreen() {
const navigation = useNavigation();
@@ -23,7 +23,7 @@ export function PersonalizationScreen() {
accessibilityRole="button"
accessibilityLabel="Close"
>
-
+
@@ -77,7 +77,7 @@ const createStyles = (colors: ThemeColors) =>
},
title: {
...typography.title,
- fontSize: 24,
+ fontSize: scaleFont(24),
color: colors.text,
},
closeButton: {
@@ -85,14 +85,14 @@ const createStyles = (colors: ThemeColors) =>
},
sectionTitle: {
...typography.title,
- fontSize: 22,
+ fontSize: scaleFont(22),
color: colors.text,
marginBottom: spacing.xs,
},
sectionHint: {
- fontSize: 13,
+ fontSize: scaleFont(13),
color: colors.textMuted,
- lineHeight: 19,
+ lineHeight: scaleFont(19),
marginBottom: spacing.lg,
},
list: {
@@ -115,12 +115,12 @@ const createStyles = (colors: ThemeColors) =>
gap: 2,
},
topicName: {
- fontSize: 17,
+ fontSize: scaleFont(17),
fontWeight: '600',
color: colors.text,
},
topicMeta: {
- fontSize: 13,
+ fontSize: scaleFont(13),
color: colors.textMuted,
},
});
diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx
index 84b91e8..79f6d8b 100644
--- a/mobile/src/screens/ProfileScreen.tsx
+++ b/mobile/src/screens/ProfileScreen.tsx
@@ -1,6 +1,6 @@
import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants';
-import { useNavigation } from '@react-navigation/native';
+import { CompositeNavigationProp, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
@@ -10,7 +10,8 @@ import { LikeCount } from '../components/LikeCount';
import { useAuth } from '../context/AuthContext';
import { useProgress } from '../context/ProgressContext';
import { useTheme } from '../context/ThemeContext';
-import { CONCEPTS } from '../data/concepts';
+import { CONCEPTS_BY_ID } from '../data/concepts';
+import { RootStackParamList } from '../navigation';
import {
getCachedNotificationPrefs,
getNotificationPrefs,
@@ -18,7 +19,7 @@ import {
putNotificationPrefs,
registerForReminders,
} from '../services/notifications';
-import { radius, shadows, spacing, ThemeColors, typography } from '../theme';
+import { scaleIcon, scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme';
export type ProfileStackParamList = {
ProfileHome: undefined;
@@ -26,11 +27,16 @@ export type ProfileStackParamList = {
About: undefined;
};
-const CONCEPTS_BY_ID = new Map(CONCEPTS.map((c) => [c.id, c]));
-
export function ProfileScreen() {
+ // Composite: navigate within the Profile stack (Personalization, About) and
+ // up to the root stack's concept-detail modal (#124).
const navigation =
- useNavigation>();
+ useNavigation<
+ CompositeNavigationProp<
+ NativeStackNavigationProp,
+ NativeStackNavigationProp
+ >
+ >();
const { progress, streaks } = useProgress();
const { email, signOut } = useAuth();
const { colors, mode, toggle } = useTheme();
@@ -92,7 +98,7 @@ export function ProfileScreen() {
-
+
@@ -107,7 +113,7 @@ export function ProfileScreen() {
0 ? colors.streak : colors.textMuted}
active={streaks.current > 0}
/>
@@ -115,7 +121,7 @@ export function ProfileScreen() {
Daily streak
-
+
{progress.likes.length} likes · {progress.bookmarks.length} saved
@@ -129,7 +135,7 @@ export function ProfileScreen() {
accessibilityRole="button"
>
-
+
Personalize your feed
@@ -137,7 +143,7 @@ export function ProfileScreen() {
-
+
-
+
About
Version, what this app is, and how it works
-
+
Dark mode
@@ -175,7 +181,7 @@ export function ProfileScreen() {
{prefs ? (
-
+
Daily reminders
@@ -202,7 +208,15 @@ export function ProfileScreen() {
) : (
{saved.map((c) => (
-
+
+ navigation.navigate('ConceptDetail', { conceptId: c.id, title: c.title })
+ }
+ style={({ pressed }) => [styles.savedRow, pressed && styles.rowPressed]}
+ accessibilityRole="button"
+ accessibilityLabel={`Open ${c.title}`}
+ >
{c.title}
@@ -210,8 +224,8 @@ export function ProfileScreen() {
-
-
+
+
))}
)}
@@ -222,7 +236,7 @@ export function ProfileScreen() {
accessibilityRole="button"
>
-
+
Sign out
@@ -262,13 +276,13 @@ const createStyles = (colors: ThemeColors) =>
},
name: {
...typography.title,
- fontSize: 24,
+ fontSize: scaleFont(24),
color: colors.text,
},
subtitle: {
- fontSize: 12,
+ fontSize: scaleFont(12),
color: colors.textMuted,
- lineHeight: 17,
+ lineHeight: scaleFont(17),
},
cardsRow: {
flexDirection: 'row',
@@ -286,12 +300,12 @@ const createStyles = (colors: ThemeColors) =>
alignItems: 'flex-start',
},
cardValue: {
- fontSize: 15,
+ fontSize: scaleFont(15),
fontWeight: '700',
color: colors.text,
},
cardLabel: {
- fontSize: 12,
+ fontSize: scaleFont(12),
color: colors.textMuted,
},
rowCard: {
@@ -315,17 +329,17 @@ const createStyles = (colors: ThemeColors) =>
flexShrink: 1,
},
rowTitle: {
- fontSize: 15,
+ fontSize: scaleFont(15),
fontWeight: '600',
color: colors.text,
},
rowSubtitle: {
- fontSize: 12,
+ fontSize: scaleFont(12),
color: colors.textMuted,
marginTop: 2,
},
sectionLabel: {
- fontSize: 13,
+ fontSize: scaleFont(13),
fontWeight: '700',
letterSpacing: 1,
textTransform: 'uppercase',
@@ -333,7 +347,7 @@ const createStyles = (colors: ThemeColors) =>
marginBottom: -spacing.sm,
},
emptyText: {
- fontSize: 14,
+ fontSize: scaleFont(14),
color: colors.textMuted,
},
savedList: {
@@ -362,12 +376,12 @@ const createStyles = (colors: ThemeColors) =>
flexWrap: 'wrap',
},
savedTitle: {
- fontSize: 15,
+ fontSize: scaleFont(15),
fontWeight: '600',
color: colors.text,
},
version: {
- fontSize: 12,
+ fontSize: scaleFont(12),
color: colors.textMuted,
textAlign: 'center',
marginTop: spacing.md,
diff --git a/mobile/src/screens/StatsScreen.tsx b/mobile/src/screens/StatsScreen.tsx
index 14c90e8..e0641fa 100644
--- a/mobile/src/screens/StatsScreen.tsx
+++ b/mobile/src/screens/StatsScreen.tsx
@@ -7,7 +7,7 @@ import { useTheme } from '../context/ThemeContext';
import { useTopics } from '../hooks/useTopics';
import { CONCEPTS } from '../data/concepts';
import { ServerTopic } from '../services/topicsApi';
-import { radius, spacing, ThemeColors, typography } from '../theme';
+import { scaleFont, radius, spacing, ThemeColors, typography } from '../theme';
import { LearnedRecord } from '../types';
interface CategoryProgress {
@@ -161,11 +161,11 @@ const createStyles = (colors: ThemeColors) =>
color: colors.text,
},
subtitle: {
- fontSize: 14,
+ fontSize: scaleFont(14),
color: colors.textMuted,
},
sectionLabel: {
- fontSize: 13,
+ fontSize: scaleFont(13),
fontWeight: '700',
letterSpacing: 1,
textTransform: 'uppercase',
@@ -182,7 +182,7 @@ const createStyles = (colors: ThemeColors) =>
},
cardTitle: {
...typography.heading,
- fontSize: 17,
+ fontSize: scaleFont(17),
color: colors.text,
},
overallRow: {
@@ -191,7 +191,7 @@ const createStyles = (colors: ThemeColors) =>
justifyContent: 'space-between',
},
overallCount: {
- fontSize: 15,
+ fontSize: scaleFont(15),
fontWeight: '700',
color: colors.primary,
},
@@ -199,13 +199,13 @@ const createStyles = (colors: ThemeColors) =>
gap: spacing.sm,
},
categoryName: {
- fontSize: 14,
+ fontSize: scaleFont(14),
fontWeight: '600',
color: colors.textSecondary,
flexShrink: 1,
},
categoryCount: {
- fontSize: 13,
+ fontSize: scaleFont(13),
fontWeight: '600',
color: colors.textMuted,
},
diff --git a/mobile/src/screens/TodayScreen.tsx b/mobile/src/screens/TodayScreen.tsx
index 9a05a8f..b4d2a63 100644
--- a/mobile/src/screens/TodayScreen.tsx
+++ b/mobile/src/screens/TodayScreen.tsx
@@ -9,7 +9,7 @@ import { StreakBadge } from '../components/StreakBadge';
import { useProgress } from '../context/ProgressContext';
import { useTheme } from '../context/ThemeContext';
import { toConcept } from '../services/dailyApi';
-import { radius, shadows, spacing, ThemeColors, typography } from '../theme';
+import { scaleIcon, scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme';
export function TodayScreen() {
const {
@@ -60,7 +60,7 @@ export function TodayScreen() {
>
@@ -81,14 +81,14 @@ export function TodayScreen() {
{offline ? (
-
+
Offline — showing your saved copy
) : null}
{outsideTopics ? (
-
+
You’ve read everything in your topics, so here’s one from further afield.
@@ -97,7 +97,7 @@ export function TodayScreen() {
{exhausted ? (
-
+
You’ve learned every concept available. New ones are on the way.
@@ -115,7 +115,7 @@ export function TodayScreen() {
{done ? (
-
+
Learned today — see you tomorrow!
) : (
@@ -154,12 +154,12 @@ const createStyles = (colors: ThemeColors) =>
},
appName: {
...typography.title,
- fontSize: 30,
+ fontSize: scaleFont(30),
color: colors.text,
},
tagline: {
- fontSize: 13.5,
- lineHeight: 19,
+ fontSize: scaleFont(13.5),
+ lineHeight: scaleFont(19),
color: colors.textMuted,
},
themeButton: {
@@ -176,7 +176,7 @@ const createStyles = (colors: ThemeColors) =>
opacity: 0.6,
},
sectionLabel: {
- fontSize: 12,
+ fontSize: scaleFont(12),
fontWeight: '700',
letterSpacing: 1.4,
textTransform: 'uppercase',
@@ -190,7 +190,7 @@ const createStyles = (colors: ThemeColors) =>
marginBottom: -spacing.sm,
},
offlineText: {
- fontSize: 12.5,
+ fontSize: scaleFont(12.5),
color: colors.textMuted,
},
noteBox: {
@@ -207,9 +207,9 @@ const createStyles = (colors: ThemeColors) =>
},
noteText: {
flex: 1,
- fontSize: 13,
+ fontSize: scaleFont(13),
color: colors.textMuted,
- lineHeight: 18,
+ lineHeight: scaleFont(18),
},
doneBox: {
flexDirection: 'row',
@@ -225,7 +225,7 @@ const createStyles = (colors: ThemeColors) =>
},
doneText: {
color: colors.success,
- fontSize: 16,
+ fontSize: scaleFont(16),
fontWeight: '600',
flexShrink: 1,
textAlign: 'center',
diff --git a/mobile/src/services/conceptApi.ts b/mobile/src/services/conceptApi.ts
new file mode 100644
index 0000000..fdae75a
--- /dev/null
+++ b/mobile/src/services/conceptApi.ts
@@ -0,0 +1,31 @@
+import { apiRequest } from '../api/client';
+import { Category, Concept } from '../types';
+
+/** Server shape from GET /v1/concepts/{slug} (matches the daily ConceptOut). */
+interface ConceptResponse {
+ id: string;
+ slug: string;
+ title: string;
+ summary: string;
+ example: string | null;
+ topic_slug: string;
+ topic_name: string;
+ like_count?: number;
+}
+
+/**
+ * Fetch a full concept by slug for the detail view. The app uses the slug as a
+ * concept's local id (see dailyApi.toConcept), so History/Saved conceptIds pass
+ * straight through here.
+ */
+export async function fetchConcept(slug: string): Promise {
+ const c = await apiRequest(`/v1/concepts/${encodeURIComponent(slug)}`);
+ return {
+ id: c.slug,
+ title: c.title,
+ category: c.topic_name as Category,
+ summary: c.summary,
+ example: c.example ?? undefined,
+ likeCount: c.like_count ?? 0,
+ };
+}
diff --git a/mobile/src/theme/index.ts b/mobile/src/theme/index.ts
index 7775722..0d73605 100644
--- a/mobile/src/theme/index.ts
+++ b/mobile/src/theme/index.ts
@@ -1,3 +1,13 @@
+/**
+ * Global type scale. Every font size and line height in the app is wrapped
+ * in scaleFont(), so the whole app's text scales from this one multiplier.
+ * Rounded to the nearest half-point to stay crisp on screen.
+ */
+export const FONT_SCALE = 0.9;
+export const scaleFont = (n: number): number => Math.round(n * FONT_SCALE * 2) / 2;
+/** Same scale for icon glyphs, rounded to whole pixels so they stay crisp. */
+export const scaleIcon = (n: number): number => Math.round(n * FONT_SCALE);
+
export interface ThemeColors {
background: string;
surface: string;
@@ -87,8 +97,8 @@ export const shadows = {
export const typography = {
/** Display font for screen titles; loaded in App via expo-font.
* No fontWeight here — Android would apply faux bold on top of the 700 font file. */
- title: { fontSize: 28, fontFamily: 'SpaceGrotesk_700Bold' },
- heading: { fontSize: 22, fontWeight: '700' as const },
- body: { fontSize: 16, lineHeight: 24 },
- caption: { fontSize: 13 },
+ title: { fontSize: scaleFont(28), fontFamily: 'SpaceGrotesk_700Bold' },
+ heading: { fontSize: scaleFont(22), fontWeight: '700' as const },
+ body: { fontSize: scaleFont(16), lineHeight: scaleFont(24) },
+ caption: { fontSize: scaleFont(13) },
};