跳到主要内容

页面说明

导航架构 (AppNavigation)

应用采用自定义底部导航栏,包含三个 Tab:

Tab枚举值选中图标未选中图标对应页面
动态DYNAMICIcons.Filled.NewspaperIcons.Outlined.NewspaperDynamicTab
聊天CHATIcons.Filled.ChatBubbleIcons.Outlined.ChatBubbleChatTab
设置SETTINGSIcons.Filled.SettingsIcons.Outlined.SettingsSettingsTab

底部导航栏在聊天全屏模式(chatIsFullScreen = true)时通过 AnimatedVisibility 滑动隐藏。

手势导航

  • DynamicTab 内:左右滑动切换分类筛选 (FilterChip)
  • ChatTab 会话列表:右滑切换到 DynamicTab
  • ChatView 聊天界面:右滑返回会话列表
// AppNavigation.kt 核心结构
@Composable
fun AppNavigation(themeMode: String, onThemeChange: (String) -> Unit) {
var selectedTab by remember { mutableStateOf(Tab.DYNAMIC) }
var chatIsFullScreen by remember { mutableStateOf(false) }

Scaffold(
bottomBar = {
AnimatedVisibility(visible = !chatIsFullScreen) {
// 自定义 Row 底部导航栏
}
}
) { padding ->
when (selectedTab) {
Tab.DYNAMIC -> DynamicTab()
Tab.CHAT -> ChatTab(
onChatActiveChange = { chatIsFullScreen = it },
onSwipeToPrevTab = { selectedTab = prevTab },
)
Tab.SETTINGS -> SettingsTab(themeMode, onThemeChange)
}
}
}

OnboardingScreen — 引导页

用途

首次启动时引导用户完成服务器配置和初始同步。通过 OnboardingStep 枚举管理 5 个步骤的流程。

ASCII 布局

┌─────────────────────────────┐
│ ○─1───○─2───○ │ ← 步骤指示器 (StepDot + StepLine)
├─────────────────────────────┤
│ │
│ ◈ Logo │ ← WelcomeStep
│ │
│ Welcome to Evatar │
│ 描述文字... │
│ │
│ ┌─────────────────────────┐│
│ │ 开始设置 ││ ← Button
│ └─────────────────────────┘│
└─────────────────────────────┘

五步流程

步骤枚举值内容关键交互
1WELCOMELogo + 欢迎文字 + "开始设置" 按钮点击进入下一步
2SERVER_SETUPURL 输入框 + 测试连接按钮 + 连接状态输入 URL → 测试 → 连接成功后可继续
3SYNC_TIME5 个同步范围选项卡片 (1天/3天/7天/30天/全部)点击选择 → "开始同步"
4SYNCING加载动画 + 进度条 + "已同步 X/Y" 文字自动执行同步,不可交互
5DONE绿色勾 + 完成文字 + "进入应用" 按钮点击进入主界面

同步范围选项

data class SyncTimeOption(val labelResId: Int, val days: Int, val descResId: Int)

val options = listOf(
SyncTimeOption(R.string.sync_time_1day_label, 1, ...), // "最近 1 天"
SyncTimeOption(R.string.sync_time_3day_label, 3, ...), // "最近 3 天"
SyncTimeOption(R.string.sync_time_7day_label, 7, ...), // "最近 7 天" (默认选中)
SyncTimeOption(R.string.sync_time_30day_label, 30, ...), // "最近 30 天"
SyncTimeOption(R.string.sync_time_all_label, 0, ...), // "全部历史"
)

初始同步流程

// SyncTimeStep 的 onStart 回调
scope.launch {
// 1. 注册设备
apiClient.registerDevice(syncManager.deviceId)

// 2. 计算起始时间戳
val sinceMs = if (selectedDays == 0) 0L
else System.currentTimeMillis() - selectedDays * 24 * 60 * 60 * 1000L

// 3. 在服务端设置同步状态
apiClient.setSyncSince(syncManager.deviceId, sinceMs)

// 4. 使用 sinceMsOverride 直接执行同步 (不查询服务端)
syncManager.runSync(sinceMsOverride = sinceMs) { synced, failed, total ->
syncProgress = "已同步 $synced/$total"
}
}

状态管理

使用 remember + mutableStateOf 管理局部状态(非 ViewModel,因为 OnboardingScreen 只需一次性流程):

