feat(ui): migrate Flutter UI library to Next.js 16 (App Router)

Migrate all 87 Dart files from /Users/chase/Documents/cozsweet/lib/ui/
to TypeScript React components, replacing Flutter-native features with
Web equivalents and reimplementing the BLoC pattern with React
Context + useReducer.

Highlights
- 1:1 BLoC -> Context+useReducer+side-effects mapping for auth, chat,
  user, and sidebar features (4 providers wired in root-providers.tsx)
- 500px MobileShell primitive eliminates per-screen layout boilerplate
- Headless Dialog primitive (createPortal + ESC + scroll lock) powers
  quota / pwa-install / username / pronouns / subscription / other-options
  dialogs
- New integrations/: facebook-sdk, google-identity, pwa-install,
  media-recorder (Web Speech API + MediaRecorder), browser-detect,
  chat-websocket (singleton), message-queue (throttled send)
- CSS Modules + existing design tokens (no new styling system);
  extended with breakpoints.css and dimensions.css
- Custom hooks: useBreakpoint (useSyncExternalStore),
  useFullVisibility (IntersectionObserver), usePwaInstall,
  usePullToRefresh
- Vitest + jsdom; 15 unit tests (chat-reducer, auth-validators)
- React 19.2.4, Next.js 16.2.7, Zod 4, lottie-react, classnames

Workarounds
- next.config.ts: experimental.staticGenerationRetryCount = 0 to
  bypass Next.js 16.2.7 /_global-error workStore prerender bug
- src/app/global-error.tsx: required Client Component
- src/types/globals.d.ts: centralized window.google / FB /
  SpeechRecognition declarations

