From 8ad27455d703a770145a4c0d2c25c59e840274af Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sat, 5 Sep 2026 19:09:23 +0500 Subject: [PATCH 01/15] Fix APK native-gating: stop capturing the trailing period in runtimeVersion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate reads the previous runtimeVersion out of the apk-latest release notes with a regex whose [0-9.]* class greedily swallowed the period that ends the sentence ("runtimeVersion: 1.3.0. JS updates…"), yielding "1.3.0.". That never equals the clean "1.3.0" from app.config.js, so every JS-only release looked native and rebuilt the APK needlessly (v1.4.1 did exactly this). Anchor the capture on a trailing digit so the period is left out, and drop the period right after the version in the notes as a second guard. --- .github/workflows/release-apk.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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" From cf4535cfd097e2f4e711c5b181d8c4376585daf1 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sat, 5 Sep 2026 22:53:33 +0500 Subject: [PATCH 02/15] Add /reset-password landing page for password recovery (#112) Supabase emails a recovery link; it needs somewhere to land so the user can set a new password. Mirroring the existing /confirmed web landing (the app is native, so there is no web app), this serves a small dark-themed page that: - reads the recovery session from the URL fragment (implicit flow), which the browser never sends to the server, so the access token stays client-side; - posts the new password to Supabase's PUT /auth/v1/user with the public anon key + the recovery bearer token; - validates length/match, surfaces expired-link and error states, and clears the spent token from history on success. Adds a public SUPABASE_ANON_KEY setting (already shipped in the app bundle) for the page to use; when it is unset the route serves a clear 'unavailable' page rather than a form that can't submit. --- backend/.env.example | 3 + backend/app/api/v1/pages.py | 159 ++++++++++++++++++++++++++++++++++++ backend/app/config.py | 4 + 3 files changed, 166 insertions(+) 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/pages.py b/backend/app/api/v1/pages.py index 153e203..a613469 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,158 @@ @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 = _CONFIRMED.replace( + "One Concept — email confirmed", + "One Concept — reset password", +).replace( + "

Email confirmed ✓

", + "

Reset unavailable

", +).replace( + "

You're all set. Open the One Concept app on your\n" + " phone and sign in to get today's concept.

