add unistyles

This commit is contained in:
Aman Varshney
2025-05-07 14:29:11 +05:30
parent d09a284ce7
commit 6c269a4c5b
74 changed files with 1762 additions and 208 deletions

View File

@@ -0,0 +1,5 @@
---
"create-better-t-stack": minor
---
add expo with unistyles

View File

@@ -23,21 +23,21 @@ Follow the prompts to configure your project or use the `--yes` flag for default
## Features
| Category | Options |
|----------|---------|
| **TypeScript** | End-to-end type safety across all parts of your application |
| **Frontend** | • React with TanStack Router<br>• React with React Router<br>• React with TanStack Start (SSR)<br>• Next.js<br>• SvelteKit<br>• Nuxt (Vue)<br>• SolidJS<br>• React Native with Expo<br>• None |
| **Backend** | • Hono<br>• Express<br>• Elysia<br>• Next.js API routes<br>• Convex |
| **API Layer** | • tRPC (type-safe APIs)<br>• oRPC (OpenAPI-compatible type-safe APIs) |
| **Runtime** | • Bun<br>• Node.js |
| **Database** | • SQLite<br>• PostgreSQL<br>• MySQL<br>• MongoDB<br>• None |
| **ORM** | • Drizzle (TypeScript-first)<br>• Prisma (feature-rich)<br>• Mongoose (for MongoDB)<br>• None |
| **Database Setup** | • Turso (SQLite)<br>• Neon (PostgreSQL)<br>• Prisma Postgres (via Prisma Accelerate)<br>• MongoDB Atlas<br>• None (manual setup) |
| **Authentication** | Better-Auth (email/password, with more options coming soon) |
| **Styling** | Tailwind CSS with shadcn/ui components |
| **Addons** | • PWA support<br>• Tauri (desktop applications)<br>• Starlight (documentation site)<br>• Biome (linting and formatting)<br>• Husky (Git hooks)<br>• Turborepo (optimized builds) |
| **Examples** | • Todo app<br>• AI Chat interface (using Vercel AI SDK) |
| **Developer Experience** | • Automatic Git initialization<br>• Package manager choice (npm, pnpm, bun)<br>• Automatic dependency installation |
| Category | Options |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **TypeScript** | End-to-end type safety across all parts of your application |
| **Frontend** | • React with TanStack Router<br>• React with React Router<br>• React with TanStack Start (SSR)<br>• Next.js<br>• SvelteKit<br>• Nuxt (Vue)<br>• SolidJS<br>• React Native with NativeWind (via Expo)<br>• React Native with Unistyles (via Expo)<br>• None |
| **Backend** | • Hono<br>• Express<br>• Elysia<br>• Next.js API routes<br>• Convex<br>• None |
| **API Layer** | • tRPC (type-safe APIs)<br>• oRPC (OpenAPI-compatible type-safe APIs)<br>• None |
| **Runtime** | • Bun<br>• Node.js |
| **Database** | • SQLite<br>• PostgreSQL<br>• MySQL<br>• MongoDB<br>• None |
| **ORM** | • Drizzle (TypeScript-first)<br>• Prisma (feature-rich)<br>• Mongoose (for MongoDB)<br>• None |
| **Database Setup** | • Turso (SQLite)<br>• Neon (PostgreSQL)<br>• Prisma Postgres (via Prisma Accelerate)<br>• MongoDB Atlas<br>• None (manual setup) |
| **Authentication** | Better-Auth (email/password, with more options coming soon) |
| **Styling** | Tailwind CSS with shadcn/ui components |
| **Addons** | • PWA support<br>• Tauri (desktop applications)<br>• Starlight (documentation site)<br>• Biome (linting and formatting)<br>• Husky (Git hooks)<br>• Turborepo (optimized builds) |
| **Examples** | • Todo app<br>• AI Chat interface (using Vercel AI SDK) |
| **Developer Experience** | • Automatic Git initialization<br>• Package manager choice (npm, pnpm, bun)<br>• Automatic dependency installation |
## Usage
@@ -51,7 +51,7 @@ Options:
--orm <type> ORM type (none, drizzle, prisma, mongoose)
--auth Include authentication
--no-auth Exclude authentication
--frontend <types...> Frontend types (tanstack-router, react-router, tanstack-start, next, nuxt, svelte, solid, native, none)
--frontend <types...> Frontend types (tanstack-router, react-router, tanstack-start, next, nuxt, svelte, solid, native-nativewind, native-unistyles, none)
--addons <types...> Additional addons (pwa, tauri, starlight, biome, husky, turborepo, none)
--examples <types...> Examples to include (todo, ai, none)
--git Initialize git repository
@@ -119,6 +119,7 @@ npx create-better-t-stack my-app --addons starlight
## Compatibility Notes
- **Convex backend**: Automatically disables authentication, database, ORM, and API options
- **Backend 'none'**: If selected, this option will force related options like API, ORM, database, authentication, and runtime to 'none'. Examples will also be disabled (set to none/empty).
- **SvelteKit, Nuxt, and SolidJS** frontends are only compatible with oRPC API layer
- **PWA support** requires React with TanStack Router, React Router, or SolidJS
- **Tauri desktop app** requires React (TanStack Router/React Router), Nuxt, SvelteKit, or SolidJS

View File