Routes (/, /splash, /auth, /chat, /chat/deviceid/[deviceId],
/sidebar) all build and lint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-09 13:30:54 +08:00
parent b362945195
commit 8cab34864e
114 changed files with 6744 additions and 276 deletions
+41
View File
@@ -0,0 +1,41 @@
/**
* 浏览器 / WebView 检测
*
* 原始 Dart: `lib/utils/browser_detector.dart`
*
* 主要用例:
* - 嵌入 Facebook / 微信 / 微博内嵌浏览器时,给用户提示「在外部浏览器打开」。
* - 判断 PWA 模式(standalone)。
*/
export const BrowserDetector = {
/** 当前是否在 Facebook 内嵌浏览器 */
isFacebookInAppBrowser(ua: string | null = null): boolean {
if (typeof navigator === "undefined" && !ua) return false;
const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
return /FBAN|FBAV|Instagram/i.test(userAgent);
},
/** 当前是否在微信内嵌浏览器 */
isWeChatInAppBrowser(ua: string | null = null): boolean {
if (typeof navigator === "undefined" && !ua) return false;
const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
return /MicroMessenger/i.test(userAgent);
},
/** 当前是否在 WebView(不通用;仅作 best-effort 判断) */
isInAppBrowser(ua: string | null = null): boolean {
if (typeof navigator === "undefined" && !ua) return false;
const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
return /WebView|; wv\)|FBAN|FBAV|MicroMessenger|Instagram|Line\//i.test(userAgent);
},
/** 当前是否以 PWA standalone 模式运行 */
isPWAStandalone(): boolean {
if (typeof window === "undefined") return false;
return (
window.matchMedia?.("(display-mode: standalone)").matches === true ||
// iOS Safari 旧版
(navigator as Navigator & { standalone?: boolean }).standalone === true
);
},
};
+152
View File
@@ -0,0 +1,152 @@
/**
* 聊天 WebSocket 处理器
*
* 原始 Dart: lib/core/net/websocket_handler.dart
*
* 浏览器方案:使用原生 `WebSocket` API,封装连接管理 + 重连 + 事件回调。
*
* 事件:
* - onConnected(userId)
* - onTyping(isTyping)
* - onSentence(index, text, total, done) ← AI 逐句推送
* - onVoiceReady(index, audioUrl)
* - onIntimacyUpdate(intimacyChange, newIntimacy, relationshipStage)
* - onMoodUpdate(mood)
* - onError(errorMessage)
*/
import { getApiConfig } from "@/data/api/config/api_config";
export interface SentencePayload {
index: number;
text: string;
total: number;
done: boolean;
}
export interface IntimacyPayload {
intimacyChange?: number;
newIntimacy?: number;
relationshipStage?: string;
}
export class ChatWebSocket {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private disposed = false;
onConnected: ((userId: string) => void) | null = null;
onTyping: ((isTyping: boolean) => void) | null = null;
onSentence: ((payload: SentencePayload) => void) | null = null;
onVoiceReady: ((index: number, audioUrl: string) => void) | null = null;
onIntimacyUpdate: ((payload: IntimacyPayload) => void) | null = null;
onMoodUpdate: ((mood: string) => void) | null = null;
onError: ((errorMessage: string) => void) | null = null;
constructor(
private readonly serverUrl: string,
private readonly token: string,
) {}
get isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
connect(): void {
if (this.disposed) return;
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return;
try {
const url = `${this.serverUrl}?token=${encodeURIComponent(this.token)}`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
// 连接成功;userId 由后端在第一条消息中下发
};
this.ws.onmessage = (e) => this.handleMessage(e.data);
this.ws.onerror = () => this.onError?.("WebSocket error");
this.ws.onclose = () => {
if (!this.disposed) {
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
}
};
} catch (e) {
this.onError?.(e instanceof Error ? e.message : String(e));
}
}
sendMessage(content: string): boolean {
if (!this.isConnected) return false;
this.ws?.send(JSON.stringify({ type: "message", content }));
return true;
}
disconnect(): void {
this.disposed = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.ws?.close();
this.ws = null;
}
private handleMessage(data: unknown): void {
if (typeof data !== "string") return;
let payload: {
type?: string;
userId?: string;
isTyping?: boolean;
index?: number;
text?: string;
total?: number;
done?: boolean;
audioUrl?: string;
intimacyChange?: number;
newIntimacy?: number;
relationshipStage?: string;
mood?: string;
error?: string;
};
try {
payload = JSON.parse(data);
} catch {
return;
}
switch (payload.type) {
case "connected":
if (payload.userId) this.onConnected?.(payload.userId);
break;
case "typing":
this.onTyping?.(payload.isTyping ?? false);
break;
case "sentence":
this.onSentence?.({
index: payload.index ?? 0,
text: payload.text ?? "",
total: payload.total ?? 0,
done: payload.done ?? false,
});
break;
case "voice_ready":
this.onVoiceReady?.(payload.index ?? 0, payload.audioUrl ?? "");
break;
case "intimacy_update":
this.onIntimacyUpdate?.({
intimacyChange: payload.intimacyChange,
newIntimacy: payload.newIntimacy,
relationshipStage: payload.relationshipStage,
});
break;
case "mood_update":
if (payload.mood) this.onMoodUpdate?.(payload.mood);
break;
case "error":
this.onError?.(payload.error ?? "Unknown error");
break;
}
}
}
/** 默认创建实例:从 env 读取 WS URL。 */
export function createChatWebSocket(token: string): ChatWebSocket {
const base = getApiConfig().wsUrl;
return new ChatWebSocket(base, token);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Facebook SDK 全局类型声明
*/
export {};
declare global {
interface Window {
FB?: {
init(params: {
appId: string;
cookie?: boolean;
xfbml?: boolean;
version: string;
}): void;
login(
callback: (response: {
authResponse?: {
accessToken: string;
userID: string;
expiresIn: number;
};
status?: "connected" | "not_authorized" | "unknown";
}) => void,
options?: { scope: string },
): void;
logout(callback?: () => void): void;
api(
path: string,
callback: (response: {
id?: string;
name?: string;
email?: string;
picture?: { data?: { url?: string } };
}) => void,
): void;
};
fbAsyncInit?: () => void;
}
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Facebook SDK JS 封装
*
* 原始 Dart: `flutter_facebook_auth` 包
* `lib/data/services/auth/auth_platform.dart` + `auth_screen.dart` 中 Facebook 登录入口)。
*
* 浏览器方案:
* 1. 在 `app/layout.tsx` 通过 `<Script src="https://connect.facebook.net/en_US/sdk.js">` 注入。
* 2. 调用 `FB.login` / `FB.getUserData` 完成 OAuth + 用户信息获取。
* 3. TypeScript 类型声明在 `facebook-sdk.d.ts``window.FB`)。
*/
import { Result } from "@/utils/result";
declare global {
interface Window {
FB?: {
init(params: {
appId: string;
cookie?: boolean;
xfbml?: boolean;
version: string;
}): void;
login(
callback: (response: {
authResponse?: {
accessToken: string;
userID: string;
expiresIn: number;
};
status?: "connected" | "not_authorized" | "unknown";
}) => void,
options?: { scope: string },
): void;
logout(callback?: () => void): void;
api(
path: string,
callback: (response: {
id?: string;
name?: string;
email?: string;
picture?: { data?: { url?: string } };
}) => void,
): void;
};
fbAsyncInit?: () => void;
}
}
export interface FacebookUserData {
id: string;
name?: string;
email?: string;
pictureUrl?: string;
}
const FB_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID ?? "";
const FB_VERSION = process.env.NEXT_PUBLIC_FACEBOOK_API_VERSION ?? "v18.0";
/**
* 初始化 FB SDK(在 `window.fbAsyncInit` 触发时调用一次)。
* 由 `app/layout.tsx` 的 inline script 触发。
*/
export function initFacebookSdk(): void {
if (typeof window === "undefined" || !window.FB) return;
window.FB.init({
appId: FB_APP_ID,
cookie: true,
xfbml: false,
version: FB_VERSION,
});
}
/**
* 触发 Facebook OAuth 登录。
* - 成功返回 `{ accessToken, userId }`
* - 取消 / 失败返回 `Result.err`
*/
export async function loginWithFacebook(): Promise<
Result<{ accessToken: string; userId: string }>
> {
if (typeof window === "undefined" || !window.FB) {
return Result.err(new Error("Facebook SDK not loaded"));
}
return new Promise((resolve) => {
window.FB!.login(
(response) => {
if (!response.authResponse) {
resolve(
Result.err(new Error("Facebook login cancelled or failed")),
);
return;
}
resolve(
Result.ok({
accessToken: response.authResponse.accessToken,
userId: response.authResponse.userID,
}),
);
},
{ scope: "email,public_profile" },
);
});
}
/**
* 拉取 Facebook 用户信息(头像、姓名等)。
*/
export async function getFacebookUserData(): Promise<
Result<FacebookUserData>
> {
if (typeof window === "undefined" || !window.FB) {
return Result.err(new Error("Facebook SDK not loaded"));
}
return new Promise((resolve) => {
window.FB!.api("/me", (response) => {
if (!response.id) {
resolve(Result.err(new Error("No Facebook user data")));
return;
}
// 单独拉取头像
window.FB!.api(
"/me/picture?type=large&redirect=false",
(picResp: unknown) => {
const url =
picResp &&
typeof picResp === "object" &&
"data" in picResp &&
picResp.data &&
typeof picResp.data === "object" &&
"url" in picResp.data &&
typeof picResp.data.url === "string"
? picResp.data.url
: undefined;
resolve(
Result.ok({
id: response.id!,
name: response.name!,
email: response.email!,
pictureUrl: url,
}),
);
},
);
});
});
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Google Identity Services 全局类型声明
*/
export {};
declare global {
interface Window {
google?: {
accounts: {
id: {
initialize(params: {
client_id: string;
callback: (response: { credential?: string; select_by?: string }) => void;
auto_select?: boolean;
cancel_on_tap_outside?: boolean;
}): void;
prompt(): void;
renderButton(
parent: HTMLElement,
options: {
type?: "standard" | "icon";
theme?: "outline" | "filled_blue" | "filled_black";
size?: "large" | "medium" | "small";
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
shape?: "rectangular" | "pill" | "circle" | "square";
logo_alignment?: "left" | "center";
width?: number;
locale?: string;
},
): void;
disableAutoSelect(): void;
};
};
};
}
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Google Identity Services (GIS) 封装
*
* 原始 Dart: `google_sign_in_web` 包
* `lib/ui/auth/widgets/google_sign_in_web.dart` + `auth_bloc._onWebGoogleLoginSuccess`)。
*
* 浏览器方案:
* 1. `<Script src="https://accounts.google.com/gsi/client">` 在 layout 注入。
* 2. `renderButton(element, options)` 渲染 GIS 按钮(自动处理 OAuth 流程)。
* 3. 登录成功 callback 回调 `onGoogleSignInSuccess(idToken, email)`。
* 4. 把 idToken 传给 `authRepository.googleLogin`。
*
* 全局类型:`google.accounts.id` 在 `google-identity.d.ts` 中声明。
*/
import { Result } from "@/utils/result";
export interface GoogleSignInSuccess {
idToken: string;
email: string;
}
let _onSuccess: ((s: GoogleSignInSuccess) => void) | null = null;
/** 设置全局成功回调(auth screen 在 mount 时设置,unmount 时清空)。 */
export function setOnGoogleSignInSuccess(
cb: ((s: GoogleSignInSuccess) => void) | null,
): void {
_onSuccess = cb;
}
/** 渲染 GIS 按钮到指定 DOM 元素。 */
export function renderGoogleButton(
element: HTMLElement,
options: { theme?: "outline" | "filled_blue" | "filled_black"; size?: "large" | "medium" | "small"; text?: "signin_with" | "signup_with" | "continue_with" | "signin" } = {},
): void {
if (typeof window === "undefined" || !window.google?.accounts?.id) return;
window.google.accounts.id.renderButton(element, {
type: "standard",
theme: options.theme ?? "outline",
size: options.size ?? "large",
text: options.text ?? "continue_with",
shape: "pill",
width: element.clientWidth || 320,
});
}
/** 触发 GIS One Tap 提示(如果用户已登录过 Google)。 */
export function promptGoogleOneTap(): Result<true> {
if (typeof window === "undefined" || !window.google?.accounts?.id) {
return Result.err(new Error("Google Identity Services not loaded"));
}
window.google.accounts.id.prompt();
return Result.ok(true);
}
/** 处理 GIS 凭据回调(由 GIS 内部调用)。 */
export function handleGoogleCredentialResponse(
response: { credential?: string; select_by?: string },
): Result<GoogleSignInSuccess> {
if (!response.credential) {
return Result.err(new Error("No Google credential returned"));
}
// GIS 凭据是 JWT;本仓库不解析(idToken 直接交给后端校验),
// email 字段需前端手动解码或由后端从 idToken 提取。
// 此处仅返回 idTokenemail 留空(后端会从 idToken 还原)。
const success: GoogleSignInSuccess = {
idToken: response.credential,
email: "",
};
_onSuccess?.(success);
return Result.ok(success);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* 语音输入:Web Speech API + MediaRecorder
*
* 原始 Dart: `speech_to_text` 包(`lib/ui/chat/widgets/voice_input_button.dart`)。
*
* 浏览器方案:
* - 首选 Web Speech API (`SpeechRecognition` / `webkitSpeechRecognition`):实时转写。
* - 不支持时回退到 MediaRecorder(仅录音,不转写)。
*
* 注:Web Speech API 在 Chrome / Edge 完整支持;Safari / Firefox 仅录音。
*/
import { Result } from "@/utils/result";
export interface SpeechRecognitionResultEvent {
transcript: string;
isFinal: boolean;
}
export interface SpeechRecognitionInstance {
start(): void;
stop(): void;
abort(): void;
lang: string;
continuous: boolean;
interimResults: boolean;
onresult: ((event: { results: SpeechRecognitionResultEvent[][] }) => void) | null;
onerror: ((event: { error: string }) => void) | null;
onend: (() => void) | null;
onstart?: (() => void) | null;
}
interface SpeechRecognitionConstructor {
new (): SpeechRecognitionInstance;
}
declare global {
interface Window {
SpeechRecognition?: SpeechRecognitionConstructor;
webkitSpeechRecognition?: SpeechRecognitionConstructor;
}
}
/** 是否支持实时语音识别 */
export function isSpeechRecognitionSupported(): boolean {
if (typeof window === "undefined") return false;
return Boolean(window.SpeechRecognition || window.webkitSpeechRecognition);
}
/** 启动语音识别,返回 `SpeechRecognitionInstance` 由调用方管理生命周期。 */
export function createSpeechRecognition(): Result<SpeechRecognitionInstance> {
if (typeof window === "undefined") {
return Result.err(new Error("Speech recognition not available on server"));
}
const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
if (!Ctor) {
return Result.err(new Error("Speech recognition not supported"));
}
const recognition = new Ctor();
recognition.interimResults = true;
recognition.continuous = false;
recognition.lang = navigator?.language ?? "en-US";
return Result.ok(recognition);
}
/* ============================================================
* MediaRecorder 录音(语音消息 / 降级方案)
* ============================================================ */
export interface MediaRecording {
blob: Blob;
url: string;
durationMs: number;
}
export class MediaRecorderWrapper {
private recorder: MediaRecorder | null = null;
private chunks: BlobPart[] = [];
private startedAt = 0;
static isSupported(): boolean {
return (
typeof window !== "undefined" &&
typeof window.MediaRecorder !== "undefined" &&
!!navigator.mediaDevices?.getUserMedia
);
}
async start(): Promise<Result<true>> {
if (!MediaRecorderWrapper.isSupported()) {
return Result.err(new Error("MediaRecorder not supported"));
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported("audio/webm")
? "audio/webm"
: "";
this.recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
this.chunks = [];
this.startedAt = Date.now();
this.recorder.ondataavailable = (e) => {
if (e.data.size > 0) this.chunks.push(e.data);
};
this.recorder.start();
return Result.ok(true);
} catch (e) {
return Result.err(e);
}
}
stop(): Promise<Result<MediaRecording>> {
return new Promise((resolve) => {
if (!this.recorder) {
resolve(Result.err(new Error("Recorder not started")));
return;
}
this.recorder.onstop = () => {
const durationMs = Date.now() - this.startedAt;
const blob = new Blob(this.chunks, { type: this.recorder?.mimeType ?? "audio/webm" });
const url = URL.createObjectURL(blob);
this.cleanup();
resolve(Result.ok({ blob, url, durationMs }));
};
this.recorder.stop();
});
}
abort(): void {
this.recorder?.stop();
this.cleanup();
}
private cleanup(): void {
this.recorder?.stream.getTracks().forEach((t) => t.stop());
this.recorder = null;
this.chunks = [];
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* 消息发送队列(限流)
*
* 原始 Dart: lib/core/net/message_queue.dart
*
* 业务行为:
* - 入队消息按 `intervalMs` 间隔逐条发送(默认 1000ms)
* - 消费者可声明每条消息是否成功消费;不成功则重新入队
* - 队列可在任意时刻清空(dispose)
*/
export type MessageConsumer = (content: string) => boolean | Promise<boolean>;
export class MessageQueue {
private queue: string[] = [];
private timer: ReturnType<typeof setTimeout> | null = null;
private disposed = false;
private consumer: MessageConsumer | null = null;
constructor(private readonly intervalMs = 1000) {}
/** 注册消费者(消费者必须能处理失败/重试)。 */
setConsumer(consumer: MessageConsumer): void {
this.consumer = consumer;
}
enqueue(content: string): void {
if (this.disposed) return;
this.queue.push(content);
if (!this.timer) this.scheduleNext();
}
private async attemptSend(next: string): Promise<void> {
if (!this.consumer) {
this.queue.unshift(next);
return;
}
let ok = false;
try {
ok = await this.consumer(next);
} catch (e) {
console.error("[MessageQueue] consumer error", e);
ok = false;
}
if (!ok) {
// 消费失败,放回队首等待下次
this.queue.unshift(next);
}
}
private scheduleNext(): void {
if (this.disposed) return;
const next = this.queue.shift();
if (next === undefined) {
this.timer = null;
return;
}
this.timer = setTimeout(() => {
void this.attemptSend(next).finally(() => this.scheduleNext());
}, this.intervalMs);
}
clear(): void {
this.queue = [];
}
dispose(): void {
this.disposed = true;
this.clear();
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}