", + "

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 = "" From 42489e2c9fad3ef9d30ab61cc36a9e1895dcf739 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sat, 5 Sep 2026 22:55:49 +0500 Subject: [PATCH 03/15] Add 'Forgot password?' to sign-in (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resetPassword() sends a Supabase recovery email whose link lands on the backend /reset-password page. The sign-in screen gets a 'Forgot password?' link (sign-in mode only) that emails a reset to the address already typed. The confirmation is deliberately neutral — 'if an account exists…' — because Supabase returns success regardless of whether the email is registered, and the UI must not become an account-enumeration oracle. --- mobile/src/context/AuthContext.tsx | 17 +++++++++++-- mobile/src/screens/AuthScreen.tsx | 41 +++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/mobile/src/context/AuthContext.tsx b/mobile/src/context/AuthContext.tsx index c7120b1..24d5a05 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,17 @@ 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. + const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { + redirectTo: `${API_BASE_URL}/reset-password`, + }); + 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 +148,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/screens/AuthScreen.tsx b/mobile/src/screens/AuthScreen.tsx index 32fed57..e6a53b2 100644 --- a/mobile/src/screens/AuthScreen.tsx +++ b/mobile/src/screens/AuthScreen.tsx @@ -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 ( + {mode === 'signIn' ? ( + + Forgot password? + + ) : null} {error ? ( @@ -226,4 +259,10 @@ const createStyles = (colors: ThemeColors) => fontWeight: '600', paddingVertical: spacing.sm, }, + forgot: { alignSelf: 'flex-end', paddingTop: spacing.xs }, + forgotText: { + color: colors.primary, + fontSize: 13, + fontWeight: '600', + }, }); From 28e9fbc378517d8d3b6d5a8d342aef404d21e05a Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sat, 5 Sep 2026 23:10:10 +0500 Subject: [PATCH 04/15] Harden /reset-password page (review of #122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build the 'reset unavailable' page from its own template instead of string-replacing substrings of _CONFIRMED, which silently no-oped (and showed 'Email confirmed ✓') if that copy ever changed. - Surface Supabase failures reported in the query string as well as the fragment, so an expired/invalid link shows the real reason. - Drop a dead .replace(/+/g,' ') — URLSearchParams already decodes '+'. --- backend/app/api/v1/pages.py | 51 ++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/backend/app/api/v1/pages.py b/backend/app/api/v1/pages.py index a613469..13f30b1 100644 --- a/backend/app/api/v1/pages.py +++ b/backend/app/api/v1/pages.py @@ -118,15 +118,19 @@ async def confirmed() -> str: intro.classList.add('hidden'); } - // Recovery session arrives in the URL fragment (implicit flow). - var params = new URLSearchParams(location.hash.slice(1)); - var accessToken = params.get('access_token'); - var type = params.get('type'); - var linkError = params.get('error_description'); + // Recovery session arrives in the URL fragment (implicit flow). Supabase + // can report a failure in either the fragment or the query string, so + // check both and surface the real reason instead of the generic message. + var hashParams = new URLSearchParams(location.hash.slice(1)); + var queryParams = new URLSearchParams(location.search.slice(1)); + var accessToken = hashParams.get('access_token'); + var type = hashParams.get('type'); + var linkError = hashParams.get('error_description') || queryParams.get('error_description') + || hashParams.get('error') || queryParams.get('error'); if (linkError) { disableForm(); - fail(linkError.replace(/\\+/g, ' ')); + fail(linkError); } else if (!accessToken || type !== 'recovery') { disableForm(); fail('This reset link is invalid or has expired. Open the One Concept app and request a new link.'); @@ -172,18 +176,29 @@ async def confirmed() -> str: """ -_RESET_UNCONFIGURED = _CONFIRMED.replace( - "One Concept — email confirmed", - "One Concept — reset password", -).replace( - "

Email confirmed ✓

", - "

Reset unavailable

", -).replace( - "

You're all set. Open the One Concept app on your\n" - " phone and sign in to get today's concept.

", - "

Password reset isn't configured on the server yet. " - "Please try again later.

", -) +_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) From 182b8cf622484e57a6a2526275b94662630ee8dd Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sat, 5 Sep 2026 23:10:10 +0500 Subject: [PATCH 05/15] resetPassword: skip redirectTo when API base URL is unset (review of #122) An empty EXPO_PUBLIC_API_BASE_URL made redirectTo the relative '/reset-password', which isn't a valid Supabase redirect. Fall back to the project's Site URL in that case instead of sending a broken redirect. --- mobile/src/context/AuthContext.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/mobile/src/context/AuthContext.tsx b/mobile/src/context/AuthContext.tsx index 24d5a05..89c4306 100644 --- a/mobile/src/context/AuthContext.tsx +++ b/mobile/src/context/AuthContext.tsx @@ -119,9 +119,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { // 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. - const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { - redirectTo: `${API_BASE_URL}/reset-password`, - }); + // + // 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)); }, []); From cc1454970f462cbfe1d8ccc3cc81f5c496274812 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sun, 6 Sep 2026 00:03:22 +0500 Subject: [PATCH 06/15] Fix jumping heart icon in History cards (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The category chip and like badge shared one flex-wrap row, so on a narrow card a long category label pushed the heart onto a second line for some concepts but not others — the icon appeared to jump between cards. Give the like badge its own row beneath the category chip (as the issue suggests) so its position is identical on every card, and left-align the column so the badge hugs its content. --- mobile/src/screens/HistoryScreen.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/mobile/src/screens/HistoryScreen.tsx b/mobile/src/screens/HistoryScreen.tsx index e38fd99..9fb5926 100644 --- a/mobile/src/screens/HistoryScreen.tsx +++ b/mobile/src/screens/HistoryScreen.tsx @@ -36,10 +36,10 @@ function HistoryRow({ {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)} @@ -134,12 +134,7 @@ const createStyles = (colors: ThemeColors) => rowText: { gap: spacing.sm, flexShrink: 1, - }, - metaRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - flexWrap: 'wrap', + alignItems: 'flex-start', }, rowTitle: { fontSize: 16, From 252f36866517b46509e4faab56cf0509cf2d9cb6 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sun, 6 Sep 2026 00:07:34 +0500 Subject: [PATCH 07/15] Scale the whole app's type down ~10% from one lever Add scaleFont() + FONT_SCALE (0.9) to the theme and wrap every fontSize and lineHeight through it, so the app's text is uniformly ~10% smaller and can be retuned from a single constant. Sizes keep their original readable numbers in the code; scaleFont rounds to the nearest half-point (e.g. 16->14.5, 28->25, smallest 11->10) so everything stays crisp and legible. --- mobile/src/components/CategoryChip.tsx | 4 ++-- mobile/src/components/ConceptActions.tsx | 4 ++-- mobile/src/components/ConceptCard.tsx | 16 ++++++------- mobile/src/components/FollowPill.tsx | 4 ++-- mobile/src/components/LikeCount.tsx | 4 ++-- mobile/src/components/PrimaryButton.tsx | 4 ++-- mobile/src/components/StreakBadge.tsx | 6 ++--- mobile/src/components/WhatsNewCard.tsx | 10 ++++---- mobile/src/screens/AboutScreen.tsx | 18 +++++++-------- mobile/src/screens/AuthScreen.tsx | 16 ++++++------- mobile/src/screens/HistoryScreen.tsx | 12 +++++----- mobile/src/screens/PersonalizationScreen.tsx | 14 ++++++------ mobile/src/screens/ProfileScreen.tsx | 24 ++++++++++---------- mobile/src/screens/StatsScreen.tsx | 14 ++++++------ mobile/src/screens/TodayScreen.tsx | 18 +++++++-------- mobile/src/theme/index.ts | 16 +++++++++---- 16 files changed, 96 insertions(+), 88 deletions(-) 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..56de6f2 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 { scaleFont, radius, spacing, ThemeColors } from '../theme'; import { Concept } from '../types'; function usePop() { @@ -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..7e088fa 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 { scaleFont, radius, spacing, ThemeColors } from '../theme'; interface Props { following: boolean; @@ -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..61a594f 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 { scaleFont, ThemeColors } from '../theme'; /** * A small "♥ N" badge showing how many people liked a concept. Renders nothing @@ -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..da2e0c3 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 { scaleFont, radius, shadows, spacing, ThemeColors } from '../theme'; import { StreakStats } from '../services/streak'; export function StreakBadge({ streaks }: { streaks: StreakStats }) { @@ -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..c3caedb 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 { scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme'; interface Props { entry: WhatsNewEntry; @@ -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/screens/AboutScreen.tsx b/mobile/src/screens/AboutScreen.tsx index 4d53924..2550463 100644 --- a/mobile/src/screens/AboutScreen.tsx +++ b/mobile/src/screens/AboutScreen.tsx @@ -5,7 +5,7 @@ import Constants from 'expo-constants'; import { useMemo } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../context/ThemeContext'; -import { radius, spacing, ThemeColors, typography } from '../theme'; +import { scaleFont, radius, spacing, ThemeColors, typography } from '../theme'; import { ProfileStackParamList } from './ProfileScreen'; const HOW_IT_WORKS = [ @@ -72,7 +72,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 +84,24 @@ 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 }, + credit: { fontSize: scaleFont(13), color: colors.textMuted, textAlign: 'center' }, }); diff --git a/mobile/src/screens/AuthScreen.tsx b/mobile/src/screens/AuthScreen.tsx index e6a53b2..eb36d29 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 { scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme'; type Mode = 'signIn' | 'signUp'; @@ -219,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', @@ -237,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, }, @@ -250,19 +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: 13, + fontSize: scaleFont(13), fontWeight: '600', }, }); diff --git a/mobile/src/screens/HistoryScreen.tsx b/mobile/src/screens/HistoryScreen.tsx index 9fb5926..9aec63c 100644 --- a/mobile/src/screens/HistoryScreen.tsx +++ b/mobile/src/screens/HistoryScreen.tsx @@ -8,7 +8,7 @@ import { useProgress } from '../context/ProgressContext'; import { useTheme } from '../context/ThemeContext'; import { CONCEPTS } from '../data/concepts'; import { formatDateKey } from '../services/dates'; -import { radius, shadows, spacing, ThemeColors, typography } from '../theme'; +import { 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])); @@ -113,7 +113,7 @@ const createStyles = (colors: ThemeColors) => color: colors.text, }, subtitle: { - fontSize: 14, + fontSize: scaleFont(14), color: colors.textMuted, }, list: { @@ -137,12 +137,12 @@ const createStyles = (colors: ThemeColors) => alignItems: 'flex-start', }, rowTitle: { - fontSize: 16, + fontSize: scaleFont(16), fontWeight: '600', color: colors.text, }, rowDate: { - fontSize: 13, + fontSize: scaleFont(13), color: colors.textMuted, }, empty: { @@ -151,12 +151,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..6046cad 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 { scaleFont, spacing, ThemeColors, typography } from '../theme'; export function PersonalizationScreen() { const navigation = useNavigation(); @@ -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..8b44d61 100644 --- a/mobile/src/screens/ProfileScreen.tsx +++ b/mobile/src/screens/ProfileScreen.tsx @@ -18,7 +18,7 @@ import { putNotificationPrefs, registerForReminders, } from '../services/notifications'; -import { radius, shadows, spacing, ThemeColors, typography } from '../theme'; +import { scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme'; export type ProfileStackParamList = { ProfileHome: undefined; @@ -262,13 +262,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 +286,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 +315,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 +333,7 @@ const createStyles = (colors: ThemeColors) => marginBottom: -spacing.sm, }, emptyText: { - fontSize: 14, + fontSize: scaleFont(14), color: colors.textMuted, }, savedList: { @@ -362,12 +362,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..48a02e4 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 { scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme'; export function TodayScreen() { const { @@ -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/theme/index.ts b/mobile/src/theme/index.ts index 7775722..b8a73f0 100644 --- a/mobile/src/theme/index.ts +++ b/mobile/src/theme/index.ts @@ -1,3 +1,11 @@ +/** + * 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; + export interface ThemeColors { background: string; surface: string; @@ -87,8 +95,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) }, }; From 1a9ec48312d32c4ec044f1fd69bbdb0d264208c5 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sun, 6 Sep 2026 00:13:00 +0500 Subject: [PATCH 08/15] Scale icon sizes with the text so proportions stay uniform (review of #125) Text shrank ~10% via scaleFont but the 35 Ionicons/AnimatedFlame size props stayed put, leaving icons proportionally large. Add scaleIcon() (same FONT_SCALE, rounded to whole pixels) and route every icon size through it, so the whole UI scales together from one lever. --- mobile/src/components/ConceptActions.tsx | 8 +++---- mobile/src/components/FollowPill.tsx | 4 ++-- mobile/src/components/LikeCount.tsx | 4 ++-- mobile/src/components/StreakBadge.tsx | 8 +++---- mobile/src/components/WhatsNewCard.tsx | 8 +++---- mobile/src/screens/AboutScreen.tsx | 8 +++---- mobile/src/screens/AuthScreen.tsx | 8 +++---- mobile/src/screens/HistoryScreen.tsx | 4 ++-- mobile/src/screens/PersonalizationScreen.tsx | 4 ++-- mobile/src/screens/ProfileScreen.tsx | 24 ++++++++++---------- mobile/src/screens/TodayScreen.tsx | 12 +++++----- mobile/src/theme/index.ts | 2 ++ 12 files changed, 48 insertions(+), 46 deletions(-) diff --git a/mobile/src/components/ConceptActions.tsx b/mobile/src/components/ConceptActions.tsx index 56de6f2..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 { scaleFont, 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" > - + ); diff --git a/mobile/src/components/FollowPill.tsx b/mobile/src/components/FollowPill.tsx index 7e088fa..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 { scaleFont, 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) { > diff --git a/mobile/src/components/LikeCount.tsx b/mobile/src/components/LikeCount.tsx index 61a594f..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 { scaleFont, 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} ); diff --git a/mobile/src/components/StreakBadge.tsx b/mobile/src/components/StreakBadge.tsx index da2e0c3..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 { scaleFont, 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 diff --git a/mobile/src/components/WhatsNewCard.tsx b/mobile/src/components/WhatsNewCard.tsx index c3caedb..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 { scaleFont, 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) { diff --git a/mobile/src/screens/AboutScreen.tsx b/mobile/src/screens/AboutScreen.tsx index 2550463..7e0005e 100644 --- a/mobile/src/screens/AboutScreen.tsx +++ b/mobile/src/screens/AboutScreen.tsx @@ -5,7 +5,7 @@ import Constants from 'expo-constants'; import { useMemo } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../context/ThemeContext'; -import { scaleFont, radius, spacing, ThemeColors, typography } from '../theme'; +import { scaleIcon, scaleFont, radius, spacing, ThemeColors, typography } from '../theme'; import { ProfileStackParamList } from './ProfileScreen'; const HOW_IT_WORKS = [ @@ -30,13 +30,13 @@ export function AboutScreen() { accessibilityRole="button" accessibilityLabel="Close" > - + - + One Concept Version {version} @@ -51,7 +51,7 @@ export function AboutScreen() { {HOW_IT_WORKS.map((line, i) => ( - + {line} ))} diff --git a/mobile/src/screens/AuthScreen.tsx b/mobile/src/screens/AuthScreen.tsx index eb36d29..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 { scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme'; +import { scaleIcon, scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme'; type Mode = 'signIn' | 'signUp'; @@ -98,7 +98,7 @@ export function AuthScreen() { > @@ -156,14 +156,14 @@ export function AuthScreen() { {error ? ( - + {error} ) : null} {notice ? ( - + {notice} ) : null} diff --git a/mobile/src/screens/HistoryScreen.tsx b/mobile/src/screens/HistoryScreen.tsx index 9aec63c..96df2ca 100644 --- a/mobile/src/screens/HistoryScreen.tsx +++ b/mobile/src/screens/HistoryScreen.tsx @@ -8,7 +8,7 @@ import { useProgress } from '../context/ProgressContext'; import { useTheme } from '../context/ThemeContext'; import { CONCEPTS } from '../data/concepts'; import { formatDateKey } from '../services/dates'; -import { scaleFont, 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])); @@ -78,7 +78,7 @@ export function HistoryScreen() { ) : ( - + Nothing here yet Learn today’s concept and it will show up here. diff --git a/mobile/src/screens/PersonalizationScreen.tsx b/mobile/src/screens/PersonalizationScreen.tsx index 6046cad..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 { scaleFont, 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" > - + diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx index 8b44d61..29b8595 100644 --- a/mobile/src/screens/ProfileScreen.tsx +++ b/mobile/src/screens/ProfileScreen.tsx @@ -18,7 +18,7 @@ import { putNotificationPrefs, registerForReminders, } from '../services/notifications'; -import { scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme'; +import { scaleIcon, scaleFont, radius, shadows, spacing, ThemeColors, typography } from '../theme'; export type ProfileStackParamList = { ProfileHome: undefined; @@ -92,7 +92,7 @@ export function ProfileScreen() { - + @@ -107,7 +107,7 @@ export function ProfileScreen() { 0 ? colors.streak : colors.textMuted} active={streaks.current > 0} /> @@ -115,7 +115,7 @@ export function ProfileScreen() { Daily streak - + {progress.likes.length} likes · {progress.bookmarks.length} saved @@ -129,7 +129,7 @@ export function ProfileScreen() { accessibilityRole="button" > - + Personalize your feed @@ -137,7 +137,7 @@ export function ProfileScreen() { - + - + About Version, what this app is, and how it works - + Dark mode @@ -175,7 +175,7 @@ export function ProfileScreen() { {prefs ? ( - + Daily reminders @@ -210,7 +210,7 @@ export function ProfileScreen() { - + ))} @@ -222,7 +222,7 @@ export function ProfileScreen() { accessibilityRole="button" > - + Sign out diff --git a/mobile/src/screens/TodayScreen.tsx b/mobile/src/screens/TodayScreen.tsx index 48a02e4..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 { scaleFont, 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! ) : ( diff --git a/mobile/src/theme/index.ts b/mobile/src/theme/index.ts index b8a73f0..0d73605 100644 --- a/mobile/src/theme/index.ts +++ b/mobile/src/theme/index.ts @@ -5,6 +5,8 @@ */ 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; From 20610582730f1e340291b7f6fe8ce1ed5e44ad2f Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sun, 6 Sep 2026 00:17:47 +0500 Subject: [PATCH 09/15] Add branding links, developer attribution & feedback actions to About (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the static 'Made by Coding Moves' footer into interactive links and add a support section: - Footer: 'Built with ❤️ by Coding Moves' -> github.com/Coding-Moves, with a 'Developed by @Muawiya-contact' subline -> the developer's GitHub. - Feedback & support: 'Contact us' opens the mail client prefilled with the app version in the subject, and 'Report an issue on GitHub' opens issues/new. Links use React Native's Linking.openURL (best-effort). The version in the mailto subject and the hero are read from expo-constants, so they track the shipped version automatically. --- mobile/src/screens/AboutScreen.tsx | 73 ++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/mobile/src/screens/AboutScreen.tsx b/mobile/src/screens/AboutScreen.tsx index 7e0005e..26a5839 100644 --- a/mobile/src/screens/AboutScreen.tsx +++ b/mobile/src/screens/AboutScreen.tsx @@ -3,7 +3,7 @@ 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 { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../context/ThemeContext'; import { scaleIcon, scaleFont, radius, spacing, ThemeColors, typography } from '../theme'; import { ProfileStackParamList } from './ProfileScreen'; @@ -14,12 +14,27 @@ 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'; + +/** Best-effort open; a device with no handler (rare) simply does nothing. */ +function openURL(url: string) { + Linking.openURL(url).catch(() => {}); +} + 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}`); + }; + return ( @@ -57,7 +72,44 @@ export function AboutScreen() { ))} - 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)} + 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)} accessibilityRole="link"> + Coding Moves + + + + Developed by{' '} + openURL(GITHUB_DEV)} accessibilityRole="link"> + @Muawiya-contact + + + ); } @@ -103,5 +155,20 @@ const createStyles = (colors: ThemeColors) => item: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm }, itemIcon: { marginTop: 1 }, itemText: { flex: 1, fontSize: scaleFont(15), lineHeight: scaleFont(21), color: colors.textSecondary }, - credit: { fontSize: scaleFont(13), color: colors.textMuted, textAlign: 'center' }, + 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' }, }); From b4367c1f16ffc7fb5cf44ebfc41beceb6ce643d5 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sun, 6 Sep 2026 00:31:26 +0500 Subject: [PATCH 10/15] About: show a fallback alert when a link can't open (review of #126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openURL swallowed every rejection, so tapping Contact/links on a device with no handler (e.g. no mail app) did nothing. Now it surfaces an alert with the destination — the email address or the URL — so the tap is never a dead end. --- mobile/src/screens/AboutScreen.tsx | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/mobile/src/screens/AboutScreen.tsx b/mobile/src/screens/AboutScreen.tsx index 26a5839..6ce6b13 100644 --- a/mobile/src/screens/AboutScreen.tsx +++ b/mobile/src/screens/AboutScreen.tsx @@ -3,7 +3,7 @@ import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import Constants from 'expo-constants'; import { useMemo } from 'react'; -import { Linking, 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 { scaleIcon, scaleFont, radius, spacing, ThemeColors, typography } from '../theme'; import { ProfileStackParamList } from './ProfileScreen'; @@ -19,9 +19,15 @@ 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'; -/** Best-effort open; a device with no handler (rare) simply does nothing. */ -function openURL(url: string) { - Linking.openURL(url).catch(() => {}); +/** + * 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() { @@ -32,7 +38,7 @@ export function AboutScreen() { const contact = () => { const subject = encodeURIComponent(`One Concept App Feedback (v${version})`); - openURL(`mailto:${FEEDBACK_EMAIL}?subject=${subject}`); + openURL(`mailto:${FEEDBACK_EMAIL}?subject=${subject}`, `Reach us at ${FEEDBACK_EMAIL}`); }; return ( @@ -85,7 +91,7 @@ export function AboutScreen() { openURL(ISSUES_URL)} + onPress={() => openURL(ISSUES_URL, ISSUES_URL)} style={({ pressed }) => [styles.actionRow, pressed && styles.pressed]} accessibilityRole="link" accessibilityLabel="Report an issue on GitHub" @@ -99,13 +105,13 @@ export function AboutScreen() { Built with ❤️ by{' '} - openURL(GITHUB_ORG)} accessibilityRole="link"> + openURL(GITHUB_ORG, GITHUB_ORG)} accessibilityRole="link"> Coding Moves Developed by{' '} - openURL(GITHUB_DEV)} accessibilityRole="link"> + openURL(GITHUB_DEV, GITHUB_DEV)} accessibilityRole="link"> @Muawiya-contact From dabb488a397d028d52bdb33efad6d26f5ebbfcbe Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sun, 6 Sep 2026 00:36:03 +0500 Subject: [PATCH 11/15] Add GET /v1/concepts/{slug} to fetch a full concept (#124) History and Saved rows only carry a concept's name/topic, so reopening one needs its body. This returns the published concept (summary + example + topic) by slug, with other users' like_count (the client adds the viewer's own, like the daily and state endpoints), or 404 if there's no such concept. --- backend/app/api/v1/concepts.py | 17 ++++++++++- backend/app/services/concepts.py | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/concepts.py 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/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, + ) From b95f313255bb89e748306797e9fc8c9e45bb6421 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sun, 6 Sep 2026 00:38:41 +0500 Subject: [PATCH 12/15] Add a concept-detail modal reachable from any tab (#124) - conceptApi.fetchConcept(slug) loads a full concept from the new endpoint. - ConceptDetailScreen renders the concept via the existing ConceptCard + ConceptActions (so like/save/share work there too), with a loading state and an offline/not-found fallback to the bundled catalog. - Wrap the bottom tabs in a root stack so the detail modal can be presented above them from both History and Profile. --- mobile/App.tsx | 83 ++++++++++------ mobile/src/navigation.ts | 13 +++ mobile/src/screens/ConceptDetailScreen.tsx | 108 +++++++++++++++++++++ mobile/src/services/conceptApi.ts | 31 ++++++ 4 files changed, 203 insertions(+), 32 deletions(-) create mode 100644 mobile/src/navigation.ts create mode 100644 mobile/src/screens/ConceptDetailScreen.tsx create mode 100644 mobile/src/services/conceptApi.ts 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/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/ConceptDetailScreen.tsx b/mobile/src/screens/ConceptDetailScreen.tsx new file mode 100644 index 0000000..097d309 --- /dev/null +++ b/mobile/src/screens/ConceptDetailScreen.tsx @@ -0,0 +1,108 @@ +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 } from '../data/concepts'; +import { fetchConcept } from '../services/conceptApi'; +import { RootStackParamList } from '../navigation'; +import { scaleFont, scaleIcon, spacing, ThemeColors, typography } from '../theme'; +import { Concept } from '../types'; + +const CONCEPTS_BY_ID = new Map(CONCEPTS.map((c) => [c.id, c])); + +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/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, + }; +} From 14ee19573927d164eef73ce99adf40f598686768 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sun, 6 Sep 2026 00:41:39 +0500 Subject: [PATCH 13/15] Make History & Saved cards open the concept detail; cap History at 10 (#124) - Tapping a History row or a Saved concept opens the detail modal for it. - History now shows only the last 10 learned concepts (subtitle updated to match); the rows are display-only (no nested toggles), so a card tap can't collide with an inner control. - Profile's navigation is now composite so it can reach the root modal. --- mobile/src/screens/HistoryScreen.tsx | 38 +++++++++++++++++++++++----- mobile/src/screens/ProfileScreen.tsx | 24 +++++++++++++++--- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/mobile/src/screens/HistoryScreen.tsx b/mobile/src/screens/HistoryScreen.tsx index 96df2ca..54b4170 100644 --- a/mobile/src/screens/HistoryScreen.tsx +++ b/mobile/src/screens/HistoryScreen.tsx @@ -1,18 +1,24 @@ import { Ionicons } from '@expo/vector-icons'; +import { useNavigation } from '@react-navigation/native'; +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 { RootStackParamList } from '../navigation'; import { formatDateKey } from '../services/dates'; 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 +27,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,7 +41,12 @@ 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 and likes each get their own row, so the heart never @@ -42,7 +55,7 @@ function HistoryRow({ {formatDateKey(record.date)} - + ); } @@ -50,23 +63,35 @@ export function HistoryScreen() { const { loading, progress } = useProgress(); const { colors } = useTheme(); const styles = useMemo(() => createStyles(colors), [colors]); + const navigation = useNavigation>(); - 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={ @@ -131,6 +156,7 @@ const createStyles = (colors: ThemeColors) => gap: spacing.md, ...shadows.card, }, + rowPressed: { opacity: 0.7 }, rowText: { gap: spacing.sm, flexShrink: 1, diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx index 29b8595..a8e41ec 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'; @@ -11,6 +11,7 @@ import { useAuth } from '../context/AuthContext'; import { useProgress } from '../context/ProgressContext'; import { useTheme } from '../context/ThemeContext'; import { CONCEPTS } from '../data/concepts'; +import { RootStackParamList } from '../navigation'; import { getCachedNotificationPrefs, getNotificationPrefs, @@ -29,8 +30,15 @@ export type ProfileStackParamList = { 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(); @@ -202,7 +210,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} @@ -211,7 +227,7 @@ export function ProfileScreen() { - + ))} )} From 99e663b6d22749ecabb72bd48a2618035ddaf4ed Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sun, 6 Sep 2026 00:48:39 +0500 Subject: [PATCH 14/15] Test the concept endpoint; dedup CONCEPTS_BY_ID; fix History nav type (review of #127) - Add integration tests for get_concept_out: returns the body with only other users' likes (excludes the viewer's own), and None for an unknown slug. - Export a single CONCEPTS_BY_ID from data/concepts and import it in the three screens instead of each rebuilding the same map. - Type HistoryScreen's navigation as a CompositeNavigationProp (tab + root stack) to match how it's actually used, mirroring ProfileScreen. --- backend/tests/test_writes.py | 31 ++++++++++++++++++++++ mobile/src/data/concepts.ts | 4 +++ mobile/src/screens/ConceptDetailScreen.tsx | 4 +-- mobile/src/screens/HistoryScreen.tsx | 17 ++++++++---- mobile/src/screens/ProfileScreen.tsx | 4 +-- 5 files changed, 49 insertions(+), 11 deletions(-) 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/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/screens/ConceptDetailScreen.tsx b/mobile/src/screens/ConceptDetailScreen.tsx index 097d309..823f89e 100644 --- a/mobile/src/screens/ConceptDetailScreen.tsx +++ b/mobile/src/screens/ConceptDetailScreen.tsx @@ -5,14 +5,12 @@ import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from import { ConceptActions } from '../components/ConceptActions'; import { ConceptCard } from '../components/ConceptCard'; import { useTheme } from '../context/ThemeContext'; -import { CONCEPTS } from '../data/concepts'; +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'; -const CONCEPTS_BY_ID = new Map(CONCEPTS.map((c) => [c.id, c])); - type Status = 'loading' | 'ready' | 'error'; /** Full-concept modal opened from a History or Saved card. */ diff --git a/mobile/src/screens/HistoryScreen.tsx b/mobile/src/screens/HistoryScreen.tsx index 54b4170..327f572 100644 --- a/mobile/src/screens/HistoryScreen.tsx +++ b/mobile/src/screens/HistoryScreen.tsx @@ -1,5 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; -import { useNavigation } from '@react-navigation/native'; +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, Pressable, StyleSheet, Text, View } from 'react-native'; @@ -8,14 +9,12 @@ 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 { 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; @@ -63,7 +62,15 @@ export function HistoryScreen() { const { loading, progress } = useProgress(); const { colors } = useTheme(); const styles = useMemo(() => createStyles(colors), [colors]); - const navigation = useNavigation>(); + // Composite: History is a tab screen that reaches up to the root stack's + // concept-detail modal (#124). + const navigation = + useNavigation< + CompositeNavigationProp< + BottomTabNavigationProp, + NativeStackNavigationProp + > + >(); // Newest first, capped at the last HISTORY_LIMIT to keep the feed focused. const records = [...progress.learned] diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx index a8e41ec..79f6d8b 100644 --- a/mobile/src/screens/ProfileScreen.tsx +++ b/mobile/src/screens/ProfileScreen.tsx @@ -10,7 +10,7 @@ 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, @@ -27,8 +27,6 @@ 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). From 21a9330798de64a1524fe4bd4be1192b515c335c Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Sun, 6 Sep 2026 00:51:42 +0500 Subject: [PATCH 15/15] Bump version to 1.5.0 + What's New card JS-only release (runtimeVersion stays 1.3.0, ships over the air). Highlights: forgot-password reset, tap-to-reopen History/Saved concepts, About screen links. Polish (smaller text, card alignment) is intentionally left off the card per the features-only policy (#97). --- mobile/app.config.js | 2 +- mobile/src/data/whatsNew.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/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: [