var step by remember { mutableStateOf(OnboardingStep.WELCOME) }
var serverUrl by remember { mutableStateOf("") }
var urlError by remember { mutableStateOf<String?>(null) }
var serverConnected by remember { mutableStateOf(false) }
var selectedDays by remember { mutableIntStateOf(7) }
var syncProgress by remember { mutableStateOf("") }

ChatTab — 聊天页

用途

AI 聊天助手,支持多会话管理、消息收发、失败重试。

两种视图状态

ASCII 布局 — 会话列表

┌─────────────────────────────┐
│ 聊天 │ ← largeTitle
├─────────────────────────────┤
│ ┌─┐ 标题文字 │ ← ConversationRow
│ │E│ 最后一条消息预览... │ (E = avatar, 删除按钮在右侧)
│ └─┘ │
│ ─────────────────────────── │ ← HorizontalDivider (start=78.dp)
│ ┌─┐ 标题文字 │
│ │E│ 最后一条消息预览... │
│ └─┘ │
│ │
│ │
│ ┌─┐ │ ← FAB (新会话)
│ │+│ │
│ └─┘ │
└─────────────────────────────┘

ASCII 布局 — 聊天界面

┌─────────────────────────────┐
│ ← 聊天 新对话 │ ← TopBar (52.dp)
├─────────────────────────────┤
│ │
│ 你好!我是 Evatar AI... │ ← assistant 气泡 (左对齐)
│ │
│ 你好,帮我看看... │ ← user 气泡 (右对齐, primary 色)
│ │
│ 我查看了你的截图库... │ ← assistant 气泡 (MarkdownText)
│ │
│ ●●○ 发送中... │ ← TypingDots 动画
│ │
├─────────────────────────────┤
│ ┌───────────────────────┐ ⊙ │ ← 输入框 + 发送按钮
│ │ 输入消息... │ │
│ └───────────────────────┘ │
└─────────────────────────────┘

关键交互

交互实现
发送消息sendMessage(text) → 立即显示用户消息 → 异步等待 AI 回复
消息失败重试底部红色重试栏显示 lastFailedMessage,点击"重试"重新发送
下拉刷新PullRefreshIndicator + rememberPullRefreshState 刷新会话列表
自动滚动LaunchedEffect(messages.size)listState.animateScrollToItem()
自动刷新会话列表每 30 秒自动刷新 (delay(30_000L))
删除会话AlertDialog 确认后调用 deleteConversation()

Composable 结构

@Composable
fun ChatTab(viewModel: ChatViewModel = viewModel()) {
val state by viewModel.state.collectAsState()

if (state.activeConvId == null) {
ConversationList(
conversations = state.conversations,
onSelect = { viewModel.selectConversation(it.id) },
onNew = { viewModel.startNewConversation() },
// ...
)
} else {
ChatView(
messages = state.messages,
onSend = { viewModel.sendMessage(text) },
onBack = { viewModel.goBackToList() },
// ...
)
}
}

气泡样式

@Composable
private fun ChatBubble(msg: UiMessage) {
val isUser = msg.role == "user"
val bubbleColor = if (isUser) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant
val shape = RoundedCornerShape(
topStart = 18.dp, topEnd = 18.dp,
bottomStart = if (isUser) 18.dp else 4.dp, // AI 气泡左下角尖角
bottomEnd = if (isUser) 4.dp else 18.dp, // 用户气泡右下角尖角
)

// 用户消息: 纯文本
// AI 消息: MarkdownText 渲染
if (isUser) Text(text = msg.content)
else MarkdownText(text = msg.content)
}

打字动画

@Composable
private fun TypingDots() {
var dotCount by remember { mutableIntStateOf(1) }
LaunchedEffect(Unit) {
while (true) { delay(400); dotCount = (dotCount % 3) + 1 }
}
// 显示: "●○○" → "●●○" → "●●●" → "●○○" 循环
Text("●".repeat(dotCount) + "○".repeat(3 - dotCount))
}

DynamicTab — 动态页

用途

展示 AI 生成的动态信息流,支持分类筛选、已读标记、无限滚动加载。

ASCII 布局