@@ -125,7 +125,8 @@ export async function setupApi(config: ProjectConfig): Promise<void> {
"tanstack-router",
"tanstack-start",
"next",
"native",
"native-nativewind",
"native-unistyles",
];
const needsSolidQuery = frontend.includes("solid");
const needsReactQuery = frontend.some((f) => reactBasedFrontends.includes(f));
@@ -137,9 +138,14 @@ export async function setupApi(config: ProjectConfig): Promise<void> {
];
const hasReactWeb = frontend.some(
(f) => f !== "native" && reactBasedFrontends.includes(f),
(f) =>
f !== "native-nativewind" &&
f !== "native-unistyles" &&
reactBasedFrontends.includes(f),
);
const hasNative = frontend.includes("native");
const hasNative =
frontend.includes("native-nativewind") ||
frontend.includes("native-unistyles");
if (hasReactWeb && webDirExists) {
const webPkgJsonPath = path.join(webDir, "package.json");

View File

@@ -45,7 +45,11 @@ export async function setupAuth(config: ProjectConfig): Promise<void> {
});
}
if (frontend.includes("native") && nativeDirExists) {
if (
(frontend.includes("native-nativewind") ||
frontend.includes("native-unistyles")) &&
nativeDirExists
) {
await addPackageDependency({
dependencies: ["better-auth", "@better-auth/expo"],
projectDir: nativeDir,

View File

@@ -39,7 +39,9 @@ function generateReadmeContent(options: ProjectConfig): string {
const isConvex = backend === "convex";
const hasReactRouter = frontend.includes("react-router");
const hasTanstackRouter = frontend.includes("tanstack-router");
const hasNative = frontend.includes("native");
const hasNative =
frontend.includes("native-nativewind") ||
frontend.includes("native-unistyles");
const hasNext = frontend.includes("next");
const hasTanstackStart = frontend.includes("tanstack-start");
const hasSvelte = frontend.includes("svelte");
@@ -78,7 +80,16 @@ This project was created with [Better-T-Stack](https://github.com/AmanVarshney01
## Features
${generateFeaturesList(database, auth, addons, orm, runtime, frontend, backend, api)}
${generateFeaturesList(
database,
auth,
addons,
orm,
runtime,
frontend,
backend,
api,
)}
## Getting Started
@@ -207,7 +218,9 @@ function generateFeaturesList(
const isConvex = backend === "convex";
const hasTanstackRouter = frontend.includes("tanstack-router");
const hasReactRouter = frontend.includes("react-router");
const hasNative = frontend.includes("native");
const hasNative =
frontend.includes("native-nativewind") ||
frontend.includes("native-unistyles");
const hasNext = frontend.includes("next");
const hasTanstackStart = frontend.includes("tanstack-start");
const hasSvelte = frontend.includes("svelte");

View File

@@ -118,7 +118,10 @@ export async function setupEnvironmentVariables(
}
}
if (frontend.includes("native")) {
if (
frontend.includes("native-nativewind") ||
frontend.includes("native-unistyles")
) {
const nativeDir = path.join(projectDir, "apps/native");
if (await fs.pathExists(nativeDir)) {
let envVarName = "EXPO_PUBLIC_SERVER_URL";

View File

@@ -1,11 +1,6 @@
import { consola } from "consola";
import pc from "picocolors";
import type {
ProjectBackend,
ProjectDatabase,
ProjectOrm,
ProjectRuntime,
} from "../types";
import type { ProjectDatabase, ProjectOrm, ProjectRuntime } from "../types";
import { getPackageExecutionCommand } from "../utils/get-package-execution-command";
import type { ProjectConfig } from "../types";
@@ -43,9 +38,11 @@ export function displayPostInstallInstructions(
const lintingInstructions = hasHuskyOrBiome
? getLintingInstructions(runCmd)
: "";
const nativeInstructions = frontend?.includes("native")
? getNativeInstructions(isConvex)
: "";
const nativeInstructions =
frontend?.includes("native-nativewind") ||
frontend?.includes("native-unistyles")
? getNativeInstructions(isConvex)
: "";
const pwaInstructions =
addons?.includes("pwa") &&
(frontend?.includes("react-router") ||
@@ -67,7 +64,9 @@ export function displayPostInstallInstructions(
"solid",
].includes(f),
);
const hasNative = frontend?.includes("native");
const hasNative =
frontend?.includes("native-nativewind") ||
frontend?.includes("native-unistyles");
const bunWebNativeWarning =
packageManager === "bun" && hasNative && hasWeb
@@ -90,7 +89,9 @@ export function displayPostInstallInstructions(
}
if (isConvex) {
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev:setup ${pc.dim("(this will guide you through Convex project setup)")}\n`;
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev:setup ${pc.dim(
"(this will guide you through Convex project setup)",
)}\n`;
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`;
} else {
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`;
@@ -101,7 +102,9 @@ export function displayPostInstallInstructions(
if (hasWeb) {
output += `${pc.cyan("•")} Frontend: http://localhost:${webPort}\n`;
} else if (!hasNative && !addons?.includes("starlight")) {
output += `${pc.yellow("NOTE:")} You are creating a backend-only app (no frontend selected)\n`;
output += `${pc.yellow(
"NOTE:",
)} You are creating a backend-only app (no frontend selected)\n`;
}
if (!isConvex) {
@@ -122,8 +125,12 @@ export function displayPostInstallInstructions(
if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`;
if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`;
output += `\n${pc.bold("Update all dependencies:\n")}${pc.cyan(tazeCommand)}\n\n`;
output += `${pc.bold("Like Better-T Stack?")} Please consider giving us a star on GitHub:\n`;
output += `\n${pc.bold("Update all dependencies:\n")}${pc.cyan(
tazeCommand,
)}\n\n`;
output += `${pc.bold(
"Like Better-T Stack?",
)} Please consider giving us a star on GitHub:\n`;
output += pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack");
consola.box(output);
@@ -183,7 +190,9 @@ function getDatabaseInstructions(
instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`);
if (database === "sqlite") {
instructions.push(
`${pc.cyan("•")} Start local DB (if needed): ${`cd apps/server && ${runCmd} db:local`}`,
`${pc.cyan(
"•",
)} Start local DB (if needed): ${`cd apps/server && ${runCmd} db:local`}`,
);
}
} else if (orm === "none") {

View File

@@ -70,7 +70,9 @@ export async function setupFrontendTemplates(
const hasNuxtWeb = context.frontend.includes("nuxt");
const hasSvelteWeb = context.frontend.includes("svelte");
const hasSolidWeb = context.frontend.includes("solid");
const hasNative = context.frontend.includes("native");
const hasNativeWind = context.frontend.includes("native-nativewind");
const hasUnistyles = context.frontend.includes("native-unistyles");
const hasNative = hasNativeWind || hasUnistyles;
const isConvex = context.backend === "convex";
if (hasReactWeb || hasNuxtWeb || hasSvelteWeb || hasSolidWeb) {
@@ -181,16 +183,45 @@ export async function setupFrontendTemplates(
}
}
if (hasNative) {
if (hasNativeWind || hasUnistyles) {
const nativeAppDir = path.join(projectDir, "apps/native");
await fs.ensureDir(nativeAppDir);
const nativeBaseDir = path.join(PKG_ROOT, "templates/frontend/native");
if (await fs.pathExists(nativeBaseDir)) {
await processAndCopyFiles("**/*", nativeBaseDir, nativeAppDir, context);
const nativeBaseCommonDir = path.join(
PKG_ROOT,
"templates/frontend/native/native-base",
);
if (await fs.pathExists(nativeBaseCommonDir)) {
await processAndCopyFiles(
"**/*",
nativeBaseCommonDir,
nativeAppDir,
context,
);
} else {
}
let nativeFrameworkPath = "";
if (hasNativeWind) {
nativeFrameworkPath = "nativewind";
} else if (hasUnistyles) {
nativeFrameworkPath = "unistyles";
}
const nativeSpecificDir = path.join(
PKG_ROOT,
`templates/frontend/native/${nativeFrameworkPath}`,
);
if (await fs.pathExists(nativeSpecificDir)) {
await processAndCopyFiles(
"**/*",
nativeSpecificDir,
nativeAppDir,
context,
true,
);
}
if (!isConvex && (context.api === "trpc" || context.api === "orpc")) {
const apiNativeSrcDir = path.join(
PKG_ROOT,
@@ -203,7 +234,6 @@ export async function setupFrontendTemplates(
nativeAppDir,
context,
);
} else {
}
}
}
@@ -345,7 +375,9 @@ export async function setupAuthTemplate(
const hasNuxtWeb = context.frontend.includes("nuxt");
const hasSvelteWeb = context.frontend.includes("svelte");
const hasSolidWeb = context.frontend.includes("solid");
const hasNative = context.frontend.includes("native");
const hasNativeWind = context.frontend.includes("native-nativewind");
const hasUnistyles = context.frontend.includes("native-unistyles");
const hasNative = hasNativeWind || hasUnistyles;
if (serverAppDirExists) {
const authServerBaseSrc = path.join(PKG_ROOT, "templates/auth/server/base");
@@ -475,10 +507,39 @@ export async function setupAuthTemplate(
}
if (hasNative && nativeAppDirExists) {
const authNativeSrc = path.join(PKG_ROOT, "templates/auth/native");
if (await fs.pathExists(authNativeSrc)) {
await processAndCopyFiles("**/*", authNativeSrc, nativeAppDir, context);
} else {
const authNativeBaseSrc = path.join(
PKG_ROOT,
"templates/auth/native/native-base",
);
if (await fs.pathExists(authNativeBaseSrc)) {
await processAndCopyFiles(
"**/*",
authNativeBaseSrc,
nativeAppDir,
context,
);
}
let nativeFrameworkAuthPath = "";
if (hasNativeWind) {
nativeFrameworkAuthPath = "nativewind";
} else if (hasUnistyles) {
nativeFrameworkAuthPath = "unistyles";
}
if (nativeFrameworkAuthPath) {
const authNativeFrameworkSrc = path.join(
PKG_ROOT,
`templates/auth/native/${nativeFrameworkAuthPath}`,
);
if (await fs.pathExists(authNativeFrameworkSrc)) {
await processAndCopyFiles(
"**/*",
authNativeFrameworkSrc,
nativeAppDir,
context,
);
}
}
}
}
@@ -695,6 +756,9 @@ export async function handleExtras(
context: ProjectConfig,
): Promise<void> {
const extrasDir = path.join(PKG_ROOT, "templates/extras");
const hasNativeWind = context.frontend.includes("native-nativewind");
const hasUnistyles = context.frontend.includes("native-unistyles");
const hasNative = hasNativeWind || hasUnistyles;
if (context.packageManager === "pnpm") {
const pnpmWorkspaceSrc = path.join(extrasDir, "pnpm-workspace.yaml");
@@ -706,7 +770,7 @@ export async function handleExtras(
if (
context.packageManager === "pnpm" &&
(context.frontend.includes("native") || context.frontend.includes("nuxt"))
(hasNative || context.frontend.includes("nuxt"))
) {
const npmrcTemplateSrc = path.join(extrasDir, "_npmrc.hbs");
const npmrcDest = path.join(projectDir, ".npmrc");

View File

@@ -84,7 +84,8 @@ async function main() {
"tanstack-start",
"next",
"nuxt",
"native",
"native-nativewind",
"native-unistyles",
"svelte",
"solid",
"none",
@@ -303,6 +304,9 @@ async function main() {
config.runtime = "none";
config.dbSetup = "none";
config.examples = ["todo"];
log.info(
"Due to '--backend convex' flag, the following options have been automatically set: auth=false, database=none, orm=none, api=none, runtime=none, dbSetup=none, examples=todo",
);
} else if (config.backend === "none") {
config.auth = false;
config.database = "none";
@@ -311,10 +315,24 @@ async function main() {
config.runtime = "none";
config.dbSetup = "none";
config.examples = [];
log.info(
"Due to '--backend none', the following options have been automatically set: --auth=false, --database=none, --orm=none, --api=none, --runtime=none, --db-setup=none, --examples=none",
);
} else if (config.database === "none") {
config.orm = "none";
log.info(
"Due to '--database none', '--orm' has been automatically set to 'none'.",
);
config.auth = false;
log.info(
"Due to '--database none', '--auth' has been automatically set to 'false'.",
);
config.dbSetup = "none";
log.info(
"Due to '--database none', '--db-setup' has been automatically set to 'none'.",
);
}
log.info(
@@ -380,13 +398,16 @@ function processAndValidateFlags(
if (options.api) {
config.api = options.api as ProjectApi;
if (options.api === "none") {
if (options.backend && options.backend !== "convex") {
if (
options.backend &&
options.backend !== "convex" &&
options.backend !== "none"
) {
consola.fatal(
`'--api none' is only supported with '--backend convex'. Please choose a different API setting or use '--backend convex'.`,
`'--api none' is only supported with '--backend convex' or '--backend none'. Please choose a different API setting or use '--backend convex' or '--backend none'.`,
);
process.exit(1);
}
config.backend = "convex";
}
}
@@ -468,12 +489,22 @@ function processAndValidateFlags(
f === "svelte" ||
f === "solid",
);
const nativeFrontends = validOptions.filter(
(f) => f === "native-nativewind" || f === "native-unistyles",
);
if (webFrontends.length > 1) {
consola.fatal(
"Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next, nuxt, svelte, solid",
);
process.exit(1);
}
if (nativeFrontends.length > 1) {
consola.fatal(
"Cannot select multiple native frameworks. Choose only one of: native-nativewind, native-unistyles",
);
process.exit(1);
}
config.frontend = validOptions;
}
}
@@ -595,6 +626,9 @@ function processAndValidateFlags(
process.exit(1);
}
config.examples = [];
log.info(
"Due to '--backend none', the following options have been automatically set: --auth=false, --database=none, --orm=none, --api=none, --runtime=none, --db-setup=none, --examples=none",
);
} else {
const effectiveDatabase =
config.database ?? (options.yes ? DEFAULT_CONFIG.database : undefined);
@@ -621,6 +655,9 @@ function processAndValidateFlags(
process.exit(1);
}
config.orm = "none";
log.info(
"Due to '--database none', '--orm' has been automatically set to 'none'.",
);
if (providedFlags.has("auth") && options.auth === true) {
consola.fatal(
@@ -629,6 +666,9 @@ function processAndValidateFlags(
process.exit(1);
}
config.auth = false;
log.info(
"Due to '--database none', '--auth' has been automatically set to 'false'.",
);
if (providedFlags.has("dbSetup") && options.dbSetup !== "none") {
consola.fatal(
@@ -637,6 +677,9 @@ function processAndValidateFlags(
process.exit(1);
}
config.dbSetup = "none";
log.info(
"Due to '--database none', '--db-setup' has been automatically set to 'none'.",
);
}
if (config.orm === "mongoose" && !providedFlags.has("database")) {
@@ -753,6 +796,9 @@ function processAndValidateFlags(
) {
if (config.api !== "none") {
config.api = "orpc";
log.info(
`Due to frontend selection, API has been set to 'orpc'. tRPC is not compatible with Nuxt, Svelte, or Solid Framework`,
);
}
}
@@ -813,7 +859,8 @@ function processAndValidateFlags(
const onlyNativeFrontend =
effectiveFrontend &&
effectiveFrontend.length === 1 &&
effectiveFrontend[0] === "native";
(effectiveFrontend[0] === "native-nativewind" ||
effectiveFrontend[0] === "native-unistyles");
if (
onlyNativeFrontend &&
@@ -822,7 +869,7 @@ function processAndValidateFlags(
!config.examples.includes("none")
) {
consola.fatal(
"Examples are not supported when only the 'native' frontend is selected.",
"Examples are not supported when only a native frontend (NativeWind or Unistyles) is selected.",
);
process.exit(1);
}

View File

@@ -48,7 +48,6 @@ export async function getBackendFrameworkChoice(
});
}
// Add "None" option
backendOptions.push({
value: "none" as const,
label: "None",

View File

@@ -27,7 +27,10 @@ export async function getExamplesChoice(
if (database === "none") return [];
const onlyNative =
frontends && frontends.length === 1 && frontends[0] === "native";
frontends &&
frontends.length === 1 &&
(frontends[0] === "native-nativewind" ||
frontends[0] === "native-unistyles");
if (onlyNative) {
return [];
}

View File

@@ -95,7 +95,28 @@ export async function getFrontendChoice(
}
if (frontendTypes.includes("native")) {
result.push("native");
const nativeFramework = await select<ProjectFrontend>({
message: "Choose native framework",
options: [
{
value: "native-nativewind" as const,
label: "NativeWind",
hint: "Use Tailwind CSS for React Native",
},
{
value: "native-unistyles" as const,
label: "Unistyles",
hint: "Consistent styling for React Native",
},
],
initialValue: "native-nativewind",
});
if (isCancel(nativeFramework)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
result.push(nativeFramework);
}
return result;

View File

@@ -29,7 +29,8 @@ export type ProjectFrontend =
| "tanstack-start"
| "next"
| "nuxt"
| "native"
| "native-nativewind"
| "native-unistyles"
| "svelte"
| "solid"
| "none";

View File

@@ -0,0 +1,179 @@
import { authClient } from "@/lib/auth-client";
import { useQuery } from "@tanstack/react-query";
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Container } from "@/components/container";
import { SignIn } from "@/components/sign-in";
import { SignUp } from "@/components/sign-up";
{{#if (eq api "orpc")}}
import { queryClient, orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { queryClient, trpc } from "@/utils/trpc";
{{/if}}
export default function Home() {
{{#if (eq api "orpc")}}
const healthCheck = useQuery(orpc.healthCheck.queryOptions());
const privateData = useQuery(orpc.privateData.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}}
const { data: session } = authClient.useSession();
return (
<Container>
<ScrollView>
<View style={styles.pageContainer}>
<Text style={styles.headerTitle}>BETTER T STACK</Text>
{session?.user ? (
<View style={styles.sessionInfoCard}>
<View style={styles.sessionUserRow}>
<Text style={styles.welcomeText}>
Welcome,{" "}
<Text style={styles.userNameText}>{session.user.name}</Text>
</Text>
</View>
<Text style={styles.emailText}>{session.user.email}</Text>
<TouchableOpacity
style={styles.signOutButton}
onPress={() => {
authClient.signOut();
queryClient.invalidateQueries();
}}
>
<Text style={styles.signOutButtonText}>Sign Out</Text>
</TouchableOpacity>
</View>
) : null}
<View style={styles.apiStatusCard}>
<Text style={styles.cardTitle}>API Status</Text>
<View style={styles.apiStatusRow}>
<View
style={[
styles.statusIndicatorDot,
healthCheck.data
? styles.statusIndicatorGreen
: styles.statusIndicatorRed,
]}
/>
<Text style={styles.mutedText}>
{healthCheck.isLoading
? "Checking..."
: healthCheck.data
? "Connected to API"
: "API Disconnected"}
</Text>
</View>
</View>
<View style={styles.privateDataCard}>
<Text style={styles.cardTitle}>Private Data</Text>
{privateData && (
<View>
<Text style={styles.mutedText}>
{privateData.data?.message}
</Text>
</View>
)}
</View>
{!session?.user && (
<>
<SignIn />
<SignUp />
</>
)}
</View>
</ScrollView>
</Container>
);
}
const styles = StyleSheet.create((theme) => ({
pageContainer: {
paddingHorizontal: 8,
},
headerTitle: {
color: theme?.colors?.typography,
fontSize: 30,
fontWeight: "bold",
marginBottom: 16,
},
sessionInfoCard: {
marginBottom: 24,
padding: 16,
borderRadius: 8,
borderWidth: 1,
borderColor: theme?.colors?.border,
},
sessionUserRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
},
welcomeText: {
color: theme?.colors?.typography,
fontSize: 16,
},
userNameText: {
fontWeight: "500",
color: theme?.colors?.typography,
},
emailText: {
color: theme?.colors?.typography,
fontSize: 14,
marginBottom: 16,
},
signOutButton: {
backgroundColor: theme?.colors?.destructive,
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 6,
alignSelf: "flex-start",
},
signOutButtonText: {
fontWeight: "500",
},
apiStatusCard: {
marginBottom: 24,
borderRadius: 8,
borderWidth: 1,
borderColor: theme?.colors?.border,
padding: 16,
},
cardTitle: {
marginBottom: 12,
fontWeight: "500",
color: theme?.colors?.typography,
},
apiStatusRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
statusIndicatorDot: {
height: 12,
width: 12,
borderRadius: 9999,
},
statusIndicatorGreen: {
backgroundColor: theme.colors.success,
},
statusIndicatorRed: {
backgroundColor: theme.colors.destructive,
},
mutedText: {
color: theme?.colors?.typography,
},
privateDataCard: {
marginBottom: 24,
borderRadius: 8,
borderWidth: 1,
borderColor: theme?.colors?.border,
padding: 16,
},
}));

View File

@@ -0,0 +1,134 @@
import { authClient } from "@/lib/auth-client";
{{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { queryClient } from "@/utils/orpc";
{{/if}}
import { useState } from "react";
import {
ActivityIndicator,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
export function SignIn() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async () => {
setIsLoading(true);
setError(null);
await authClient.signIn.email(
{
email,
password,
},
{
onError: (error) => {
setError(error.error?.message || "Failed to sign in");
setIsLoading(false);
},
onSuccess: () => {
setEmail("");
setPassword("");
queryClient.refetchQueries();
},
onFinished: () => {
setIsLoading(false);
},
},
);
};
return (
<View style={styles.container}>
<Text style={styles.title}>Sign In</Text>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
onPress={handleLogin}
disabled={isLoading}
style={styles.button}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginTop: 24,
padding: 16,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.colors.border,
},
title: {
fontSize: 18,
fontWeight: "600",
color: theme.colors.typography,
marginBottom: 16,
},
errorContainer: {
marginBottom: 16,
padding: 12,
borderRadius: 6,
},
errorText: {
color: theme.colors.destructive,
fontSize: 14,
},
input: {
marginBottom: 12,
padding: 16,
borderRadius: 6,
color: theme.colors.typography,
borderWidth: 1,
borderColor: theme.colors.border,
},
button: {
backgroundColor: theme.colors.primary,
padding: 16,
borderRadius: 6,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
buttonText: {
fontWeight: "500",
},
}));

View File

@@ -0,0 +1,152 @@
import { authClient } from "@/lib/auth-client";
{{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { queryClient } from "@/utils/orpc";
{{/if}}
import { useState } from "react";
import {
ActivityIndicator,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
export function SignUp() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSignUp = async () => {
setIsLoading(true);
setError(null);
await authClient.signUp.email(
{
name,
email,
password,
},
{
onError: (error) => {
setError(error.error?.message || "Failed to sign up");
setIsLoading(false);
},
onSuccess: () => {
setName("");
setEmail("");
setPassword("");
queryClient.refetchQueries();
},
onFinished: () => {
setIsLoading(false);
},
},
);
};
return (
<View style={styles.container}>
<Text style={styles.title}>Create Account</Text>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<TextInput
style={styles.input}
placeholder="Name"
value={name}
onChangeText={setName}
/>
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.inputLast}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
onPress={handleSignUp}
disabled={isLoading}
style={styles.button}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Sign Up</Text>
)}
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginTop: 24,
padding: 16,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.colors.border,
},
title: {
fontSize: 18,
fontWeight: "600",
color: theme.colors.typography,
marginBottom: 16,
},
errorContainer: {
marginBottom: 16,
padding: 12,
borderRadius: 6,
},
errorText: {
color: theme.colors.destructive,
fontSize: 14,
},
input: {
marginBottom: 12,
padding: 16,
borderRadius: 6,
color: theme.colors.typography,
borderWidth: 1,
borderColor: theme.colors.border,
},
inputLast: {
marginBottom: 16,
padding: 16,
borderRadius: 6,
color: theme.colors.typography,
borderWidth: 1,
borderColor: theme.colors.border,
},
button: {
backgroundColor: theme.colors.primary,
padding: 16,
borderRadius: 6,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
buttonText: {
fontWeight: "500",
},
}));

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,24 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# expo router
expo-env.d.ts
ios
android
# macOS
.DS_Store
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*

View File

@@ -0,0 +1,44 @@
{
"expo": {
"name": "my-better-t-app",
"slug": "my-better-t-app",
"version": "1.0.0",
"newArchEnabled": true,
"scheme": "my-better-t-app",
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"react-native-edge-to-edge",
"expo-secure-store"
],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true,
"reactCompiler": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.amanvarshney01.mybettertapp"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.amanvarshney01.mybettertapp"
}
}
}

View File

@@ -0,0 +1,34 @@
import { Tabs } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { TabBarIcon } from "@/components/tabbar-icon";
export default function TabLayout() {
const { theme } = useUnistyles();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: theme.colors.background,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: "Tab One",
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
}}
/>
<Tabs.Screen
name="two"
options={{
title: "Tab Two",
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,29 @@
import { Stack } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { Container } from "@/components/container";
import { Text, View } from "react-native";
export default function Home() {
return (
<>
<Stack.Screen options={{ title: "Tab One" }} />
<Container>
<View style={styles.container}>
<Text style={styles.text}>Tab One</Text>
</View>
</Container>
</>
);
}
const styles = StyleSheet.create((theme) => ({
text: {
color: theme.colors.typography,
},
container: {
flex: 1,
paddingBottom: 100,
justifyContent: "center",
alignItems: "center",
},
}));

View File

@@ -0,0 +1,29 @@
import { Stack } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { Container } from "@/components/container";
import { Text, View } from "react-native";
export default function Home() {
return (
<>
<Stack.Screen options={{ title: "Tab Two" }} />
<Container>
<View style={styles.container}>
<Text style={styles.text}>Tab Two</Text>
</View>
</Container>
</>
);
}
const styles = StyleSheet.create((theme) => ({
text: {
color: theme.colors.typography,
},
container: {
flex: 1,
paddingBottom: 100,
justifyContent: "center",
alignItems: "center",
},
}));

View File

@@ -0,0 +1,59 @@
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { Link } from "expo-router";
import { Drawer } from "expo-router/drawer";
import { useUnistyles } from "react-native-unistyles";
import { HeaderButton } from "../../components/header-button";
const DrawerLayout = () => {
const { theme } = useUnistyles();
return (
<Drawer
screenOptions={{
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTitleStyle: {
color: theme.colors.typography,
},
headerTintColor: theme.colors.typography,
drawerStyle: {
backgroundColor: theme.colors.background,
},
drawerLabelStyle: {
color: theme.colors.typography,
},
drawerInactiveTintColor: theme.colors.typography,
}}
>
<Drawer.Screen
name="index"
options={{
headerTitle: "Home",
drawerLabel: "Home",
drawerIcon: ({ size, color }) => (
<Ionicons name="home-outline" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="(tabs)"
options={{
headerTitle: "Tabs",
drawerLabel: "Tabs",
drawerIcon: ({ size, color }) => (
<MaterialIcons name="border-bottom" size={size} color={color} />
),
headerRight: () => (
<Link href="/modal" asChild>
<HeaderButton />
</Link>
),
}}
/>
</Drawer>
);
};
export default DrawerLayout;

View File

@@ -0,0 +1,115 @@
import { ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Container } from "@/components/container";
{{#if (eq api "orpc")}}
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { useQuery } from "@tanstack/react-query";
import { trpc } from "@/utils/trpc";
{{/if}}
{{#if (eq backend "convex")}}
import { useQuery } from "convex/react";
import { api } from "@{{ projectName }}/backend/convex/_generated/api.js";
{{/if}}
export default function Home() {
{{#if (eq api "orpc")}}
const healthCheck = useQuery(orpc.healthCheck.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
{{/if}}
{{#if (eq backend "convex")}}
const healthCheck = useQuery(api.healthCheck.get);
{{/if}}
return (
<Container>
<ScrollView contentContainerStyle={styles.pageContainer}>
<Text style={styles.headerTitle}>BETTER T STACK</Text>
<View style={styles.apiStatusCard}>
<Text style={styles.cardTitle}>API Status</Text>
<View style={styles.apiStatusRow}>
<View
style={[
styles.statusIndicatorDot,
{{#if (or (eq api "orpc") (eq api "trpc"))}}
healthCheck.data
? styles.statusIndicatorGreen
: styles.statusIndicatorRed,
{{else}}
healthCheck === "OK"
? styles.statusIndicatorGreen
: styles.statusIndicatorRed,
{{/if}}
]}
/>
<Text style={styles.statusText}>
{{#if (or (eq api "orpc") (eq api "trpc"))}}
{healthCheck.isLoading
? "Checking..."
: healthCheck.data
? "Connected"
: "Disconnected"}
{{/if}}
{{#if (eq backend "convex")}}
{healthCheck === undefined
? "Checking..."
: healthCheck === "OK"
? "Connected"
: "Error"}
{{/if}}
</Text>
</View>
</View>
</ScrollView>
</Container>
);
}
const styles = StyleSheet.create((theme) => ({
pageContainer: {
paddingHorizontal: 8,
},
headerTitle: {
color: theme?.colors?.typography,
fontSize: 30,
fontWeight: "bold",
marginBottom: 16,
},
apiStatusCard: {
marginBottom: 24,
borderRadius: 8,
borderWidth: 1,
borderColor: theme?.colors?.border,
padding: 16,
},
cardTitle: {
marginBottom: 12,
fontWeight: "500",
color: theme?.colors?.typography,
},
apiStatusRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
statusIndicatorDot: {
height: 12,
width: 12,
borderRadius: 9999,
},
statusIndicatorGreen: {
backgroundColor: theme.colors.success,
},
statusIndicatorRed: {
backgroundColor: theme.colors.destructive,
},
statusText: {
color: theme?.colors?.typography,
},
}));

View File

@@ -0,0 +1,48 @@
import { ScrollViewStyleReset } from 'expo-router/html';
import '../unistyles';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View File

@@ -0,0 +1,34 @@
import { Link, Stack } from "expo-router";
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Container } from "@/components/container";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />
<Container>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</Container>
</>
);
}
const styles = StyleSheet.create((theme) => ({
title: {
fontSize: 20,
fontWeight: "bold",
color: theme.colors.typography,
},
link: {
marginTop: 16,
paddingVertical: 16,
},
linkText: {
fontSize: 14,
},
}));

View File

@@ -0,0 +1,77 @@
{{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { queryClient } from "@/utils/orpc";
{{/if}}
{{#if (eq backend "convex")}}
import { ConvexProvider, ConvexReactClient } from "convex/react";
{{else}}
import { QueryClientProvider } from "@tanstack/react-query";
{{/if}}
import { Stack } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useUnistyles } from "react-native-unistyles";
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: "(drawer)",
};
{{#if (eq backend "convex")}}
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
unsavedChangesWarning: false,
});
{{/if}}
export default function RootLayout() {
const { theme } = useUnistyles();
return (
{{#if (eq backend "convex")}}
<ConvexProvider client={convex}>
<GestureHandlerRootView style=\{{ flex: 1 }}>
<Stack
screenOptions=\{{
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTitleStyle: {
color: theme.colors.typography,
},
headerTintColor: theme.colors.typography,
}}
>
<Stack.Screen name="(drawer)" options=\{{ headerShown: false }} />
<Stack.Screen
name="modal"
options=\{{ title: "Modal", presentation: "modal" }}
/>
</Stack>
</GestureHandlerRootView>
</ConvexProvider>
{{else}}
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style=\{{ flex: 1 }}>
<Stack
screenOptions=\{{
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTitleStyle: {
color: theme.colors.typography,
},
headerTintColor: theme.colors.typography,
}}
>
<Stack.Screen name="(drawer)" options=\{{ headerShown: false }} />
<Stack.Screen
name="modal"
options=\{{ title: "Modal", presentation: "modal" }}
/>
</Stack>
</GestureHandlerRootView>
</QueryClientProvider>
{{/if}}
);
}

View File

@@ -0,0 +1,29 @@
import { Container } from "@/components/container";
import { StatusBar } from "expo-status-bar";
import { Platform, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
export default function Modal() {
return (
<>
<StatusBar style={Platform.OS === "ios" ? "light" : "auto"} />
<Container>
<View style={styles.container}>
<Text style={styles.text}>Model</Text>
</View>
</Container>
</>
);
}
const styles = StyleSheet.create((theme) => ({
text: {
color: theme.colors.typography,
},
container: {
flex: 1,
paddingBottom: 100,
justifyContent: "center",
alignItems: "center",
},
}));

View File

@@ -0,0 +1,20 @@
module.exports = function (api) {
api.cache(true);
const plugins = [];
plugins.push([
'react-native-unistyles/plugin',
{
autoProcessRoot: 'app',
autoProcessImports: ['@/components'],
},
]);
plugins.push('react-native-reanimated/plugin');
return {
presets: ['babel-preset-expo'],
plugins,
};
};

View File

@@ -0,0 +1,9 @@
export const breakpoints = {
xs: 0,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
superLarge: 2000,
tvLike: 4000,
} as const;

View File

@@ -0,0 +1,20 @@
import React from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
export const Container = ({ children }: { children: React.ReactNode }) => {
return <View style={styles.container}>{children}</View>;
};
const styles = StyleSheet.create((theme, rt) => ({
container: {
flex: 1,
paddingBottom: rt.insets.bottom,
backgroundColor: theme.colors.background,
transform: [
{
translateY: rt.insets.ime * -1,
},
],
},
}));

View File

@@ -0,0 +1,31 @@
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { forwardRef } from 'react';
import { Pressable, StyleSheet } from 'react-native';
export const HeaderButton = forwardRef<typeof Pressable, { onPress?: () => void }>(
({ onPress }, ref) => {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color="gray"
style={[
styles.headerRight,
{
opacity: pressed ? 0.5 : 1,
},
]}
/>
)}
</Pressable>
);
}
);
export const styles = StyleSheet.create({
headerRight: {
marginRight: 15,
},
});

View File

@@ -0,0 +1,15 @@
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { StyleSheet } from 'react-native';
export const TabBarIcon = (props: {
name: React.ComponentProps<typeof FontAwesome>['name'];
color: string;
}) => {
return <FontAwesome size={28} style={styles.tabBarIcon} {...props} />;
};
export const styles = StyleSheet.create({
tabBarIcon: {
marginBottom: -3,
},
});

View File

@@ -0,0 +1,3 @@
/// <reference types="expo/types" />
// NOTE: This file should not be edited and should be in your git ignore

View File

@@ -0,0 +1,2 @@
import 'expo-router/entry';
import './unistyles';

View File

@@ -0,0 +1,20 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");
const workspaceRoot = path.resolve(__dirname, "../..");
const projectRoot = __dirname;
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo
config.watchFolders = [workspaceRoot];
// 2. Let Metro know where to resolve packages, and in what order
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
config.resolver.disableHierarchicalLookup = true;
module.exports = config;

View File

@@ -0,0 +1,47 @@
{
"name": "native",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "expo start --clear",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"dependencies": {
"@better-auth/expo": "^1.2.7",
"@expo/vector-icons": "^14.0.0",
"@react-navigation/bottom-tabs": "^7.0.5",
"@react-navigation/drawer": "^7.0.0",
"@react-navigation/native": "^7.0.3",
"@tanstack/react-form": "^1.0.5",
"babel-plugin-react-compiler": "^19.0.0-beta-af1b7da-20250417",
"expo": "^53.0.8",
"expo-constants": "~17.1.4",
"expo-linking": "~7.1.4",
"expo-router": "~5.0.3",
"expo-secure-store": "~14.2.3",
"expo-status-bar": "~2.2.3",
"expo-system-ui": "~5.0.6",
"expo-dev-client": "~5.1.8",
"expo-web-browser": "~14.1.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-native": "0.79.2",
"react-native-edge-to-edge": "1.6.0",
"react-native-gesture-handler": "~2.24.0",
"react-native-nitro-modules": "0.25.2",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.10.0",
"react-native-unistyles": "3.0.0-rc.3",
"react-native-web": "^0.20.0"
},
"devDependencies": {
"ajv": "^8.12.0",
"@babel/core": "^7.20.0",
"@types/react": "~19.0.10",
"typescript": "~5.8.3"
},
"private": true
}

View File

@@ -0,0 +1,35 @@
const sharedColors = {
success: "#22C55E",
destructive: "#DC2626",
border: "#D1D5DB",
} as const;
export const lightTheme = {
colors: {
...sharedColors,
typography: "#000000",
background: "#ffffff",
primary: "#3B82F6",
},
margins: {
sm: 2,
md: 4,
lg: 8,
xl: 12,
},
} as const;
export const darkTheme = {
colors: {
...sharedColors,
typography: "#ffffff",
background: "#000000",
primary: "#60A5FA",
},
margins: {
sm: 2,
md: 4,
lg: 8,
xl: 12,
},
} as const;

View File

@@ -0,0 +1,12 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

View File

@@ -0,0 +1,27 @@
import { StyleSheet } from 'react-native-unistyles';
import { breakpoints } from './breakpoints';
import { lightTheme, darkTheme } from './theme';
type AppBreakpoints = typeof breakpoints;
type AppThemes = {
light: typeof lightTheme;
dark: typeof darkTheme;
};
declare module 'react-native-unistyles' {
export interface UnistylesBreakpoints extends AppBreakpoints {}
export interface UnistylesThemes extends AppThemes {}
}
StyleSheet.configure({
breakpoints,
themes: {
light: lightTheme,
dark: darkTheme,
},
settings: {
adaptiveThemes: true,
},
});

View File

@@ -41,10 +41,10 @@ export default function Header() {
);
{{else if (includes frontend "react-router")}}
return (
<NavLink
key={to}
to={to}
className={({ isActive }) => isActive ? "font-bold" : ""}
<NavLink
key={to}
to={to}
className={({ isActive }) => isActive ? "font-bold" : ""}
end
>
{label}
@@ -60,7 +60,6 @@ export default function Header() {
</Link>
);
{{else}}
// Fallback case (shouldn't happen with valid frontend selection)
return null;
{{/if}}
})}
@@ -77,4 +76,4 @@ export default function Header() {
<hr />
</div>
);
}
}

View File

@@ -92,7 +92,9 @@ const hasWebFrontend = (frontend: string[]) =>
].includes(f),
);
const hasNativeFrontend = (frontend: string[]) => frontend.includes("native");
const checkHasNativeFrontend = (frontend: string[]) =>
frontend.includes("native-nativewind") ||
frontend.includes("native-unistyles");
const hasPWACompatibleFrontend = (frontend: string[]) =>
frontend.some((f) =>
@@ -183,6 +185,7 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
}
const isConvex = nextStack.backend === "convex";
const isBackendNone = nextStack.backend === "none";
if (isConvex) {
const convexOverrides: Partial<StackState> = {
@@ -247,6 +250,40 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
message: "Frontend defaulted to TanStack Router",
});
}
} else if (isBackendNone) {
const noneOverrides: Partial<StackState> = {
auth: "false",
database: "none",
orm: "none",
api: "none",
runtime: "none",
dbSetup: "none",
examples: [],
};
for (const [key, value] of Object.entries(noneOverrides)) {
const catKey = key as keyof StackState;
if (JSON.stringify(nextStack[catKey]) !== JSON.stringify(value)) {
const displayName = getCategoryDisplayName(catKey);
const valueDisplay = Array.isArray(value) ? "none" : value;
const message = `${displayName} set to '${valueDisplay}'`;
notes[catKey].notes.push(
`No backend selected: ${displayName} will be set to '${valueDisplay}'.`,
);
notes.backend.notes.push(
`No backend requires ${displayName} to be '${valueDisplay}'.`,
);
notes[catKey].hasIssue = true;
notes.backend.hasIssue = true;
(nextStack[catKey] as string | string[]) = value;
changed = true;
changes.push({
category: "backend-none",
message,
});
}
}
} else {
if (nextStack.runtime === "none") {
notes.runtime.notes.push(
@@ -562,7 +599,7 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
const incompatibleExamples: string[] = [];
const isWeb = hasWebFrontend(nextStack.frontend);
const isNativeOnly = hasNativeFrontend(nextStack.frontend) && !isWeb;
const isNativeOnly = checkHasNativeFrontend(nextStack.frontend) && !isWeb;
if (isNativeOnly) {
if (nextStack.examples.length > 0) {
@@ -694,17 +731,19 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
const getCompatibilityRules = (stack: StackState) => {
const isConvex = stack.backend === "convex";
const isBackendNone = stack.backend === "none";
const hasWebFrontendSelected = hasWebFrontend(stack.frontend);
const hasNativeOnly =
hasNativeFrontend(stack.frontend) && !hasWebFrontendSelected;
const hasNativeFrontend = checkHasNativeFrontend(stack.frontend);
const hasNativeOnly = hasNativeFrontend && !hasWebFrontendSelected;
const hasSolid = stack.frontend.includes("solid");
const hasNuxt = stack.frontend.includes("nuxt");
const hasSvelte = stack.frontend.includes("svelte");
return {
isConvex,
isBackendNone,
hasWebFrontend: hasWebFrontendSelected,
hasNativeFrontend: hasNativeFrontend(stack.frontend),
hasNativeFrontend,
hasNativeOnly,
hasPWACompatible: hasPWACompatibleFrontend(stack.frontend),
hasTauriCompatible: hasTauriCompatibleFrontend(stack.frontend),
@@ -853,7 +892,7 @@ const StackArchitect = () => {
};
for (const category of CATEGORY_ORDER) {
const options = TECH_OPTIONS[category] || [];
const options = TECH_OPTIONS[category as keyof typeof TECH_OPTIONS] || [];
const catKey = category as keyof StackState;
for (const tech of options) {
@@ -879,7 +918,9 @@ const StackArchitect = () => {
addRule(
category,
techId,
`Convex backend requires ${getCategoryDisplayName(catKey)} to be '${requiredValue}'.`,
`Convex backend requires ${getCategoryDisplayName(
catKey,
)} to be '${requiredValue}'.`,
);
}
}
@@ -897,12 +938,43 @@ const StackArchitect = () => {
addRule(
category,
techId,
`${tech.name} is not compatible with Convex backend.`,
`Convex backend is not compatible with ${tech.name}.`,
);
}
continue;
}
if (rules.isBackendNone) {
if (
[
"auth",
"database",
"orm",
"api",
"runtime",
"dbSetup",
"examples",
].includes(catKey)
) {
if (
(catKey === "auth" && techId === "true") ||
((catKey === "database" ||
catKey === "orm" ||
catKey === "api" ||
catKey === "runtime" ||
catKey === "dbSetup") &&
techId !== "none") ||
(catKey === "examples" && techId !== "none")
) {
addRule(
category,
techId,
`Cannot be selected when 'No Backend' is chosen. Will be set to 'None' or disabled.`,
);
}
}
}
if (catKey === "runtime" && techId === "none") {
addRule(
category,
@@ -934,114 +1006,68 @@ const StackArchitect = () => {
}
if (catKey === "orm") {
if (stack.database === "none" && techId !== "none") {
addRule(
category,
techId,
"Select a database to enable ORM options.",
);
}
if (
stack.database === "mongodb" &&
techId !== "prisma" &&
techId !== "mongoose" &&
techId !== "none"
) {
addRule(
category,
techId,
"MongoDB requires the Prisma or Mongoose ORM.",
);
}
if (
stack.database !== "mongodb" &&
stack.database !== "none" &&
techId === "mongoose"
) {
addRule(
category,
techId,
"Mongoose ORM is only compatible with MongoDB.",
);
}
if (stack.dbSetup === "turso" && techId !== "drizzle") {
addRule(category, techId, "Turso DB setup requires Drizzle ORM.");
}
if (stack.dbSetup === "prisma-postgres" && techId !== "prisma") {
addRule(
category,
techId,
"Prisma PostgreSQL setup requires Prisma ORM.",
);
}
if (
stack.dbSetup === "mongodb-atlas" &&
techId !== "prisma" &&
techId !== "mongoose"
) {
addRule(
category,
techId,
"MongoDB Atlas setup requires Prisma or Mongoose ORM.",
);
}
if (techId === "none") {
if (stack.database === "mongodb") {
addRule(
category,
techId,
"MongoDB requires Prisma or Mongoose ORM.",
);
}
if (stack.dbSetup === "turso") {
addRule(category, techId, "Turso DB setup requires Drizzle ORM.");
}
if (stack.dbSetup === "prisma-postgres") {
addRule(category, techId, "This DB setup requires Prisma ORM.");
}
if (stack.dbSetup === "mongodb-atlas") {
addRule(
category,
techId,
"This DB setup requires Prisma or Mongoose ORM.",
);
}
addRule(
category,
techId,
"ORM 'None' is only available with the Convex backend.",
);
}
}
if (catKey === "dbSetup" && techId !== "none") {
if (stack.database === "none") {
addRule(
category,
techId,
"Select a database before choosing a cloud setup.",
);
} else {
if (techId === "turso") {
if (stack.database !== "sqlite") {
addRule(category, techId, "Turso requires SQLite database.");
}
if (stack.orm !== "drizzle") {
addRule(category, techId, "Turso requires Drizzle ORM.");
}
} else if (techId === "prisma-postgres") {
if (stack.database !== "postgres") {
addRule(category, techId, "Requires PostgreSQL database.");
}
if (stack.orm !== "prisma") {
addRule(category, techId, "Requires Prisma ORM.");
}
} else if (techId === "mongodb-atlas") {
if (stack.database !== "mongodb") {
addRule(category, techId, "Requires MongoDB database.");
}
if (stack.orm !== "prisma" && stack.orm !== "mongoose") {
addRule(category, techId, "Requires Prisma or Mongoose ORM.");
}
} else if (techId === "neon") {
if (stack.database !== "postgres") {
addRule(category, techId, "Requires PostgreSQL database.");
}
if (techId === "turso") {
if (stack.database !== "sqlite") {
addRule(
category,
techId,
"Turso requires SQLite. It will be selected.",
);
}
if (stack.orm !== "drizzle") {
addRule(
category,
techId,
"Turso requires Drizzle ORM. It will be selected.",
);
}
} else if (techId === "prisma-postgres") {
if (stack.database !== "postgres") {
addRule(
category,
techId,
"Prisma PostgreSQL setup requires PostgreSQL. It will be selected.",
);
}
if (stack.orm !== "prisma") {
addRule(
category,
techId,
"Prisma PostgreSQL setup requires Prisma ORM. It will be selected.",
);
}
} else if (techId === "mongodb-atlas") {
if (stack.database !== "mongodb") {
addRule(
category,
techId,
"MongoDB Atlas setup requires MongoDB. It will be selected.",
);
}
if (stack.orm !== "prisma" && stack.orm !== "mongoose") {
addRule(
category,
techId,
"MongoDB Atlas setup requires Prisma or Mongoose ORM. Prisma will be selected.",
);
}
} else if (techId === "neon") {
if (stack.database !== "postgres") {
addRule(
category,
techId,
"Neon requires PostgreSQL. It will be selected.",
);
}
}
}
@@ -1055,56 +1081,164 @@ const StackArchitect = () => {
}
if (catKey === "addons") {
if (techId === "pwa" && !rules.hasPWACompatible) {
const incompatibleAddons: string[] = [];
const isPWACompat = hasPWACompatibleFrontend(stack.frontend);
const isTauriCompat = hasTauriCompatibleFrontend(stack.frontend);
if (!isPWACompat && stack.addons.includes("pwa")) {
incompatibleAddons.push("pwa");
addRule(
category,
techId,
"Requires TanStack Router, React Router or Solid frontend.",
"PWA addon removed (requires compatible frontend)",
);
}
if (techId === "tauri" && !rules.hasTauriCompatible) {
if (!isTauriCompat && stack.addons.includes("tauri")) {
incompatibleAddons.push("tauri");
addRule(
category,
techId,
"Requires TanStack Router, React Router, Nuxt, Svelte or Solid frontend.",
"Tauri addon removed (requires compatible frontend)",
);
}
const originalAddonsLength = stack.addons.length;
if (incompatibleAddons.length > 0) {
stack.addons = stack.addons.filter(
(addon) => !incompatibleAddons.includes(addon),
);
if (stack.addons.length !== originalAddonsLength) {
addRule(
category,
techId,
"Addons filtered (requires compatible frontend)",
);
}
}
if (
stack.addons.includes("husky") &&
!stack.addons.includes("biome")
) {
addRule(
category,
techId,
"Husky addon is selected without Biome. Consider adding Biome for lint-staged integration.",
);
}
}
if (catKey === "examples") {
if (rules.hasNativeOnly) {
addRule(
category,
techId,
"Examples are not supported with Native-only frontend.",
);
} else {
if (!rules.hasWebFrontend) {
const incompatibleExamples: string[] = [];
const isWeb = hasWebFrontend(stack.frontend);
const isNativeOnly = checkHasNativeFrontend(stack.frontend) && !isWeb;
if (isNativeOnly) {
if (stack.examples.length > 0) {
addRule(
category,
techId,
"Requires a web frontend (TanStack Router, React Router, etc.).",
"Examples removed (not supported with Native-only frontend)",
);
}
if (techId === "todo" && stack.database === "none") {
addRule(category, techId, "Todo example requires a database.");
} else {
if (!isWeb) {
if (stack.examples.includes("todo")) {
incompatibleExamples.push("todo");
addRule(
category,
techId,
"Todo example removed (requires web frontend)",
);
}
if (stack.examples.includes("ai")) {
incompatibleExamples.push("ai");
addRule(
category,
techId,
"AI example removed (requires web frontend)",
);
}
}
if (techId === "ai") {
if (stack.backend === "elysia") {
if (stack.database === "none" && stack.examples.includes("todo")) {
incompatibleExamples.push("todo");
addRule(
category,
techId,
"Todo example removed (requires a database)",
);
}
if (stack.backend === "elysia" && stack.examples.includes("ai")) {
incompatibleExamples.push("ai");
addRule(
category,
techId,
"AI example removed (not compatible with Elysia)",
);
}
if (rules.hasSolid && stack.examples.includes("ai")) {
incompatibleExamples.push("ai");
addRule(
category,
techId,
"AI example removed (not compatible with Solid)",
);
}
}
const uniqueIncompatibleExamples = [...new Set(incompatibleExamples)];
if (uniqueIncompatibleExamples.length > 0) {
if (!isWeb && !isNativeOnly) {
if (
uniqueIncompatibleExamples.includes("todo") ||
uniqueIncompatibleExamples.includes("ai")
) {
addRule(
category,
techId,
"AI example is not compatible with Elysia backend.",
);
}
if (rules.hasSolid) {
addRule(
category,
techId,
"AI example is not compatible with Solid frontend.",
"Examples require a web frontend. Incompatible examples will be removed.",
);
}
}
if (
stack.database === "none" &&
uniqueIncompatibleExamples.includes("todo")
) {
addRule(
category,
techId,
"Todo example requires a database. It will be removed.",
);
}
if (
stack.backend === "elysia" &&
uniqueIncompatibleExamples.includes("ai")
) {
addRule(
category,
techId,
"AI example is not compatible with Elysia. It will be removed.",
);
}
if (rules.hasSolid && uniqueIncompatibleExamples.includes("ai")) {
addRule(
category,
techId,
"AI example is not compatible with Solid. It will be removed.",
);
}
const originalExamplesLength = stack.examples.length;
stack.examples = stack.examples.filter(
(ex) => !uniqueIncompatibleExamples.includes(ex),
);
if (stack.examples.length !== originalExamplesLength) {
addRule(
category,
techId,
"Examples filtered (incompatible examples removed)",
);
}
}
}
}
@@ -1256,6 +1390,8 @@ const StackArchitect = () => {
nextArray = nextArray.filter((id) => id !== "none");
if (webTypes.includes(techId)) {
nextArray = nextArray.filter((id) => !webTypes.includes(id));
} else if (techId.startsWith("native-")) {
nextArray = nextArray.filter((id) => !id.startsWith("native-"));
}
nextArray.push(techId);
}

View File

@@ -75,13 +75,21 @@ export const TECH_OPTIONS = {
default: false,
},
{
id: "native",
name: "React Native",
description: "Expo with NativeWind",
id: "native-nativewind",
name: "React Native + NativeWind",
description: "Expo with NativeWind (Tailwind)",
icon: "/icon/expo.svg",
color: "from-purple-400 to-purple-600",
default: false,
},
{
id: "native-unistyles",
name: "React Native + Unistyles",
description: "Expo with Unistyles",
icon: "/icon/expo.svg",
color: "from-pink-400 to-pink-600",
default: false,
},
{
id: "none",
name: "No Frontend",
@@ -145,6 +153,13 @@ export const TECH_OPTIONS = {
icon: "/icon/convex.svg",
color: "from-pink-500 to-pink-700",
},
{
id: "none",
name: "No Backend",
description: "Skip backend integration (frontend only)",
icon: "⚙️",
color: "from-gray-400 to-gray-600",
},
],
database: [
{
@@ -447,7 +462,7 @@ export const PRESET_TEMPLATES = [
description: "React Native with Expo and SQLite database",
stack: {
projectName: "my-better-t-app",
frontend: ["native"],
frontend: ["native-nativewind"],
runtime: "bun",
backend: "hono",
database: "sqlite",
@@ -489,7 +504,7 @@ export const PRESET_TEMPLATES = [
description: "Complete setup with web, native, Turso, and addons",
stack: {
projectName: "my-better-t-app",
frontend: ["tanstack-router", "native"],
frontend: ["tanstack-router", "native-nativewind"],
runtime: "bun",
backend: "hono",
database: "sqlite",