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) }, };