┌─────────────────────────────┐
│ 动态 ● │ ← title + 连接状态圆点 (绿/红)
├─────────────────────────────┤
│ [全部] [洞察] [提醒] [报告] │ ← LazyRow FilterChip + 未读 Badge
│ │
│ ┌─────────────────────────┐ │
│ │ 💡 洞察标题 [红点]│ │ ← 未读卡片 (primary 6% 背景)
│ │ 摘要文字预览... │ │
│ │ insight 06-05 │ │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ 📝 笔记标题 │ │ ← 已读卡片 (surface 背景)
│ │ 摘要文字预览... │ │
│ │ note 06-04 │ │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ 🔔 提醒标题 [红点]│ │
│ │ 摘要文字预览... │ │
│ │ reminder 06-03 │ │
│ └─────────────────────────┘ │
│ ◯ 加载更多... │ ← 无限滚动触发器
└─────────────────────────────┘

分类筛选

private val FILTER_OPTIONS = listOf(
FilterOption("", Icons.Outlined.List, R.string.dynamic_filter_all),
FilterOption("insight", Icons.Outlined.Lightbulb, R.string.dynamic_filter_insight),
FilterOption("reminder", Icons.Outlined.Notifications, R.string.dynamic_filter_reminder),
FilterOption("report", Icons.Outlined.Assessment, R.string.dynamic_filter_report),
FilterOption("note", Icons.Outlined.Article, R.string.dynamic_filter_note),
)

每个 FilterChip 旁显示未读数量 Badge:

BadgedBox(badge = {
if (count > 0) Badge {
Text(if (count > 99) "99+" else count.toString())
}
}) {
FilterChip(selected = ..., onClick = { viewModel.setFilter(option.key) }, ...)
}

分类图标

category图标含义
insightLightbulbAI 洞察
reminderNotifications提醒
reportAssessment报告
noteArticle笔记

关键交互

交互实现
下拉刷新PullRefreshIndicatorviewModel.refresh()
无限滚动LazyColumn 底部 LaunchedEffect(Unit) { viewModel.loadMore() }
点击展开expandedId == item.id 控制展开状态,首次展开自动标记已读
滑动切换分类detectHorizontalDragGestures 左右滑动切换 FilterChip
自动刷新每 60 秒自动 refresh() + checkConnection()

展开/折叠逻辑

DynamicCard(
item = item,
expanded = expandedId == item.id,
onToggle = {
val wasExpanded = expandedId == item.id
expandedId = if (wasExpanded) -1 else item.id
if (!wasExpanded && !item.isRead) {
viewModel.markAsRead(item.id) // 首次展开自动标记已读
}
},
)

展开后显示 MarkdownText 渲染的完整内容:

if (expanded && item.content.isNotEmpty()) {
HorizontalDivider(/* ... */)
MarkdownText(text = item.content)
}

卡片样式

Card(
colors = CardDefaults.cardColors(
containerColor = if (!item.isRead)
MaterialTheme.colorScheme.primary.copy(alpha = 0.06f) // 未读: 淡蓝背景
else MaterialTheme.colorScheme.surface, // 已读: 普通背景
),
)

SettingsTab — 设置页

用途

管理服务器配置、同步控制、外观设置和系统权限。

ASCII 布局

┌─────────────────────────────┐
│ 设置 │
├─────────────────────────────┤
│ 服务器 │ ← SectionHeader
│ ┌─────────────────────────┐ │
│ │ ● 已连接 │ │ ← 连接状态圆点 + 文字
│ │ http://192.168.0.107:.. │ │ ← OutlinedTextField
│ │ [保存] │ │ ← Button
│ └─────────────────────────┘ │
│ │
│ 同步统计 │
│ ┌─────────────────────────┐ │
│ │ 12 0 12 │ │ ← MiniStat (成功/失败/总数)
│ │ 已同步 错误 总数 │ │
│ │ 同步完成: 12 张新截图 │ │ ← lastSyncMessage
│ │ [手动同步] [保活悬浮窗] │ │ ← 两个按钮
│ └─────────────────────────┘ │
│ │
│ 外观 │
│ ┌─────────────────────────┐ │
│ │ ☀ 主题 暗色 > │ │ ← SettingsRow (循环切换)
│ └─────────────────────────┘ │
│ │
│ 系统 │
│ ┌─────────────────────────┐ │
│ │ 🔋 电池优化 允许 > │ │ ← 跳转系统设置
│ │ 📱 悬浮窗权限 管理 > │ │
│ │ 🌐 语言 中文 > │ │ ← 循环: system → zh → en
│ └─────────────────────────┘ │
│ │
│ 关于 │
│ ┌─────────────────────────┐ │
│ │ 版本 0.1.0 │ │
│ │ 设备 ID Pixel_7_.. │ │
│ └─────────────────────────┘ │
└─────────────────────────────┘

