apps\online\src\main\java\cn\nfarms\online\MainActivity.kt¶
源码路径:
apps\online\src\main\java\cn\nfarms\online\MainActivity.kt· 322 行
package cn.nfarms.online
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import cn.nfarms.core.ui.BrandErr
import cn.nfarms.core.ui.BrandOk
import cn.nfarms.core.ui.BrandWarn
import cn.nfarms.core.ui.CardBox
import cn.nfarms.core.ui.EmptyState
import cn.nfarms.core.ui.KV
import cn.nfarms.core.ui.Pill
import cn.nfarms.core.ui.Section
import cn.nfarms.core.ui.ago
import cn.nfarms.core.ui.bytes as fmtBytes
import cn.nfarms.core.ui.nfarmsColorScheme
import cn.nfarms.online.data.LocalTicket
import cn.nfarms.online.data.SyncState
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val container = (application as OnlineApp).container
setContent {
MaterialTheme(colorScheme = nfarmsColorScheme(isSystemInDarkTheme())) {
Surface(Modifier.fillMaxSize()) { Root(container) }
}
}
}
}
@Composable
private fun Root(container: Container) {
val vm: OnlineViewModel = viewModel(factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = OnlineViewModel(container) as T
})
val ui by vm.ui.collectAsStateWithLifecycle()
val snack = remember { SnackbarHostState() }
LaunchedEffect(ui.message) {
ui.message?.let { snack.showSnackbar(it); vm.dismissMessage() }
}
Scaffold(snackbarHost = { SnackbarHost(snack) }) { pad ->
Box(Modifier.fillMaxSize().padding(pad)) {
if (ui.editing != null) {
EditorScreen(ui, vm)
} else {
ListScreen(ui, vm)
}
}
}
ui.conflict?.let { c ->
AlertDialog(
onDismissRequest = { vm.closeConflict() },
title = { Text("冲突:${c.local.id}") },
text = {
Column(Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState())) {
Text("两边都改了这一单。基线版本:rev ${c.base?.rev ?: "-"}")
Spacer(Modifier.height(8.dp))
Text("我的", fontWeight = FontWeight.SemiBold)
Text("${c.local.title}\n${c.local.body}", fontSize = 13.sp)
Spacer(Modifier.height(8.dp))
Text("远端", fontWeight = FontWeight.SemiBold)
Text("${c.remote.title}\n${c.remote.body} (rev ${c.remote.rev})", fontSize = 13.sp)
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TextButton(onClick = { vm.resolveKeepMine() }) { Text("用我的") }
TextButton(onClick = { vm.resolveTakeTheirs() }) { Text("用远端的") }
}
},
dismissButton = { TextButton(onClick = { vm.closeConflict() }) { Text("稍后") } },
)
}
}
@Composable
private fun ListScreen(ui: OnlineUi, vm: OnlineViewModel) {
val tickets = ui.state.tickets.sortedByDescending { it.ticket.updatedAt }
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 14.dp),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
CardBox {
Section("工单", "${tickets.size} 张 · 上次同步 ${if (ui.state.lastSyncAt > 0) ago(ui.state.lastSyncAt) else "(从未)"}")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { vm.sync() }, enabled = !ui.syncing) {
Text(if (ui.syncing) "同步中…" else "同步")
}
OutlinedButton(onClick = { vm.newDraft() }) { Text("新建草稿") }
}
Spacer(Modifier.height(4.dp))
Text(
"演示后端自带两张单(T-1001 / T-1002),首次同步会拉下来。它还会在 8 秒后自己改一次 T-1001 —— 你在本地改同一单再同步,就能看到真冲突。",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (tickets.isEmpty()) {
item { EmptyState("还没有工单", "点「新建草稿」;草稿先落本地,联网后同步") }
}
items(tickets, key = { it.ticket.id }) { lt ->
CardBox {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
lt.ticket.title.ifBlank { "(未命名草稿)" },
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
Spacer(Modifier.width(6.dp))
statePill(lt)
}
Text(
"rev ${lt.ticket.rev} · ${lt.ticket.status} · ${ago(lt.ticket.updatedAt)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = { vm.edit(lt.ticket.id) }) { Text("打开") }
}
if (lt.ticket.body.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
lt.ticket.body.take(120),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (lt.ticket.attachments.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
"附件 ${lt.ticket.attachments.size} 个:" +
lt.ticket.attachments.joinToString("、") { it.name },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (lt.state == SyncState.Conflicted) {
Spacer(Modifier.height(6.dp))
OutlinedButton(onClick = { vm.openConflict(lt.ticket.id) }) {
Text("处理冲突", color = BrandErr)
}
}
}
}
if (ui.state.audit.isNotEmpty()) {
item { Section("审计", null) }
items(ui.state.audit.takeLast(8).reversed()) { a ->
Text(
"[${a.action}] ${a.detail}",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun statePill(lt: LocalTicket) {
when {
lt.draft -> Pill("草稿", BrandWarn)
lt.state == SyncState.Conflicted -> Pill("冲突", BrandErr)
lt.state == SyncState.Synced -> Pill("已同步", BrandOk)
lt.isDirty() -> Pill("待提交", BrandWarn)
else -> Pill("本地", MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun EditorScreen(ui: OnlineUi, vm: OnlineViewModel) {
val lt = ui.editing ?: return
var title by remember(lt.ticket.id) { mutableStateOf(lt.ticket.title) }
var body by remember(lt.ticket.id) { mutableStateOf(lt.ticket.body) }
var status by remember(lt.ticket.id) { mutableStateOf(lt.ticket.status) }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
CardBox {
Row(verticalAlignment = Alignment.CenterVertically) {
Section("编辑 ${lt.ticket.id}", if (lt.draft) "草稿(还没提交过)" else "rev ${lt.ticket.rev}")
Spacer(Modifier.weight(1f))
OutlinedButton(onClick = { vm.closeEditor() }) { Text("返回") }
}
OutlinedTextField(
value = title,
onValueChange = { title = it; vm.updateEditing(title = it) },
label = { Text("标题") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = body,
onValueChange = { body = it; vm.updateEditing(body = it) },
label = { Text("描述") },
modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp),
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
for (s in listOf("open", "in_progress", "done")) {
if (status == s) {
Button(onClick = { status = s; vm.updateEditing(status = s) }) { Text(s) }
} else {
OutlinedButton(onClick = { status = s; vm.updateEditing(status = s) }) { Text(s) }
}
}
}
Spacer(Modifier.height(4.dp))
Text(
"每次改动都即时落盘 —— 草稿不需要点保存,切走再回来还在。",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
CardBox {
Section("附件", "上限 10 MB;先本地算 SHA-256,上传后服务端会再算一遍比对")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { vm.addDemoAttachment() }) { Text("造一个演示附件") }
OutlinedButton(onClick = { vm.attachToEditing() }, enabled = ui.attachments.any { it.ok }) {
Text("附加到这张工单")
}
}
if (ui.attachments.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
for (a in ui.attachments) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(a.name, fontSize = 12.sp)
Text(
"${fmtBytes(a.sizeBytes)} · ${a.sha256.take(16)}…",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Pill(if (a.ok) "可附加" else a.why, if (a.ok) BrandOk else BrandErr)
}
}
}
if (lt.ticket.attachments.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
for (a in lt.ticket.attachments) {
KV(a.name, "${fmtBytes(a.sizeBytes)} · ${a.sha256.take(12)}…", mono = true)
}
}
}
CardBox {
Section("合并规则", null)
Text(
"三路合并:本地 == 基线 → 用远端;远端 == 基线 → 用本地;两边都改了且不一样 → 冲突,交给你决定。" +
"「最后写入者赢」是被刻意排除的 —— 它会静默吃掉别人的修改。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}