feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档

- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件
- alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT
- tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行)
- web: React18+Vite 骨架(登录、工作区、邀请落地页)
- android: Compose 骨架(登录、工作区列表)
- 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
This commit is contained in:
Steven
2026-10-28 02:38:53 +08:00
commit 0c53aa7062
72 changed files with 5320 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# 安卓端(M0 骨架)
Kotlin + Jetpack Compose。当前只做了 M0 的骨架:登录、工作区列表、新建工作区、生成邀请码。
任务/工单/知识(M1 起)在 `ui/` 下按同样结构扩展。
## 打开与运行
1. Android Studio 打开 `android/` 目录(它会把缺失的 Gradle Wrapper 补齐)。
2. 在 `android/local.properties` 里指定后端地址(模拟器访问宿主机用 `10.0.2.2`):
```
dadao.apiBase=http://10.0.2.2:8100
```
3. 运行 `app` 配置。后端需先在本机 8100 端口起来。
> 仓库里**没有提交 `gradle/wrapper/gradle-wrapper.jar`**(二进制,且 M0 阶段由 Android Studio 生成更稳)。
> 等 M5 正式做安卓时把 wrapper 一起提交,保证构建可复现。
## 结构
```
app/src/main/java/com/dadao/task/
├── MainActivity.kt 入口 + 极简三态路由(Splash/Login/Home)
├── data/
│ ├── Api.kt OkHttp + kotlinx.serialization;信封解包、业务码抛 ApiException
│ └── TokenStore.kt 令牌本地存储(M5 换 EncryptedSharedPreferences)
└── ui/
├── Theme.kt 与 Web 一致的墨青 + 陶土色板
├── LoginScreen.kt 手机号 + 验证码(开发环境后端回显验证码)
└── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
```
## 已知待办
- 明文令牌存储(`TokenStore` 里已标 TODO),M5 换 Keystore 加密。
- 无离线缓存,弱网直接报错——与产品 D7「在线优先」一致。
- 邀请链接 `https://dadaotask.com/join/<wsId>/<code>` 已在 Manifest 注册,但落地处理尚未实现。
+60
View File
@@ -0,0 +1,60 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
/**
* 后端地址不走硬编码:优先读 local.properties 的 dadao.apiBase,
* 缺省指向模拟器访问宿主机的 10.0.2.2:8100。
*/
val apiBase: String = run {
val props = Properties()
val file = rootProject.file("local.properties")
if (file.exists()) file.inputStream().use { props.load(it) }
props.getProperty("dadao.apiBase") ?: "http://10.0.2.2:8100"
}
android {
namespace = "com.dadao.task"
compileSdk = 35
defaultConfig {
applicationId = "com.dadao.task"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
buildConfigField("String", "API_BASE", "\"$apiBase\"")
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
debugImplementation("androidx.compose.ui:ui-tooling")
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:label="达道Task"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.DadaoTask">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- 邀请链接落地: https://<domain>/join/<wsId>/<code> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="dadaotask.com" android:pathPrefix="/join/" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,69 @@
package com.dadao.task
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.Modifier
import com.dadao.task.data.Api
import com.dadao.task.data.TokenStore
import com.dadao.task.ui.DadaoTheme
import com.dadao.task.ui.HomeScreen
import com.dadao.task.ui.LoginScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
DadaoTheme {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
AppRoot()
}
}
}
}
}
private enum class Screen { Splash, Login, Home }
@Composable
private fun AppRoot() {
val context = androidx.compose.ui.platform.LocalContext.current
val tokens = remember { TokenStore(context) }
val api = remember { Api(tokens) }
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) }
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
LaunchedEffect(screen) {
if (screen == Screen.Splash) {
screen = try {
api.me()
Screen.Home
} catch (e: Exception) {
tokens.clear()
Screen.Login
}
}
}
when (screen) {
Screen.Splash -> Unit // 校验中,空白过渡即可
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
Screen.Home -> HomeScreen(
api = api,
onLogout = {
tokens.clear()
screen = Screen.Login
},
)
}
}
@@ -0,0 +1,168 @@
package com.dadao.task.data
import com.dadao.task.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
/** 与后端信封一致的错误类型。 */
class ApiException(val code: Int, override val message: String, val requestId: String? = null) :
Exception(message)
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
@Serializable
private data class Envelope<T>(
val code: Int,
val message: String,
val data: T? = null,
@SerialName("request_id") val requestId: String? = null,
)
// ---- 与 backend/app/api/schemas.py 一一对应 ----
@Serializable
data class User(val id: String, val phone: String, @SerialName("display_name") val displayName: String)
@Serializable
data class Tokens(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("expires_in") val expiresIn: Int,
)
@Serializable
data class LoginResult(val user: User, val tokens: Tokens)
@Serializable
data class Workspace(
val id: String,
val name: String,
val slug: String,
val role: String? = null,
)
@Serializable
data class MeResult(val user: User, val workspaces: List<Workspace> = emptyList())
@Serializable
data class Member(
val userId: String,
@SerialName("display_name") val displayName: String,
val role: String,
val status: String,
)
@Serializable
data class Invitation(
val code: String,
@SerialName("expires_at") val expiresAt: String,
)
private data class SmsRequest(val phone: String)
private data class LoginRequest(val phone: String, val code: String)
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
private data class WorkspaceCreateRequest(val name: String)
/**
* 后端 API 客户端。
*
* 约定:所有方法返回业务 data,业务失败抛 [ApiException](code != 0),
* 与 Web 端 lib/client.ts 的行为保持一致。
*/
class Api(private val tokens: TokenStore) {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
// 与 backend/app/config.py 的 api_prefix 保持一致
private val base = BuildConfig.API_BASE.trimEnd('/') + "/api/v1"
suspend fun sendSms(phone: String): JsonElement? = call(
path = "/auth/sms/send",
body = json.encodeToString(SmsRequest(phone)),
)
suspend fun login(phone: String, code: String): LoginResult {
val result: LoginResult = call(
path = "/auth/login",
body = json.encodeToString(LoginRequest(phone, code)),
)
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
return result
}
suspend fun me(): MeResult = call(path = "/auth/me", auth = true)
suspend fun workspaces(): List<Workspace> = call(path = "/workspaces", auth = true)
suspend fun createWorkspace(name: String): Workspace = call(
path = "/workspaces",
body = json.encodeToString(WorkspaceCreateRequest(name)),
auth = true,
)
suspend fun members(workspaceId: String): List<Member> =
call(path = "/workspaces/$workspaceId/members", auth = true)
suspend fun createInvitation(workspaceId: String): Invitation =
call(path = "/workspaces/$workspaceId/invitations", body = "{}", auth = true)
suspend fun join(workspaceId: String, code: String): Workspace = call(
path = "/workspaces/$workspaceId/join",
body = json.encodeToString(mapOf("code" to code)),
auth = true,
)
private suspend inline fun <reified T> call(
path: String,
body: String? = null,
auth: Boolean = false,
method: String? = null,
): T = withContext(Dispatchers.IO) {
val builder = Request.Builder().url(base + path)
val verb = method ?: if (body == null) "GET" else "POST"
if (body != null) {
builder.method(verb, body.toRequestBody(jsonMedia))
} else {
builder.method(verb, null)
}
builder.header("Accept", "application/json")
if (auth) {
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
}
http.newCall(builder.build()).execute().use { response ->
val text = response.body?.string().orEmpty()
if (text.isBlank()) {
throw ApiException(1500, "服务端返回空响应(HTTP ${response.code})")
}
val envelope = runCatching { json.decodeFromString<Envelope<T>>(text) }.getOrElse {
throw ApiException(1500, "响应格式异常(HTTP ${response.code})")
}
if (envelope.code != 0) {
if (envelope.code == 2002 && auth) tokens.clear()
throw ApiException(envelope.code, envelope.message, envelope.requestId)
}
envelope.data ?: throw ApiException(1500, "响应缺少 data")
}
}
}
@@ -0,0 +1,38 @@
package com.dadao.task.data
import android.content.Context
import android.content.SharedPreferences
/**
* 令牌存储。
*
* TODO(M5): 换成 EncryptedSharedPreferences / Keystore 包装,明文 SP 只用于 M0 联调。
*/
class TokenStore(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("dadao.session", Context.MODE_PRIVATE)
var accessToken: String?
get() = prefs.getString(KEY_ACCESS, null)
set(value) = prefs.edit().putString(KEY_ACCESS, value).apply()
var refreshToken: String?
get() = prefs.getString(KEY_REFRESH, null)
set(value) = prefs.edit().putString(KEY_REFRESH, value).apply()
fun save(access: String, refresh: String) {
prefs.edit().putString(KEY_ACCESS, access).putString(KEY_REFRESH, refresh).apply()
}
fun clear() {
prefs.edit().remove(KEY_ACCESS).remove(KEY_REFRESH).apply()
}
val isLoggedIn: Boolean get() = !accessToken.isNullOrBlank()
private companion object {
const val KEY_ACCESS = "access"
const val KEY_REFRESH = "refresh"
}
}
@@ -0,0 +1,184 @@
package com.dadao.task.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch
private val ROLE_LABEL = mapOf(
"owner" to "所有者",
"admin" to "管理员",
"member" to "成员",
"guest" to "访客",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(api: Api, onLogout: () -> Unit) {
val workspaces = remember { mutableStateListOf<Workspace>() }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var showCreate by remember { mutableStateOf(false) }
var invitation by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
suspend fun reload() {
loading = true
error = null
try {
workspaces.clear()
workspaces.addAll(api.workspaces())
} catch (e: ApiException) {
error = e.message
} finally {
loading = false
}
}
LaunchedEffect(Unit) { reload() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("我的工作区") },
actions = { TextButton(onClick = onLogout) { Text("退出") } },
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreate = true }) { Text("+") }
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
}
when {
loading -> Text("加载中…", style = MaterialTheme.typography.bodyMedium)
workspaces.isEmpty() -> Text(
"还没有工作区。点右下角 + 新建一个,任务、工单、知识都放在里面。",
style = MaterialTheme.typography.bodyMedium,
)
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(workspaces, key = { it.id }) { ws ->
Card(
modifier = Modifier.fillMaxWidth().clickable {
scope.launch {
try {
val inv = api.createInvitation(ws.id)
invitation = "${ws.name}\n邀请码 ${inv.code}\n有效期至 ${inv.expiresAt}\n\n分享链接:/join/${ws.id}/${inv.code}"
} catch (e: ApiException) {
error = e.message
}
}
},
colors = CardDefaults.cardColors(),
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(ws.name, style = MaterialTheme.typography.titleMedium)
Text(
ws.slug,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
}
Text(
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
style = MaterialTheme.typography.labelMedium,
color = Accent,
)
}
}
}
}
}
Spacer(Modifier.height(8.dp))
Text(
"点工作区可生成邀请码(M0 临时入口,M1 换成工作区详情页)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f),
)
}
}
if (showCreate) {
var name by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showCreate = false },
title = { Text("新建工作区") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it.take(40) },
label = { Text("名称") },
singleLine = true,
)
},
confirmButton = {
TextButton(enabled = !busy && name.isNotBlank(), onClick = {
scope.launch {
busy = true
try {
api.createWorkspace(name.trim())
showCreate = false
reload()
} catch (e: ApiException) {
error = e.message
} finally {
busy = false
}
}
}) { Text("创建") }
},
dismissButton = { TextButton(onClick = { showCreate = false }) { Text("取消") } },
)
}
invitation?.let { text ->
AlertDialog(
onDismissRequest = { invitation = null },
title = { Text("邀请成员") },
text = { Text(text) },
confirmButton = { TextButton(onClick = { invitation = null }) { Text("知道了") } },
)
}
}
@@ -0,0 +1,136 @@
package com.dadao.task.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
var phone by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var devCode by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var cooldown by remember { mutableStateOf(0) }
val scope = rememberCoroutineScope()
fun sendCode() {
if (phone.length < 6 || cooldown > 0) return
scope.launch {
busy = true
error = null
try {
val data = api.sendSms(phone)
// 开发环境后端会回显验证码,联调时省事;生产不会返回。
devCode = data?.toString()?.let { Regex("\"dev_code\"\\s*:\\s*\"(\\d+)\"").find(it)?.groupValues?.get(1) }
cooldown = 60
} catch (e: ApiException) {
error = e.message
} finally {
busy = false
}
}
}
fun submit() {
if (phone.isBlank() || code.isBlank()) return
scope.launch {
busy = true
error = null
try {
api.login(phone, code)
onLoggedIn()
} catch (e: ApiException) {
error = e.message
} finally {
busy = false
}
}
}
Column(
modifier = Modifier.fillMaxSize().padding(28.dp),
verticalArrangement = Arrangement.Center,
) {
Text("达道Task", style = MaterialTheme.typography.headlineMedium)
Text(
"从任务到知识,一条道走到底",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = phone,
onValueChange = { phone = it.filter(Char::isDigit).take(11) },
label = { Text("手机号") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = code,
onValueChange = { code = it.filter(Char::isDigit).take(6) },
label = { Text("验证码") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
TextButton(enabled = !busy && cooldown == 0, onClick = { sendCode() }) {
Text(if (cooldown > 0) "${cooldown}s" else "获取验证码")
}
},
)
devCode?.let {
Spacer(Modifier.height(8.dp))
Text("开发环境验证码:$it", style = MaterialTheme.typography.bodySmall, color = Accent)
}
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { submit() },
enabled = !busy && phone.length == 11 && code.isNotBlank(),
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
if (busy) CircularProgressIndicator(Modifier.height(20.dp), strokeWidth = 2.dp)
else Text("登录 / 注册")
}
Spacer(Modifier.height(12.dp))
Text(
"未注册的手机号将自动创建账号",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
@@ -0,0 +1,30 @@
package com.dadao.task.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// 与 Web 端 tailwind.config.js 的色板保持一致:墨青主色 + 陶土色点缀。
val Ink = Color(0xFF3F5A68)
val InkDark = Color(0xFF2C4049)
val Accent = Color(0xFFC2703D)
val Paper = Color(0xFFF7F8F8)
private val colors = lightColorScheme(
primary = Ink,
onPrimary = Color.White,
primaryContainer = Color(0xFFDCE6EA),
onPrimaryContainer = InkDark,
secondary = Accent,
onSecondary = Color.White,
background = Paper,
surface = Color.White,
onSurface = Color(0xFF1B2528),
)
@Composable
fun DadaoTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = colors, typography = Typography(), content = content)
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Compose 自己管配色,这里只提供无 ActionBar 的容器主题 -->
<style name="Theme.DadaoTask" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">#3F5A68</item>
<item name="android:windowBackground">#F7F8F8</item>
</style>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
只对本机开发地址放行明文 HTTP。生产域名必须走 HTTPS,不放行任何 cleartext。
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
+6
View File
@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
}
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
kotlin.code.style=official
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "DadaoTask"
include(":app")