关键交互

交互实现
保存服务器 URL校验 (非空 + http/https 前缀) → apiClient.setServerUrl()checkConnection()
手动同步viewModel.manualSync()syncManager.runSync() → 显示结果统计
保活悬浮窗请求 SYSTEM_ALERT_WINDOW 权限 → 启动/停止 KeepAliveService
主题切换循环: 暗色 → 亮色 → 跟随系统,存储到 SharedPreferences
语言切换循环: system → zh → en,Toast 提示需重启生效
电池优化跳转 ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS

服务器 URL 校验

fun saveUrl() {
val trimmed = urlField.trim()
when {
trimmed.isEmpty() ->
urlError = "URL 不能为空"
!trimmed.startsWith("http://") && !trimmed.startsWith("https://") ->
urlError = "URL 必须以 http:// 或 https:// 开头"
else -> {
apiClient.setServerUrl(trimmed)
_state.value = _state.value.copy(serverUrl = trimmed, saved = true)
checkConnection()
}
}
}

同步结果展示

// SettingsTab.kt
if (state.lastResult != null && state.lastResult!!.total > 0) {
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
MiniStat("已同步", state.lastResult!!.success, DarkSuccess)
MiniStat("错误", state.lastResult!!.failed, DarkError)
MiniStat("总数", state.lastResult!!.total, onSurface)
}
}

Composable 结构

@Composable
fun SettingsTab(
viewModel: SettingsViewModel = viewModel(),
themeMode: String = "dark",
onThemeChange: (String) -> Unit = {},
) {
val state by viewModel.state.collectAsState()

Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// 服务器配置区
SectionHeader("服务器")
SettingsGroup {
// 连接状态 + URL 输入 + 保存按钮
}

// 同步统计区
SectionHeader("同步统计")
SettingsGroup {
// MiniStat + 手动同步 + 保活按钮
}

// 外观设置区
SectionHeader("外观")
SettingsGroup {
SettingsRow(icon, "主题", subtitle, onClick = { cycleTheme() })
}

// 系统设置区
SectionHeader("系统")
SettingsGroup {
SettingsRow(icon, "电池优化", ...) // 跳转系统设置
SettingsRow(icon, "悬浮窗权限", ...) // 跳转系统设置
SettingsRow(icon, "语言", ...) // 循环切换
}

// 关于区
SectionHeader("关于")
SettingsGroup {
SettingsInfo("版本", versionName)
SettingsInfo("设备 ID", deviceId)
}
}
}

ShareReceiverActivity — 分享接收

用途

处理从其他应用分享到 Evatar 的图片和文本。

处理逻辑

分享类型处理方式
image/*复制 URI 到临时文件 → apiClient.uploadPhoto() → 删除临时文件
text/plainapiClient.sendMessage(text, conversationId = null)
其他Toast 提示 "不支持的类型" → finish()
// ShareReceiverActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
when (intent?.action) {
Intent.ACTION_SEND -> {
when {
type.startsWith("image/") -> handleShareImage(intent)
type == "text/plain" -> handleShareText(intent)
else -> finish()
}
}
}
}

主题系统 (Theme)

色板

应用使用自定义 Observatory 色板,与前端共享配色方案:

色彩名称暗色模式亮色模式用途
Primary#F0A500 (琥珀)#C88500主色调、选中状态
Background#141425#FAF7F2页面背景
Surface#1C1C32#FFFFFF卡片/面板
Error#E85D75#D44459错误状态
Success#00D9A6#00B88C成功状态

字体排版

EvatarTypography 定义了 11 级字体样式:

名称字重字号行高使用场景
largeTitleBold34sp41sp页面大标题
title1Bold28sp34sp一级标题
title2Bold22sp28sp二级标题
headlineSemiBold17sp22sp卡片标题、按钮文字
bodyNormal17sp22sp正文
subheadlineNormal15sp20sp副标题、描述文字
caption1Normal12sp16sp标签、时间戳
caption2Normal11sp13sp底部导航标签