create-techtiten-app 0.1.0 → 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 +49 -9
- package/bin/index.mjs +178 -43
- package/bin/presets.mjs +68 -0
- package/package.json +29 -6
- package/templates/mobile/common/src/AppProviders.tsx +22 -0
- package/templates/mobile/common/{app/index.tsx → src/screens/Home.tsx} +1 -1
- package/templates/mobile/common/src/ui/index.tsx +6 -2
- package/templates/mobile/{common → nav-stack}/app/_layout.tsx +7 -10
- 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/packages/theme/src/index.ts +4 -31
- package/templates/root/.prettierignore +7 -0
- package/templates/root/.prettierrc +5 -0
- package/templates/root/README.md +9 -0
- package/templates/root/package.json +11 -3
- package/templates/web/src/app/globals.css +1 -1
- package/templates/web/src/app/layout.tsx +2 -1
- /package/templates/mobile/common/{app/settings.tsx → src/screens/Settings.tsx} +0 -0
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ One command for a new app, with the same structure every time:
|
|
|
6
6
|
npx create-techtiten-app my-app
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+

|
|
10
|
+
|
|
9
11
|
```
|
|
10
12
|
my-app/
|
|
11
13
|
├─ apps/mobile Expo (latest SDK), Expo Router, TypeScript
|
|
@@ -16,16 +18,54 @@ my-app/
|
|
|
16
18
|
└─ AGENTS.md, STATUS.md, .gitignore (secrets blocked)
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
##
|
|
20
|
-
|
|
21
|
+
## Options
|
|
22
|
+
Run `npx create-techtiten-app --help` any time to see them all. It asks these questions, or you pass them as flags:
|
|
23
|
+
|
|
24
|
+
| Question | Flag | Choices |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| Display name | `--name="My App"` | anything |
|
|
27
|
+
| Package id | `--package=com.techtiten.myapp` | permanent once on Play |
|
|
28
|
+
| Web app | `--web` / `--no-web` | Next.js: website + `/admin` + `/api` |
|
|
29
|
+
| Navigation | `--nav=stack` / `--nav=tabs` | stack, bottom tabs |
|
|
30
|
+
| State | `--state=zustand` / `--state=redux` | both with TanStack Query |
|
|
31
|
+
| Color theme | `--theme=<preset>` | `techtiten`, `ocean`, `sunset`, `royal`, `mono`, `retro` (green LCD) |
|
|
32
|
+
| Brand color | `--brand=#FF3366` | optional, replaces the preset's brand color |
|
|
33
|
+
| Font | `--font=<font>` | `system`, `inter`, `poppins`, `space-grotesk`, `pixel` (Press Start 2P) |
|
|
34
|
+
| Ads | `--ads` / `--no-ads` | AdMob banner, test ads in development |
|
|
35
|
+
|
|
36
|
+
Other flags: `--yes` (no questions), `--skip-install`, `--no-git`, `--help`, `--version`.
|
|
37
|
+
|
|
38
|
+
### Defaults (what you get if you just press Enter, or pass `--yes`)
|
|
39
|
+
| Option | Default |
|
|
21
40
|
|---|---|
|
|
22
|
-
| Display name
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
41
|
+
| Display name | folder name in Title Case (`my-app` → `My App`) |
|
|
42
|
+
| Package id | `com.techtiten.` + folder name without dashes (`com.techtiten.myapp`) |
|
|
43
|
+
| Web app | yes |
|
|
44
|
+
| Navigation | stack |
|
|
45
|
+
| State | Zustand + TanStack Query |
|
|
46
|
+
| Theme | `techtiten` (light + dark, follows the phone's setting) |
|
|
47
|
+
| Brand color | from the theme |
|
|
48
|
+
| Font | system |
|
|
49
|
+
| Ads | no |
|
|
50
|
+
|
|
51
|
+
### Color themes
|
|
52
|
+

|
|
53
|
+
|
|
54
|
+
Your colors end up in `packages/theme/src/colors.ts`: one file, used by the phone app and the website. Change any value there later.
|
|
55
|
+
|
|
56
|
+
### Fonts
|
|
57
|
+

|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
npx create-techtiten-app retro-games --yes --no-web --ads --theme=retro --font=pixel --nav=tabs
|
|
62
|
+
npx create-techtiten-app shop --yes --web --theme=sunset --brand=#FF3366 --font=poppins --state=redux
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Every generated project also has `npm run format` (Prettier), `npm run upgrade:mobile` (latest Expo SDK) and `npm run upgrade:web` (latest Next.js).
|
|
26
66
|
|
|
27
|
-
|
|
28
|
-
|
|
67
|
+
## Add an option to this CLI
|
|
68
|
+
All options live in one list (`OPTIONS` in `bin/index.mjs`). Theme presets and fonts are in `bin/presets.mjs`. Add an entry and it appears in the questions, the flags and `--help`.
|
|
29
69
|
|
|
30
70
|
## Always the latest
|
|
31
71
|
- The base apps come from `create-expo-app@latest` and `create-next-app@latest`, so a new project starts on the newest Expo SDK and Next.js.
|
|
@@ -35,7 +75,7 @@ Other flags: `--no-web`, `--no-ads`, `--skip-install`, `--no-git`.
|
|
|
35
75
|
- One React version for the whole repo (web is pinned to the version Expo installed), so hooks never break.
|
|
36
76
|
|
|
37
77
|
## What you get
|
|
38
|
-
**Mobile:** theme provider (system/light/dark, saved), UI primitives (Screen, Card, Text, Button with haptics), state demo, API client + TanStack Query hook, release signing plugin (CI secrets or `credentials/signing.properties`), optional AdMob banner.
|
|
78
|
+
**Mobile:** Reanimated 4 + Worklets + Gesture Handler (SDK-matched, root view already wrapped), theme provider (system/light/dark, saved), UI primitives (Screen, Card, Text, Button with haptics), state demo, API client + TanStack Query hook, release signing plugin (CI secrets or `credentials/signing.properties`), optional AdMob banner.
|
|
39
79
|
|
|
40
80
|
**Web:** landing page using the same theme colors, `/admin` dashboard behind `ADMIN_USER`/`ADMIN_PASSWORD` (`src/proxy.ts`), `GET /api/health` (shared zod schema), `POST /api/cron/[job]` protected by `CRON_API_KEY` for outside schedulers (Vercel functions are short-lived).
|
|
41
81
|
|
package/bin/index.mjs
CHANGED
|
@@ -1,30 +1,126 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// create-techtiten-app: scaffolds an npm-workspaces monorepo
|
|
3
|
-
// apps/mobile
|
|
4
|
-
// apps/web
|
|
5
|
-
// packages/theme colors, spacing, radius, fonts
|
|
6
|
-
// packages/shared functions, types, zod schemas
|
|
3
|
+
// apps/mobile Expo (latest SDK, Expo Router, typed) always
|
|
4
|
+
// apps/web Next.js (latest): website + /admin + /api optional
|
|
5
|
+
// packages/theme colors (preset), spacing, radius, fonts shared
|
|
6
|
+
// packages/shared functions, types, zod schemas shared
|
|
7
7
|
// Latest versions every time: the base apps come from create-expo-app / create-next-app,
|
|
8
8
|
// and native-aware packages are added with `npx expo install` (SDK-matched). Nothing native
|
|
9
9
|
// is committed: android/ and ios/ are generated at build time (CNG).
|
|
10
|
+
//
|
|
11
|
+
// Every option lives in OPTIONS below: the prompts, the flags and --help are all built from it,
|
|
12
|
+
// so a new feature only has to be added once.
|
|
10
13
|
import * as p from '@clack/prompts';
|
|
11
14
|
import pc from 'picocolors';
|
|
12
15
|
import { spawnSync } from 'node:child_process';
|
|
13
16
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
14
17
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
15
18
|
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { FONTS, THEMES, buildColors, isHex } from './presets.mjs';
|
|
16
20
|
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const opt = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.split('=').slice(1).join('=');
|
|
21
|
-
const positional = argv.find((a) => !a.startsWith('--'));
|
|
22
|
-
const YES = flag('yes') || flag('y');
|
|
21
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const TPL = join(ROOT, 'templates');
|
|
23
|
+
const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
|
|
23
24
|
|
|
24
25
|
const slugify = (s) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
25
26
|
const titleCase = (s) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
26
|
-
const
|
|
27
|
+
const choices = (obj) => Object.entries(obj).map(([value, o]) => ({ value, label: o.label, hint: o.hint }));
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------- options (single source of truth)
|
|
30
|
+
const OPTIONS = [
|
|
31
|
+
{ key: 'name', kind: 'text', flag: '--name=<text>', help: 'App display name (under the icon)', def: (a) => titleCase(a.slug), message: 'App display name' },
|
|
32
|
+
{
|
|
33
|
+
key: 'package', kind: 'text', flag: '--package=<id>', help: 'Android package / iOS bundle id (permanent once on Play)',
|
|
34
|
+
def: (a) => `com.techtiten.${a.slug.replace(/-/g, '')}`, message: 'Android package / iOS bundle id',
|
|
35
|
+
validate: (v) => (/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(v ?? '') ? undefined : 'Like com.techtiten.myapp (lowercase, dots)'),
|
|
36
|
+
},
|
|
37
|
+
{ key: 'web', kind: 'bool', flag: '--web / --no-web', help: 'Next.js app next to the phone app: website + /admin panel + /api backend', def: () => true, message: 'Add a Next.js app (website + admin panel + API)?' },
|
|
38
|
+
{
|
|
39
|
+
key: 'nav', kind: 'select', flag: '--nav=<stack|tabs>', help: 'Navigation layout', def: () => 'stack', message: 'Navigation',
|
|
40
|
+
options: [{ value: 'stack', label: 'Stack', hint: 'screens push on top of each other' }, { value: 'tabs', label: 'Bottom tabs', hint: 'Home + Settings tabs, add more in app/(tabs)' }],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: 'state', kind: 'select', flag: '--state=<zustand|redux>', help: 'State management (both come with TanStack Query for API data)', def: () => 'zustand', message: 'State management',
|
|
44
|
+
options: [{ value: 'zustand', label: 'Zustand + TanStack Query', hint: 'recommended: tiny stores' }, { value: 'redux', label: 'Redux Toolkit + TanStack Query', hint: 'more structure, Redux DevTools' }],
|
|
45
|
+
},
|
|
46
|
+
{ key: 'theme', kind: 'select', flag: '--theme=<preset>', help: 'Color theme preset (light + dark)', def: () => 'techtiten', message: 'Color theme', options: choices(THEMES) },
|
|
47
|
+
{
|
|
48
|
+
key: 'brand', kind: 'text', flag: '--brand=<#RRGGBB>', help: 'Optional: your own brand color instead of the preset\'s', def: () => '', message: 'Custom brand color? (#RRGGBB, empty = use the preset)',
|
|
49
|
+
validate: (v) => (!v || isHex(v) ? undefined : 'Like #00C389, or leave empty'),
|
|
50
|
+
},
|
|
51
|
+
{ key: 'font', kind: 'select', flag: '--font=<font>', help: 'App font (phone app and website)', def: () => 'system', message: 'Font', options: choices(FONTS) },
|
|
52
|
+
{ key: 'ads', kind: 'bool', flag: '--ads / --no-ads', help: 'Google AdMob banner (test ads in development)', def: () => false, message: 'Google AdMob ads?' },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const TOOL_FLAGS = [
|
|
56
|
+
['--yes, -y', 'Skip the questions, use defaults (or the flags you pass)'],
|
|
57
|
+
['--skip-install', 'Only write files, don\'t run npm install'],
|
|
58
|
+
['--no-git', 'Don\'t run git init / first commit'],
|
|
59
|
+
['--help, -h', 'Show this help'],
|
|
60
|
+
['--version, -v', 'Show the version'],
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function printHelp() {
|
|
64
|
+
const line = (a, b) => ` ${pc.cyan(a.padEnd(28))} ${b}`;
|
|
65
|
+
console.log(`
|
|
66
|
+
${pc.bold('create-techtiten-app')} v${VERSION}
|
|
67
|
+
Scaffold an Expo app (+ optional Next.js web/admin/API) with a shared theme and shared logic.
|
|
68
|
+
Always uses the latest Expo SDK and Next.js.
|
|
69
|
+
|
|
70
|
+
${pc.bold('Usage')}
|
|
71
|
+
npx create-techtiten-app ${pc.dim('<folder>')} ${pc.dim('[options]')}
|
|
72
|
+
|
|
73
|
+
${pc.bold('Options')}
|
|
74
|
+
${OPTIONS.map((o) => line(o.flag, `${o.help}${o.options ? '' : ''}`)).join('\n')}
|
|
75
|
+
|
|
76
|
+
${pc.bold('Choices')}
|
|
77
|
+
${line('themes', Object.entries(THEMES).map(([k, t]) => `${k} (${t.label})`).join(', '))}
|
|
78
|
+
${line('fonts', Object.keys(FONTS).join(', '))}
|
|
79
|
+
|
|
80
|
+
${pc.bold('Other')}
|
|
81
|
+
${TOOL_FLAGS.map(([a, b]) => line(a, b)).join('\n')}
|
|
82
|
+
|
|
83
|
+
${pc.bold('Examples')}
|
|
84
|
+
npx create-techtiten-app my-app
|
|
85
|
+
npx create-techtiten-app retro-games --yes --no-web --ads --theme=retro --font=pixel --nav=tabs
|
|
86
|
+
npx create-techtiten-app shop --yes --web --theme=sunset --brand=#FF3366 --font=poppins --state=redux
|
|
87
|
+
`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------- args
|
|
91
|
+
const argv = process.argv.slice(2);
|
|
92
|
+
const has = (...names) => names.some((n) => argv.includes(n));
|
|
93
|
+
const val = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.split('=').slice(1).join('=');
|
|
94
|
+
const positional = argv.find((a) => !a.startsWith('-'));
|
|
95
|
+
const YES = has('--yes', '-y');
|
|
96
|
+
|
|
97
|
+
function fromFlags(o) {
|
|
98
|
+
if (o.kind === 'bool') return has(`--${o.key}`) ? true : has(`--no-${o.key}`) ? false : undefined;
|
|
99
|
+
return val(o.key);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkFlag(o, v) {
|
|
103
|
+
if (v === undefined) return;
|
|
104
|
+
if (o.kind === 'select' && !o.options.some((x) => x.value === v)) fail(`--${o.key}=${v} isn't valid. Choose: ${o.options.map((x) => x.value).join(', ')}`);
|
|
105
|
+
const err = o.validate?.(v);
|
|
106
|
+
if (err) fail(`--${o.key}: ${err}`);
|
|
107
|
+
}
|
|
27
108
|
|
|
109
|
+
function fail(msg) { p.cancel(msg); process.exit(1); }
|
|
110
|
+
const cancel = (v) => { if (p.isCancel(v)) fail('Cancelled.'); return v; };
|
|
111
|
+
|
|
112
|
+
async function ask(o, answers) {
|
|
113
|
+
const given = fromFlags(o);
|
|
114
|
+
checkFlag(o, given);
|
|
115
|
+
if (given !== undefined) return given;
|
|
116
|
+
const def = o.def(answers);
|
|
117
|
+
if (YES) return def;
|
|
118
|
+
if (o.kind === 'bool') return cancel(await p.confirm({ message: o.message, initialValue: def }));
|
|
119
|
+
if (o.kind === 'select') return cancel(await p.select({ message: o.message, options: o.options, initialValue: def }));
|
|
120
|
+
return cancel(await p.text({ message: o.message, initialValue: def, placeholder: def || undefined, validate: o.validate }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------- helpers
|
|
28
124
|
function run(cmd, args, cwd, { quiet = false, env = {} } = {}) {
|
|
29
125
|
const r = spawnSync(cmd, args, { cwd, stdio: quiet ? 'pipe' : 'inherit', shell: process.platform === 'win32', env: { ...process.env, CI: '1', EXPO_NO_TELEMETRY: '1', NEXT_TELEMETRY_DISABLED: '1', ...env } });
|
|
30
126
|
if (r.status !== 0) {
|
|
@@ -34,8 +130,8 @@ function run(cmd, args, cwd, { quiet = false, env = {} } = {}) {
|
|
|
34
130
|
return String(r.stdout ?? '');
|
|
35
131
|
}
|
|
36
132
|
|
|
37
|
-
// Copy a template folder, replacing __TOKENS__ in text files.
|
|
38
|
-
// "
|
|
133
|
+
// Copy a template folder, replacing __TOKENS__ in text files. "_gitignore" -> ".gitignore",
|
|
134
|
+
// "_github" -> ".github" (npm drops real dot-files like .gitignore from published packages).
|
|
39
135
|
function copyTemplate(from, to, vars) {
|
|
40
136
|
if (!existsSync(from)) return;
|
|
41
137
|
for (const name of readdirSync(from)) {
|
|
@@ -55,33 +151,64 @@ function copyTemplate(from, to, vars) {
|
|
|
55
151
|
const readJson = (f) => JSON.parse(readFileSync(f, 'utf8'));
|
|
56
152
|
const writeJson = (f, o) => writeFileSync(f, JSON.stringify(o, null, 2) + '\n');
|
|
57
153
|
|
|
154
|
+
// Generated files for the theme preset and the font (phone + web).
|
|
155
|
+
function writeThemeAndFonts(target, a) {
|
|
156
|
+
const colors = buildColors(a.theme, a.brand);
|
|
157
|
+
writeFileSync(join(target, 'packages/theme/src/colors.ts'),
|
|
158
|
+
`// ${THEMES[a.theme].label} preset${a.brand ? `, brand ${a.brand}` : ''} (create-techtiten-app). Edit freely.\nexport const colors = ${JSON.stringify(colors, null, 2)} as const;\n`);
|
|
159
|
+
|
|
160
|
+
const f = FONTS[a.font];
|
|
161
|
+
const m = join(target, 'apps/mobile/src/theme/fonts.ts');
|
|
162
|
+
if (!f.pkg) {
|
|
163
|
+
writeFileSync(m, `// System font (San Francisco / Roboto): nothing to load.\nexport const fonts: Record<'regular' | 'medium' | 'bold' | 'black', string | undefined> = { regular: undefined, medium: undefined, bold: undefined, black: undefined };\nexport const useAppFonts = () => true;\n/** Multiplies every font size (wide fonts like pixel ones need smaller text). */\nexport const fontScale = 1;\n`);
|
|
164
|
+
} else {
|
|
165
|
+
const names = [...new Set(Object.values(f.weights))];
|
|
166
|
+
writeFileSync(m, `// ${f.label} from ${f.pkg}. Text/Button pick the family per weight.\nimport { useFonts, ${names.join(', ')} } from '${f.pkg}';\n\nexport const fonts: Record<'regular' | 'medium' | 'bold' | 'black', string | undefined> = ${JSON.stringify(f.weights)};\n\n/** true once the font files are loaded (the root layout waits for it). */\nexport function useAppFonts() {\n const [loaded, error] = useFonts({ ${names.join(', ')} });\n return loaded || !!error;\n}\n\n/** Multiplies every font size (wide fonts like pixel ones need smaller text). */\nexport const fontScale = ${f.scale ?? 1};\n`);
|
|
167
|
+
}
|
|
168
|
+
if (a.web) {
|
|
169
|
+
const w = join(target, 'apps/web/src/app/fonts.ts');
|
|
170
|
+
if (!f.next) writeFileSync(w, `// System font.\nexport const appFontClass = '';\n`);
|
|
171
|
+
else {
|
|
172
|
+
const opts = { subsets: ['latin'], variable: '--font-app', display: 'swap', ...(f.nextWeights ? { weight: f.nextWeights } : {}) };
|
|
173
|
+
// next/font reads these options at build time and needs a plain object literal (unquoted keys).
|
|
174
|
+
const literal = '{ ' + Object.entries(opts).map(([k, v]) => `${k}: ${JSON.stringify(v).replace(/"/g, "'")}`).join(', ') + ' }';
|
|
175
|
+
writeFileSync(w, `// ${f.label} via next/font (self-hosted at build time, no layout shift).\nimport { ${f.next} } from 'next/font/google';\n\nconst font = ${f.next}(${literal});\nexport const appFontClass = font.variable;\n`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------- main
|
|
58
181
|
async function main() {
|
|
182
|
+
if (has('--help', '-h')) return printHelp();
|
|
183
|
+
if (has('--version', '-v')) return console.log(VERSION);
|
|
184
|
+
|
|
59
185
|
console.log();
|
|
60
|
-
p.intro(pc.bgGreen(pc.black(
|
|
186
|
+
p.intro(pc.bgGreen(pc.black(` create-techtiten-app v${VERSION} `)));
|
|
61
187
|
|
|
62
188
|
const dirName = positional ?? (YES ? 'my-app' : cancel(await p.text({ message: 'Project folder name', placeholder: 'my-app', validate: (v) => (slugify(v || '') ? undefined : 'Required') })));
|
|
63
189
|
const slug = slugify(dirName);
|
|
64
190
|
const target = resolve(process.cwd(), dirName);
|
|
65
|
-
if (existsSync(target) && readdirSync(target).length)
|
|
66
|
-
|
|
67
|
-
const displayName = opt('name') ?? (YES ? titleCase(slug) : cancel(await p.text({ message: 'App display name', initialValue: titleCase(slug) })));
|
|
68
|
-
const packageId = opt('package') ?? (YES ? `com.techtiten.${slug.replace(/-/g, '')}` : cancel(await p.text({
|
|
69
|
-
message: 'Android package / iOS bundle id', initialValue: `com.techtiten.${slug.replace(/-/g, '')}`,
|
|
70
|
-
validate: (v) => (/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(v) ? undefined : 'Like com.techtiten.myapp (lowercase, dots)'),
|
|
71
|
-
})));
|
|
72
|
-
const web = flag('web') ? true : flag('no-web') ? false : YES ? true : cancel(await p.confirm({ message: 'Add a Next.js app (website + admin panel + API) next to the mobile app?', initialValue: true }));
|
|
73
|
-
const state = opt('state') ?? (YES ? 'zustand' : cancel(await p.select({
|
|
74
|
-
message: 'State management',
|
|
75
|
-
options: [
|
|
76
|
-
{ value: 'zustand', label: 'Zustand + TanStack Query', hint: 'recommended: tiny stores, server data cached' },
|
|
77
|
-
{ value: 'redux', label: 'Redux Toolkit + TanStack Query', hint: 'more structure, Redux DevTools' },
|
|
78
|
-
],
|
|
79
|
-
})));
|
|
80
|
-
const ads = flag('ads') ? true : flag('no-ads') ? false : YES ? false : cancel(await p.confirm({ message: 'Google AdMob ads (banner component, test ads in dev)?', initialValue: false }));
|
|
81
|
-
const install = !flag('skip-install');
|
|
191
|
+
if (existsSync(target) && readdirSync(target).length) fail(`${dirName} already exists and isn't empty.`);
|
|
82
192
|
|
|
193
|
+
const a = { slug };
|
|
194
|
+
for (const o of OPTIONS) a[o.key] = await ask(o, a);
|
|
195
|
+
if (a.brand && !a.brand.startsWith('#')) a.brand = `#${a.brand}`;
|
|
196
|
+
|
|
197
|
+
if (!YES) {
|
|
198
|
+
p.note([
|
|
199
|
+
`Name ${a.name} (${a.package})`,
|
|
200
|
+
`Apps mobile${a.web ? ' + web (site, admin, API)' : ''}`,
|
|
201
|
+
`Navigation ${a.nav} State ${a.state}`,
|
|
202
|
+
`Theme ${THEMES[a.theme].label}${a.brand ? ` with brand ${a.brand}` : ''} Font ${FONTS[a.font].label}`,
|
|
203
|
+
`Ads ${a.ads ? 'AdMob' : 'none'}`,
|
|
204
|
+
].join('\n'), 'Summary');
|
|
205
|
+
if (!cancel(await p.confirm({ message: 'Create it?', initialValue: true }))) fail('Cancelled.');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const install = !has('--skip-install');
|
|
209
|
+
const { name: displayName, package: packageId, web, state, ads } = a;
|
|
83
210
|
const scope = `@${slug}`;
|
|
84
|
-
const vars = { APP_SLUG: slug, APP_NAME: displayName, PACKAGE_ID: packageId, SCOPE: scope, STATE: state, YEAR: String(new Date().getFullYear()), WEB_ENABLED: web ? 'true' : 'false' };
|
|
211
|
+
const vars = { APP_SLUG: slug, APP_NAME: displayName, PACKAGE_ID: packageId, SCOPE: scope, STATE: state, YEAR: String(new Date().getFullYear()), WEB_ENABLED: web ? 'true' : 'false', THEME_LABEL: THEMES[a.theme].label };
|
|
85
212
|
const s = p.spinner();
|
|
86
213
|
|
|
87
214
|
// 1. Root + shared packages
|
|
@@ -90,7 +217,7 @@ async function main() {
|
|
|
90
217
|
copyTemplate(join(TPL, 'packages'), join(target, 'packages'), vars);
|
|
91
218
|
if (!web) rmSync(join(target, '.github/workflows/web-check.yml'), { force: true });
|
|
92
219
|
const rootPkg = readJson(join(target, 'package.json'));
|
|
93
|
-
if (!web) { delete rootPkg.scripts.web; delete rootPkg.scripts['build:web']; }
|
|
220
|
+
if (!web) { delete rootPkg.scripts.web; delete rootPkg.scripts['build:web']; delete rootPkg.scripts['upgrade:web']; }
|
|
94
221
|
writeJson(join(target, 'package.json'), rootPkg);
|
|
95
222
|
|
|
96
223
|
// 2. Mobile: latest Expo blank TypeScript app, then our layer on top
|
|
@@ -105,6 +232,7 @@ async function main() {
|
|
|
105
232
|
rmSync(join(m, '.git'), { recursive: true, force: true });
|
|
106
233
|
copyTemplate(join(TPL, 'mobile/common'), m, vars);
|
|
107
234
|
writeJson(join(m, 'config/base.json'), sdkBase);
|
|
235
|
+
copyTemplate(join(TPL, `mobile/nav-${a.nav}`), m, vars);
|
|
108
236
|
copyTemplate(join(TPL, `mobile/${state}`), m, vars);
|
|
109
237
|
copyTemplate(join(TPL, ads ? 'mobile/ads' : 'mobile/noads'), m, vars);
|
|
110
238
|
const mp = readJson(join(m, 'package.json'));
|
|
@@ -129,7 +257,7 @@ async function main() {
|
|
|
129
257
|
const nc = join(w, 'next.config.ts');
|
|
130
258
|
const tp = `transpilePackages: ['${scope}/theme', '${scope}/shared'],`;
|
|
131
259
|
if (existsSync(nc) && /const nextConfig: NextConfig = \{/.test(readFileSync(nc, 'utf8'))) {
|
|
132
|
-
writeFileSync(nc, readFileSync(nc, 'utf8').replace(/const nextConfig: NextConfig = \{/, (
|
|
260
|
+
writeFileSync(nc, readFileSync(nc, 'utf8').replace(/const nextConfig: NextConfig = \{/, (x) => `${x}\n ${tp}`));
|
|
133
261
|
} else {
|
|
134
262
|
writeFileSync(nc, `import type { NextConfig } from 'next';\n\nconst nextConfig: NextConfig = {\n ${tp}\n};\n\nexport default nextConfig;\n`);
|
|
135
263
|
}
|
|
@@ -141,13 +269,17 @@ async function main() {
|
|
|
141
269
|
s.stop('Next.js app created');
|
|
142
270
|
}
|
|
143
271
|
|
|
272
|
+
writeThemeAndFonts(target, a);
|
|
273
|
+
const pluginList = (added = []) => [...new Set(['expo-router', ...added, ...(ads ? ['react-native-google-mobile-ads'] : []), './plugins/withSigning.js'])];
|
|
274
|
+
|
|
144
275
|
// 4. Install: one root install, then SDK-matched native packages via expo install
|
|
145
276
|
if (install) {
|
|
146
277
|
s.start('Installing (one install for all workspaces)');
|
|
147
278
|
run('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
148
279
|
s.message('Adding Expo packages matched to this SDK');
|
|
149
|
-
const expoPkgs = ['expo-router', 'expo-linking', 'expo-constants', 'expo-status-bar', 'expo-haptics', 'expo-system-ui', 'react-native-safe-area-context', 'react-native-screens', '@react-native-async-storage/async-storage', 'react-dom', 'react-native-web'];
|
|
280
|
+
const expoPkgs = ['expo-router', 'expo-linking', 'expo-constants', 'expo-status-bar', 'expo-haptics', 'expo-system-ui', 'expo-font', '@expo/vector-icons', 'react-native-safe-area-context', 'react-native-screens', 'react-native-reanimated', 'react-native-worklets', 'react-native-gesture-handler', '@react-native-async-storage/async-storage', 'react-dom', 'react-native-web'];
|
|
150
281
|
if (ads) expoPkgs.push('react-native-google-mobile-ads');
|
|
282
|
+
if (FONTS[a.font].pkg) expoPkgs.push(FONTS[a.font].pkg);
|
|
151
283
|
const expoArgs = ['expo', 'install', ...expoPkgs, '--', '--no-audit', '--no-fund', '--loglevel=error'];
|
|
152
284
|
// expo install adds config plugins itself, but can't edit app.config.ts. Give it a temporary
|
|
153
285
|
// app.json, then copy the plugins it added into config/plugins.json (read by app.config.ts).
|
|
@@ -157,7 +289,7 @@ async function main() {
|
|
|
157
289
|
run('npx', expoArgs, m, { quiet: true });
|
|
158
290
|
} catch {
|
|
159
291
|
// Expo's version API unreachable (proxy/offline): use the versions bundled with the installed SDK.
|
|
160
|
-
s.message(
|
|
292
|
+
s.message("Expo API unreachable, using the SDK's bundled version list");
|
|
161
293
|
run('npx', expoArgs, m, { quiet: true, env: { EXPO_OFFLINE: '1' } });
|
|
162
294
|
}
|
|
163
295
|
const added = (readJson(join(m, 'app.json')).expo.plugins ?? []).map((x) => (Array.isArray(x) ? x[0] : x));
|
|
@@ -166,8 +298,9 @@ async function main() {
|
|
|
166
298
|
const statePkgs = state === 'redux' ? ['@reduxjs/toolkit', 'react-redux', '@tanstack/react-query'] : ['zustand', '@tanstack/react-query'];
|
|
167
299
|
run('npm', ['install', ...statePkgs, '-w', `${scope}/mobile`, '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
168
300
|
run('npm', ['install', 'zod', '-w', `${scope}/shared`, '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
301
|
+
run('npm', ['install', '-D', 'prettier', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
169
302
|
// Now that the packages exist, turn their config plugins on.
|
|
170
|
-
writeJson(join(m, 'config/plugins.json'),
|
|
303
|
+
writeJson(join(m, 'config/plugins.json'), pluginList(added));
|
|
171
304
|
if (web) {
|
|
172
305
|
// One React for the whole repo: pin web's react/react-dom to the version Expo installed,
|
|
173
306
|
// otherwise npm can install two Reacts and hooks break ("Invalid hook call").
|
|
@@ -183,16 +316,17 @@ async function main() {
|
|
|
183
316
|
run('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
184
317
|
}
|
|
185
318
|
s.stop('Installed');
|
|
319
|
+
} else {
|
|
320
|
+
writeJson(join(m, 'config/plugins.json'), pluginList());
|
|
186
321
|
}
|
|
187
322
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
// 5. Git
|
|
191
|
-
if (!flag('no-git')) {
|
|
323
|
+
// 5. Git (uses your git name/email; falls back to a neutral author if none is set)
|
|
324
|
+
if (!has('--no-git')) {
|
|
192
325
|
try {
|
|
193
326
|
run('git', ['init', '-b', 'main'], target, { quiet: true });
|
|
194
327
|
run('git', ['add', '-A'], target, { quiet: true });
|
|
195
|
-
run('git', ['
|
|
328
|
+
try { run('git', ['commit', '-m', 'Initial commit from create-techtiten-app', '--no-verify'], target, { quiet: true }); }
|
|
329
|
+
catch { run('git', ['-c', 'user.name=create-techtiten-app', '-c', 'user.email=noreply@techtiten.com', 'commit', '-m', 'Initial commit from create-techtiten-app', '--no-verify'], target, { quiet: true }); }
|
|
196
330
|
} catch { p.log.warn('git init skipped (git missing or commit failed). Run git init yourself.'); }
|
|
197
331
|
}
|
|
198
332
|
|
|
@@ -202,9 +336,10 @@ async function main() {
|
|
|
202
336
|
'npm run mobile # Expo dev server (press a for Android)',
|
|
203
337
|
web ? 'npm run web # Next.js on http://localhost:3000 (/admin, /api/health)' : null,
|
|
204
338
|
'npm run typecheck # all workspaces',
|
|
339
|
+
'npm run format # prettier',
|
|
205
340
|
ads ? 'Ads need a dev build (npm run android), not Expo Go.' : null,
|
|
206
341
|
].filter(Boolean).join('\n'), 'Next steps');
|
|
207
342
|
p.outro(`${pc.green('Done.')} ${displayName} is ready in ${pc.cyan(rel)}`);
|
|
208
343
|
}
|
|
209
344
|
|
|
210
|
-
main().catch((e) =>
|
|
345
|
+
main().catch((e) => fail(e.message));
|
package/bin/presets.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Theme presets and fonts. Add one here and it shows up in the prompts and in --help.
|
|
2
|
+
|
|
3
|
+
const neutrals = {
|
|
4
|
+
cool: {
|
|
5
|
+
light: { background: '#F7F8FA', surface: '#FFFFFF', surfaceAlt: '#EEF0F3', border: '#E2E5EA', text: '#0B0D12', textMuted: '#5B6270' },
|
|
6
|
+
dark: { background: '#0B0B12', surface: '#15151F', surfaceAlt: '#1D1D2A', border: '#2A2A38', text: '#ECEDF2', textMuted: '#9A9CAB' },
|
|
7
|
+
},
|
|
8
|
+
navy: {
|
|
9
|
+
light: { background: '#F5F8FC', surface: '#FFFFFF', surfaceAlt: '#EAF0F8', border: '#DCE4EF', text: '#0A1324', textMuted: '#55627A' },
|
|
10
|
+
dark: { background: '#0A1020', surface: '#121A2E', surfaceAlt: '#1A2440', border: '#26314F', text: '#E8EEF8', textMuted: '#93A0BA' },
|
|
11
|
+
},
|
|
12
|
+
warm: {
|
|
13
|
+
light: { background: '#FBF8F5', surface: '#FFFFFF', surfaceAlt: '#F4EEE8', border: '#EADFD5', text: '#1A120D', textMuted: '#6E6058' },
|
|
14
|
+
dark: { background: '#140F0C', surface: '#1E1713', surfaceAlt: '#2A201A', border: '#3A2D25', text: '#F3ECE6', textMuted: '#AE9F95' },
|
|
15
|
+
},
|
|
16
|
+
plum: {
|
|
17
|
+
light: { background: '#F8F7FC', surface: '#FFFFFF', surfaceAlt: '#EFECF9', border: '#E1DCF2', text: '#120E22', textMuted: '#5E5875' },
|
|
18
|
+
dark: { background: '#0E0B1A', surface: '#171328', surfaceAlt: '#211B37', border: '#2E2649', text: '#EEEAFB', textMuted: '#A29BBF' },
|
|
19
|
+
},
|
|
20
|
+
lcd: {
|
|
21
|
+
light: { background: '#C7D3A6', surface: '#BCC994', surfaceAlt: '#AFBD86', border: '#8F9D68', text: '#1E2A14', textMuted: '#4A5832' },
|
|
22
|
+
dark: { background: '#10160C', surface: '#182012', surfaceAlt: '#212B19', border: '#33402A', text: '#B8E07A', textMuted: '#7E9A57' },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const fixed = { danger: '#E5484D', warning: '#E9A049', success: '#2EAD5B' };
|
|
27
|
+
|
|
28
|
+
// brand per mode: [brand, brandStrong, onBrand]
|
|
29
|
+
export const THEMES = {
|
|
30
|
+
techtiten: { label: 'Techtiten', hint: 'mint green + purple (FitPrep / EzySplit feel)', neutral: 'cool', light: ['#00A372', '#008A60', '#FFFFFF'], dark: ['#00C389', '#00A372', '#04110C'], accent: '#7C5CFF' },
|
|
31
|
+
ocean: { label: 'Ocean', hint: 'calm blue, trusted (finance, productivity)', neutral: 'navy', light: ['#2F6FED', '#1F57C8', '#FFFFFF'], dark: ['#4D8DFF', '#2F6FED', '#06122A'], accent: '#00B8D9' },
|
|
32
|
+
sunset: { label: 'Sunset', hint: 'orange + pink, energetic (food, fitness)', neutral: 'warm', light: ['#F2552C', '#D4401B', '#FFFFFF'], dark: ['#FF6B4A', '#F2552C', '#1A0B06'], accent: '#FF4F8B' },
|
|
33
|
+
royal: { label: 'Royal', hint: 'violet, premium (education, lifestyle)', neutral: 'plum', light: ['#6A4CF0', '#5435D6', '#FFFFFF'], dark: ['#8B70FF', '#6A4CF0', '#0E0820'], accent: '#FF5CA8' },
|
|
34
|
+
mono: { label: 'Mono', hint: 'black & white, minimal (tools, portfolio)', neutral: 'cool', light: ['#111317', '#000000', '#FFFFFF'], dark: ['#F2F3F5', '#FFFFFF', '#0B0B12'], accent: '#7A808C' },
|
|
35
|
+
retro: { label: 'Retro LCD', hint: 'old-phone green screen (games, nostalgia)', neutral: 'lcd', light: ['#2F3B1E', '#1E2A14', '#C7D3A6'], dark: ['#9BD14A', '#B8E07A', '#10160C'], accent: '#5E7A2E' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Mobile: @expo-google-fonts/<pkg> exports; web: next/font/google import name + options.
|
|
39
|
+
export const FONTS = {
|
|
40
|
+
system: { label: 'System', hint: 'San Francisco / Roboto, nothing to load' },
|
|
41
|
+
inter: { label: 'Inter', hint: 'clean UI font', pkg: '@expo-google-fonts/inter', weights: { regular: 'Inter_400Regular', medium: 'Inter_500Medium', bold: 'Inter_700Bold', black: 'Inter_800ExtraBold' }, next: 'Inter', nextWeights: null },
|
|
42
|
+
poppins: { label: 'Poppins', hint: 'round and friendly (consumer apps)', pkg: '@expo-google-fonts/poppins', weights: { regular: 'Poppins_400Regular', medium: 'Poppins_500Medium', bold: 'Poppins_700Bold', black: 'Poppins_800ExtraBold' }, next: 'Poppins', nextWeights: ['400', '500', '700', '800'] },
|
|
43
|
+
'space-grotesk': { label: 'Space Grotesk', hint: 'techy, modern', pkg: '@expo-google-fonts/space-grotesk', weights: { regular: 'SpaceGrotesk_400Regular', medium: 'SpaceGrotesk_500Medium', bold: 'SpaceGrotesk_700Bold', black: 'SpaceGrotesk_700Bold' }, next: 'Space_Grotesk', nextWeights: null },
|
|
44
|
+
pixel: { label: 'Press Start 2P', hint: 'pixel/8-bit (retro games)', scale: 0.72, pkg: '@expo-google-fonts/press-start-2p', weights: { regular: 'PressStart2P_400Regular', medium: 'PressStart2P_400Regular', bold: 'PressStart2P_400Regular', black: 'PressStart2P_400Regular' }, next: 'Press_Start_2P', nextWeights: ['400'] },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const hex = (h) => h.replace('#', '').padEnd(6, '0').slice(0, 6);
|
|
48
|
+
const toRgb = (h) => [0, 2, 4].map((i) => parseInt(hex(h).slice(i, i + 2), 16));
|
|
49
|
+
const toHex = (rgb) => '#' + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
50
|
+
const shade = (h, f) => toHex(toRgb(h).map((v) => (f < 0 ? v * (1 + f) : v + (255 - v) * f)));
|
|
51
|
+
const luminance = (h) => { const [r, g, b] = toRgb(h).map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
|
|
52
|
+
export const isHex = (v) => /^#?[0-9a-fA-F]{6}$/.test(v ?? '');
|
|
53
|
+
|
|
54
|
+
/** { light: {...}, dark: {...} } colors for packages/theme. `brand` (#RRGGBB) overrides the preset's brand. */
|
|
55
|
+
export function buildColors(themeKey, brand) {
|
|
56
|
+
const t = THEMES[themeKey];
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const mode of ['light', 'dark']) {
|
|
59
|
+
let [b, strong, on] = t[mode];
|
|
60
|
+
if (brand) {
|
|
61
|
+
b = '#' + hex(brand).toUpperCase();
|
|
62
|
+
strong = shade(b, mode === 'light' ? -0.15 : 0.15);
|
|
63
|
+
on = luminance(b) > 0.45 ? '#0B0D12' : '#FFFFFF';
|
|
64
|
+
}
|
|
65
|
+
out[mode] = { ...neutrals[t.neutral][mode], brand: b, brandStrong: strong, onBrand: on, accent: t.accent, ...fixed };
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-techtiten-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Scaffold a Techtiten app: Expo (Android/iOS) + optional Next.js (web, admin panel, API) with a shared theme and shared logic. Always on the latest Expo and Next.js.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-techtiten-app": "bin/index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"templates"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.19"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"expo",
|
|
18
|
+
"react-native",
|
|
19
|
+
"nextjs",
|
|
20
|
+
"monorepo",
|
|
21
|
+
"template",
|
|
22
|
+
"create-app",
|
|
23
|
+
"zustand",
|
|
24
|
+
"redux"
|
|
25
|
+
],
|
|
10
26
|
"author": "Arun Kumar (Techtiten)",
|
|
11
27
|
"license": "MIT",
|
|
12
|
-
"repository": {
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/codewitharun/create-techtiten-app.git"
|
|
31
|
+
},
|
|
13
32
|
"dependencies": {
|
|
14
33
|
"@clack/prompts": "^1.8.1",
|
|
15
34
|
"picocolors": "^1.1.1"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/codewitharun/create-techtiten-app#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/codewitharun/create-techtiten-app/issues"
|
|
16
39
|
}
|
|
17
40
|
}
|
|
@@ -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
|
+
}
|
|
@@ -16,7 +16,7 @@ export default function Home() {
|
|
|
16
16
|
<Screen>
|
|
17
17
|
<Card>
|
|
18
18
|
<Text size="xl" weight="black">Welcome to __APP_NAME__</Text>
|
|
19
|
-
<Text muted>Edit
|
|
19
|
+
<Text muted>Edit src/screens/Home.tsx to start. Colors come from packages/theme, helpers from packages/shared.</Text>
|
|
20
20
|
</Card>
|
|
21
21
|
|
|
22
22
|
<Card>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Pressable, Text as RNText, View, type PressableProps, type TextProps, type ViewProps } from 'react-native';
|
|
2
2
|
import * as Haptics from 'expo-haptics';
|
|
3
3
|
import { useTheme } from '@/theme';
|
|
4
|
+
import { fontScale, fonts } from '@/theme/fonts';
|
|
4
5
|
|
|
5
6
|
export function Screen({ style, ...rest }: ViewProps) {
|
|
6
7
|
const t = useTheme();
|
|
@@ -14,7 +15,10 @@ export function Card({ style, ...rest }: ViewProps) {
|
|
|
14
15
|
|
|
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'] }) {
|
|
16
17
|
const t = useTheme();
|
|
17
|
-
|
|
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} />;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export function Button({ title, variant = 'primary', onPress, style, ...rest }: PressableProps & { title: string; variant?: 'primary' | 'ghost' }) {
|
|
@@ -31,7 +35,7 @@ export function Button({ title, variant = 'primary', onPress, style, ...rest }:
|
|
|
31
35
|
alignItems: 'center', opacity: state.pressed ? 0.8 : 1,
|
|
32
36
|
}, typeof style === 'function' ? style(state) : style]}
|
|
33
37
|
{...rest}>
|
|
34
|
-
<RNText style={{ color: primary ? t.colors.onBrand : t.colors.text,
|
|
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>
|
|
35
39
|
</Pressable>
|
|
36
40
|
);
|
|
37
41
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Stack } from 'expo-router';
|
|
2
2
|
import { StatusBar } from 'expo-status-bar';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { AppProviders } from '@/AppProviders';
|
|
4
|
+
import { useTheme } from '@/theme';
|
|
5
|
+
import { fonts } from '@/theme/fonts';
|
|
6
6
|
|
|
7
7
|
function ThemedStack() {
|
|
8
8
|
const t = useTheme();
|
|
@@ -13,6 +13,7 @@ function ThemedStack() {
|
|
|
13
13
|
screenOptions={{
|
|
14
14
|
headerStyle: { backgroundColor: t.colors.background },
|
|
15
15
|
headerTintColor: t.colors.text,
|
|
16
|
+
headerTitleStyle: { fontFamily: fonts.bold },
|
|
16
17
|
headerShadowVisible: false,
|
|
17
18
|
contentStyle: { backgroundColor: t.colors.background },
|
|
18
19
|
}}>
|
|
@@ -25,12 +26,8 @@ function ThemedStack() {
|
|
|
25
26
|
|
|
26
27
|
export default function RootLayout() {
|
|
27
28
|
return (
|
|
28
|
-
<
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
<ThemedStack />
|
|
32
|
-
</ThemeProvider>
|
|
33
|
-
</Providers>
|
|
34
|
-
</SafeAreaProvider>
|
|
29
|
+
<AppProviders>
|
|
30
|
+
<ThemedStack />
|
|
31
|
+
</AppProviders>
|
|
35
32
|
);
|
|
36
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
|
+
}
|
|
@@ -1,37 +1,10 @@
|
|
|
1
1
|
// One theme for the phone app and the website. Change a color here and both update.
|
|
2
2
|
export type Mode = 'light' | 'dark';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
danger: '#E5484D',
|
|
9
|
-
warning: '#E9A049',
|
|
10
|
-
success: '#2EAD5B',
|
|
11
|
-
} as const;
|
|
12
|
-
|
|
13
|
-
export const colors = {
|
|
14
|
-
light: {
|
|
15
|
-
background: '#F7F8FA',
|
|
16
|
-
surface: '#FFFFFF',
|
|
17
|
-
surfaceAlt: '#EEF0F3',
|
|
18
|
-
border: '#E2E5EA',
|
|
19
|
-
text: '#0B0D12',
|
|
20
|
-
textMuted: '#5B6270',
|
|
21
|
-
onBrand: '#FFFFFF',
|
|
22
|
-
...palette,
|
|
23
|
-
},
|
|
24
|
-
dark: {
|
|
25
|
-
background: '#0B0B12',
|
|
26
|
-
surface: '#15151F',
|
|
27
|
-
surfaceAlt: '#1D1D2A',
|
|
28
|
-
border: '#2A2A38',
|
|
29
|
-
text: '#ECEDF2',
|
|
30
|
-
textMuted: '#9A9CAB',
|
|
31
|
-
onBrand: '#04110C',
|
|
32
|
-
...palette,
|
|
33
|
-
},
|
|
34
|
-
} as const;
|
|
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 };
|
|
35
8
|
|
|
36
9
|
export type ColorName = keyof (typeof colors)['light'];
|
|
37
10
|
export type Colors = Record<ColorName, string>;
|
package/templates/root/README.md
CHANGED
|
@@ -20,3 +20,12 @@ npm run typecheck # every workspace
|
|
|
20
20
|
## Add a package
|
|
21
21
|
- Native/Expo package: `cd apps/mobile && npx expo install <pkg>` (picks the SDK-matched version)
|
|
22
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.
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "__APP_SLUG__",
|
|
3
3
|
"private": true,
|
|
4
|
-
"workspaces": [
|
|
4
|
+
"workspaces": [
|
|
5
|
+
"apps/*",
|
|
6
|
+
"packages/*"
|
|
7
|
+
],
|
|
5
8
|
"scripts": {
|
|
6
9
|
"mobile": "npm run start -w __SCOPE__/mobile",
|
|
7
10
|
"android": "npm run android -w __SCOPE__/mobile",
|
|
8
11
|
"web": "npm run dev -w __SCOPE__/web",
|
|
9
12
|
"build:web": "npm run build -w __SCOPE__/web",
|
|
10
|
-
"typecheck": "npm run typecheck --workspaces --if-present"
|
|
13
|
+
"typecheck": "npm run typecheck --workspaces --if-present",
|
|
14
|
+
"format": "prettier --write .",
|
|
15
|
+
"upgrade:mobile": "cd apps/mobile && npx expo install expo@latest && npx expo install --fix",
|
|
16
|
+
"upgrade:web": "npm install next@latest eslint-config-next@latest -w __SCOPE__/web"
|
|
11
17
|
},
|
|
12
|
-
"engines": {
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20.19"
|
|
20
|
+
}
|
|
13
21
|
}
|
|
@@ -19,5 +19,5 @@
|
|
|
19
19
|
body {
|
|
20
20
|
background: var(--color-background);
|
|
21
21
|
color: var(--color-text);
|
|
22
|
-
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
22
|
+
font-family: var(--font-app, ui-sans-serif), system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
23
23
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { cssVariables } from '__SCOPE__/theme';
|
|
3
|
+
import { appFontClass } from './fonts';
|
|
3
4
|
import './globals.css';
|
|
4
5
|
|
|
5
6
|
export const metadata: Metadata = {
|
|
@@ -13,7 +14,7 @@ const themeCss = `:root{${toCss(cssVariables('light'))}}@media (prefers-color-sc
|
|
|
13
14
|
|
|
14
15
|
export default function RootLayout({ children }: LayoutProps<'/'>) {
|
|
15
16
|
return (
|
|
16
|
-
<html lang="en" className=
|
|
17
|
+
<html lang="en" className={`h-full antialiased ${appFontClass}`}>
|
|
17
18
|
<head>
|
|
18
19
|
<style dangerouslySetInnerHTML={{ __html: themeCss }} />
|
|
19
20
|
</head>
|
|
File without changes
|