Compare commits
45 Commits
feat/aris-
...
a26e35cc2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
a26e35cc2f
|
|||
| 31d5aa8d50 | |||
| de29e44a08 | |||
| caf48484bf | |||
| ac80e0cdac | |||
| 96e22e227c | |||
| 8ca8a0d1d2 | |||
| 4c9ac2c61a | |||
| be3fc41a00 | |||
| 2e9c600e93 | |||
| d616fd52d3 | |||
| 2d7544500d | |||
| 9dc0cc3d2f | |||
| fe1d261f56 | |||
| 40ad90aa2d | |||
| 82ac2b577d | |||
| ffea38b986 | |||
| 28d26b3c87 | |||
| 78b0ed94bd | |||
| ee957ea7b1 | |||
| 6ae0ad1d40 | |||
|
941acb826c
|
|||
| 3d492a5d56 | |||
|
08dd437952
|
|||
| 2fc20759dd | |||
|
963bf073d1
|
|||
| c0b3db0e11 | |||
|
ca4a337dcd
|
|||
| 769e2d4eb0 | |||
|
5e9094710d
|
|||
|
5556f3fbf9
|
|||
|
0176979925
|
|||
|
971aba0932
|
|||
|
68e319e4b8
|
|||
| c042af88f3 | |||
|
0608f2ac61
|
|||
| 1ade63dd8c | |||
|
8df340d9af
|
|||
|
727280e8b1
|
|||
| d30f70494b | |||
| 413a57c156 | |||
|
d9625198d6
|
|||
| 959167a93c | |||
| cd29a60bab | |||
|
2f082b5833
|
43
.claude/skills/gpg-commit-signing/SKILL.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: gpg-commit-signing
|
||||
description: Sign git commits with GPG in non-interactive environments. Use when committing code and the `GPG_PRIVATE_KEY_PASSPHRASE` environment variable is available. Triggers on "commit", "sign commit", "GPG", "git commit -S", or any git operation requiring signed commits.
|
||||
---
|
||||
|
||||
# GPG Commit Signing
|
||||
|
||||
Sign commits in headless/non-interactive environments where `/dev/tty` is unavailable.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Check whether `GPG_PRIVATE_KEY_PASSPHRASE` is set:
|
||||
|
||||
```bash
|
||||
test -n "$GPG_PRIVATE_KEY_PASSPHRASE" && echo "available" || echo "not set"
|
||||
```
|
||||
|
||||
If not set, skip signing — commit without `-S`.
|
||||
|
||||
2. Try a direct signed commit first — the environment may already have loopback pinentry configured:
|
||||
|
||||
```bash
|
||||
git commit -S -m "message"
|
||||
```
|
||||
|
||||
If this succeeds, no further steps are needed.
|
||||
|
||||
3. If step 2 fails with a `/dev/tty` error, use `--pinentry-mode loopback` via a wrapper script:
|
||||
|
||||
```bash
|
||||
printf '#!/bin/sh\ngpg --batch --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" "$@"\n' > /tmp/gpg-sign.sh
|
||||
chmod +x /tmp/gpg-sign.sh
|
||||
git -c gpg.program=/tmp/gpg-sign.sh commit -S -m "message"
|
||||
rm /tmp/gpg-sign.sh
|
||||
```
|
||||
|
||||
This passes the passphrase directly to gpg on each signing invocation, bypassing the need for a configured gpg-agent.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Do not echo or log `GPG_PRIVATE_KEY_PASSPHRASE`.
|
||||
- Do not commit without `-S` when the passphrase is available — the project expects signed commits.
|
||||
- Do not leave wrapper scripts on disk after committing.
|
||||
@@ -1,8 +1,8 @@
|
||||
services:
|
||||
expo:
|
||||
name: Expo Dev Server
|
||||
description: Expo development server for aris-client
|
||||
description: Expo development server for aelis-client
|
||||
triggeredBy:
|
||||
- postDevcontainerStart
|
||||
commands:
|
||||
start: cd apps/aris-client && ./scripts/run-dev-server.sh
|
||||
start: cd apps/aelis-client && ./scripts/run-dev-server.sh
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project
|
||||
|
||||
ARIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
|
||||
AELIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# aris
|
||||
# aelis
|
||||
|
||||
To install dependencies:
|
||||
|
||||
@@ -8,14 +8,14 @@ bun install
|
||||
|
||||
## Packages
|
||||
|
||||
### @aris/source-tfl
|
||||
### @aelis/source-tfl
|
||||
|
||||
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
|
||||
|
||||
#### Testing
|
||||
|
||||
```bash
|
||||
cd packages/aris-source-tfl
|
||||
cd packages/aelis-source-tfl
|
||||
bun run test
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@aris/backend",
|
||||
"name": "@aelis/backend",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/server.ts",
|
||||
@@ -9,12 +9,10 @@
|
||||
"test": "bun test src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"@aris/source-location": "workspace:*",
|
||||
"@aris/source-tfl": "workspace:*",
|
||||
"@aris/source-weatherkit": "workspace:*",
|
||||
"@hono/trpc-server": "^0.3",
|
||||
"@trpc/server": "^11",
|
||||
"@aelis/core": "workspace:*",
|
||||
"@aelis/source-location": "workspace:*",
|
||||
"@aelis/source-tfl": "workspace:*",
|
||||
"@aelis/source-weatherkit": "workspace:*",
|
||||
"arktype": "^2.1.29",
|
||||
"better-auth": "^1",
|
||||
"hono": "^4",
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { Context, Next } from "hono"
|
||||
import type { Context, MiddlewareHandler, Next } from "hono"
|
||||
|
||||
import type { AuthSession, AuthUser } from "./session.ts"
|
||||
|
||||
import { auth } from "./index.ts"
|
||||
|
||||
type SessionUser = typeof auth.$Infer.Session.user
|
||||
type Session = typeof auth.$Infer.Session.session
|
||||
|
||||
export interface SessionVariables {
|
||||
user: SessionUser | null
|
||||
session: Session | null
|
||||
user: AuthUser | null
|
||||
session: AuthSession | null
|
||||
}
|
||||
|
||||
export type AuthSessionEnv = { Variables: SessionVariables }
|
||||
|
||||
export type AuthSessionMiddleware = MiddlewareHandler<AuthSessionEnv>
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap extends SessionVariables {}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +55,22 @@ export async function requireSession(c: Context, next: Next): Promise<Response |
|
||||
*/
|
||||
export async function getSessionFromHeaders(
|
||||
headers: Headers,
|
||||
): Promise<{ user: SessionUser; session: Session } | null> {
|
||||
): Promise<{ user: AuthUser; session: AuthSession } | null> {
|
||||
const session = await auth.api.getSession({ headers })
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only middleware that injects a fake user and session.
|
||||
* Pass userId to simulate an authenticated request, or omit to get 401.
|
||||
*/
|
||||
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
|
||||
return async (c: Context, next: Next): Promise<Response | void> => {
|
||||
if (!userId) {
|
||||
return c.json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
c.set("user", { id: userId } as AuthUser)
|
||||
c.set("session", { id: "mock-session" } as AuthSession)
|
||||
await next()
|
||||
}
|
||||
}
|
||||
4
apps/aelis-backend/src/auth/session.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { auth } from "./index.ts"
|
||||
|
||||
export type AuthUser = typeof auth.$Infer.Session.user
|
||||
export type AuthSession = typeof auth.$Infer.Session.session
|
||||
140
apps/aelis-backend/src/feed/http.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import { UserSessionManager } from "../session/index.ts"
|
||||
import { registerFeedHttpHandlers } from "./http.ts"
|
||||
|
||||
interface FeedResponse {
|
||||
items: Array<{
|
||||
id: string
|
||||
type: string
|
||||
priority: number
|
||||
timestamp: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
errors: Array<{ sourceId: string; error: string }>
|
||||
}
|
||||
|
||||
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
||||
return {
|
||||
id,
|
||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||
return {}
|
||||
},
|
||||
async executeAction(): Promise<unknown> {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
return items
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
||||
const app = new Hono()
|
||||
registerFeedHttpHandlers(app, {
|
||||
sessionManager,
|
||||
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
describe("GET /api/feed", () => {
|
||||
test("returns 401 without auth", async () => {
|
||||
const manager = new UserSessionManager([])
|
||||
const app = buildTestApp(manager)
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test("returns cached feed when available", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
type: "test",
|
||||
priority: 0.8,
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
// Prime the cache
|
||||
const session = manager.getOrCreate("user-1")
|
||||
await session.engine.refresh()
|
||||
expect(session.engine.lastFeed()).not.toBeNull()
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as FeedResponse
|
||||
expect(body.items).toHaveLength(1)
|
||||
expect(body.items[0]!.id).toBe("item-1")
|
||||
expect(body.items[0]!.type).toBe("test")
|
||||
expect(body.items[0]!.priority).toBe(0.8)
|
||||
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
|
||||
expect(body.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("forces refresh when no cached feed", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "fresh-1",
|
||||
type: "test",
|
||||
priority: 0.5,
|
||||
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
||||
data: { fresh: true },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as FeedResponse
|
||||
expect(body.items).toHaveLength(1)
|
||||
expect(body.items[0]!.id).toBe("fresh-1")
|
||||
expect(body.items[0]!.data.fresh).toBe(true)
|
||||
expect(body.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("serializes source errors as message strings", async () => {
|
||||
const failingSource: FeedSource = {
|
||||
id: "failing",
|
||||
async listActions() {
|
||||
return {}
|
||||
},
|
||||
async executeAction() {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext() {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
throw new Error("connection timeout")
|
||||
},
|
||||
}
|
||||
const manager = new UserSessionManager([() => failingSource])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as FeedResponse
|
||||
expect(body.items).toHaveLength(0)
|
||||
expect(body.errors).toHaveLength(1)
|
||||
expect(body.errors[0]!.sourceId).toBe("failing")
|
||||
expect(body.errors[0]!.error).toBe("connection timeout")
|
||||
})
|
||||
})
|
||||
41
apps/aelis-backend/src/feed/http.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Context, Hono } from "hono"
|
||||
|
||||
import { createMiddleware } from "hono/factory"
|
||||
|
||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
|
||||
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||
|
||||
interface FeedHttpHandlersDeps {
|
||||
sessionManager: UserSessionManager
|
||||
authSessionMiddleware: AuthSessionMiddleware
|
||||
}
|
||||
|
||||
export function registerFeedHttpHandlers(
|
||||
app: Hono,
|
||||
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
||||
) {
|
||||
const inject = createMiddleware<Env>(async (c, next) => {
|
||||
c.set("sessionManager", sessionManager)
|
||||
await next()
|
||||
})
|
||||
|
||||
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
|
||||
}
|
||||
|
||||
async function handleGetFeed(c: Context<Env>) {
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
|
||||
const feed = session.engine.lastFeed() ?? (await session.engine.refresh())
|
||||
|
||||
return c.json({
|
||||
items: feed.items,
|
||||
errors: feed.errors.map((e) => ({
|
||||
sourceId: e.sourceId,
|
||||
error: e.error.message,
|
||||
})),
|
||||
})
|
||||
}
|
||||
56
apps/aelis-backend/src/location/http.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Context, Hono } from "hono"
|
||||
|
||||
import { type } from "arktype"
|
||||
import { createMiddleware } from "hono/factory"
|
||||
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
|
||||
import { requireSession } from "../auth/session-middleware.ts"
|
||||
|
||||
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||
|
||||
const locationInput = type({
|
||||
lat: "number",
|
||||
lng: "number",
|
||||
accuracy: "number",
|
||||
timestamp: "string.date.iso",
|
||||
})
|
||||
|
||||
export function registerLocationHttpHandlers(
|
||||
app: Hono,
|
||||
{ sessionManager }: { sessionManager: UserSessionManager },
|
||||
) {
|
||||
const inject = createMiddleware<Env>(async (c, next) => {
|
||||
c.set("sessionManager", sessionManager)
|
||||
await next()
|
||||
})
|
||||
|
||||
app.post("/api/location", inject, requireSession, handleUpdateLocation)
|
||||
}
|
||||
|
||||
async function handleUpdateLocation(c: Context<Env>) {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON" }, 400)
|
||||
}
|
||||
|
||||
const result = locationInput(body)
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
return c.json({ error: result.summary }, 400)
|
||||
}
|
||||
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: result.lat,
|
||||
lng: result.lng,
|
||||
accuracy: result.accuracy,
|
||||
timestamp: new Date(result.timestamp),
|
||||
})
|
||||
|
||||
return c.body(null, 204)
|
||||
}
|
||||
40
apps/aelis-backend/src/server.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { LocationSource } from "@aelis/source-location"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { registerAuthHandlers } from "./auth/http.ts"
|
||||
import { requireSession } from "./auth/session-middleware.ts"
|
||||
import { registerFeedHttpHandlers } from "./feed/http.ts"
|
||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||
import { UserSessionManager } from "./session/index.ts"
|
||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||
|
||||
function main() {
|
||||
const sessionManager = new UserSessionManager([
|
||||
() => new LocationSource(),
|
||||
new WeatherSourceProvider({
|
||||
credentials: {
|
||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||
keyId: process.env.WEATHERKIT_KEY_ID!,
|
||||
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||
|
||||
registerAuthHandlers(app)
|
||||
registerFeedHttpHandlers(app, { sessionManager, authSessionMiddleware: requireSession })
|
||||
registerLocationHttpHandlers(app, { sessionManager })
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
const app = main()
|
||||
|
||||
export default {
|
||||
port: 3000,
|
||||
fetch: app.fetch,
|
||||
}
|
||||
9
apps/aelis-backend/src/session/feed-source-provider.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { FeedSource } from "@aelis/core"
|
||||
|
||||
export interface FeedSourceProvider {
|
||||
feedSourceForUser(userId: string): FeedSource
|
||||
}
|
||||
|
||||
export type FeedSourceProviderFn = (userId: string) => FeedSource
|
||||
|
||||
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn
|
||||
7
apps/aelis-backend/src/session/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
FeedSourceProvider,
|
||||
FeedSourceProviderFn,
|
||||
FeedSourceProviderInput,
|
||||
} from "./feed-source-provider.ts"
|
||||
export { UserSession } from "./user-session.ts"
|
||||
export { UserSessionManager } from "./user-session-manager.ts"
|
||||
166
apps/aelis-backend/src/session/user-session-manager.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit"
|
||||
|
||||
import { LocationSource } from "@aelis/source-location"
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { WeatherSourceProvider } from "../weather/provider.ts"
|
||||
import { UserSessionManager } from "./user-session-manager.ts"
|
||||
|
||||
const mockWeatherClient: WeatherKitClient = {
|
||||
fetch: async () => ({}) as WeatherKitResponse,
|
||||
}
|
||||
|
||||
describe("UserSessionManager", () => {
|
||||
test("getOrCreate creates session on first call", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session.engine).toBeDefined()
|
||||
})
|
||||
|
||||
test("getOrCreate returns same session for same user", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session1).toBe(session2)
|
||||
})
|
||||
|
||||
test("getOrCreate returns different sessions for different users", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-2")
|
||||
|
||||
expect(session1).not.toBe(session2)
|
||||
})
|
||||
|
||||
test("each user gets independent source instances", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-2")
|
||||
|
||||
const source1 = session1.getSource<LocationSource>("aelis.location")
|
||||
const source2 = session2.getSource<LocationSource>("aelis.location")
|
||||
|
||||
expect(source1).not.toBe(source2)
|
||||
})
|
||||
|
||||
test("remove destroys session and allows re-creation", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
manager.remove("user-1")
|
||||
const session2 = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session1).not.toBe(session2)
|
||||
})
|
||||
|
||||
test("remove is no-op for unknown user", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
expect(() => manager.remove("unknown")).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts function providers", async () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
const result = await session.engine.refresh()
|
||||
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("accepts object providers", () => {
|
||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||
const manager = new UserSessionManager([() => new LocationSource(), provider])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
||||
})
|
||||
|
||||
test("accepts mixed providers", () => {
|
||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||
const manager = new UserSessionManager([() => new LocationSource(), provider])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session.getSource("aelis.location")).toBeDefined()
|
||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
||||
})
|
||||
|
||||
test("refresh returns feed result through session", async () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
const result = await session.engine.refresh()
|
||||
|
||||
expect(result).toHaveProperty("context")
|
||||
expect(result).toHaveProperty("items")
|
||||
expect(result).toHaveProperty("errors")
|
||||
expect(result.context.time).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
test("location update via executeAction works", async () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
const source = session.getSource<LocationSource>("aelis.location")
|
||||
expect(source?.lastLocation?.lat).toBe(51.5074)
|
||||
})
|
||||
|
||||
test("subscribe receives updates after location push", async () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
const callback = mock()
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
session.engine.subscribe(callback)
|
||||
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
// Wait for async update propagation
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("remove stops reactive updates", async () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
const callback = mock()
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
session.engine.subscribe(callback)
|
||||
|
||||
manager.remove("user-1")
|
||||
|
||||
// Create new session and push location — old callback should not fire
|
||||
const session2 = manager.getOrCreate("user-1")
|
||||
await session2.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
32
apps/aelis-backend/src/session/user-session-manager.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
||||
|
||||
import { UserSession } from "./user-session.ts"
|
||||
|
||||
export class UserSessionManager {
|
||||
private sessions = new Map<string, UserSession>()
|
||||
private readonly providers: FeedSourceProviderInput[]
|
||||
|
||||
constructor(providers: FeedSourceProviderInput[]) {
|
||||
this.providers = providers
|
||||
}
|
||||
|
||||
getOrCreate(userId: string): UserSession {
|
||||
let session = this.sessions.get(userId)
|
||||
if (!session) {
|
||||
const sources = this.providers.map((p) =>
|
||||
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
||||
)
|
||||
session = new UserSession(sources)
|
||||
this.sessions.set(userId, session)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
remove(userId: string): void {
|
||||
const session = this.sessions.get(userId)
|
||||
if (session) {
|
||||
session.destroy()
|
||||
this.sessions.delete(userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
72
apps/aelis-backend/src/session/user-session.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ActionDefinition, ContextEntry, FeedSource } from "@aelis/core"
|
||||
|
||||
import { LocationSource } from "@aelis/source-location"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { UserSession } from "./user-session.ts"
|
||||
|
||||
function createStubSource(id: string): FeedSource {
|
||||
return {
|
||||
id,
|
||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||
return {}
|
||||
},
|
||||
async executeAction(): Promise<unknown> {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("UserSession", () => {
|
||||
test("registers sources and starts engine", async () => {
|
||||
const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")])
|
||||
|
||||
const result = await session.engine.refresh()
|
||||
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("getSource returns registered source", () => {
|
||||
const location = new LocationSource()
|
||||
const session = new UserSession([location])
|
||||
|
||||
const result = session.getSource<LocationSource>("aelis.location")
|
||||
|
||||
expect(result).toBe(location)
|
||||
})
|
||||
|
||||
test("getSource returns undefined for unknown source", () => {
|
||||
const session = new UserSession([createStubSource("test")])
|
||||
|
||||
expect(session.getSource("unknown")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("destroy stops engine and clears sources", () => {
|
||||
const session = new UserSession([createStubSource("test")])
|
||||
|
||||
session.destroy()
|
||||
|
||||
expect(session.getSource("test")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("engine.executeAction routes to correct source", async () => {
|
||||
const location = new LocationSource()
|
||||
const session = new UserSession([location])
|
||||
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5,
|
||||
lng: -0.1,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
expect(location.lastLocation).toBeDefined()
|
||||
expect(location.lastLocation!.lat).toBe(51.5)
|
||||
})
|
||||
})
|
||||
24
apps/aelis-backend/src/session/user-session.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { FeedEngine, type FeedSource } from "@aelis/core"
|
||||
|
||||
export class UserSession {
|
||||
readonly engine: FeedEngine
|
||||
private sources = new Map<string, FeedSource>()
|
||||
|
||||
constructor(sources: FeedSource[]) {
|
||||
this.engine = new FeedEngine()
|
||||
for (const source of sources) {
|
||||
this.sources.set(source.id, source)
|
||||
this.engine.register(source)
|
||||
}
|
||||
this.engine.start()
|
||||
}
|
||||
|
||||
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
||||
return this.sources.get(sourceId) as T | undefined
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.engine.stop()
|
||||
this.sources.clear()
|
||||
}
|
||||
}
|
||||
19
apps/aelis-backend/src/tfl/provider.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { TflSource, type ITflApi } from "@aelis/source-tfl"
|
||||
|
||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||
|
||||
export type TflSourceProviderOptions =
|
||||
| { apiKey: string; client?: never }
|
||||
| { apiKey?: never; client: ITflApi }
|
||||
|
||||
export class TflSourceProvider implements FeedSourceProvider {
|
||||
private readonly options: TflSourceProviderOptions
|
||||
|
||||
constructor(options: TflSourceProviderOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
feedSourceForUser(_userId: string): TflSource {
|
||||
return new TflSource(this.options)
|
||||
}
|
||||
}
|
||||
15
apps/aelis-backend/src/weather/provider.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { WeatherSource, type WeatherSourceOptions } from "@aelis/source-weatherkit"
|
||||
|
||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||
|
||||
export class WeatherSourceProvider implements FeedSourceProvider {
|
||||
private readonly options: WeatherSourceOptions
|
||||
|
||||
constructor(options: WeatherSourceOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
feedSourceForUser(_userId: string): WeatherSource {
|
||||
return new WeatherSource(this.options)
|
||||
}
|
||||
}
|
||||
152
apps/aelis-client/app.json
Normal file
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Aelis",
|
||||
"slug": "aelis-client",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "aelis",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"bundleIdentifier": "sh.nym.aelis"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "sh.nym.aelis"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-font",
|
||||
{
|
||||
"android": {
|
||||
"fonts": [
|
||||
{
|
||||
"fontFamily": "Inter",
|
||||
"fontDefinitions": [
|
||||
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
|
||||
{ "path": "./assets/fonts/Inter_100Thin_Italic.ttf", "weight": 100, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
|
||||
{ "path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
|
||||
{ "path": "./assets/fonts/Inter_300Light_Italic.ttf", "weight": 300, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
|
||||
{ "path": "./assets/fonts/Inter_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
|
||||
{ "path": "./assets/fonts/Inter_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
|
||||
{ "path": "./assets/fonts/Inter_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
|
||||
{ "path": "./assets/fonts/Inter_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
|
||||
{ "path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
|
||||
{ "path": "./assets/fonts/Inter_900Black_Italic.ttf", "weight": 900, "style": "italic" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"fontFamily": "Source Serif 4",
|
||||
"fontDefinitions": [
|
||||
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf", "weight": 300, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf", "weight": 900, "style": "italic" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"fonts": [
|
||||
"./assets/fonts/Inter_100Thin.ttf",
|
||||
"./assets/fonts/Inter_100Thin_Italic.ttf",
|
||||
"./assets/fonts/Inter_200ExtraLight.ttf",
|
||||
"./assets/fonts/Inter_200ExtraLight_Italic.ttf",
|
||||
"./assets/fonts/Inter_300Light.ttf",
|
||||
"./assets/fonts/Inter_300Light_Italic.ttf",
|
||||
"./assets/fonts/Inter_400Regular.ttf",
|
||||
"./assets/fonts/Inter_400Regular_Italic.ttf",
|
||||
"./assets/fonts/Inter_500Medium.ttf",
|
||||
"./assets/fonts/Inter_500Medium_Italic.ttf",
|
||||
"./assets/fonts/Inter_600SemiBold.ttf",
|
||||
"./assets/fonts/Inter_600SemiBold_Italic.ttf",
|
||||
"./assets/fonts/Inter_700Bold.ttf",
|
||||
"./assets/fonts/Inter_700Bold_Italic.ttf",
|
||||
"./assets/fonts/Inter_800ExtraBold.ttf",
|
||||
"./assets/fonts/Inter_800ExtraBold_Italic.ttf",
|
||||
"./assets/fonts/Inter_900Black.ttf",
|
||||
"./assets/fonts/Inter_900Black_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_200ExtraLight.ttf",
|
||||
"./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_300Light.ttf",
|
||||
"./assets/fonts/SourceSerif4_300Light_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_400Regular.ttf",
|
||||
"./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_500Medium.ttf",
|
||||
"./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_600SemiBold.ttf",
|
||||
"./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_700Bold.ttf",
|
||||
"./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_800ExtraBold.ttf",
|
||||
"./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_900Black.ttf",
|
||||
"./assets/fonts/SourceSerif4_900Black_Italic.ttf"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "61092d23-36aa-418e-929d-ea40dc912e8f"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/aelis-client/assets/fonts/Inter_100Thin.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_100Thin_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_200ExtraLight.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_200ExtraLight_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_300Light.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_300Light_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_400Regular.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_400Regular_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_500Medium.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_500Medium_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_600SemiBold.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_600SemiBold_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_700Bold.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_700Bold_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_800ExtraBold.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_800ExtraBold_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_900Black.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/Inter_900Black_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_200ExtraLight.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_300Light.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_300Light_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_400Regular.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_500Medium.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_500Medium_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_600SemiBold.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_700Bold.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_700Bold_Italic.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_800ExtraBold.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_900Black.ttf
Normal file
BIN
apps/aelis-client/assets/fonts/SourceSerif4_900Black_Italic.ttf
Normal file
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
@@ -8,6 +8,12 @@
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"development-simulator": {
|
||||
"extends": "development",
|
||||
"ios": {
|
||||
"simulator": "true"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "aris-client",
|
||||
"name": "aelis-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
@@ -11,10 +11,12 @@
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
"build:ios": "eas build --profile development --platform ios --non-interactive",
|
||||
"build:ios-simulator": "eas build --profile development-simulator --platform ios --non-interactive",
|
||||
"debugger": "bun run scripts/open-debugger.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
@@ -40,8 +42,10 @@
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
"react-native-worklets": "0.5.1",
|
||||
"twrnc": "^4.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
import { Platform } from "react-native"
|
||||