create-techtiten-app 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -2
- package/bin/index.mjs +345 -0
- package/bin/presets.mjs +68 -0
- package/package.json +38 -4
- package/templates/mobile/ads/src/ads/Banner.tsx +14 -0
- package/templates/mobile/ads/src/ads/Banner.web.tsx +4 -0
- package/templates/mobile/common/app.config.ts +36 -0
- package/templates/mobile/common/config/plugins.json +1 -0
- package/templates/mobile/common/distribution/whatsnew/whatsnew-en-US +1 -0
- package/templates/mobile/common/plugins/withSigning.js +63 -0
- package/templates/mobile/common/src/AppProviders.tsx +22 -0
- package/templates/mobile/common/src/api/client.ts +16 -0
- package/templates/mobile/common/src/screens/Home.tsx +48 -0
- package/templates/mobile/common/src/screens/Settings.tsx +22 -0
- package/templates/mobile/common/src/theme/index.tsx +17 -0
- package/templates/mobile/common/src/ui/index.tsx +41 -0
- package/templates/mobile/common/tsconfig.json +8 -0
- package/templates/mobile/nav-stack/app/_layout.tsx +33 -0
- package/templates/mobile/nav-stack/app/index.tsx +1 -0
- package/templates/mobile/nav-stack/app/settings.tsx +1 -0
- package/templates/mobile/nav-tabs/app/(tabs)/_layout.tsx +25 -0
- package/templates/mobile/nav-tabs/app/(tabs)/index.tsx +1 -0
- package/templates/mobile/nav-tabs/app/(tabs)/settings.tsx +1 -0
- package/templates/mobile/nav-tabs/app/_layout.tsx +25 -0
- package/templates/mobile/noads/src/ads/Banner.tsx +4 -0
- package/templates/mobile/redux/src/state/Providers.tsx +13 -0
- package/templates/mobile/redux/src/state/index.ts +16 -0
- package/templates/mobile/redux/src/state/store.ts +33 -0
- package/templates/mobile/zustand/src/state/Providers.tsx +7 -0
- package/templates/mobile/zustand/src/state/index.ts +25 -0
- package/templates/packages/shared/package.json +7 -0
- package/templates/packages/shared/src/format.ts +18 -0
- package/templates/packages/shared/src/index.ts +5 -0
- package/templates/packages/shared/src/money.ts +13 -0
- package/templates/packages/shared/src/schemas.ts +9 -0
- package/templates/packages/theme/package.json +7 -0
- package/templates/packages/theme/src/index.ts +26 -0
- package/templates/root/.prettierignore +7 -0
- package/templates/root/.prettierrc +5 -0
- package/templates/root/AGENTS.md +16 -0
- package/templates/root/README.md +31 -0
- package/templates/root/STATUS.md +13 -0
- package/templates/root/_github/workflows/android.yml +79 -0
- package/templates/root/_github/workflows/web-check.yml +19 -0
- package/templates/root/_gitignore +33 -0
- package/templates/root/package.json +21 -0
- package/templates/web/src/app/(site)/page.tsx +18 -0
- package/templates/web/src/app/admin/layout.tsx +17 -0
- package/templates/web/src/app/admin/page.tsx +32 -0
- package/templates/web/src/app/api/cron/[job]/route.ts +17 -0
- package/templates/web/src/app/api/health/route.ts +8 -0
- package/templates/web/src/app/globals.css +23 -0
- package/templates/web/src/app/layout.tsx +24 -0
- package/templates/web/src/proxy.ts +26 -0
- package/templates/web/src/server/jobs.ts +9 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release + debug signing for the generated android/ project (CNG).
|
|
3
|
+
* Release key, first match wins:
|
|
4
|
+
* 1. CI env: ANDROID_KEYSTORE_PATH / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD
|
|
5
|
+
* 2. credentials/signing.properties (MYAPP_UPLOAD_* keys, git-ignored) for local release builds
|
|
6
|
+
* 3. neither: release falls back to debug signing (Play rejects it, so nothing ships by accident)
|
|
7
|
+
* Debug: credentials/debug.keystore if present (keeps the same SHA-1 for Google Sign-In), else Expo's key.
|
|
8
|
+
*/
|
|
9
|
+
const { withAppBuildGradle } = require('expo/config-plugins');
|
|
10
|
+
|
|
11
|
+
const MARK = '// @techtiten-signing';
|
|
12
|
+
const HEADER = `${MARK}
|
|
13
|
+
def ttCredDir = new File(rootDir, "../credentials")
|
|
14
|
+
def ttSigning = new Properties()
|
|
15
|
+
def ttSigningFile = new File(ttCredDir, "signing.properties")
|
|
16
|
+
if (ttSigningFile.exists()) { ttSigningFile.withInputStream { ttSigning.load(it) } }
|
|
17
|
+
def ttDebugKeystore = new File(ttCredDir, "debug.keystore")
|
|
18
|
+
def ttHasRelease = System.getenv("ANDROID_KEYSTORE_PATH") || ttSigning['MYAPP_UPLOAD_STORE_FILE']
|
|
19
|
+
|
|
20
|
+
`;
|
|
21
|
+
const RELEASE_CONFIG = `
|
|
22
|
+
release {
|
|
23
|
+
if (System.getenv("ANDROID_KEYSTORE_PATH")) {
|
|
24
|
+
storeFile file(System.getenv("ANDROID_KEYSTORE_PATH"))
|
|
25
|
+
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
|
26
|
+
keyAlias System.getenv("ANDROID_KEY_ALIAS")
|
|
27
|
+
keyPassword System.getenv("ANDROID_KEY_PASSWORD")
|
|
28
|
+
} else if (ttSigning['MYAPP_UPLOAD_STORE_FILE']) {
|
|
29
|
+
storeFile new File(ttCredDir, ttSigning['MYAPP_UPLOAD_STORE_FILE'])
|
|
30
|
+
storePassword ttSigning['MYAPP_UPLOAD_STORE_PASSWORD']
|
|
31
|
+
keyAlias ttSigning['MYAPP_UPLOAD_KEY_ALIAS']
|
|
32
|
+
keyPassword ttSigning['MYAPP_UPLOAD_KEY_PASSWORD']
|
|
33
|
+
}
|
|
34
|
+
}`;
|
|
35
|
+
|
|
36
|
+
function patch(gradle) {
|
|
37
|
+
if (gradle.includes(MARK)) return gradle;
|
|
38
|
+
const fail = (what) => { throw new Error(`[withSigning] Could not find ${what} in android/app/build.gradle. The Expo template changed: update plugins/withSigning.js.`); };
|
|
39
|
+
if (!/\nandroid\s*\{/.test(gradle)) fail('"android {"');
|
|
40
|
+
gradle = gradle.replace(/\nandroid\s*\{/, `\n${HEADER}android {`);
|
|
41
|
+
const debugStore = /storeFile file\('debug\.keystore'\)/;
|
|
42
|
+
if (!debugStore.test(gradle)) fail("storeFile file('debug.keystore')");
|
|
43
|
+
gradle = gradle.replace(debugStore, "storeFile(ttDebugKeystore.exists() ? ttDebugKeystore : file('debug.keystore'))");
|
|
44
|
+
const sc = /signingConfigs\s*\{/;
|
|
45
|
+
if (!sc.test(gradle)) fail('"signingConfigs {"');
|
|
46
|
+
gradle = gradle.replace(sc, (m) => m + RELEASE_CONFIG);
|
|
47
|
+
const bt = gradle.indexOf('buildTypes');
|
|
48
|
+
if (bt < 0) fail('"buildTypes"');
|
|
49
|
+
const rel = gradle.indexOf('release', bt);
|
|
50
|
+
const target = 'signingConfig signingConfigs.debug';
|
|
51
|
+
const at = rel < 0 ? -1 : gradle.indexOf(target, rel);
|
|
52
|
+
if (at < 0) fail('release signingConfig');
|
|
53
|
+
return gradle.slice(0, at) + 'signingConfig ttHasRelease ? signingConfigs.release : signingConfigs.debug' + gradle.slice(at + target.length);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = function withSigning(config) {
|
|
57
|
+
return withAppBuildGradle(config, (cfg) => {
|
|
58
|
+
if (cfg.modResults.language !== 'groovy') throw new Error('[withSigning] expected a Groovy build.gradle');
|
|
59
|
+
cfg.modResults.contents = patch(cfg.modResults.contents);
|
|
60
|
+
return cfg;
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
module.exports.patch = patch;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
3
|
+
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
4
|
+
import { Providers } from '@/state/Providers';
|
|
5
|
+
import { ThemeProvider } from '@/theme';
|
|
6
|
+
import { useAppFonts } from '@/theme/fonts';
|
|
7
|
+
|
|
8
|
+
/** Everything the app needs around it: gestures, safe area, state + query client, theme, fonts.
|
|
9
|
+
* Reanimated/Worklets need no setup: babel-preset-expo adds their Babel plugin automatically. */
|
|
10
|
+
export function AppProviders({ children }: { children: ReactNode }) {
|
|
11
|
+
const fontsLoaded = useAppFonts();
|
|
12
|
+
if (!fontsLoaded) return null; // the splash screen stays up until fonts are ready
|
|
13
|
+
return (
|
|
14
|
+
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
15
|
+
<SafeAreaProvider>
|
|
16
|
+
<Providers>
|
|
17
|
+
<ThemeProvider>{children}</ThemeProvider>
|
|
18
|
+
</Providers>
|
|
19
|
+
</SafeAreaProvider>
|
|
20
|
+
</GestureHandlerRootView>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { healthSchema } from '__SCOPE__/shared';
|
|
3
|
+
|
|
4
|
+
// Your Next.js API. Set EXPO_PUBLIC_API_URL in apps/mobile/.env (e.g. https://myapp.vercel.app).
|
|
5
|
+
// On an Android emulator, your Mac's localhost is http://10.0.2.2:3000.
|
|
6
|
+
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
|
|
7
|
+
|
|
8
|
+
export async function api<T>(path: string, parse: (data: unknown) => T, init?: RequestInit): Promise<T> {
|
|
9
|
+
const r = await fetch(`${API_URL}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers } });
|
|
10
|
+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
11
|
+
return parse(await r.json());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Example server query: cached, retried and refetched by TanStack Query. */
|
|
15
|
+
export const useHealth = (enabled = true) =>
|
|
16
|
+
useQuery({ queryKey: ['health'], queryFn: () => api('/api/health', (d) => healthSchema.parse(d)), enabled, retry: 1 });
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Link } from 'expo-router';
|
|
2
|
+
import { formatCurrency, splitEvenly, fromMinor, timeAgo } from '__SCOPE__/shared';
|
|
3
|
+
import { useHealth } from '@/api/client';
|
|
4
|
+
import { Banner } from '@/ads/Banner';
|
|
5
|
+
import { useTaps } from '@/state';
|
|
6
|
+
import { Button, Card, Screen, Text } from '@/ui';
|
|
7
|
+
|
|
8
|
+
const WEB_API = __WEB_ENABLED__;
|
|
9
|
+
|
|
10
|
+
export default function Home() {
|
|
11
|
+
const { taps, addTap } = useTaps();
|
|
12
|
+
const health = useHealth(WEB_API);
|
|
13
|
+
const shares = splitEvenly(100000, 3).map((m) => formatCurrency(fromMinor(m)));
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Screen>
|
|
17
|
+
<Card>
|
|
18
|
+
<Text size="xl" weight="black">Welcome to __APP_NAME__</Text>
|
|
19
|
+
<Text muted>Edit src/screens/Home.tsx to start. Colors come from packages/theme, helpers from packages/shared.</Text>
|
|
20
|
+
</Card>
|
|
21
|
+
|
|
22
|
+
<Card>
|
|
23
|
+
<Text weight="bold">State demo (__STATE__)</Text>
|
|
24
|
+
<Text muted>Tapped {taps} times</Text>
|
|
25
|
+
<Button title="Tap" onPress={addTap} />
|
|
26
|
+
</Card>
|
|
27
|
+
|
|
28
|
+
<Card>
|
|
29
|
+
<Text weight="bold">Shared logic demo</Text>
|
|
30
|
+
<Text muted>₹1,000 split 3 ways: {shares.join(' · ')}</Text>
|
|
31
|
+
</Card>
|
|
32
|
+
|
|
33
|
+
{WEB_API ? (
|
|
34
|
+
<Card>
|
|
35
|
+
<Text weight="bold">API demo (Next.js /api/health)</Text>
|
|
36
|
+
<Text muted>
|
|
37
|
+
{health.isLoading ? 'Checking…' : health.data ? `OK · ${timeAgo(health.data.time)}` : `Not reachable (${health.error?.message ?? 'start npm run web'})`}
|
|
38
|
+
</Text>
|
|
39
|
+
</Card>
|
|
40
|
+
) : null}
|
|
41
|
+
|
|
42
|
+
<Link href="/settings" asChild>
|
|
43
|
+
<Button title="Settings" variant="ghost" />
|
|
44
|
+
</Link>
|
|
45
|
+
<Banner />
|
|
46
|
+
</Screen>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { View } from 'react-native';
|
|
2
|
+
import { useAppearance, type Appearance } from '@/state';
|
|
3
|
+
import { Button, Card, Screen, Text } from '@/ui';
|
|
4
|
+
|
|
5
|
+
const OPTIONS: Appearance[] = ['system', 'light', 'dark'];
|
|
6
|
+
|
|
7
|
+
export default function Settings() {
|
|
8
|
+
const { appearance, setAppearance } = useAppearance();
|
|
9
|
+
return (
|
|
10
|
+
<Screen>
|
|
11
|
+
<Card>
|
|
12
|
+
<Text weight="bold">Appearance</Text>
|
|
13
|
+
<Text muted>Saved on this phone.</Text>
|
|
14
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
15
|
+
{OPTIONS.map((o) => (
|
|
16
|
+
<Button key={o} title={o} variant={appearance === o ? 'primary' : 'ghost'} onPress={() => setAppearance(o)} style={{ flex: 1 }} />
|
|
17
|
+
))}
|
|
18
|
+
</View>
|
|
19
|
+
</Card>
|
|
20
|
+
</Screen>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
2
|
+
import { useColorScheme } from 'react-native';
|
|
3
|
+
import { theme as makeTheme, type Theme } from '__SCOPE__/theme';
|
|
4
|
+
import { useAppearance } from '@/state';
|
|
5
|
+
|
|
6
|
+
const ThemeContext = createContext<Theme>(makeTheme('dark'));
|
|
7
|
+
|
|
8
|
+
/** Light/dark from the user's choice in Settings, or the phone's setting when it's "system". */
|
|
9
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
10
|
+
const system = useColorScheme();
|
|
11
|
+
const { appearance } = useAppearance();
|
|
12
|
+
const mode = appearance === 'system' ? (system === 'light' ? 'light' : 'dark') : appearance;
|
|
13
|
+
const value = useMemo(() => makeTheme(mode), [mode]);
|
|
14
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const useTheme = () => useContext(ThemeContext);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Pressable, Text as RNText, View, type PressableProps, type TextProps, type ViewProps } from 'react-native';
|
|
2
|
+
import * as Haptics from 'expo-haptics';
|
|
3
|
+
import { useTheme } from '@/theme';
|
|
4
|
+
import { fontScale, fonts } from '@/theme/fonts';
|
|
5
|
+
|
|
6
|
+
export function Screen({ style, ...rest }: ViewProps) {
|
|
7
|
+
const t = useTheme();
|
|
8
|
+
return <View style={[{ flex: 1, backgroundColor: t.colors.background, padding: t.spacing.lg, gap: t.spacing.lg }, style]} {...rest} />;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Card({ style, ...rest }: ViewProps) {
|
|
12
|
+
const t = useTheme();
|
|
13
|
+
return <View style={[{ backgroundColor: t.colors.surface, borderColor: t.colors.border, borderWidth: 1, borderRadius: t.radius.lg, padding: t.spacing.lg, gap: t.spacing.sm }, style]} {...rest} />;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Text({ style, muted, size = 'md', weight = 'regular', ...rest }: TextProps & { muted?: boolean; size?: keyof ReturnType<typeof useTheme>['fontSize']; weight?: keyof ReturnType<typeof useTheme>['fontWeight'] }) {
|
|
17
|
+
const t = useTheme();
|
|
18
|
+
// Custom fonts carry their weight in the family name (fonts.bold…); the system font uses fontWeight.
|
|
19
|
+
const family = fonts[weight];
|
|
20
|
+
const fontSize = Math.round(t.fontSize[size] * fontScale);
|
|
21
|
+
return <RNText style={[{ color: muted ? t.colors.textMuted : t.colors.text, fontSize, lineHeight: Math.round(fontSize * (fontScale < 1 ? 1.6 : 1.35)) }, family ? { fontFamily: family } : { fontWeight: t.fontWeight[weight] }, style]} {...rest} />;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Button({ title, variant = 'primary', onPress, style, ...rest }: PressableProps & { title: string; variant?: 'primary' | 'ghost' }) {
|
|
25
|
+
const t = useTheme();
|
|
26
|
+
const primary = variant === 'primary';
|
|
27
|
+
return (
|
|
28
|
+
<Pressable
|
|
29
|
+
accessibilityRole="button"
|
|
30
|
+
onPress={(e) => { Haptics.selectionAsync().catch(() => {}); onPress?.(e); }}
|
|
31
|
+
style={(state) => [{
|
|
32
|
+
backgroundColor: primary ? t.colors.brand : 'transparent',
|
|
33
|
+
borderColor: primary ? t.colors.brand : t.colors.border,
|
|
34
|
+
borderWidth: 1, borderRadius: t.radius.md, paddingVertical: t.spacing.md, paddingHorizontal: t.spacing.lg,
|
|
35
|
+
alignItems: 'center', opacity: state.pressed ? 0.8 : 1,
|
|
36
|
+
}, typeof style === 'function' ? style(state) : style]}
|
|
37
|
+
{...rest}>
|
|
38
|
+
<RNText style={[{ color: primary ? t.colors.onBrand : t.colors.text, fontSize: Math.round(t.fontSize.md * fontScale) }, fonts.bold ? { fontFamily: fonts.bold } : { fontWeight: t.fontWeight.bold }]}>{title}</RNText>
|
|
39
|
+
</Pressable>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Stack } from 'expo-router';
|
|
2
|
+
import { StatusBar } from 'expo-status-bar';
|
|
3
|
+
import { AppProviders } from '@/AppProviders';
|
|
4
|
+
import { useTheme } from '@/theme';
|
|
5
|
+
import { fonts } from '@/theme/fonts';
|
|
6
|
+
|
|
7
|
+
function ThemedStack() {
|
|
8
|
+
const t = useTheme();
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<StatusBar style={t.mode === 'dark' ? 'light' : 'dark'} />
|
|
12
|
+
<Stack
|
|
13
|
+
screenOptions={{
|
|
14
|
+
headerStyle: { backgroundColor: t.colors.background },
|
|
15
|
+
headerTintColor: t.colors.text,
|
|
16
|
+
headerTitleStyle: { fontFamily: fonts.bold },
|
|
17
|
+
headerShadowVisible: false,
|
|
18
|
+
contentStyle: { backgroundColor: t.colors.background },
|
|
19
|
+
}}>
|
|
20
|
+
<Stack.Screen name="index" options={{ title: '__APP_NAME__' }} />
|
|
21
|
+
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
|
|
22
|
+
</Stack>
|
|
23
|
+
</>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function RootLayout() {
|
|
28
|
+
return (
|
|
29
|
+
<AppProviders>
|
|
30
|
+
<ThemedStack />
|
|
31
|
+
</AppProviders>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@/screens/Home';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@/screens/Settings';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
2
|
+
import { Tabs } from 'expo-router';
|
|
3
|
+
import { useTheme } from '@/theme';
|
|
4
|
+
import { fonts } from '@/theme/fonts';
|
|
5
|
+
|
|
6
|
+
export default function TabsLayout() {
|
|
7
|
+
const t = useTheme();
|
|
8
|
+
return (
|
|
9
|
+
<Tabs
|
|
10
|
+
screenOptions={{
|
|
11
|
+
headerStyle: { backgroundColor: t.colors.background },
|
|
12
|
+
headerTintColor: t.colors.text,
|
|
13
|
+
headerTitleStyle: { fontFamily: fonts.bold },
|
|
14
|
+
headerShadowVisible: false,
|
|
15
|
+
tabBarStyle: { backgroundColor: t.colors.surface, borderTopColor: t.colors.border },
|
|
16
|
+
tabBarActiveTintColor: t.colors.brand,
|
|
17
|
+
tabBarInactiveTintColor: t.colors.textMuted,
|
|
18
|
+
tabBarLabelStyle: { fontFamily: fonts.medium },
|
|
19
|
+
sceneStyle: { backgroundColor: t.colors.background },
|
|
20
|
+
}}>
|
|
21
|
+
<Tabs.Screen name="index" options={{ title: '__APP_NAME__', tabBarLabel: 'Home', tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color as string} size={size} /> }} />
|
|
22
|
+
<Tabs.Screen name="settings" options={{ title: 'Settings', tabBarIcon: ({ color, size }) => <Ionicons name="settings" color={color as string} size={size} /> }} />
|
|
23
|
+
</Tabs>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@/screens/Home';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@/screens/Settings';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Stack } from 'expo-router';
|
|
2
|
+
import { StatusBar } from 'expo-status-bar';
|
|
3
|
+
import { AppProviders } from '@/AppProviders';
|
|
4
|
+
import { useTheme } from '@/theme';
|
|
5
|
+
|
|
6
|
+
function Root() {
|
|
7
|
+
const t = useTheme();
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
<StatusBar style={t.mode === 'dark' ? 'light' : 'dark'} />
|
|
11
|
+
{/* Tabs live in app/(tabs). Push full-screen pages (details, modals) on this stack. */}
|
|
12
|
+
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: t.colors.background } }}>
|
|
13
|
+
<Stack.Screen name="(tabs)" />
|
|
14
|
+
</Stack>
|
|
15
|
+
</>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function RootLayout() {
|
|
20
|
+
return (
|
|
21
|
+
<AppProviders>
|
|
22
|
+
<Root />
|
|
23
|
+
</AppProviders>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { useState, type ReactNode } from 'react';
|
|
3
|
+
import { Provider } from 'react-redux';
|
|
4
|
+
import { store } from './store';
|
|
5
|
+
|
|
6
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
7
|
+
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } }));
|
|
8
|
+
return (
|
|
9
|
+
<Provider store={store}>
|
|
10
|
+
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
11
|
+
</Provider>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
2
|
+
import { actions, type Appearance, type AppDispatch, type RootState } from './store';
|
|
3
|
+
|
|
4
|
+
export type { Appearance } from './store';
|
|
5
|
+
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
|
6
|
+
export const useAppSelector = useSelector.withTypes<RootState>();
|
|
7
|
+
|
|
8
|
+
// The hooks screens use (same names in the Zustand version of this template).
|
|
9
|
+
export function useAppearance() {
|
|
10
|
+
const dispatch = useAppDispatch();
|
|
11
|
+
return { appearance: useAppSelector((s) => s.settings.appearance), setAppearance: (a: Appearance) => dispatch(actions.setAppearance(a)) };
|
|
12
|
+
}
|
|
13
|
+
export function useTaps() {
|
|
14
|
+
const dispatch = useAppDispatch();
|
|
15
|
+
return { taps: useAppSelector((s) => s.taps.count), addTap: () => dispatch(actions.addTap()) };
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// App state with Redux Toolkit. Server data (API calls) lives in TanStack Query instead.
|
|
2
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
3
|
+
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
|
4
|
+
|
|
5
|
+
export type Appearance = 'system' | 'light' | 'dark';
|
|
6
|
+
|
|
7
|
+
const settings = createSlice({
|
|
8
|
+
name: 'settings',
|
|
9
|
+
initialState: { appearance: 'system' as Appearance },
|
|
10
|
+
reducers: { setAppearance: (s, a: PayloadAction<Appearance>) => { s.appearance = a.payload; } },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const taps = createSlice({
|
|
14
|
+
name: 'taps',
|
|
15
|
+
initialState: { count: 0 },
|
|
16
|
+
reducers: { addTap: (s) => { s.count += 1; } },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const actions = { ...settings.actions, ...taps.actions };
|
|
20
|
+
export const store = configureStore({ reducer: { settings: settings.reducer, taps: taps.reducer } });
|
|
21
|
+
export type RootState = ReturnType<typeof store.getState>;
|
|
22
|
+
export type AppDispatch = typeof store.dispatch;
|
|
23
|
+
|
|
24
|
+
// Persist settings on this phone (small and explicit; add redux-persist if you need more).
|
|
25
|
+
const KEY = 'settings';
|
|
26
|
+
AsyncStorage.getItem(KEY)
|
|
27
|
+
.then((raw) => { const s = raw ? JSON.parse(raw) : null; if (s?.appearance) store.dispatch(actions.setAppearance(s.appearance)); })
|
|
28
|
+
.catch(() => {});
|
|
29
|
+
let last = store.getState().settings;
|
|
30
|
+
store.subscribe(() => {
|
|
31
|
+
const now = store.getState().settings;
|
|
32
|
+
if (now !== last) { last = now; AsyncStorage.setItem(KEY, JSON.stringify(now)).catch(() => {}); }
|
|
33
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { useState, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
5
|
+
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } }));
|
|
6
|
+
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// App state with Zustand. One small store per concern; `persist` saves to the phone.
|
|
2
|
+
// Server data (API calls) lives in TanStack Query instead (see src/api/client.ts).
|
|
3
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
4
|
+
import { create } from 'zustand';
|
|
5
|
+
import { createJSONStorage, persist } from 'zustand/middleware';
|
|
6
|
+
|
|
7
|
+
export type Appearance = 'system' | 'light' | 'dark';
|
|
8
|
+
|
|
9
|
+
type SettingsState = { appearance: Appearance; setAppearance: (a: Appearance) => void };
|
|
10
|
+
export const useSettingsStore = create<SettingsState>()(
|
|
11
|
+
persist((set) => ({ appearance: 'system', setAppearance: (appearance) => set({ appearance }) }), {
|
|
12
|
+
name: 'settings',
|
|
13
|
+
storage: createJSONStorage(() => AsyncStorage),
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
type TapsState = { taps: number; addTap: () => void };
|
|
18
|
+
export const useTapsStore = create<TapsState>()((set) => ({ taps: 0, addTap: () => set((s) => ({ taps: s.taps + 1 })) }));
|
|
19
|
+
|
|
20
|
+
// The hooks screens use (same names in the Redux version of this template).
|
|
21
|
+
export const useAppearance = () => ({
|
|
22
|
+
appearance: useSettingsStore((s) => s.appearance),
|
|
23
|
+
setAppearance: useSettingsStore((s) => s.setAppearance),
|
|
24
|
+
});
|
|
25
|
+
export const useTaps = () => ({ taps: useTapsStore((s) => s.taps), addTap: useTapsStore((s) => s.addTap) });
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function formatCurrency(amount: number, currency = 'INR', locale = 'en-IN'): string {
|
|
2
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 2 }).format(amount);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function formatDate(d: Date | number | string, locale = 'en-IN'): string {
|
|
6
|
+
return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', year: 'numeric' }).format(new Date(d));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** "just now", "5 min ago", "3 h ago", "2 d ago" */
|
|
10
|
+
export function timeAgo(d: Date | number | string, now = Date.now()): string {
|
|
11
|
+
const s = Math.max(0, Math.round((now - new Date(d).getTime()) / 1000));
|
|
12
|
+
if (s < 45) return 'just now';
|
|
13
|
+
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
|
14
|
+
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
|
|
15
|
+
return `${Math.round(s / 86400)} d ago`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split an amount (in paise/cents, an integer) into `parts` shares that add up exactly.
|
|
3
|
+
* splitEvenly(1000, 3) -> [334, 333, 333]
|
|
4
|
+
*/
|
|
5
|
+
export function splitEvenly(total: number, parts: number): number[] {
|
|
6
|
+
if (!Number.isInteger(total) || !Number.isInteger(parts) || parts <= 0) throw new Error('splitEvenly: integers only, parts > 0');
|
|
7
|
+
const base = Math.floor(total / parts);
|
|
8
|
+
const extra = total - base * parts;
|
|
9
|
+
return Array.from({ length: parts }, (_, i) => base + (i < extra ? 1 : 0));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const toMinor = (amount: number) => Math.round(amount * 100);
|
|
13
|
+
export const fromMinor = (minor: number) => minor / 100;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** GET /api/health response. Used by the API (to build it) and the app (to parse it). */
|
|
4
|
+
export const healthSchema = z.object({
|
|
5
|
+
ok: z.boolean(),
|
|
6
|
+
app: z.string(),
|
|
7
|
+
time: z.string(),
|
|
8
|
+
});
|
|
9
|
+
export type Health = z.infer<typeof healthSchema>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// One theme for the phone app and the website. Change a color here and both update.
|
|
2
|
+
export type Mode = 'light' | 'dark';
|
|
3
|
+
|
|
4
|
+
// colors.ts is written by create-techtiten-app from the theme preset you picked (__THEME_LABEL__).
|
|
5
|
+
// Edit it freely: both the phone app and the website read it.
|
|
6
|
+
import { colors } from './colors';
|
|
7
|
+
export { colors };
|
|
8
|
+
|
|
9
|
+
export type ColorName = keyof (typeof colors)['light'];
|
|
10
|
+
export type Colors = Record<ColorName, string>;
|
|
11
|
+
|
|
12
|
+
export const spacing = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 } as const;
|
|
13
|
+
export const radius = { sm: 8, md: 12, lg: 16, xl: 24, full: 999 } as const;
|
|
14
|
+
export const fontSize = { xs: 12, sm: 14, md: 16, lg: 18, xl: 22, xxl: 28, display: 36 } as const;
|
|
15
|
+
export const fontWeight = { regular: '400', medium: '500', bold: '700', black: '800' } as const;
|
|
16
|
+
|
|
17
|
+
export const theme = (mode: Mode) => ({ mode, colors: colors[mode] as Colors, spacing, radius, fontSize, fontWeight });
|
|
18
|
+
export type Theme = ReturnType<typeof theme>;
|
|
19
|
+
|
|
20
|
+
/** CSS custom properties for the web app: `--color-brand`, `--color-text`… */
|
|
21
|
+
export function cssVariables(mode: Mode): Record<string, string> {
|
|
22
|
+
const out: Record<string, string> = {};
|
|
23
|
+
for (const [k, v] of Object.entries(colors[mode])) out[`--color-${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}`] = v;
|
|
24
|
+
for (const [k, v] of Object.entries(radius)) out[`--radius-${k}`] = `${v}px`;
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# __APP_NAME__: rules for every agent (Claude Code, Cowork, Codex, Cursor)
|
|
2
|
+
|
|
3
|
+
**Start of a session:** read `STATUS.md`, then check `git status`, the branch and HEAD vs `origin/main`. Say so if it doesn't match.
|
|
4
|
+
**End of a session:** update `STATUS.md` (Now, Next, to-dos, one dated Log line).
|
|
5
|
+
|
|
6
|
+
## Layout
|
|
7
|
+
- `apps/mobile`: Expo (Expo Router). Screens in `app/`, logic in `src/`. No `android/`/`ios/` in git: they're generated (`npx expo prebuild`).
|
|
8
|
+
- `apps/web`: Next.js App Router. Website `src/app/(site)`, admin `src/app/admin`, API `src/app/api`. Runs on Vercel (serverless: short requests, no websockets; heavy jobs go in batches or a separate worker).
|
|
9
|
+
- `packages/theme` and `packages/shared`: used by both apps. Put colors and shared logic here, never copy-paste between apps.
|
|
10
|
+
|
|
11
|
+
## Rules
|
|
12
|
+
- Ship flow: branch from `origin/main` → PR → merge. **A merge to `main` deploys** (Vercel for web; the Android workflow for mobile, only when `apps/mobile/**` or `packages/**` change).
|
|
13
|
+
- `git add` exact files, never `-A`. Never commit `.env*`, keystores, `credentials/`, `google-services.json` or service-account JSON.
|
|
14
|
+
- Expo and Next.js here are newer than most training data: read the versioned docs (`node_modules/next/dist/docs/`, docs.expo.dev for this SDK) before writing API code.
|
|
15
|
+
- Native packages need a new store build; they can't go out as an OTA update.
|
|
16
|
+
- Add Expo packages with `npx expo install`, so versions match the SDK.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
Made with `create-techtiten-app`.
|
|
4
|
+
|
|
5
|
+
| Folder | What |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `apps/mobile` | Expo app (Android/iOS), Expo Router, __STATE__ |
|
|
8
|
+
| `apps/web` | Next.js: website, `/admin` panel, `/api` backend (if enabled) |
|
|
9
|
+
| `packages/theme` | Colors, spacing, radius, font sizes: one source for both apps |
|
|
10
|
+
| `packages/shared` | Functions, types and zod schemas used by both apps |
|
|
11
|
+
|
|
12
|
+
## Run
|
|
13
|
+
```
|
|
14
|
+
npm install
|
|
15
|
+
npm run mobile # Expo (press a for Android)
|
|
16
|
+
npm run web # Next.js on http://localhost:3000
|
|
17
|
+
npm run typecheck # every workspace
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Add a package
|
|
21
|
+
- Native/Expo package: `cd apps/mobile && npx expo install <pkg>` (picks the SDK-matched version)
|
|
22
|
+
- Anything else: `npm install <pkg> -w __SCOPE__/mobile` (or `__SCOPE__/web`, `__SCOPE__/shared`)
|
|
23
|
+
|
|
24
|
+
## Upgrade later
|
|
25
|
+
New projects always start on the latest Expo / Next.js. To move this project to newer versions:
|
|
26
|
+
```
|
|
27
|
+
npm run upgrade:mobile # latest Expo SDK + matching React Native and packages
|
|
28
|
+
npm run upgrade:web # latest Next.js
|
|
29
|
+
npm run typecheck
|
|
30
|
+
```
|
|
31
|
+
Read the Expo SDK and Next.js release notes first: big versions can need small code changes.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# STATUS: __APP_NAME__
|
|
2
|
+
|
|
3
|
+
## Now
|
|
4
|
+
- Fresh project from create-techtiten-app.
|
|
5
|
+
|
|
6
|
+
## Next
|
|
7
|
+
- [ ] Replace the starter screens.
|
|
8
|
+
- [ ] Set GitHub secrets for the Android release (see `.github/workflows/android.yml`).
|
|
9
|
+
|
|
10
|
+
## Open to-dos
|
|
11
|
+
|
|
12
|
+
## Log
|
|
13
|
+
- __YEAR__: project created.
|