feat: 完成 Q 群組 — APP(React Native + Expo)

新增 apps/mobile:Expo Router 檔案系統路由(同 Next.js App Router 的
心智模型),底部四分頁(聊天/角色/日常/設定)皆為真實 API 串接(/chat、
/tachie、/characters、/schedule、/health、/push)。聊天分頁完整重用
H/O 群組已驗證過的對話+表情查詢節奏。立繪暫以依情緒 magnitude 調整
透明度的色塊佔位(PixiJS 是瀏覽器 DOM/Canvas API,未移植到 React
Native,需要 react-native-skia 等原生繪圖套件才能重現分層合成,超出
本群組範圍)。

新增 apps/api/src/push/:PushProvider 抽象(第三次套用 F-1/P-4 的
Provider 模式),MockPushProvider 供測試、ExpoPushProvider 是可直接
打真實 HTTP 請求的完整實作(Expo 推播匿名發送不需金鑰,不像 LLM/TTS
需要另外挑供應商待人工確認)。PushToken/PushNotificationLog 記錄裝置
註冊與每次推播嘗試。J 群組 TaskTriggerService 觸發時會嘗試推播給
使用者已註冊裝置,用觸發訊息本身當推播內容。

本環境沒有 Android/iOS 模擬器,改用 `expo start --web` + playwright-core
(同 O 群組手法)驅動無頭瀏覽器驗證四分頁切換、真實 API 串接、聊天
完整往返、推播按鈕在無裝置環境下的優雅降級——不是原生 APP 的完整驗證,
但驗證了畫面邏輯與 API 契約層。過程中發現並修正 CORS 設定只放行網頁版
origin、擋掉 Expo 網頁版請求的問題(永久修正:新增 localhost:8081 白名單,
不影響原生 APP,原生環境沒有 CORS 限制)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-08-13 19:19:20 +08:00
co-authored by Claude Sonnet 5
parent 0e9401b891
commit 5da8b3a63e
40 changed files with 8115 additions and 92 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+30
View File
@@ -0,0 +1,30 @@
{
"expo": {
"name": "心音 Kokorone",
"slug": "kokorone",
"scheme": "kokorone",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router"
]
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Tabs } from "expo-router";
// Q-2 底部分頁籤:聊天/角色/日常/設定,同行動網頁版佈局+這一層分頁籤是 APP 相對於行動網頁的唯一差異。
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: "#FF7E9D",
}}
>
<Tabs.Screen name="index" options={{ title: "聊天" }} />
<Tabs.Screen name="character" options={{ title: "角色" }} />
<Tabs.Screen name="daily" options={{ title: "日常" }} />
<Tabs.Screen name="settings" options={{ title: "設定" }} />
</Tabs>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import type { Character } from "@kokorone/shared";
import { API_BASE_URL } from "../../lib/api";
const CHARACTER_ID = "seed-character-genki";
// Q-2 角色分頁:展示角色設定表(沿用 B 群組的 /characters 端點),驗證分頁本身可獨立運作、能打 API。
export default function CharacterScreen() {
const [character, setCharacter] = useState<Character | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`${API_BASE_URL}/characters`)
.then((res) => res.json())
.then((characters: Character[]) => {
setCharacter(characters.find((c) => c.id === CHARACTER_ID) ?? characters[0] ?? null);
})
.catch(() => setError("角色資料載入失敗,請確認 api 服務是否已啟動"));
}, []);
const formalName = character?.aliases?.[0]?.formalName ?? character?.id ?? "";
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>{formalName || "角色"}</Text>
{error && <Text style={styles.error}>{error}</Text>}
{character && (
<View style={styles.card}>
<Field label="性格原型" value={character.personalityArchetype} />
<Field label="基本資料" value={character.basicInfo} />
<Field label="喜好/厭惡" value={character.likesDislikes} />
<Field label="目標/執念" value={character.goalsObsessions} />
<Field label="說話方式" value={character.speechStyle} />
</View>
)}
</ScrollView>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>{label}</Text>
<Text style={styles.fieldValue}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 20, gap: 16 },
title: { fontSize: 22, fontWeight: "bold", color: "#FF7E9D" },
card: { gap: 14 },
field: { gap: 2 },
fieldLabel: { fontSize: 12, color: "#8a6b6f" },
fieldValue: { fontSize: 14, color: "#222" },
error: { color: "#FF7E9D", fontSize: 12 },
});
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { API_BASE_URL } from "../../lib/api";
const CHARACTER_ID = "seed-character-genki";
interface ScheduleInfo {
dayType: string;
minute: number;
slot: { status: string; label: string | null } | null;
isWaking: boolean;
isPreSleep: boolean;
availability: string;
}
// Q-2 日常分頁:展示 I 群組的作息狀態(她現在在做什麼、回應可用度),是「她有自己的生活」這件事
// 在 APP 上最直接的呈現位置。若種子角色沒有掛作息表(見 I 群組實作記錄),slot 會是 null。
export default function DailyScreen() {
const [info, setInfo] = useState<ScheduleInfo | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`${API_BASE_URL}/schedule/${CHARACTER_ID}/info`)
.then((res) => res.json())
.then(setInfo)
.catch(() => setError("作息資料載入失敗,請確認 api 服務是否已啟動"));
}, []);
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>今天的她</Text>
{error && <Text style={styles.error}>{error}</Text>}
{info && (
<View style={styles.card}>
<Text style={styles.line}>作息類型:{info.dayType}</Text>
<Text style={styles.line}>目前時段:{info.slot?.label ?? info.slot?.status ?? "(未設定作息表)"}</Text>
<Text style={styles.line}>回應可用度:{info.availability}</Text>
{info.isWaking && <Text style={styles.badge}>剛睡醒</Text>}
{info.isPreSleep && <Text style={styles.badge}>快睡了</Text>}
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 20, gap: 16 },
title: { fontSize: 22, fontWeight: "bold", color: "#FF7E9D" },
card: { gap: 8 },
line: { fontSize: 14, color: "#222" },
badge: { fontSize: 12, color: "#FF7E9D", fontWeight: "bold" },
error: { color: "#FF7E9D", fontSize: 12 },
});
+169
View File
@@ -0,0 +1,169 @@
import { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import type { EmotionTag } from "@kokorone/shared";
import { sendChatMessage, fetchExpression, type TachieExpressionResponse } from "../../lib/api";
import { EMOTION_LABELS } from "../../lib/emotion-labels";
import { generateSessionId } from "../../lib/id";
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
const EXPRESSION_LEAD_MS = 220; // O-2/Q-2:表情先變化、文字延後一小段時間出現,跟 apps/web 的作法一致。
interface Message {
role: "user" | "character";
text: string;
}
// Q-2 行動版佈局:立繪半身像置於上方 40% 為背景層,對話流覆蓋其上向下延伸——
// 跟行動網頁版同一份設計原則,只是這裡用 View 的 flex 排版而非 CSS。立繪本體目前是佔位色塊
// (O 群組的 PixiJS 渲染器是網頁 DOM/Canvas 實作,沒有移植到 React Native,見本群組實作記錄)。
export default function ChatScreen() {
const [sessionId] = useState(() => generateSessionId());
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [dominant, setDominant] = useState<EmotionTag>("CALM");
const [intimacy, setIntimacy] = useState(0);
const [expression, setExpression] = useState<TachieExpressionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSend() {
const text = input.trim();
if (!text || sending) {
return;
}
setSending(true);
setError(null);
setInput("");
setMessages((prev) => [...prev, { role: "user", text }]);
try {
const result = await sendChatMessage(CHARACTER_ID, USER_ID, sessionId, text);
if (result.availability === "NO_REPLY") {
return;
}
if (result.context) {
setDominant(result.context.emotion.dominant);
setIntimacy(result.context.relationship.intimacy);
}
try {
setExpression(await fetchExpression(CHARACTER_ID, USER_ID));
} catch {
// 表情查詢失敗不影響對話本身。
}
await new Promise((resolve) => setTimeout(resolve, EXPRESSION_LEAD_MS));
setMessages((prev) => [...prev, { role: "character", text: result.text }]);
} catch {
setError("訊息傳送失敗,請確認 api 服務是否已啟動");
} finally {
setSending(false);
}
}
return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={styles.tachieArea}>
<Text style={styles.tachieLabel}>親密度 {intimacy}</Text>
<View style={styles.emotionChip}>
<Text style={styles.emotionChipText}>{EMOTION_LABELS[dominant]}</Text>
</View>
<View style={[styles.tachiePlaceholder, expression ? { opacity: 0.85 + expression.magnitude * 0.15 } : null]} />
<Text style={styles.tachieCaption}>立繪(表情差分即時變化)</Text>
</View>
<ScrollView style={styles.messages} contentContainerStyle={styles.messagesContent}>
{messages.length === 0 && <Text style={styles.hint}>傳個訊息,開始今天的對話吧。</Text>}
{messages.map((message, index) => (
<View
key={index}
style={[styles.bubble, message.role === "user" ? styles.bubbleUser : styles.bubbleCharacter]}
>
<Text style={styles.bubbleText}>{message.text}</Text>
</View>
))}
</ScrollView>
{error && <Text style={styles.error}>{error}</Text>}
<View style={styles.inputRow}>
<TextInput
value={input}
onChangeText={setInput}
placeholder="輸入訊息……"
style={styles.input}
editable={!sending}
onSubmitEditing={handleSend}
/>
<Pressable onPress={handleSend} disabled={sending}>
<Text style={[styles.sendButton, sending ? styles.sendButtonDisabled : null]}>送出</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: "#FFF5F7" },
tachieArea: {
height: "40%",
backgroundColor: "#FCE4EC",
alignItems: "center",
justifyContent: "flex-end",
paddingBottom: 16,
},
tachieLabel: { position: "absolute", top: 14, left: 14, fontSize: 11, color: "#8a6b6f" },
emotionChip: {
position: "absolute",
top: 14,
right: 14,
backgroundColor: "#FFE0EA",
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 4,
},
emotionChipText: { color: "#FF7E9D", fontWeight: "bold", fontSize: 12 },
tachiePlaceholder: {
width: 120,
height: 190,
borderTopLeftRadius: 60,
borderTopRightRadius: 60,
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
backgroundColor: "#FF9EB5",
},
tachieCaption: { fontSize: 11, color: "#8a6b6f", marginTop: 8 },
messages: { flex: 1 },
messagesContent: { padding: 16, gap: 8 },
hint: { fontSize: 13, color: "#8a6b6f" },
bubble: { maxWidth: "80%", borderRadius: 16, padding: 10 },
bubbleUser: { alignSelf: "flex-end", backgroundColor: "#FF7E9D" },
bubbleCharacter: { alignSelf: "flex-start", backgroundColor: "#EFEFEF" },
bubbleText: { color: "#222" },
error: { color: "#FF7E9D", fontSize: 12, paddingHorizontal: 16 },
inputRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
margin: 12,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 999,
borderWidth: 1,
borderColor: "#E5D5D8",
},
input: { flex: 1, fontSize: 14 },
sendButton: { color: "#FF7E9D", fontWeight: "bold" },
sendButtonDisabled: { opacity: 0.5 },
});
+81
View File
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import { fetchHealth, registerPushToken } from "../../lib/api";
const USER_ID = "seed-user-primary";
// Q-1 驗收:APP 能呼叫 api 的 /health 並顯示結果。
// Q-3 推播通知:請求權限、取得 Expo push token、註冊到後端——實機/模擬器才能真的收到推播,
// 這裡的按鈕操作與畫面回饋在沒有實體裝置的環境下也能驗證到「流程本身有沒有跑起來、有沒有回報錯誤」。
export default function SettingsScreen() {
const [healthStatus, setHealthStatus] = useState<string | null>(null);
const [healthError, setHealthError] = useState<string | null>(null);
const [pushStatus, setPushStatus] = useState<string | null>(null);
async function checkHealth() {
setHealthError(null);
try {
const result = await fetchHealth();
setHealthStatus(result.status);
} catch {
setHealthError("無法連線到 api 服務");
}
}
async function enablePushNotifications() {
setPushStatus("請求權限中…");
if (!Device.isDevice) {
setPushStatus("推播僅能在實機或支援的模擬器上取得 token(目前環境無法取得真實 token)");
return;
}
const { status } = await Notifications.requestPermissionsAsync();
if (status !== "granted") {
setPushStatus("使用者未授權推播通知");
return;
}
try {
const tokenResponse = await Notifications.getExpoPushTokenAsync();
await registerPushToken(USER_ID, tokenResponse.data, "expo");
setPushStatus(`已註冊推播 token:${tokenResponse.data}`);
} catch (err) {
setPushStatus(`推播 token 取得失敗:${err instanceof Error ? err.message : String(err)}`);
}
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>設定</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>API 連線狀態</Text>
<Pressable style={styles.button} onPress={checkHealth}>
<Text style={styles.buttonText}>檢查連線</Text>
</Pressable>
{healthStatus && <Text style={styles.line}>狀態:{healthStatus}</Text>}
{healthError && <Text style={styles.error}>{healthError}</Text>}
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>推播通知</Text>
<Pressable style={styles.button} onPress={enablePushNotifications}>
<Text style={styles.buttonText}>開啟推播通知</Text>
</Pressable>
{pushStatus && <Text style={styles.line}>{pushStatus}</Text>}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 20, gap: 24 },
title: { fontSize: 22, fontWeight: "bold", color: "#FF7E9D" },
card: { gap: 10 },
cardTitle: { fontSize: 15, fontWeight: "bold", color: "#222" },
button: { alignSelf: "flex-start", backgroundColor: "#FF7E9D", borderRadius: 999, paddingHorizontal: 16, paddingVertical: 8 },
buttonText: { color: "#fff", fontWeight: "bold" },
line: { fontSize: 13, color: "#222" },
error: { color: "#FF7E9D", fontSize: 12 },
});
+12
View File
@@ -0,0 +1,12 @@
import { Stack } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
// Q-1/Q-2 根佈局:只有一層 Stack 包住底部分頁籤群組,佈局策略跟 apps/web 的 RWD 行動版共用同一份設計原則
// (立繪置頂 40% + 對話流覆蓋其上),只是 React Native 沒有 CSS,改用 View 的 flex 排版達成同樣效果。
export default function RootLayout() {
return (
<SafeAreaProvider>
<Stack screenOptions={{ headerShown: false }} />
</SafeAreaProvider>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+87
View File
@@ -0,0 +1,87 @@
import type { EmotionTag, RelationshipStage } from "@kokorone/shared";
// Q-1:與 apps/web/lib/api.ts 共用同一套 @kokorone/shared 型別,API client 本身雖是各端各自實作
// (React Native 沒有 Next.js 的伺服器端環境變數機制),但契約(回應形狀)三端一致。
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3001";
export interface HealthResponse {
status: string;
}
export async function fetchHealth(): Promise<HealthResponse> {
const res = await fetch(`${API_BASE_URL}/health`);
if (!res.ok) {
throw new Error(`健康檢查失敗(${res.status})`);
}
return res.json();
}
export interface ChatResponseStyle {
tone: string;
sentenceLength: "short" | "medium" | "long";
initiative: "low" | "medium" | "high";
}
export interface ChatContext {
emotion: { dominant: EmotionTag; style: ChatResponseStyle };
relationship: { intimacy: number; trust: number; stage: RelationshipStage };
}
export type ChatAvailability = "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
export interface ChatResult {
text: string;
actions: string[];
isFastChannel: boolean;
availability: ChatAvailability;
exceptionType: string | null;
context: ChatContext | null;
}
export async function sendChatMessage(
characterId: string,
userId: string,
sessionId: string,
text: string,
): Promise<ChatResult> {
const res = await fetch(`${API_BASE_URL}/chat/${characterId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, sessionId, text }),
});
if (!res.ok) {
throw new Error(`對話請求失敗(${res.status})`);
}
return res.json();
}
export interface TachieExpressionResponse {
emotionTag: EmotionTag;
rawEmotionTag: EmotionTag;
eyebrow: string;
eye: string;
mouth: string;
effect: string | null;
pose: string;
magnitude: number;
gaze: "UP" | "AWAY" | "DIRECT" | "NEUTRAL";
availableCostumes: string[];
lockedFallbackApplied: boolean;
usedCryingVariant: boolean;
}
export async function fetchExpression(characterId: string, userId: string): Promise<TachieExpressionResponse> {
const res = await fetch(`${API_BASE_URL}/tachie/${characterId}/${userId}/expression`);
if (!res.ok) {
throw new Error(`表情查詢失敗(${res.status})`);
}
return res.json();
}
export async function registerPushToken(userId: string, token: string, platform: string): Promise<void> {
await fetch(`${API_BASE_URL}/push/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, token, platform }),
});
}
+12
View File
@@ -0,0 +1,12 @@
import type { EmotionTag } from "@kokorone/shared";
// 跟 apps/web/lib/emotion-labels.ts 內容相同——標籤本身很小,先各自維護一份,
// 之後若三端要共用更多 UI 邏輯,這是第一個適合搬進 packages/shared 的候選。
export const EMOTION_LABELS: Record<EmotionTag, string> = {
CALM: "平靜",
JOY: "愉悅",
SAD: "低落",
ALERT: "警戒",
SHY: "害羞",
GRUMPY: "彆扭",
};
+7
View File
@@ -0,0 +1,7 @@
// 部分 Hermes 版本沒有全域 crypto.randomUUID,這裡提供一個不依賴額外套件的簡易後備方案。
export function generateSessionId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@kokorone/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"dependencies": {
"@expo/metro-runtime": "~57.0.9",
"@kokorone/shared": "*",
"expo": "~57.0.12",
"expo-constants": "~57.0.10",
"expo-device": "~57.0.1",
"expo-linking": "~57.0.5",
"expo-notifications": "~57.0.10",
"expo-router": "~57.0.12",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-web": "^0.21.2"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"private": true
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}