2026-04-24 11:03:30 +08:00
|
|
|
|
import React, {startTransition, useEffect, useRef, useState} from 'react'
|
2026-04-24 10:58:27 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Alert,
|
|
|
|
|
|
Pressable,
|
|
|
|
|
|
ScrollView,
|
|
|
|
|
|
StatusBar,
|
|
|
|
|
|
StyleSheet,
|
|
|
|
|
|
Text,
|
|
|
|
|
|
useColorScheme,
|
|
|
|
|
|
View,
|
2026-04-24 11:03:30 +08:00
|
|
|
|
} from 'react-native'
|
|
|
|
|
|
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'
|
2026-04-24 11:14:26 +08:00
|
|
|
|
import {ImSDK, type ImEventListener, type ImMessage, type MsgType, XuqmSDK} from '@xuqm/rn-sdk'
|
2026-04-24 11:03:30 +08:00
|
|
|
|
import {MessageBubble} from './src/components/MessageBubble'
|
|
|
|
|
|
import {DEMO_CONTENT, MessageComposer} from './src/components/MessageComposer'
|
2026-04-24 11:14:26 +08:00
|
|
|
|
import {GroupChatPanel} from './src/components/GroupChatPanel'
|
|
|
|
|
|
import {UpdatePanel} from './src/components/UpdatePanel'
|
2026-04-24 10:58:27 +08:00
|
|
|
|
|
|
|
|
|
|
const APP_ID = 'ak_demo_chat'
|
|
|
|
|
|
const MODULE_ID = 'chat-home'
|
|
|
|
|
|
const CURRENT_APP_VERSION_CODE = 1
|
|
|
|
|
|
const CURRENT_RN_VERSION = '1.0.0'
|
|
|
|
|
|
const API_BASE_URL = 'https://sentry.xuqinmin.com'
|
|
|
|
|
|
const IM_WS_URL = 'wss://sentry.xuqinmin.com/ws/im'
|
|
|
|
|
|
|
2026-04-24 11:03:30 +08:00
|
|
|
|
type DemoUser = {id: string; nickname: string}
|
2026-04-24 10:58:27 +08:00
|
|
|
|
|
|
|
|
|
|
const DEMO_USERS: DemoUser[] = [
|
|
|
|
|
|
{id: 'demo_alice', nickname: 'Alice'},
|
|
|
|
|
|
{id: 'demo_bob', nickname: 'Bob'},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-24 11:03:30 +08:00
|
|
|
|
const ALL_DEMO_TYPES: MsgType[] = [
|
|
|
|
|
|
'TEXT', 'IMAGE', 'VIDEO', 'AUDIO', 'FILE', 'CUSTOM',
|
|
|
|
|
|
'LOCATION', 'NOTIFY', 'RICH_TEXT', 'CALL_AUDIO', 'CALL_VIDEO', 'FORWARD',
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-24 10:58:27 +08:00
|
|
|
|
function App() {
|
|
|
|
|
|
const isDarkMode = useColorScheme() === 'dark'
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SafeAreaProvider>
|
|
|
|
|
|
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
|
|
|
|
|
<SafeAreaView style={styles.safeArea}>
|
|
|
|
|
|
<DemoConsole />
|
|
|
|
|
|
</SafeAreaView>
|
|
|
|
|
|
</SafeAreaProvider>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function DemoConsole() {
|
|
|
|
|
|
const [currentUser, setCurrentUser] = useState<DemoUser>(DEMO_USERS[0])
|
|
|
|
|
|
const [connected, setConnected] = useState(false)
|
2026-04-24 11:14:26 +08:00
|
|
|
|
const [singleMessages, setSingleMessages] = useState<ImMessage[]>([])
|
|
|
|
|
|
const [groupMessages, setGroupMessages] = useState<ImMessage[]>([])
|
2026-04-24 11:03:30 +08:00
|
|
|
|
const [sending, setSending] = useState(false)
|
2026-04-24 10:58:27 +08:00
|
|
|
|
const [activityLog, setActivityLog] = useState<string[]>([])
|
|
|
|
|
|
const activeUserRef = useRef(currentUser)
|
2026-04-24 11:03:30 +08:00
|
|
|
|
const peerUser = DEMO_USERS.find(u => u.id !== currentUser.id) ?? DEMO_USERS[1]
|
2026-04-24 10:58:27 +08:00
|
|
|
|
|
2026-04-24 11:03:30 +08:00
|
|
|
|
const appendLog = (msg: string) => {
|
2026-04-24 10:58:27 +08:00
|
|
|
|
startTransition(() => {
|
2026-04-24 11:14:26 +08:00
|
|
|
|
setActivityLog(prev =>
|
|
|
|
|
|
[`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, 30),
|
|
|
|
|
|
)
|
2026-04-24 10:58:27 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 11:14:26 +08:00
|
|
|
|
const mergeSingle = (message: ImMessage) => {
|
2026-04-24 10:58:27 +08:00
|
|
|
|
startTransition(() => {
|
2026-04-24 11:14:26 +08:00
|
|
|
|
setSingleMessages(prev => {
|
|
|
|
|
|
const exists = prev.some(m => m.id === message.id)
|
|
|
|
|
|
if (exists) return prev.map(m => m.id === message.id ? message : m)
|
2026-04-24 10:58:27 +08:00
|
|
|
|
return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 11:14:26 +08:00
|
|
|
|
const mergeGroup = (message: ImMessage) => {
|
|
|
|
|
|
startTransition(() => {
|
|
|
|
|
|
setGroupMessages(prev => {
|
|
|
|
|
|
const exists = prev.some(m => m.id === message.id)
|
|
|
|
|
|
if (exists) return prev.map(m => m.id === message.id ? message : m)
|
|
|
|
|
|
return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-04-24 10:58:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
XuqmSDK.init({
|
|
|
|
|
|
appId: APP_ID,
|
|
|
|
|
|
appKey: APP_ID,
|
|
|
|
|
|
appSecret: 'demo-secret-not-used-by-current-services',
|
|
|
|
|
|
apiBaseUrl: API_BASE_URL,
|
|
|
|
|
|
imWsUrl: IM_WS_URL,
|
|
|
|
|
|
debug: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
activeUserRef.current = currentUser
|
2026-04-24 11:03:30 +08:00
|
|
|
|
}, [currentUser])
|
2026-04-24 10:58:27 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const listener: ImEventListener = {
|
|
|
|
|
|
onConnected() {
|
|
|
|
|
|
setConnected(true)
|
|
|
|
|
|
appendLog(`WebSocket 已连接:${activeUserRef.current.nickname}`)
|
|
|
|
|
|
},
|
|
|
|
|
|
onDisconnected(reason) {
|
|
|
|
|
|
setConnected(false)
|
|
|
|
|
|
appendLog(`WebSocket 断开:${reason || 'unknown'}`)
|
|
|
|
|
|
},
|
2026-04-24 11:14:26 +08:00
|
|
|
|
onMessage(msg) {
|
|
|
|
|
|
if (msg.chatType === 'SINGLE') mergeSingle(msg)
|
|
|
|
|
|
else mergeGroup(msg)
|
|
|
|
|
|
appendLog(`收到 [${msg.chatType}/${msg.msgType}] from ${msg.fromUserId}`)
|
2026-04-24 10:58:27 +08:00
|
|
|
|
},
|
2026-04-24 11:14:26 +08:00
|
|
|
|
onGroupMessage(msg) {
|
|
|
|
|
|
mergeGroup(msg)
|
|
|
|
|
|
appendLog(`收到群消息 [${msg.msgType}]`)
|
2026-04-24 10:58:27 +08:00
|
|
|
|
},
|
|
|
|
|
|
onError(error) {
|
|
|
|
|
|
appendLog(`IM 错误:${error}`)
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
ImSDK.addListener(listener)
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
ImSDK.removeListener(listener)
|
|
|
|
|
|
ImSDK.disconnect()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-24 11:03:30 +08:00
|
|
|
|
const run = async () => {
|
2026-04-24 10:58:27 +08:00
|
|
|
|
try {
|
|
|
|
|
|
ImSDK.disconnect()
|
|
|
|
|
|
setConnected(false)
|
2026-04-24 11:14:26 +08:00
|
|
|
|
setSingleMessages([])
|
2026-04-24 10:58:27 +08:00
|
|
|
|
await ImSDK.login(currentUser.id, currentUser.nickname)
|
|
|
|
|
|
appendLog(`已登录 IM:${currentUser.nickname}`)
|
|
|
|
|
|
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
2026-04-24 11:14:26 +08:00
|
|
|
|
history.slice().reverse().forEach(m => mergeSingle(m))
|
2026-04-24 10:58:27 +08:00
|
|
|
|
setConnected(ImSDK.isConnected())
|
2026-04-24 11:14:26 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
const msg = e instanceof Error ? e.message : 'IM 登录失败'
|
2026-04-24 11:03:30 +08:00
|
|
|
|
appendLog(`连接失败:${msg}`)
|
|
|
|
|
|
Alert.alert('IM 登录失败', msg)
|
2026-04-24 10:58:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
run()
|
|
|
|
|
|
}, [currentUser, peerUser.id])
|
|
|
|
|
|
|
2026-04-24 11:14:26 +08:00
|
|
|
|
const handleSendSingle = async (msgType: MsgType, content: string) => {
|
2026-04-24 11:03:30 +08:00
|
|
|
|
setSending(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', msgType, content)
|
2026-04-24 11:14:26 +08:00
|
|
|
|
mergeSingle(sent)
|
2026-04-24 11:03:30 +08:00
|
|
|
|
appendLog(`已发送 [${msgType}] 给 ${peerUser.nickname}`)
|
2026-04-24 11:14:26 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
const msg = e instanceof Error ? e.message : '发送失败'
|
2026-04-24 11:03:30 +08:00
|
|
|
|
appendLog(`发送失败 [${msgType}]:${msg}`)
|
|
|
|
|
|
Alert.alert('发送失败', msg)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSending(false)
|
|
|
|
|
|
}
|
2026-04-24 10:58:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 11:14:26 +08:00
|
|
|
|
const handleSendAllSingle = async () => {
|
2026-04-24 11:03:30 +08:00
|
|
|
|
setSending(true)
|
2026-04-24 11:14:26 +08:00
|
|
|
|
appendLog(`开始发送全部 ${ALL_DEMO_TYPES.length} 种消息类型(单聊)…`)
|
2026-04-24 11:03:30 +08:00
|
|
|
|
for (const type of ALL_DEMO_TYPES) {
|
2026-04-24 11:14:26 +08:00
|
|
|
|
const content = DEMO_CONTENT[type]
|
|
|
|
|
|
if (!content) continue
|
2026-04-24 11:03:30 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', type, content)
|
2026-04-24 11:14:26 +08:00
|
|
|
|
mergeSingle(sent)
|
2026-04-24 11:03:30 +08:00
|
|
|
|
appendLog(`✓ [${type}]`)
|
|
|
|
|
|
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
2026-04-24 11:14:26 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
appendLog(`✗ [${type}]:${e instanceof Error ? e.message : 'error'}`)
|
2026-04-24 11:03:30 +08:00
|
|
|
|
}
|
2026-04-24 10:58:27 +08:00
|
|
|
|
}
|
2026-04-24 11:14:26 +08:00
|
|
|
|
appendLog('单聊全类型演示完成')
|
2026-04-24 11:03:30 +08:00
|
|
|
|
setSending(false)
|
|
|
|
|
|
}
|
2026-04-24 10:58:27 +08:00
|
|
|
|
|
2026-04-24 11:14:26 +08:00
|
|
|
|
const handleRevokeSingle = async (messageId: string) => {
|
2026-04-24 10:58:27 +08:00
|
|
|
|
try {
|
2026-04-24 11:03:30 +08:00
|
|
|
|
const revoked = await ImSDK.revokeMessage(messageId)
|
2026-04-24 11:14:26 +08:00
|
|
|
|
mergeSingle(revoked)
|
2026-04-24 11:03:30 +08:00
|
|
|
|
appendLog(`消息已撤回:${messageId}`)
|
2026-04-24 11:14:26 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
const msg = e instanceof Error ? e.message : '撤回失败'
|
2026-04-24 11:03:30 +08:00
|
|
|
|
Alert.alert('撤回失败', msg)
|
2026-04-24 10:58:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 11:14:26 +08:00
|
|
|
|
const handleReloadSingle = async () => {
|
2026-04-24 10:58:27 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
2026-04-24 11:14:26 +08:00
|
|
|
|
setSingleMessages(history.slice().reverse())
|
2026-04-24 10:58:27 +08:00
|
|
|
|
appendLog(`历史消息已刷新,共 ${history.length} 条`)
|
2026-04-24 11:14:26 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
appendLog(`历史拉取失败:${e instanceof Error ? e.message : 'error'}`)
|
2026-04-24 10:58:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-24 11:03:30 +08:00
|
|
|
|
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
|
|
|
|
|
<Text style={styles.eyebrow}>XuqmGroup RN SDK Demo</Text>
|
2026-04-24 11:14:26 +08:00
|
|
|
|
<Text style={styles.title}>IM · 全消息类型 · 群聊 · App 更新 · 热更新</Text>
|
2026-04-24 11:03:30 +08:00
|
|
|
|
<Text style={styles.subtitle}>
|
2026-04-24 11:14:26 +08:00
|
|
|
|
连接 {API_BASE_URL},演示单聊/群聊全消息类型、整包更新和 RN 插件热更新完整流程。
|
2026-04-24 10:58:27 +08:00
|
|
|
|
</Text>
|
|
|
|
|
|
|
2026-04-24 11:03:30 +08:00
|
|
|
|
{/* Section 1: 当前会话 */}
|
2026-04-24 10:58:27 +08:00
|
|
|
|
<SectionCard title="1. 当前会话">
|
2026-04-24 11:03:30 +08:00
|
|
|
|
<View style={styles.userRow}>
|
2026-04-24 10:58:27 +08:00
|
|
|
|
{DEMO_USERS.map(user => {
|
|
|
|
|
|
const active = user.id === currentUser.id
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Pressable
|
|
|
|
|
|
key={user.id}
|
2026-04-24 11:03:30 +08:00
|
|
|
|
onPress={() => setCurrentUser(user)}
|
2026-04-24 10:58:27 +08:00
|
|
|
|
style={[styles.userChip, active && styles.userChipActive]}>
|
|
|
|
|
|
<Text style={[styles.userChipText, active && styles.userChipTextActive]}>
|
|
|
|
|
|
{user.nickname}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</Pressable>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</View>
|
2026-04-24 11:14:26 +08:00
|
|
|
|
<Text style={styles.meta}>当前用户:{currentUser.nickname}({currentUser.id})</Text>
|
|
|
|
|
|
<Text style={styles.meta}>单聊对象:{peerUser.nickname}({peerUser.id})</Text>
|
2026-04-24 11:03:30 +08:00
|
|
|
|
<View style={styles.statusRow}>
|
|
|
|
|
|
<View style={[styles.dot, connected ? styles.dotGreen : styles.dotGray]} />
|
2026-04-24 11:14:26 +08:00
|
|
|
|
<Text style={styles.meta}>{connected ? 'IM 已连接' : '连接中 / 未连接'}</Text>
|
2026-04-24 11:03:30 +08:00
|
|
|
|
</View>
|
|
|
|
|
|
<View style={styles.row}>
|
2026-04-24 11:14:26 +08:00
|
|
|
|
<PrimaryBtn title="刷新历史" onPress={handleReloadSingle} />
|
|
|
|
|
|
<GhostBtn title="重新登录" onPress={() => {
|
|
|
|
|
|
ImSDK.disconnect()
|
|
|
|
|
|
setConnected(false)
|
|
|
|
|
|
setSingleMessages([])
|
|
|
|
|
|
ImSDK.login(currentUser.id, currentUser.nickname)
|
|
|
|
|
|
.then(() => ImSDK.fetchHistory(peerUser.id, 0, 50))
|
|
|
|
|
|
.then(h => { h.slice().reverse().forEach(m => mergeSingle(m)); setConnected(ImSDK.isConnected()) })
|
|
|
|
|
|
.catch(e => appendLog(`重登录失败:${e instanceof Error ? e.message : 'error'}`))
|
|
|
|
|
|
}} />
|
2026-04-24 10:58:27 +08:00
|
|
|
|
</View>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
2026-04-24 11:14:26 +08:00
|
|
|
|
{/* Section 2: 单聊 */}
|
|
|
|
|
|
<SectionCard title="2. 单聊演示(全 12 种消息类型)">
|
|
|
|
|
|
<Text style={styles.hint}>长按自己的消息可撤回。"全部类型演示"依次发送所有 12 种消息类型。</Text>
|
2026-04-24 11:03:30 +08:00
|
|
|
|
<ScrollView
|
|
|
|
|
|
style={styles.chatPanel}
|
|
|
|
|
|
contentContainerStyle={styles.chatContent}
|
|
|
|
|
|
showsVerticalScrollIndicator={false}>
|
2026-04-24 11:14:26 +08:00
|
|
|
|
{singleMessages.length === 0 ? (
|
|
|
|
|
|
<Text style={styles.empty}>还没有消息,发一条试试看。</Text>
|
2026-04-24 10:58:27 +08:00
|
|
|
|
) : (
|
2026-04-24 11:14:26 +08:00
|
|
|
|
singleMessages.map(m => (
|
2026-04-24 11:03:30 +08:00
|
|
|
|
<MessageBubble
|
2026-04-24 11:14:26 +08:00
|
|
|
|
key={m.id}
|
|
|
|
|
|
message={m}
|
|
|
|
|
|
isOwn={m.fromUserId === currentUser.id}
|
2026-04-24 11:03:30 +08:00
|
|
|
|
peerNickname={peerUser.nickname}
|
2026-04-24 11:14:26 +08:00
|
|
|
|
onRevoke={handleRevokeSingle}
|
2026-04-24 11:03:30 +08:00
|
|
|
|
/>
|
|
|
|
|
|
))
|
2026-04-24 10:58:27 +08:00
|
|
|
|
)}
|
2026-04-24 11:03:30 +08:00
|
|
|
|
</ScrollView>
|
|
|
|
|
|
<MessageComposer
|
2026-04-24 11:14:26 +08:00
|
|
|
|
onSend={handleSendSingle}
|
|
|
|
|
|
onSendAll={handleSendAllSingle}
|
2026-04-24 11:03:30 +08:00
|
|
|
|
disabled={sending || !connected}
|
2026-04-24 10:58:27 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
2026-04-24 11:14:26 +08:00
|
|
|
|
{/* Section 3: 群聊 */}
|
|
|
|
|
|
<SectionCard title="3. 群聊演示">
|
|
|
|
|
|
<Text style={styles.hint}>
|
|
|
|
|
|
点击"创建演示群"创建包含 Alice+Bob 的群组,"加载群列表"列出已有群,然后即可发送群消息。
|
2026-04-24 10:58:27 +08:00
|
|
|
|
</Text>
|
2026-04-24 11:14:26 +08:00
|
|
|
|
<GroupChatPanel
|
|
|
|
|
|
currentUserId={currentUser.id}
|
|
|
|
|
|
peerUserId={peerUser.id}
|
|
|
|
|
|
peerNickname={peerUser.nickname}
|
|
|
|
|
|
appId={APP_ID}
|
|
|
|
|
|
onLog={appendLog}
|
|
|
|
|
|
groupMessages={groupMessages}
|
|
|
|
|
|
onMergeMessage={mergeGroup}
|
|
|
|
|
|
/>
|
2026-04-24 10:58:27 +08:00
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
2026-04-24 11:14:26 +08:00
|
|
|
|
{/* Section 4: 更新演示 */}
|
|
|
|
|
|
<SectionCard title="4. 更新演示">
|
|
|
|
|
|
<UpdatePanel
|
|
|
|
|
|
appVersionCode={CURRENT_APP_VERSION_CODE}
|
|
|
|
|
|
rnModuleId={MODULE_ID}
|
|
|
|
|
|
rnCurrentVersion={CURRENT_RN_VERSION}
|
|
|
|
|
|
onLog={appendLog}
|
|
|
|
|
|
/>
|
2026-04-24 10:58:27 +08:00
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
2026-04-24 11:03:30 +08:00
|
|
|
|
{/* Section 5: 活动日志 */}
|
2026-04-24 10:58:27 +08:00
|
|
|
|
<SectionCard title="5. 活动日志">
|
|
|
|
|
|
{activityLog.length === 0 ? (
|
2026-04-24 11:14:26 +08:00
|
|
|
|
<Text style={styles.empty}>暂无日志</Text>
|
2026-04-24 10:58:27 +08:00
|
|
|
|
) : (
|
2026-04-24 11:03:30 +08:00
|
|
|
|
activityLog.map((item, i) => (
|
|
|
|
|
|
<Text key={i} style={styles.logLine}>{item}</Text>
|
2026-04-24 10:58:27 +08:00
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
</ScrollView>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SectionCard({title, children}: {title: string; children: React.ReactNode}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View style={styles.card}>
|
|
|
|
|
|
<Text style={styles.cardTitle}>{title}</Text>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 11:14:26 +08:00
|
|
|
|
function PrimaryBtn({title, onPress, disabled}: {title: string; onPress: () => void; disabled?: boolean}) {
|
2026-04-24 10:58:27 +08:00
|
|
|
|
return (
|
2026-04-24 11:03:30 +08:00
|
|
|
|
<Pressable onPress={onPress} disabled={disabled} style={[styles.primaryBtn, disabled && styles.btnDisabled]}>
|
|
|
|
|
|
<Text style={styles.primaryBtnText}>{title}</Text>
|
2026-04-24 10:58:27 +08:00
|
|
|
|
</Pressable>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 11:14:26 +08:00
|
|
|
|
function GhostBtn({title, onPress}: {title: string; onPress: () => void}) {
|
2026-04-24 10:58:27 +08:00
|
|
|
|
return (
|
2026-04-24 11:03:30 +08:00
|
|
|
|
<Pressable onPress={onPress} style={styles.ghostBtn}>
|
|
|
|
|
|
<Text style={styles.ghostBtnText}>{title}</Text>
|
2026-04-24 10:58:27 +08:00
|
|
|
|
</Pressable>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({
|
2026-04-24 11:03:30 +08:00
|
|
|
|
safeArea: {flex: 1, backgroundColor: '#f5efe3'},
|
|
|
|
|
|
scroll: {flex: 1, backgroundColor: '#f5efe3'},
|
2026-04-24 11:14:26 +08:00
|
|
|
|
content: {padding: 18, gap: 14, paddingBottom: 40},
|
2026-04-24 11:03:30 +08:00
|
|
|
|
eyebrow: {fontSize: 13, fontWeight: '700', color: '#915f34', textTransform: 'uppercase', letterSpacing: 1.1},
|
2026-04-24 11:14:26 +08:00
|
|
|
|
title: {fontSize: 26, lineHeight: 32, fontWeight: '800', color: '#1f2933'},
|
|
|
|
|
|
subtitle: {fontSize: 13, lineHeight: 20, color: '#4b5563'},
|
2026-04-24 11:03:30 +08:00
|
|
|
|
card: {borderRadius: 24, padding: 16, backgroundColor: '#fffaf2', borderWidth: 1, borderColor: '#ead7b7', gap: 12},
|
2026-04-24 11:14:26 +08:00
|
|
|
|
cardTitle: {fontSize: 17, fontWeight: '800', color: '#1f2933'},
|
2026-04-24 11:03:30 +08:00
|
|
|
|
hint: {fontSize: 13, lineHeight: 19, color: '#6b7280'},
|
|
|
|
|
|
userRow: {flexDirection: 'row', gap: 10},
|
|
|
|
|
|
userChip: {paddingHorizontal: 16, paddingVertical: 10, borderRadius: 999, backgroundColor: '#efe2c5'},
|
|
|
|
|
|
userChipActive: {backgroundColor: '#1f2933'},
|
|
|
|
|
|
userChipText: {color: '#4b5563', fontWeight: '700'},
|
|
|
|
|
|
userChipTextActive: {color: '#fffdf8'},
|
|
|
|
|
|
statusRow: {flexDirection: 'row', alignItems: 'center', gap: 6},
|
|
|
|
|
|
dot: {width: 8, height: 8, borderRadius: 4},
|
|
|
|
|
|
dotGreen: {backgroundColor: '#22c55e'},
|
|
|
|
|
|
dotGray: {backgroundColor: '#9ca3af'},
|
2026-04-24 11:14:26 +08:00
|
|
|
|
meta: {fontSize: 13, color: '#4b5563'},
|
2026-04-24 11:03:30 +08:00
|
|
|
|
row: {flexDirection: 'row', gap: 10},
|
2026-04-24 11:14:26 +08:00
|
|
|
|
chatPanel: {maxHeight: 340, borderRadius: 18, backgroundColor: '#f4ecdf'},
|
2026-04-24 11:03:30 +08:00
|
|
|
|
chatContent: {padding: 12, gap: 6},
|
2026-04-24 11:14:26 +08:00
|
|
|
|
empty: {fontSize: 14, color: '#7c7f85', textAlign: 'center', paddingVertical: 20},
|
|
|
|
|
|
primaryBtn: {minHeight: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: '#1f2933', paddingHorizontal: 16},
|
|
|
|
|
|
primaryBtnText: {color: '#fffdf8', fontSize: 14, fontWeight: '800'},
|
|
|
|
|
|
ghostBtn: {minHeight: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: '#efe2c5', paddingHorizontal: 16},
|
|
|
|
|
|
ghostBtnText: {color: '#4b5563', fontSize: 14, fontWeight: '700'},
|
2026-04-24 11:03:30 +08:00
|
|
|
|
btnDisabled: {opacity: 0.45},
|
2026-04-24 11:14:26 +08:00
|
|
|
|
logLine: {fontSize: 11, lineHeight: 17, color: '#374151', fontFamily: 'monospace'},
|
2026-04-24 10:58:27 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
export default App
|