stage 1: project init
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
ios/
|
||||||
|
android/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
expo-env.d.ts
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ExpoConfig } from "expo/config";
|
||||||
|
|
||||||
|
const APP_ENV = (process.env.EXPO_PUBLIC_APP_ENV ?? "dev") as "dev" | "prod";
|
||||||
|
|
||||||
|
const config: ExpoConfig = {
|
||||||
|
name: "Flowy",
|
||||||
|
slug: "flowy",
|
||||||
|
version: "0.1.0",
|
||||||
|
orientation: "portrait",
|
||||||
|
icon: "./assets/icon.png",
|
||||||
|
scheme: "flowy",
|
||||||
|
userInterfaceStyle: "automatic",
|
||||||
|
newArchEnabled: true,
|
||||||
|
splash: {
|
||||||
|
image: "./assets/splash.png",
|
||||||
|
resizeMode: "contain",
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
},
|
||||||
|
ios: {
|
||||||
|
supportsTablet: true,
|
||||||
|
bundleIdentifier: "com.llink.flowy",
|
||||||
|
infoPlist: {
|
||||||
|
NSCameraUsageDescription:
|
||||||
|
"Flowy uses your camera to record video messages.",
|
||||||
|
NSMicrophoneUsageDescription:
|
||||||
|
"Flowy uses your microphone to record voice and video messages.",
|
||||||
|
ITSAppUsesNonExemptEncryption: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
"expo-build-properties",
|
||||||
|
{
|
||||||
|
ios: {
|
||||||
|
deploymentTarget: "16.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"expo-secure-store",
|
||||||
|
],
|
||||||
|
experiments: {
|
||||||
|
typedRoutes: false,
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
appEnv: APP_ENV,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||||
|
"nativewind/babel",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import "./global.css";
|
||||||
|
import { registerRootComponent } from "expo";
|
||||||
|
import App from "./src/App";
|
||||||
|
|
||||||
|
registerRootComponent(App);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
const { withNativeWind } = require("nativewind/metro");
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
module.exports = withNativeWind(config, { input: "./global.css" });
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="nativewind/types" />
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "flowy-mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"compile": "tsc --noEmit",
|
||||||
|
"lint": "expo lint"
|
||||||
|
},
|
||||||
|
"packageManager": "[email protected]",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "2.1.2",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"expo": "~54.0.0",
|
||||||
|
"expo-constants": "~17.0.0",
|
||||||
|
"expo-secure-store": "~14.0.0",
|
||||||
|
"expo-status-bar": "~2.0.0",
|
||||||
|
"nativewind": "^4.1.23",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-native": "0.81.4",
|
||||||
|
"react-native-gesture-handler": "~2.21.0",
|
||||||
|
"react-native-reanimated": "~4.0.0",
|
||||||
|
"react-native-safe-area-context": "~4.12.0",
|
||||||
|
"react-native-screens": "~4.4.0",
|
||||||
|
"sonner-native": "^0.21.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"expo-build-properties": "~0.13.0",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "~5.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import { Toaster } from "sonner-native";
|
||||||
|
import { createQueryClient } from "@/lib/query-client";
|
||||||
|
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<View className="flex-1 items-center justify-center bg-background">
|
||||||
|
<Text className="text-foreground text-2xl font-semibold">Flowy</Text>
|
||||||
|
<Text className="text-muted-foreground mt-2">
|
||||||
|
Mobile scaffold — step 1
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Toaster />
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { appConfig } from "@/config/env";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { ApiError } from "@/lib/errors";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import {
|
||||||
|
BillingStatusSchema,
|
||||||
|
CheckoutSessionResponseSchema,
|
||||||
|
DepotObjectSchema,
|
||||||
|
FirebaseTokenResponseSchema,
|
||||||
|
GetLivekitTokenResponseSchema,
|
||||||
|
HumanSchema,
|
||||||
|
ListInvitationsResponseSchema,
|
||||||
|
ListNetworksResponseSchema,
|
||||||
|
NetworkSchema,
|
||||||
|
NetworkUsageSchema,
|
||||||
|
PortalSessionResponseSchema,
|
||||||
|
PrepareUploadResponseSchema,
|
||||||
|
SignInResponseSchema,
|
||||||
|
} from "./types";
|
||||||
|
import type {
|
||||||
|
AcceptInvitationRequest,
|
||||||
|
AddMembersRequest,
|
||||||
|
BillingCadence,
|
||||||
|
CreateNetworkRequest,
|
||||||
|
PrepareUploadRequest,
|
||||||
|
RequestCodeRequest,
|
||||||
|
RevokeInvitationRequest,
|
||||||
|
SignInRequest,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface ApiClientConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
getToken: () => string | null;
|
||||||
|
onUnauthorized: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private config: ApiClientConfig;
|
||||||
|
|
||||||
|
constructor(config: ApiClientConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = this.config.getToken();
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
this.config.onUnauthorized();
|
||||||
|
throw new ApiError(401, "Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => "Unknown error");
|
||||||
|
throw new ApiError(response.status, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await this.fetch(method, path, body);
|
||||||
|
const json = await response.json();
|
||||||
|
return schema.parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestVoid(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.fetch(method, path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
|
async requestCode(data: RequestCodeRequest): Promise<void> {
|
||||||
|
await this.requestVoid("POST", "/auth/request-code", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async signIn(data: SignInRequest) {
|
||||||
|
return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async me() {
|
||||||
|
return this.request(HumanSchema, "GET", "/auth/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
async signOut(): Promise<void> {
|
||||||
|
await this.requestVoid("POST", "/auth/sign-out");
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFirebaseToken() {
|
||||||
|
return this.request(
|
||||||
|
FirebaseTokenResponseSchema,
|
||||||
|
"POST",
|
||||||
|
"/auth/firebase-token",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: security: require passing in the particle id once api deprecates this
|
||||||
|
async getParticleDownloadUrl(objectId: string): Promise<string> {
|
||||||
|
const response = await this.fetch(
|
||||||
|
"GET",
|
||||||
|
`/particles/${objectId}/download`,
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Settings ---
|
||||||
|
|
||||||
|
async updateSettings(data: {
|
||||||
|
email_notifications_enabled?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.requestVoid("PATCH", "/humans/me/settings", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Depot ---
|
||||||
|
|
||||||
|
async prepareUpload(data: PrepareUploadRequest) {
|
||||||
|
return this.request(
|
||||||
|
PrepareUploadResponseSchema,
|
||||||
|
"POST",
|
||||||
|
"/depot/upload",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmUpload(objectId: string) {
|
||||||
|
return this.request(
|
||||||
|
DepotObjectSchema,
|
||||||
|
"POST",
|
||||||
|
`/depot/objects/${objectId}/confirm`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Networks ---
|
||||||
|
|
||||||
|
async listNetworks() {
|
||||||
|
return this.request(ListNetworksResponseSchema, "GET", "/networks");
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNetwork(data: CreateNetworkRequest) {
|
||||||
|
return this.request(NetworkSchema, "POST", "/networks", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNetwork(id: string) {
|
||||||
|
return this.request(NetworkSchema, "GET", `/networks/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
|
||||||
|
await this.requestVoid("POST", `/networks/${networkId}/members`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(networkId: string, humanId: string): Promise<void> {
|
||||||
|
await this.requestVoid(
|
||||||
|
"DELETE",
|
||||||
|
`/networks/${networkId}/members/${humanId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Invitations ---
|
||||||
|
|
||||||
|
async listNetworkInvitations(networkId: string) {
|
||||||
|
return this.request(
|
||||||
|
ListInvitationsResponseSchema,
|
||||||
|
"GET",
|
||||||
|
`/networks/${networkId}/invitations`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMyInvitations() {
|
||||||
|
return this.request(ListInvitationsResponseSchema, "GET", "/invitations");
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptInvitation(data: AcceptInvitationRequest): Promise<void> {
|
||||||
|
await this.requestVoid("POST", "/invitations/accept", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeInvitation(
|
||||||
|
networkId: string,
|
||||||
|
data: RevokeInvitationRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.requestVoid(
|
||||||
|
"DELETE",
|
||||||
|
`/networks/${networkId}/invitations`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LiveKit ---
|
||||||
|
|
||||||
|
async getLivekitToken(networkId: string, streamId: string) {
|
||||||
|
return this.request(
|
||||||
|
GetLivekitTokenResponseSchema,
|
||||||
|
"POST",
|
||||||
|
"/livekit/token",
|
||||||
|
{ network_id: networkId, stream_id: streamId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Billing (network admin only) ---
|
||||||
|
|
||||||
|
async getNetworkBilling(networkId: string) {
|
||||||
|
return this.request(
|
||||||
|
BillingStatusSchema,
|
||||||
|
"GET",
|
||||||
|
`/networks/${networkId}/billing`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
|
||||||
|
return this.request(
|
||||||
|
CheckoutSessionResponseSchema,
|
||||||
|
"POST",
|
||||||
|
`/networks/${networkId}/billing/checkout-session`,
|
||||||
|
{ cadence },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPortalSession(networkId: string) {
|
||||||
|
return this.request(
|
||||||
|
PortalSessionResponseSchema,
|
||||||
|
"POST",
|
||||||
|
`/networks/${networkId}/billing/portal-session`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNetworkUsage(networkId: string) {
|
||||||
|
return this.request(
|
||||||
|
NetworkUsageSchema,
|
||||||
|
"GET",
|
||||||
|
`/networks/${networkId}/usage`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient({
|
||||||
|
baseUrl: appConfig.orionUrl,
|
||||||
|
getToken: () => useSessionStore.getState().token,
|
||||||
|
// SecureStore writes are async; we fire-and-forget so the throwing
|
||||||
|
// request doesn't have to wait for persistence to finish.
|
||||||
|
onUnauthorized: () => {
|
||||||
|
void useSessionStore.getState().clearToken();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const HumanSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
created_at: z.coerce.date(),
|
||||||
|
email: z.string().email(),
|
||||||
|
email_prefix: z.string(),
|
||||||
|
email_notifications_enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Human = z.infer<typeof HumanSchema>;
|
||||||
|
|
||||||
|
export const NetworkSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
admin_human: HumanSchema,
|
||||||
|
humans: z.array(HumanSchema),
|
||||||
|
created_at: z.coerce.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Network = z.infer<typeof NetworkSchema>;
|
||||||
|
|
||||||
|
export const ListNetworksResponseSchema = z.array(NetworkSchema);
|
||||||
|
export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
|
||||||
|
|
||||||
|
// --- Network request/response types ---
|
||||||
|
|
||||||
|
const CreateNetworkRequestSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
|
||||||
|
|
||||||
|
const AddMembersRequestSchema = z.object({
|
||||||
|
email_addresses: z.array(z.string().email()),
|
||||||
|
});
|
||||||
|
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
|
||||||
|
|
||||||
|
// --- Invitation types ---
|
||||||
|
|
||||||
|
export const InvitationSchema = z.object({
|
||||||
|
network_id: z.string(),
|
||||||
|
network_name: z.string(),
|
||||||
|
email: z.string(),
|
||||||
|
created_at: z.coerce.date(),
|
||||||
|
});
|
||||||
|
export type Invitation = z.infer<typeof InvitationSchema>;
|
||||||
|
|
||||||
|
export const ListInvitationsResponseSchema = z.array(InvitationSchema);
|
||||||
|
|
||||||
|
export type AcceptInvitationRequest = { network_id: string };
|
||||||
|
export type RevokeInvitationRequest = { email: string };
|
||||||
|
|
||||||
|
// --- Depot types ---
|
||||||
|
|
||||||
|
const PrepareUploadRequestSchema = z.object({
|
||||||
|
network_id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
content_type: z.string(),
|
||||||
|
content_length: z.number(),
|
||||||
|
});
|
||||||
|
export type PrepareUploadRequest = z.infer<typeof PrepareUploadRequestSchema>;
|
||||||
|
|
||||||
|
export const PrepareUploadResponseSchema = z.object({
|
||||||
|
object_id: z.string(),
|
||||||
|
upload_url: z.string(),
|
||||||
|
upload_headers: z.record(z.string(), z.string()),
|
||||||
|
});
|
||||||
|
export type PrepareUploadResponse = z.infer<typeof PrepareUploadResponseSchema>;
|
||||||
|
|
||||||
|
export const DepotObjectSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
content_type: z.string(),
|
||||||
|
content_length: z.number(),
|
||||||
|
contains_content: z.boolean(),
|
||||||
|
created_at: z.coerce.date(),
|
||||||
|
});
|
||||||
|
export type DepotObject = z.infer<typeof DepotObjectSchema>;
|
||||||
|
|
||||||
|
// --- Particle property schemas ---
|
||||||
|
|
||||||
|
export const StreamPropertiesSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type StreamProperties = z.infer<typeof StreamPropertiesSchema>;
|
||||||
|
|
||||||
|
export const FolderPropertiesSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type FolderProperties = z.infer<typeof FolderPropertiesSchema>;
|
||||||
|
|
||||||
|
const TranscriptWordSchema = z.object({
|
||||||
|
word: z.string(),
|
||||||
|
start: z.number(),
|
||||||
|
end: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TranscriptSentenceSchema = z.object({
|
||||||
|
text: z.string(),
|
||||||
|
start: z.number(),
|
||||||
|
end: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TranscriptParagraphSchema = z.object({
|
||||||
|
sentences: z.array(TranscriptSentenceSchema),
|
||||||
|
start: z.number(),
|
||||||
|
end: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TranscriptSchema = z.object({
|
||||||
|
transcript: z.string(),
|
||||||
|
words: z.array(TranscriptWordSchema),
|
||||||
|
paragraphs: z.array(TranscriptParagraphSchema),
|
||||||
|
});
|
||||||
|
export type Transcript = z.infer<typeof TranscriptSchema>;
|
||||||
|
|
||||||
|
export const MediaPropertiesSchema = z.object({
|
||||||
|
object_id: z.string(),
|
||||||
|
mime_type: z.string(),
|
||||||
|
duration_ms: z.number(),
|
||||||
|
size_bytes: z.number(),
|
||||||
|
transcript: TranscriptSchema.optional(),
|
||||||
|
source: z.enum(["camera", "screen"]).optional(),
|
||||||
|
});
|
||||||
|
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
|
||||||
|
|
||||||
|
export const FilePropertiesSchema = z.object({
|
||||||
|
object_id: z.string(),
|
||||||
|
filename: z.string(),
|
||||||
|
mime_type: z.string(),
|
||||||
|
size_bytes: z.number(),
|
||||||
|
});
|
||||||
|
export type FileProperties = z.infer<typeof FilePropertiesSchema>;
|
||||||
|
|
||||||
|
export const TextPropertiesSchema = z.object({
|
||||||
|
content: z.string(),
|
||||||
|
edited_at: z.coerce.date().optional(),
|
||||||
|
});
|
||||||
|
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
||||||
|
|
||||||
|
export const QuestPropertiesSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
// humanId
|
||||||
|
assigned_to: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
|
||||||
|
|
||||||
|
export const PaperPropertiesSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
|
||||||
|
|
||||||
|
// --- Reactions ---
|
||||||
|
|
||||||
|
export const ReactionsSchema = z.record(z.string(), z.array(z.string())).optional();
|
||||||
|
export type Reactions = z.infer<typeof ReactionsSchema>;
|
||||||
|
|
||||||
|
// --- Tombstone (soft-delete) ---
|
||||||
|
|
||||||
|
// Fields added to non-container particles when their creator deletes them.
|
||||||
|
// We keep the doc around so concurrent viewers can see a "This particle was
|
||||||
|
// deleted" message in place, rather than being jumped to the next particle.
|
||||||
|
const TombstoneFields = {
|
||||||
|
deleted_at: z.coerce.date().optional(),
|
||||||
|
deleted_by_human_id: z.string().optional(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REACTION_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F525}", "\u{1F440}", "\u{2705}", "\u{2753}", "\u{1F602}"] as const;
|
||||||
|
|
||||||
|
export interface ParticlePropertiesMap {
|
||||||
|
stream: StreamProperties;
|
||||||
|
folder: FolderProperties;
|
||||||
|
media: MediaProperties;
|
||||||
|
file: FileProperties;
|
||||||
|
text: TextProperties;
|
||||||
|
quest: QuestProperties;
|
||||||
|
paper: PaperProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Unified Particle types ---
|
||||||
|
|
||||||
|
const ParticleBaseSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
created_at: z.coerce.date(),
|
||||||
|
created_by_human_id: z.string(),
|
||||||
|
updated_at: z.coerce.date().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ParticleSchema = z.discriminatedUnion("type", [
|
||||||
|
ParticleBaseSchema.extend({
|
||||||
|
type: z.literal("stream"),
|
||||||
|
properties: StreamPropertiesSchema,
|
||||||
|
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||||
|
// e.g. ["network:xywx"] - visible to everyone in the network
|
||||||
|
visible_to: z.array(z.string()),
|
||||||
|
// Marks human_id to their `playback_position_at`: where they left off in a conversation
|
||||||
|
playback_markers: z.record(z.string(), z.coerce.date()).optional(),
|
||||||
|
// Timestamp of the most recent child particle
|
||||||
|
// used for sorting streams by recent activity without needing to query subcollections
|
||||||
|
last_child_created_at: z.coerce.date().optional(),
|
||||||
|
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
|
||||||
|
huddle_active_participants: z.array(z.string()).optional(),
|
||||||
|
status: z.enum(["open", "closed"]).optional(),
|
||||||
|
}),
|
||||||
|
ParticleBaseSchema.extend({
|
||||||
|
type: z.literal("folder"), properties: FolderPropertiesSchema,
|
||||||
|
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||||
|
// e.g. ["network:123"] - visible to everyone in the network
|
||||||
|
visible_to: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
|
||||||
|
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema, ...TombstoneFields }),
|
||||||
|
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
|
||||||
|
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema, ...TombstoneFields }),
|
||||||
|
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema, ...TombstoneFields }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type Particle = z.infer<typeof ParticleSchema>;
|
||||||
|
|
||||||
|
export type ParticleType = Particle["type"];
|
||||||
|
|
||||||
|
/** Container types can have children subcollections */
|
||||||
|
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set(["stream", "folder"]);
|
||||||
|
|
||||||
|
export function isContainerType(type: ParticleType): boolean {
|
||||||
|
return CONTAINER_TYPES.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a non-container particle has been soft-deleted (tombstoned). */
|
||||||
|
export function isParticleDeleted(particle: Particle): boolean {
|
||||||
|
return "deleted_at" in particle && particle.deleted_at != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LiveKit types ---
|
||||||
|
|
||||||
|
export const GetLivekitTokenResponseSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
server_url: z.string(),
|
||||||
|
});
|
||||||
|
export type GetLivekitTokenResponse = z.infer<typeof GetLivekitTokenResponseSchema>;
|
||||||
|
|
||||||
|
// --- Auth types ---
|
||||||
|
|
||||||
|
const RequestCodeRequestSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
|
||||||
|
|
||||||
|
const SignInRequestSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
code: z.string(),
|
||||||
|
});
|
||||||
|
export type SignInRequest = z.infer<typeof SignInRequestSchema>;
|
||||||
|
|
||||||
|
export const SignInResponseSchema = z.object({
|
||||||
|
human: HumanSchema,
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
|
||||||
|
|
||||||
|
export const FirebaseTokenResponseSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
export type FirebaseTokenResponse = z.infer<typeof FirebaseTokenResponseSchema>;
|
||||||
|
|
||||||
|
// --- Billing types ---
|
||||||
|
|
||||||
|
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
|
||||||
|
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
|
||||||
|
|
||||||
|
export const NetworkPlanSchema = z.enum(["free", "pro"]);
|
||||||
|
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
|
||||||
|
|
||||||
|
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
|
||||||
|
export const BillingPlanStatusSchema = z.enum([
|
||||||
|
"active",
|
||||||
|
"trialing",
|
||||||
|
"past_due",
|
||||||
|
"canceled",
|
||||||
|
"incomplete",
|
||||||
|
"incomplete_expired",
|
||||||
|
"unpaid",
|
||||||
|
]);
|
||||||
|
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
|
||||||
|
|
||||||
|
export const BillingStatusSchema = z.object({
|
||||||
|
plan: NetworkPlanSchema,
|
||||||
|
plan_status: BillingPlanStatusSchema,
|
||||||
|
cadence: BillingCadenceSchema.nullable(),
|
||||||
|
seats: z.number().int(),
|
||||||
|
current_period_end: z.coerce.date().nullable(),
|
||||||
|
cancel_at_period_end: z.boolean(),
|
||||||
|
price_monthly_cents: z.number().int(),
|
||||||
|
price_annual_cents: z.number().int(),
|
||||||
|
});
|
||||||
|
export type BillingStatus = z.infer<typeof BillingStatusSchema>;
|
||||||
|
|
||||||
|
export const CheckoutSessionResponseSchema = z.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
});
|
||||||
|
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>;
|
||||||
|
|
||||||
|
export const PortalSessionResponseSchema = z.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
});
|
||||||
|
export type PortalSessionResponse = z.infer<typeof PortalSessionResponseSchema>;
|
||||||
|
|
||||||
|
export const NetworkUsageSchema = z.object({
|
||||||
|
plan: NetworkPlanSchema,
|
||||||
|
used: z.number().int().nonnegative(),
|
||||||
|
limit: z.number().int().nonnegative().nullable(),
|
||||||
|
reset_at: z.coerce.date(),
|
||||||
|
});
|
||||||
|
export type NetworkUsage = z.infer<typeof NetworkUsageSchema>;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Constants from "expo-constants";
|
||||||
|
|
||||||
|
// Expo-side equivalent of desktop's __APP_ENV__ build-time replacement
|
||||||
|
// (see js/desktop/src/config/env.ts). On mobile we read from app.config.ts
|
||||||
|
// `extra.appEnv`, which itself reads `process.env.EXPO_PUBLIC_APP_ENV` at
|
||||||
|
// build time. Defaults to "dev".
|
||||||
|
//
|
||||||
|
// Firebase web config is public by design (security is enforced via
|
||||||
|
// Firestore rules + App Check), so both configs live in source. To refresh,
|
||||||
|
// run: cd infra/gcp/{dev,prod} && terraform output -json firebase_config
|
||||||
|
|
||||||
|
type FirebaseConfig = {
|
||||||
|
apiKey: string;
|
||||||
|
appId: string;
|
||||||
|
authDomain: string;
|
||||||
|
messagingSenderId: string;
|
||||||
|
projectId: string;
|
||||||
|
storageBucket: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppConfig = {
|
||||||
|
orionUrl: string;
|
||||||
|
pusherUrl: string;
|
||||||
|
firebase: FirebaseConfig;
|
||||||
|
/** Empty string disables Sentry. Same DSN across envs; events are split by `environment` tag. */
|
||||||
|
sentryDsn: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const configs: Record<"dev" | "prod", AppConfig> = {
|
||||||
|
dev: {
|
||||||
|
orionUrl: "https://orion.dev.flowy.live",
|
||||||
|
pusherUrl: "wss://pusher.dev.flowy.live/ws",
|
||||||
|
firebase: {
|
||||||
|
apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk",
|
||||||
|
appId: "1:1006580076785:web:e2a0736d60a78e02b15950",
|
||||||
|
authDomain: "flowy-dev-440017.firebaseapp.com",
|
||||||
|
messagingSenderId: "1006580076785",
|
||||||
|
projectId: "flowy-dev-440017",
|
||||||
|
storageBucket: "flowy-dev-440017.firebasestorage.app",
|
||||||
|
},
|
||||||
|
sentryDsn:
|
||||||
|
"https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
|
||||||
|
},
|
||||||
|
prod: {
|
||||||
|
orionUrl: "https://orion.flowy.live",
|
||||||
|
pusherUrl: "wss://pusher.flowy.live/ws",
|
||||||
|
firebase: {
|
||||||
|
apiKey: "AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg",
|
||||||
|
appId: "1:68063426854:web:5054f16f50898f5706e9e7",
|
||||||
|
authDomain: "flowy-prod-440017.firebaseapp.com",
|
||||||
|
messagingSenderId: "68063426854",
|
||||||
|
projectId: "flowy-prod-440017",
|
||||||
|
storageBucket: "flowy-prod-440017.firebasestorage.app",
|
||||||
|
},
|
||||||
|
sentryDsn:
|
||||||
|
"https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawEnv = (Constants.expoConfig?.extra as { appEnv?: string } | undefined)
|
||||||
|
?.appEnv;
|
||||||
|
export const appEnv: "dev" | "prod" = rawEnv === "prod" ? "prod" : "dev";
|
||||||
|
export const appConfig: AppConfig = configs[appEnv];
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { appEnv } from "@/config/env";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a free-plan network attempts to create a non-container particle
|
||||||
|
* after hitting its daily message limit. Compose UI also disables triggers
|
||||||
|
* proactively via `useNetworkUsage` — this throw is a last-line defense.
|
||||||
|
*/
|
||||||
|
export class QuotaExceededError extends Error {
|
||||||
|
constructor(public readonly networkId: string) {
|
||||||
|
super("Daily message limit reached");
|
||||||
|
this.name = "QuotaExceededError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessage(message: string): string {
|
||||||
|
return message.replace(/^Error:\s*/, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toUserMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
if (err.status === 401) return "Please sign in again.";
|
||||||
|
if (err.status === 403) return "You don't have permission to do that.";
|
||||||
|
if (err.status === 404) return "Not found.";
|
||||||
|
if (err.status === 408 || err.status === 429) {
|
||||||
|
return "Please try again in a moment.";
|
||||||
|
}
|
||||||
|
if (err.status >= 500) {
|
||||||
|
return "Something went wrong on our end. Please try again.";
|
||||||
|
}
|
||||||
|
return normalizeMessage(err.message) || "Request failed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
return "Received unexpected data from the server.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof TypeError && /fetch|network/i.test(err.message)) {
|
||||||
|
return "Network error. Check your connection.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return normalizeMessage(err.message) || "Something went wrong.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Something went wrong.";
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorContext = Record<string, unknown>;
|
||||||
|
type ErrorSink = (err: unknown, context?: ErrorContext) => void;
|
||||||
|
|
||||||
|
// Sentry (or any observability backend) installs itself via `installErrorSinks`
|
||||||
|
// from App.tsx. Until then, logError is a dev-only console call and reportError
|
||||||
|
// always prints — no call site needs to know.
|
||||||
|
let captureSink: ErrorSink | null = null;
|
||||||
|
let breadcrumbSink: ErrorSink | null = null;
|
||||||
|
|
||||||
|
export function installErrorSinks(sinks: {
|
||||||
|
capture: ErrorSink;
|
||||||
|
breadcrumb: ErrorSink;
|
||||||
|
}): void {
|
||||||
|
captureSink = sinks.capture;
|
||||||
|
breadcrumbSink = sinks.breadcrumb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */
|
||||||
|
export function logError(err: unknown, context?: ErrorContext): void {
|
||||||
|
if (appEnv === "dev") {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[error]", err, context ?? {});
|
||||||
|
}
|
||||||
|
breadcrumbSink?.(err, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unexpected failures the user may not see. Always captured. */
|
||||||
|
export function reportError(err: unknown, context?: ErrorContext): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[error]", err, context ?? {});
|
||||||
|
captureSink?.(err, context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
MutationCache,
|
||||||
|
QueryCache,
|
||||||
|
QueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
|
||||||
|
|
||||||
|
declare module "@tanstack/react-query" {
|
||||||
|
interface Register {
|
||||||
|
queryMeta: { toastOnError?: boolean };
|
||||||
|
mutationMeta: { suppressToast?: boolean };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryQuery(failureCount: number, err: unknown): boolean {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
// Retry only on transient status codes; 4xx generally won't succeed on retry.
|
||||||
|
if (err.status === 408 || err.status === 429) return failureCount < 2;
|
||||||
|
if (err.status >= 400 && err.status < 500) return false;
|
||||||
|
}
|
||||||
|
return failureCount < 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: shouldRetryQuery,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
// Mutations have side effects — never auto-retry.
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (err, query) => {
|
||||||
|
logError(err, { scope: "query", queryKey: query.queryKey });
|
||||||
|
if (query.meta?.toastOnError) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: (err, _variables, _context, mutation) => {
|
||||||
|
reportError(err, {
|
||||||
|
scope: "mutation",
|
||||||
|
mutationKey: mutation.options.mutationKey,
|
||||||
|
});
|
||||||
|
if (mutation.meta?.suppressToast) return;
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
const AUTH_TOKEN_KEY = "auth_token";
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
token: string | null;
|
||||||
|
/**
|
||||||
|
* False until SecureStore returns the persisted token (or confirms absence).
|
||||||
|
* The API client should treat requests as unauthenticated until this flips —
|
||||||
|
* see `useSessionStore.subscribe` in App.tsx for the bootstrap.
|
||||||
|
*/
|
||||||
|
hydrated: boolean;
|
||||||
|
setToken: (token: string) => Promise<void>;
|
||||||
|
clearToken: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSessionStore = create<SessionState>((set) => ({
|
||||||
|
token: null,
|
||||||
|
hydrated: false,
|
||||||
|
|
||||||
|
setToken: async (token: string) => {
|
||||||
|
await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token);
|
||||||
|
set({ token });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearToken: async () => {
|
||||||
|
await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY);
|
||||||
|
set({ token: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap the session by reading SecureStore once. Call from App.tsx before
|
||||||
|
* mounting the navigator. Resolves after the store reflects whatever was in
|
||||||
|
* persistent storage.
|
||||||
|
*/
|
||||||
|
export async function hydrateSession(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const token = await SecureStore.getItemAsync(AUTH_TOKEN_KEY);
|
||||||
|
useSessionStore.setState({ token: token ?? null, hydrated: true });
|
||||||
|
} catch {
|
||||||
|
// SecureStore failures are non-fatal — proceed unauthenticated.
|
||||||
|
useSessionStore.setState({ token: null, hydrated: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
// Tokens mirror js/desktop/src/styles/globals.css. Desktop authors values in
|
||||||
|
// OKLCH; React Native's color parser is not guaranteed to handle oklch(), so
|
||||||
|
// we ship hex equivalents here and keep the OKLCH source in comments.
|
||||||
|
//
|
||||||
|
// Names match desktop 1:1 — components written against `bg-background`,
|
||||||
|
// `text-foreground`, etc. behave identically.
|
||||||
|
module.exports = {
|
||||||
|
content: ["./index.ts", "./src/**/*.{ts,tsx}"],
|
||||||
|
presets: [require("nativewind/preset")],
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// ---- Light tokens (desktop :root) ----
|
||||||
|
background: { DEFAULT: "#ffffff", dark: "#252525" }, // oklch(1 0 0) / oklch(0.145 0 0)
|
||||||
|
foreground: { DEFAULT: "#252525", dark: "#fafafa" }, // oklch(0.145 0 0) / oklch(0.985 0 0)
|
||||||
|
card: { DEFAULT: "#ffffff", dark: "#363636" }, // oklch(1 0 0) / oklch(0.205 0 0)
|
||||||
|
"card-foreground": { DEFAULT: "#252525", dark: "#fafafa" },
|
||||||
|
popover: { DEFAULT: "#ffffff", dark: "#363636" },
|
||||||
|
"popover-foreground": { DEFAULT: "#252525", dark: "#fafafa" },
|
||||||
|
primary: { DEFAULT: "#363636", dark: "#ebebeb" }, // oklch(0.205 0 0) / oklch(0.922 0 0)
|
||||||
|
"primary-foreground": { DEFAULT: "#fafafa", dark: "#363636" },
|
||||||
|
secondary: { DEFAULT: "#f4f4f4", dark: "#454545" }, // oklch(0.97 0 0) / oklch(0.269 0 0)
|
||||||
|
"secondary-foreground": { DEFAULT: "#363636", dark: "#fafafa" },
|
||||||
|
muted: { DEFAULT: "#f4f4f4", dark: "#454545" },
|
||||||
|
"muted-foreground": { DEFAULT: "#878787", dark: "#a6a6a6" }, // oklch(0.556 0 0) / oklch(0.708 0 0)
|
||||||
|
accent: { DEFAULT: "#f4f4f4", dark: "#454545" },
|
||||||
|
"accent-foreground": { DEFAULT: "#363636", dark: "#fafafa" },
|
||||||
|
destructive: { DEFAULT: "#dc2626", dark: "#ef4444" }, // oklch(0.577 0.245 27.325) / oklch(0.704 0.191 22.216)
|
||||||
|
border: { DEFAULT: "#dcdcdc", dark: "rgba(255,255,255,0.1)" },
|
||||||
|
input: { DEFAULT: "#dcdcdc", dark: "rgba(255,255,255,0.15)" },
|
||||||
|
ring: { DEFAULT: "#a6a6a6", dark: "#878787" }, // oklch(0.708 0 0) / oklch(0.556 0 0)
|
||||||
|
// Sidebar (drawer) tokens — desktop also defines these
|
||||||
|
sidebar: { DEFAULT: "#fafafa", dark: "#363636" },
|
||||||
|
"sidebar-foreground": { DEFAULT: "#252525", dark: "#fafafa" },
|
||||||
|
"sidebar-primary": { DEFAULT: "#363636", dark: "#6366f1" }, // chart-1 dark = oklch(0.488 0.243 264.376)
|
||||||
|
"sidebar-primary-foreground": { DEFAULT: "#fafafa", dark: "#fafafa" },
|
||||||
|
"sidebar-accent": { DEFAULT: "#f4f4f4", dark: "#454545" },
|
||||||
|
"sidebar-accent-foreground": { DEFAULT: "#363636", dark: "#fafafa" },
|
||||||
|
"sidebar-border": { DEFAULT: "#dcdcdc", dark: "rgba(255,255,255,0.1)" },
|
||||||
|
"sidebar-ring": { DEFAULT: "#a6a6a6", dark: "#878787" },
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "10px", // --radius: 0.625rem
|
||||||
|
md: "8px",
|
||||||
|
sm: "6px",
|
||||||
|
xl: "14px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"types": ["nativewind/types"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts",
|
||||||
|
"nativewind-env.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
+5343
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user