diff --git a/.gitignore b/.gitignore
index e458ed5..2f81833 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
.worktrees/
+.learnings/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3f73f9b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,44 @@
+# Sub2API Monitor
+
+Android home-screen widget prototype for monitoring Sub2API usage and service status.
+
+## Current Scope
+
+- Native Android app in Kotlin.
+- Jetpack Compose configuration screen.
+- Jetpack Glance home-screen widget.
+- Local configuration with DataStore.
+- Mock Sub2API monitoring data.
+- Manual widget refresh action.
+- WorkManager refresh scheduling boundary.
+- Last successful snapshot is preserved when refresh fails.
+
+## Configuration
+
+The app lets the user enter:
+
+- Sub2API base URL.
+- Admin key.
+- Automatic refresh interval in minutes.
+
+The admin key is stored locally through DataStore and is not rendered in the widget.
+
+## Widget States
+
+- Missing configuration: shows `请先配置 Sub2API`.
+- Configured with data: shows dashboard metrics, recent calls, model TOP4, and lifetime totals.
+- Refresh failure: stores the error while keeping the last successful snapshot visible.
+
+## Replacing Mock Data
+
+The first real integration should replace `MockSub2ApiRepository` with a network-backed implementation of `Sub2ApiRepository`. Keep the widget and Compose UI consuming `Sub2ApiSnapshot` so the rendering layer does not need structural changes.
+
+## Build
+
+Open this folder in Android Studio or run:
+
+```powershell
+.\gradlew :app:assembleDebug
+```
+
+This workspace currently does not include a Gradle wrapper jar. If Android Studio is used, let it sync the Gradle project and generate/use its configured Gradle installation.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..16527d9
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,49 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("org.jetbrains.kotlin.plugin.serialization")
+}
+
+android {
+ namespace = "com.sub2api.monitor"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.sub2api.monitor"
+ minSdk = 26
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
+ implementation("androidx.glance:glance-appwidget:1.1.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
+ implementation("androidx.work:work-runtime-ktx:2.10.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..266228a
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/sub2api/monitor/MainActivity.kt b/app/src/main/java/com/sub2api/monitor/MainActivity.kt
new file mode 100644
index 0000000..32da817
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/MainActivity.kt
@@ -0,0 +1,60 @@
+package com.sub2api.monitor
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.sub2api.monitor.data.DataStoreConfigRepository
+import com.sub2api.monitor.data.MockSub2ApiRepository
+import com.sub2api.monitor.sync.WorkManagerRefreshScheduler
+import com.sub2api.monitor.ui.ConfigScreen
+import com.sub2api.monitor.ui.ConfigViewModel
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ val configRepository = remember { DataStoreConfigRepository(applicationContext) }
+ val sub2ApiRepository = remember { MockSub2ApiRepository() }
+ val refreshScheduler = remember { WorkManagerRefreshScheduler(applicationContext) }
+ val configViewModel: ConfigViewModel = viewModel(
+ factory = ConfigViewModelFactory(
+ configRepository = configRepository,
+ sub2ApiRepository = sub2ApiRepository,
+ refreshScheduler = refreshScheduler,
+ ),
+ )
+ Sub2ApiMonitorApp(configViewModel)
+ }
+ }
+}
+
+@Composable
+fun Sub2ApiMonitorApp(configViewModel: ConfigViewModel) {
+ MaterialTheme {
+ ConfigScreen(
+ state = configViewModel.uiState,
+ onBaseUrlChange = configViewModel::updateBaseUrl,
+ onAdminKeyChange = configViewModel::updateAdminKey,
+ onRefreshIntervalChange = configViewModel::updateRefreshInterval,
+ onTestConnection = configViewModel::testConnection,
+ onSave = configViewModel::saveConfig,
+ )
+ }
+}
+
+private class ConfigViewModelFactory(
+ private val configRepository: DataStoreConfigRepository,
+ private val sub2ApiRepository: MockSub2ApiRepository,
+ private val refreshScheduler: WorkManagerRefreshScheduler,
+) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return ConfigViewModel(configRepository, sub2ApiRepository, refreshScheduler) as T
+ }
+}
diff --git a/app/src/main/java/com/sub2api/monitor/data/AppConfig.kt b/app/src/main/java/com/sub2api/monitor/data/AppConfig.kt
new file mode 100644
index 0000000..3a42f5f
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/data/AppConfig.kt
@@ -0,0 +1,10 @@
+package com.sub2api.monitor.data
+
+data class AppConfig(
+ val baseUrl: String = "",
+ val adminKey: String = "",
+ val refreshIntervalMinutes: Int = 30,
+) {
+ val isConfigured: Boolean
+ get() = baseUrl.trim().isNotEmpty() && adminKey.trim().isNotEmpty()
+}
diff --git a/app/src/main/java/com/sub2api/monitor/data/ConfigRepository.kt b/app/src/main/java/com/sub2api/monitor/data/ConfigRepository.kt
new file mode 100644
index 0000000..d5e07d7
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/data/ConfigRepository.kt
@@ -0,0 +1,8 @@
+package com.sub2api.monitor.data
+
+import kotlinx.coroutines.flow.Flow
+
+interface ConfigRepository {
+ val config: Flow
+ suspend fun save(config: AppConfig)
+}
diff --git a/app/src/main/java/com/sub2api/monitor/data/DataStoreConfigRepository.kt b/app/src/main/java/com/sub2api/monitor/data/DataStoreConfigRepository.kt
new file mode 100644
index 0000000..26d270f
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/data/DataStoreConfigRepository.kt
@@ -0,0 +1,37 @@
+package com.sub2api.monitor.data
+
+import android.content.Context
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.configDataStore by preferencesDataStore(name = "sub2api_config")
+
+class DataStoreConfigRepository(
+ private val context: Context,
+) : ConfigRepository {
+ override val config: Flow = context.configDataStore.data.map { preferences ->
+ AppConfig(
+ baseUrl = preferences[BASE_URL].orEmpty(),
+ adminKey = preferences[ADMIN_KEY].orEmpty(),
+ refreshIntervalMinutes = preferences[REFRESH_INTERVAL] ?: 30,
+ )
+ }
+
+ override suspend fun save(config: AppConfig) {
+ context.configDataStore.edit { preferences ->
+ preferences[BASE_URL] = config.baseUrl.trim()
+ preferences[ADMIN_KEY] = config.adminKey.trim()
+ preferences[REFRESH_INTERVAL] = config.refreshIntervalMinutes.coerceAtLeast(15)
+ }
+ }
+
+ private companion object {
+ val BASE_URL = stringPreferencesKey("base_url")
+ val ADMIN_KEY = stringPreferencesKey("admin_key")
+ val REFRESH_INTERVAL = intPreferencesKey("refresh_interval")
+ }
+}
diff --git a/app/src/main/java/com/sub2api/monitor/data/MockSub2ApiRepository.kt b/app/src/main/java/com/sub2api/monitor/data/MockSub2ApiRepository.kt
new file mode 100644
index 0000000..7eb7892
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/data/MockSub2ApiRepository.kt
@@ -0,0 +1,45 @@
+package com.sub2api.monitor.data
+
+import com.sub2api.monitor.domain.ModelUsage
+import com.sub2api.monitor.domain.RecentCall
+import com.sub2api.monitor.domain.ServiceStatus
+import com.sub2api.monitor.domain.Sub2ApiSnapshot
+
+class MockSub2ApiRepository : Sub2ApiRepository {
+ override suspend fun fetchSnapshot(): Sub2ApiSnapshot {
+ val now = System.currentTimeMillis()
+ return Sub2ApiSnapshot(
+ lastUpdatedAtMillis = now,
+ todayTokens = 1_284_920,
+ todayCost = 18.73,
+ todayRequests = 8_426,
+ averageLatencyMs = 248,
+ rpm = 42,
+ tpm = 18_500,
+ serviceStatus = ServiceStatus.Healthy,
+ activeKeyCount = 23,
+ userCount = 156,
+ recentCalls = listOf(
+ RecentCall("gpt-4.1-mini", 200, 214, 1_842, 0.012, now - 60_000),
+ RecentCall("gpt-4o", 200, 352, 4_126, 0.084, now - 180_000),
+ RecentCall("claude-sonnet-4", 200, 441, 6_220, 0.121, now - 260_000),
+ RecentCall("gpt-4.1", 429, 96, 0, 0.0, now - 330_000),
+ RecentCall("gemini-2.5-flash", 200, 188, 2_019, 0.009, now - 420_000),
+ ),
+ modelTop = listOf(
+ ModelUsage("gpt-4o", 482_300, 7.82),
+ ModelUsage("claude-sonnet-4", 361_220, 6.13),
+ ModelUsage("gpt-4.1-mini", 288_140, 3.41),
+ ModelUsage("gemini-2.5-flash", 153_260, 1.37),
+ ),
+ totalTokens = 92_518_400,
+ totalCost = 1_438.29,
+ )
+ }
+
+ override suspend fun testConnection(config: AppConfig): Result {
+ return if (config.isConfigured) Result.success(Unit) else Result.failure(
+ IllegalStateException("Missing Sub2API URL or admin key"),
+ )
+ }
+}
diff --git a/app/src/main/java/com/sub2api/monitor/data/Sub2ApiRepository.kt b/app/src/main/java/com/sub2api/monitor/data/Sub2ApiRepository.kt
new file mode 100644
index 0000000..e69d578
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/data/Sub2ApiRepository.kt
@@ -0,0 +1,8 @@
+package com.sub2api.monitor.data
+
+import com.sub2api.monitor.domain.Sub2ApiSnapshot
+
+interface Sub2ApiRepository {
+ suspend fun fetchSnapshot(): Sub2ApiSnapshot
+ suspend fun testConnection(config: AppConfig): Result
+}
diff --git a/app/src/main/java/com/sub2api/monitor/domain/MetricFormatters.kt b/app/src/main/java/com/sub2api/monitor/domain/MetricFormatters.kt
new file mode 100644
index 0000000..0cc6367
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/domain/MetricFormatters.kt
@@ -0,0 +1,29 @@
+package com.sub2api.monitor.domain
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+fun formatTokens(value: Long): String = compactNumber(value)
+
+fun formatCurrency(value: Double): String = "$" + String.format(Locale.US, "%.2f", value)
+
+fun formatLatency(valueMs: Int): String = "$valueMs ms"
+
+fun formatRpm(value: Int): String = "$value RPM"
+
+fun formatTpm(value: Long): String = "${compactNumber(value)} TPM"
+
+fun formatDisplayTime(valueMillis: Long): String {
+ val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
+ return formatter.format(Date(valueMillis))
+}
+
+private fun compactNumber(value: Long): String {
+ val abs = kotlin.math.abs(value)
+ return when {
+ abs >= 1_000_000 -> String.format(Locale.US, "%.1fM", value / 1_000_000.0)
+ abs >= 1_000 -> String.format(Locale.US, "%.1fK", value / 1_000.0)
+ else -> value.toString()
+ }
+}
diff --git a/app/src/main/java/com/sub2api/monitor/domain/Sub2ApiModels.kt b/app/src/main/java/com/sub2api/monitor/domain/Sub2ApiModels.kt
new file mode 100644
index 0000000..7988109
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/domain/Sub2ApiModels.kt
@@ -0,0 +1,45 @@
+package com.sub2api.monitor.domain
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Sub2ApiSnapshot(
+ val lastUpdatedAtMillis: Long,
+ val todayTokens: Long,
+ val todayCost: Double,
+ val todayRequests: Int,
+ val averageLatencyMs: Int,
+ val rpm: Int,
+ val tpm: Long,
+ val serviceStatus: ServiceStatus,
+ val activeKeyCount: Int,
+ val userCount: Int,
+ val recentCalls: List,
+ val modelTop: List,
+ val totalTokens: Long,
+ val totalCost: Double,
+)
+
+@Serializable
+data class RecentCall(
+ val model: String,
+ val statusCode: Int,
+ val latencyMs: Int,
+ val tokens: Long,
+ val cost: Double,
+ val createdAtMillis: Long,
+)
+
+@Serializable
+data class ModelUsage(
+ val model: String,
+ val tokens: Long,
+ val cost: Double,
+)
+
+@Serializable
+enum class ServiceStatus {
+ Healthy,
+ Degraded,
+ Down,
+}
diff --git a/app/src/main/java/com/sub2api/monitor/sync/RefreshScheduler.kt b/app/src/main/java/com/sub2api/monitor/sync/RefreshScheduler.kt
new file mode 100644
index 0000000..d90135d
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/sync/RefreshScheduler.kt
@@ -0,0 +1,7 @@
+package com.sub2api.monitor.sync
+
+interface RefreshScheduler {
+ fun schedule(intervalMinutes: Int)
+}
+
+fun sanitizeRefreshIntervalMinutes(intervalMinutes: Int): Int = intervalMinutes.coerceAtLeast(15)
diff --git a/app/src/main/java/com/sub2api/monitor/sync/WidgetRefreshWorker.kt b/app/src/main/java/com/sub2api/monitor/sync/WidgetRefreshWorker.kt
new file mode 100644
index 0000000..a784e99
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/sync/WidgetRefreshWorker.kt
@@ -0,0 +1,41 @@
+package com.sub2api.monitor.sync
+
+import android.content.Context
+import androidx.glance.appwidget.updateAll
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.sub2api.monitor.data.DataStoreConfigRepository
+import com.sub2api.monitor.data.MockSub2ApiRepository
+import com.sub2api.monitor.widget.Sub2ApiMonitorWidget
+import com.sub2api.monitor.widget.WidgetStateRepository
+import kotlinx.coroutines.flow.first
+
+class WidgetRefreshWorker(
+ appContext: Context,
+ params: WorkerParameters,
+) : CoroutineWorker(appContext, params) {
+ override suspend fun doWork(): Result {
+ val configRepository = DataStoreConfigRepository(applicationContext)
+ val config = configRepository.config.first()
+ if (!config.isConfigured) {
+ Sub2ApiMonitorWidget().updateAll(applicationContext)
+ return Result.success()
+ }
+
+ val widgetStateRepository = WidgetStateRepository(applicationContext)
+ val repository = MockSub2ApiRepository()
+
+ return runCatching {
+ val snapshot = repository.fetchSnapshot()
+ widgetStateRepository.saveSuccess(snapshot)
+ Sub2ApiMonitorWidget().updateAll(applicationContext)
+ }.fold(
+ onSuccess = { Result.success() },
+ onFailure = { error ->
+ widgetStateRepository.saveError(error.message ?: "刷新失败")
+ Sub2ApiMonitorWidget().updateAll(applicationContext)
+ Result.retry()
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/com/sub2api/monitor/sync/WorkManagerRefreshScheduler.kt b/app/src/main/java/com/sub2api/monitor/sync/WorkManagerRefreshScheduler.kt
new file mode 100644
index 0000000..50523ca
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/sync/WorkManagerRefreshScheduler.kt
@@ -0,0 +1,31 @@
+package com.sub2api.monitor.sync
+
+import android.content.Context
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import java.util.concurrent.TimeUnit
+
+class WorkManagerRefreshScheduler(
+ context: Context,
+) : RefreshScheduler {
+ private val workManager = WorkManager.getInstance(context.applicationContext)
+
+ override fun schedule(intervalMinutes: Int) {
+ val sanitized = sanitizeRefreshIntervalMinutes(intervalMinutes)
+ val request = PeriodicWorkRequestBuilder(
+ sanitized.toLong(),
+ TimeUnit.MINUTES,
+ ).build()
+
+ workManager.enqueueUniquePeriodicWork(
+ WORK_NAME,
+ ExistingPeriodicWorkPolicy.UPDATE,
+ request,
+ )
+ }
+
+ private companion object {
+ const val WORK_NAME = "sub2api_widget_refresh"
+ }
+}
diff --git a/app/src/main/java/com/sub2api/monitor/ui/ConfigScreen.kt b/app/src/main/java/com/sub2api/monitor/ui/ConfigScreen.kt
new file mode 100644
index 0000000..88368f0
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/ui/ConfigScreen.kt
@@ -0,0 +1,149 @@
+package com.sub2api.monitor.ui
+
+import androidx.compose.foundation.background
+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.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+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 androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun ConfigScreen(
+ state: ConfigUiState,
+ onBaseUrlChange: (String) -> Unit,
+ onAdminKeyChange: (String) -> Unit,
+ onRefreshIntervalChange: (String) -> Unit,
+ onTestConnection: () -> Unit,
+ onSave: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier.fillMaxSize(),
+ color = Color(0xFFE8EEF7),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ listOf(Color(0xFFE8EEF7), Color(0xFFF7FAFC), Color(0xFFEFF6F2)),
+ ),
+ )
+ .verticalScroll(rememberScrollState())
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = "Sub2API Monitor",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFF172033),
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f)),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ OutlinedTextField(
+ value = state.baseUrl,
+ onValueChange = onBaseUrlChange,
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ label = { Text("Sub2API 地址") },
+ placeholder = { Text("https://sub2api.example.com") },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
+ )
+ OutlinedTextField(
+ value = state.adminKey,
+ onValueChange = onAdminKeyChange,
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ label = { Text("Admin Key") },
+ visualTransformation = PasswordVisualTransformation(),
+ )
+ OutlinedTextField(
+ value = state.refreshIntervalMinutes,
+ onValueChange = onRefreshIntervalChange,
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ label = { Text("自动刷新间隔(分钟)") },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Button(
+ onClick = onTestConnection,
+ enabled = !state.isTesting,
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2563EB)),
+ ) {
+ Text(if (state.isTesting) "测试中" else "测试连接")
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Button(
+ onClick = onSave,
+ enabled = !state.isSaving,
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0F766E)),
+ ) {
+ Text(if (state.isSaving) "保存中" else "保存配置")
+ }
+ }
+
+ state.statusMessage?.let { message ->
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color(0xFF0F766E),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ConfigScreenPreviewState() {
+ var state by remember { mutableStateOf(ConfigUiState()) }
+ ConfigScreen(
+ state = state,
+ onBaseUrlChange = { state = state.withBaseUrl(it) },
+ onAdminKeyChange = { state = state.withAdminKey(it) },
+ onRefreshIntervalChange = { state = state.withRefreshInterval(it) },
+ onTestConnection = {},
+ onSave = {},
+ )
+}
diff --git a/app/src/main/java/com/sub2api/monitor/ui/ConfigViewModel.kt b/app/src/main/java/com/sub2api/monitor/ui/ConfigViewModel.kt
new file mode 100644
index 0000000..40c0359
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/ui/ConfigViewModel.kt
@@ -0,0 +1,88 @@
+package com.sub2api.monitor.ui
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.sub2api.monitor.data.AppConfig
+import com.sub2api.monitor.data.ConfigRepository
+import com.sub2api.monitor.data.Sub2ApiRepository
+import com.sub2api.monitor.sync.RefreshScheduler
+import kotlinx.coroutines.launch
+
+data class ConfigUiState(
+ val baseUrl: String = "",
+ val adminKey: String = "",
+ val refreshIntervalMinutes: String = "30",
+ val isSaving: Boolean = false,
+ val isTesting: Boolean = false,
+ val statusMessage: String? = null,
+) {
+ fun withBaseUrl(value: String): ConfigUiState = copy(baseUrl = value)
+ fun withAdminKey(value: String): ConfigUiState = copy(adminKey = value)
+ fun withRefreshInterval(value: String): ConfigUiState = copy(refreshIntervalMinutes = value.filter { it.isDigit() })
+
+ fun toAppConfig(): AppConfig = AppConfig(
+ baseUrl = baseUrl,
+ adminKey = adminKey,
+ refreshIntervalMinutes = refreshIntervalMinutes.toIntOrNull()?.coerceAtLeast(15) ?: 30,
+ )
+}
+
+class ConfigViewModel(
+ private val configRepository: ConfigRepository,
+ private val sub2ApiRepository: Sub2ApiRepository,
+ private val refreshScheduler: RefreshScheduler,
+) : ViewModel() {
+ var uiState by mutableStateOf(ConfigUiState())
+ private set
+
+ init {
+ viewModelScope.launch {
+ configRepository.config.collect { config ->
+ uiState = uiState.copy(
+ baseUrl = config.baseUrl,
+ adminKey = config.adminKey,
+ refreshIntervalMinutes = config.refreshIntervalMinutes.toString(),
+ )
+ }
+ }
+ }
+
+ fun updateBaseUrl(value: String) {
+ uiState = uiState.withBaseUrl(value)
+ }
+
+ fun updateAdminKey(value: String) {
+ uiState = uiState.withAdminKey(value)
+ }
+
+ fun updateRefreshInterval(value: String) {
+ uiState = uiState.withRefreshInterval(value)
+ }
+
+ fun testConnection() {
+ viewModelScope.launch {
+ uiState = uiState.copy(isTesting = true, statusMessage = null)
+ val result = sub2ApiRepository.testConnection(uiState.toAppConfig())
+ uiState = uiState.copy(
+ isTesting = false,
+ statusMessage = result.fold(
+ onSuccess = { "连接测试成功" },
+ onFailure = { it.message ?: "连接测试失败" },
+ ),
+ )
+ }
+ }
+
+ fun saveConfig() {
+ viewModelScope.launch {
+ uiState = uiState.copy(isSaving = true, statusMessage = null)
+ val config = uiState.toAppConfig()
+ configRepository.save(config)
+ refreshScheduler.schedule(config.refreshIntervalMinutes)
+ uiState = uiState.copy(isSaving = false, statusMessage = "配置已保存")
+ }
+ }
+}
diff --git a/app/src/main/java/com/sub2api/monitor/widget/RefreshWidgetAction.kt b/app/src/main/java/com/sub2api/monitor/widget/RefreshWidgetAction.kt
new file mode 100644
index 0000000..5ec08e6
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/widget/RefreshWidgetAction.kt
@@ -0,0 +1,39 @@
+package com.sub2api.monitor.widget
+
+import android.content.Context
+import androidx.glance.GlanceId
+import androidx.glance.action.ActionCallback
+import androidx.glance.action.ActionParameters
+import androidx.glance.appwidget.update
+import com.sub2api.monitor.data.DataStoreConfigRepository
+import com.sub2api.monitor.data.MockSub2ApiRepository
+import kotlinx.coroutines.flow.first
+
+class RefreshWidgetAction : ActionCallback {
+ override suspend fun onAction(
+ context: Context,
+ glanceId: GlanceId,
+ parameters: ActionParameters,
+ ) {
+ val config = DataStoreConfigRepository(context).config.first()
+ val widgetStateRepository = WidgetStateRepository(context)
+
+ if (!config.isConfigured) {
+ Sub2ApiMonitorWidget().update(context, glanceId)
+ return
+ }
+
+ runCatching {
+ MockSub2ApiRepository().fetchSnapshot()
+ }.fold(
+ onSuccess = { snapshot ->
+ widgetStateRepository.saveSuccess(snapshot)
+ },
+ onFailure = { error ->
+ widgetStateRepository.saveError(error.message ?: "刷新失败")
+ },
+ )
+
+ Sub2ApiMonitorWidget().update(context, glanceId)
+ }
+}
diff --git a/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidget.kt b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidget.kt
new file mode 100644
index 0000000..f4e32f5
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidget.kt
@@ -0,0 +1,255 @@
+package com.sub2api.monitor.widget
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.glance.Button
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.action.actionRunCallback
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.defaultWeight
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import androidx.glance.unit.ColorProvider
+import com.sub2api.monitor.R
+import com.sub2api.monitor.data.DataStoreConfigRepository
+import com.sub2api.monitor.domain.ModelUsage
+import com.sub2api.monitor.domain.RecentCall
+import com.sub2api.monitor.domain.ServiceStatus
+import com.sub2api.monitor.domain.formatCurrency
+import com.sub2api.monitor.domain.formatDisplayTime
+import com.sub2api.monitor.domain.formatLatency
+import com.sub2api.monitor.domain.formatRpm
+import com.sub2api.monitor.domain.formatTokens
+import com.sub2api.monitor.domain.formatTpm
+import kotlinx.coroutines.flow.first
+
+class Sub2ApiMonitorWidget : GlanceAppWidget() {
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val config = DataStoreConfigRepository(context).config.first()
+ val stateRepository = WidgetStateRepository(context)
+ val snapshot = stateRepository.lastSuccessfulSnapshot.first()
+ val error = stateRepository.lastError.first()
+ val state = buildWidgetState(config, snapshot, error)
+
+ provideContent {
+ WidgetContent(state)
+ }
+ }
+}
+
+@Composable
+private fun WidgetContent(state: Sub2ApiWidgetState) {
+ Box(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(ColorProvider(Color(0xCCFFFFFF)))
+ .cornerRadius(24.dp)
+ .padding(14.dp),
+ ) {
+ if (!state.isConfigured) {
+ EmptyState()
+ } else {
+ DashboardState(state)
+ }
+ }
+}
+
+@Composable
+private fun EmptyState() {
+ Column(
+ modifier = GlanceModifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ provider = ImageProvider(R.drawable.ic_launcher),
+ contentDescription = null,
+ modifier = GlanceModifier.size(36.dp),
+ )
+ Spacer(modifier = GlanceModifier.height(8.dp))
+ Text(
+ text = "请先配置 Sub2API",
+ style = TextStyle(
+ color = ColorProvider(Color(0xFF172033)),
+ fontWeight = FontWeight.Bold,
+ ),
+ )
+ }
+}
+
+@Composable
+private fun DashboardState(state: Sub2ApiWidgetState) {
+ val snapshot = state.snapshot
+ if (snapshot == null) {
+ Column(
+ modifier = GlanceModifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text("暂无监控数据", style = titleStyle())
+ Spacer(modifier = GlanceModifier.height(8.dp))
+ Button(text = "刷新", onClick = actionRunCallback())
+ }
+ return
+ }
+
+ Column(modifier = GlanceModifier.fillMaxSize()) {
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = GlanceModifier.defaultWeight()) {
+ Text("Sub2API", style = titleStyle())
+ Text(
+ text = "更新 ${formatDisplayTime(snapshot.lastUpdatedAtMillis)}",
+ style = mutedStyle(),
+ )
+ }
+ Button(text = "刷新", onClick = actionRunCallback())
+ }
+
+ state.errorMessage?.let {
+ Spacer(modifier = GlanceModifier.height(6.dp))
+ Text("错误:$it", style = errorStyle())
+ }
+
+ Spacer(modifier = GlanceModifier.height(8.dp))
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ MetricBlock("今日 Token", formatTokens(snapshot.todayTokens), Color(0xFF2563EB), GlanceModifier.defaultWeight())
+ Spacer(modifier = GlanceModifier.width(8.dp))
+ MetricBlock("今日消耗", formatCurrency(snapshot.todayCost), Color(0xFF0F766E), GlanceModifier.defaultWeight())
+ }
+ Spacer(modifier = GlanceModifier.height(8.dp))
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ MetricBlock("请求次数", snapshot.todayRequests.toString(), Color(0xFF7C3AED), GlanceModifier.defaultWeight())
+ Spacer(modifier = GlanceModifier.width(8.dp))
+ MetricBlock("服务状态", statusText(snapshot.serviceStatus), statusColor(snapshot.serviceStatus), GlanceModifier.defaultWeight())
+ }
+
+ Spacer(modifier = GlanceModifier.height(8.dp))
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ TinyMetric("平均", formatLatency(snapshot.averageLatencyMs), GlanceModifier.defaultWeight())
+ TinyMetric("RPM", formatRpm(snapshot.rpm), GlanceModifier.defaultWeight())
+ TinyMetric("TPM", formatTpm(snapshot.tpm), GlanceModifier.defaultWeight())
+ TinyMetric("Key", snapshot.activeKeyCount.toString(), GlanceModifier.defaultWeight())
+ TinyMetric("用户", snapshot.userCount.toString(), GlanceModifier.defaultWeight())
+ }
+
+ Spacer(modifier = GlanceModifier.height(8.dp))
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ Column(modifier = GlanceModifier.defaultWeight()) {
+ SectionTitle("最近调用")
+ snapshot.recentCalls.take(5).forEach { RecentCallRow(it) }
+ }
+ Spacer(modifier = GlanceModifier.width(10.dp))
+ Column(modifier = GlanceModifier.defaultWeight()) {
+ SectionTitle("模型 TOP4")
+ snapshot.modelTop.take(4).forEach { ModelUsageRow(it) }
+ }
+ }
+
+ Spacer(modifier = GlanceModifier.height(6.dp))
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ Text("累计 ${formatTokens(snapshot.totalTokens)}", style = mutedStyle(), modifier = GlanceModifier.defaultWeight())
+ Text("累计 ${formatCurrency(snapshot.totalCost)}", style = mutedStyle())
+ }
+ }
+}
+
+@Composable
+private fun MetricBlock(label: String, value: String, color: Color, modifier: GlanceModifier = GlanceModifier) {
+ Column(
+ modifier = modifier
+ .background(ColorProvider(Color(0x26FFFFFF)))
+ .cornerRadius(8.dp)
+ .padding(8.dp),
+ ) {
+ Text(label, style = mutedStyle())
+ Text(
+ value,
+ style = TextStyle(
+ color = ColorProvider(color),
+ fontWeight = FontWeight.Bold,
+ ),
+ )
+ }
+}
+
+@Composable
+private fun TinyMetric(label: String, value: String, modifier: GlanceModifier = GlanceModifier) {
+ Column(modifier = modifier.padding(end = 4.dp)) {
+ Text(label, style = mutedStyle())
+ Text(value, style = smallStrongStyle())
+ }
+}
+
+@Composable
+private fun SectionTitle(text: String) {
+ Text(text, style = smallStrongStyle())
+ Spacer(modifier = GlanceModifier.height(3.dp))
+}
+
+@Composable
+private fun RecentCallRow(call: RecentCall) {
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ Text(call.model, style = mutedStyle(), modifier = GlanceModifier.defaultWeight())
+ Text("${call.statusCode}", style = mutedStyle())
+ }
+}
+
+@Composable
+private fun ModelUsageRow(model: ModelUsage) {
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ Text(model.model, style = mutedStyle(), modifier = GlanceModifier.defaultWeight())
+ Text(formatCurrency(model.cost), style = mutedStyle())
+ }
+}
+
+private fun titleStyle(): TextStyle = TextStyle(
+ color = ColorProvider(Color(0xFF172033)),
+ fontWeight = FontWeight.Bold,
+)
+
+private fun smallStrongStyle(): TextStyle = TextStyle(
+ color = ColorProvider(Color(0xFF172033)),
+ fontWeight = FontWeight.Bold,
+)
+
+private fun mutedStyle(): TextStyle = TextStyle(color = ColorProvider(Color(0xFF5F6B7A)))
+
+private fun errorStyle(): TextStyle = TextStyle(
+ color = ColorProvider(Color(0xFFB42318)),
+ fontWeight = FontWeight.Bold,
+)
+
+private fun statusText(status: ServiceStatus): String = when (status) {
+ ServiceStatus.Healthy -> "正常"
+ ServiceStatus.Degraded -> "降级"
+ ServiceStatus.Down -> "异常"
+}
+
+private fun statusColor(status: ServiceStatus): Color = when (status) {
+ ServiceStatus.Healthy -> Color(0xFF0F766E)
+ ServiceStatus.Degraded -> Color(0xFFB45309)
+ ServiceStatus.Down -> Color(0xFFB42318)
+}
diff --git a/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidgetReceiver.kt b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidgetReceiver.kt
new file mode 100644
index 0000000..4ba6aeb
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidgetReceiver.kt
@@ -0,0 +1,8 @@
+package com.sub2api.monitor.widget
+
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+
+class Sub2ApiMonitorWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = Sub2ApiMonitorWidget()
+}
diff --git a/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModel.kt b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModel.kt
new file mode 100644
index 0000000..b9a782f
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModel.kt
@@ -0,0 +1,24 @@
+package com.sub2api.monitor.widget
+
+import com.sub2api.monitor.data.AppConfig
+import com.sub2api.monitor.domain.Sub2ApiSnapshot
+
+fun buildWidgetState(
+ config: AppConfig,
+ lastSuccessfulSnapshot: Sub2ApiSnapshot?,
+ errorMessage: String?,
+): Sub2ApiWidgetState {
+ if (!config.isConfigured) {
+ return Sub2ApiWidgetState(
+ isConfigured = false,
+ snapshot = null,
+ errorMessage = null,
+ )
+ }
+
+ return Sub2ApiWidgetState(
+ isConfigured = true,
+ snapshot = lastSuccessfulSnapshot,
+ errorMessage = errorMessage,
+ )
+}
diff --git a/app/src/main/java/com/sub2api/monitor/widget/WidgetState.kt b/app/src/main/java/com/sub2api/monitor/widget/WidgetState.kt
new file mode 100644
index 0000000..4518c0a
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/widget/WidgetState.kt
@@ -0,0 +1,12 @@
+package com.sub2api.monitor.widget
+
+import com.sub2api.monitor.domain.Sub2ApiSnapshot
+
+data class Sub2ApiWidgetState(
+ val isConfigured: Boolean,
+ val snapshot: Sub2ApiSnapshot?,
+ val errorMessage: String?,
+) {
+ val hasError: Boolean = errorMessage != null
+ val primaryMessage: String = if (isConfigured) "Sub2API" else "请先配置 Sub2API"
+}
diff --git a/app/src/main/java/com/sub2api/monitor/widget/WidgetStateRepository.kt b/app/src/main/java/com/sub2api/monitor/widget/WidgetStateRepository.kt
new file mode 100644
index 0000000..b4dddfa
--- /dev/null
+++ b/app/src/main/java/com/sub2api/monitor/widget/WidgetStateRepository.kt
@@ -0,0 +1,47 @@
+package com.sub2api.monitor.widget
+
+import android.content.Context
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.sub2api.monitor.domain.Sub2ApiSnapshot
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+private val Context.widgetStateDataStore by preferencesDataStore(name = "sub2api_widget_state")
+
+class WidgetStateRepository(
+ private val context: Context,
+ private val json: Json = Json { ignoreUnknownKeys = true },
+) {
+ val lastSuccessfulSnapshot: Flow = context.widgetStateDataStore.data.map { preferences ->
+ preferences[SNAPSHOT]?.let { value ->
+ runCatching { json.decodeFromString(value) }.getOrNull()
+ }
+ }
+
+ val lastError: Flow = context.widgetStateDataStore.data.map { preferences ->
+ preferences[ERROR]
+ }
+
+ suspend fun saveSuccess(snapshot: Sub2ApiSnapshot) {
+ context.widgetStateDataStore.edit { preferences ->
+ preferences[SNAPSHOT] = json.encodeToString(snapshot)
+ preferences.remove(ERROR)
+ }
+ }
+
+ suspend fun saveError(message: String) {
+ context.widgetStateDataStore.edit { preferences ->
+ preferences[ERROR] = message
+ }
+ }
+
+ private companion object {
+ val SNAPSHOT = stringPreferencesKey("snapshot")
+ val ERROR = stringPreferencesKey("error")
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..73264be
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_loading.xml b/app/src/main/res/layout/widget_loading.xml
new file mode 100644
index 0000000..aa3de57
--- /dev/null
+++ b/app/src/main/res/layout/widget_loading.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..2707d40
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+
+ Sub2API Monitor
+ Sub2API monitoring dashboard widget
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..43bf033
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/xml/sub2api_monitor_widget.xml b/app/src/main/res/xml/sub2api_monitor_widget.xml
new file mode 100644
index 0000000..655a86d
--- /dev/null
+++ b/app/src/main/res/xml/sub2api_monitor_widget.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/app/src/test/java/com/sub2api/monitor/SmokeTest.kt b/app/src/test/java/com/sub2api/monitor/SmokeTest.kt
new file mode 100644
index 0000000..5cd1e97
--- /dev/null
+++ b/app/src/test/java/com/sub2api/monitor/SmokeTest.kt
@@ -0,0 +1,11 @@
+package com.sub2api.monitor
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SmokeTest {
+ @Test
+ fun projectRunsUnitTests() {
+ assertEquals(4, 2 + 2)
+ }
+}
diff --git a/app/src/test/java/com/sub2api/monitor/data/ConfigRepositoryTest.kt b/app/src/test/java/com/sub2api/monitor/data/ConfigRepositoryTest.kt
new file mode 100644
index 0000000..9314041
--- /dev/null
+++ b/app/src/test/java/com/sub2api/monitor/data/ConfigRepositoryTest.kt
@@ -0,0 +1,23 @@
+package com.sub2api.monitor.data
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ConfigRepositoryTest {
+ @Test
+ fun detectsMissingConfiguration() {
+ assertFalse(AppConfig().isConfigured)
+ }
+
+ @Test
+ fun detectsSavedConfiguration() {
+ val config = AppConfig(
+ baseUrl = "https://sub2api.example.com",
+ adminKey = "secret",
+ refreshIntervalMinutes = 30,
+ )
+
+ assertTrue(config.isConfigured)
+ }
+}
diff --git a/app/src/test/java/com/sub2api/monitor/data/MockSub2ApiRepositoryTest.kt b/app/src/test/java/com/sub2api/monitor/data/MockSub2ApiRepositoryTest.kt
new file mode 100644
index 0000000..bcb541d
--- /dev/null
+++ b/app/src/test/java/com/sub2api/monitor/data/MockSub2ApiRepositoryTest.kt
@@ -0,0 +1,18 @@
+package com.sub2api.monitor.data
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MockSub2ApiRepositoryTest {
+ @Test
+ fun returnsCompleteSnapshot() = runTest {
+ val snapshot = MockSub2ApiRepository().fetchSnapshot()
+
+ assertTrue(snapshot.todayTokens > 0)
+ assertTrue(snapshot.todayCost > 0.0)
+ assertEquals(5, snapshot.recentCalls.size)
+ assertEquals(4, snapshot.modelTop.size)
+ }
+}
diff --git a/app/src/test/java/com/sub2api/monitor/domain/MetricFormattersTest.kt b/app/src/test/java/com/sub2api/monitor/domain/MetricFormattersTest.kt
new file mode 100644
index 0000000..213bd0e
--- /dev/null
+++ b/app/src/test/java/com/sub2api/monitor/domain/MetricFormattersTest.kt
@@ -0,0 +1,24 @@
+package com.sub2api.monitor.domain
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class MetricFormattersTest {
+ @Test
+ fun formatsLargeTokenCounts() {
+ assertEquals("12.3K", formatTokens(12_345))
+ assertEquals("9.9M", formatTokens(9_876_543))
+ }
+
+ @Test
+ fun formatsCurrencyWithDollarPrefix() {
+ assertEquals("$3.21", formatCurrency(3.214))
+ }
+
+ @Test
+ fun formatsLatencyAndRates() {
+ assertEquals("248 ms", formatLatency(248))
+ assertEquals("42 RPM", formatRpm(42))
+ assertEquals("18.5K TPM", formatTpm(18_500))
+ }
+}
diff --git a/app/src/test/java/com/sub2api/monitor/sync/RefreshSchedulerTest.kt b/app/src/test/java/com/sub2api/monitor/sync/RefreshSchedulerTest.kt
new file mode 100644
index 0000000..9b45789
--- /dev/null
+++ b/app/src/test/java/com/sub2api/monitor/sync/RefreshSchedulerTest.kt
@@ -0,0 +1,12 @@
+package com.sub2api.monitor.sync
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class RefreshSchedulerTest {
+ @Test
+ fun clampsRefreshIntervalToWorkManagerMinimum() {
+ assertEquals(15, sanitizeRefreshIntervalMinutes(5))
+ assertEquals(30, sanitizeRefreshIntervalMinutes(30))
+ }
+}
diff --git a/app/src/test/java/com/sub2api/monitor/ui/ConfigViewModelTest.kt b/app/src/test/java/com/sub2api/monitor/ui/ConfigViewModelTest.kt
new file mode 100644
index 0000000..6e61b64
--- /dev/null
+++ b/app/src/test/java/com/sub2api/monitor/ui/ConfigViewModelTest.kt
@@ -0,0 +1,18 @@
+package com.sub2api.monitor.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ConfigViewModelTest {
+ @Test
+ fun updatesFormFields() {
+ val state = ConfigUiState()
+ .withBaseUrl("https://sub2api.example.com")
+ .withAdminKey("secret")
+ .withRefreshInterval("30")
+
+ assertEquals("https://sub2api.example.com", state.baseUrl)
+ assertEquals("secret", state.adminKey)
+ assertEquals("30", state.refreshIntervalMinutes)
+ }
+}
diff --git a/app/src/test/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModelTest.kt b/app/src/test/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModelTest.kt
new file mode 100644
index 0000000..6ddb148
--- /dev/null
+++ b/app/src/test/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModelTest.kt
@@ -0,0 +1,31 @@
+package com.sub2api.monitor.widget
+
+import com.sub2api.monitor.data.AppConfig
+import com.sub2api.monitor.data.MockSub2ApiRepository
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class Sub2ApiWidgetViewModelTest {
+ @Test
+ fun missingConfigShowsSetupPrompt() = runTest {
+ val state = buildWidgetState(AppConfig(), null, null)
+ assertFalse(state.isConfigured)
+ }
+
+ @Test
+ fun failedRefreshPreservesLastSuccessfulSnapshot() = runTest {
+ val previous = MockSub2ApiRepository().fetchSnapshot()
+ val state = buildWidgetState(
+ config = AppConfig("https://example.com", "secret", 30),
+ lastSuccessfulSnapshot = previous,
+ errorMessage = "Network error",
+ )
+
+ assertTrue(state.isConfigured)
+ assertNotNull(state.snapshot)
+ assertTrue(state.hasError)
+ }
+}
diff --git a/app/src/test/java/com/sub2api/monitor/widget/WidgetStateTextTest.kt b/app/src/test/java/com/sub2api/monitor/widget/WidgetStateTextTest.kt
new file mode 100644
index 0000000..78a0643
--- /dev/null
+++ b/app/src/test/java/com/sub2api/monitor/widget/WidgetStateTextTest.kt
@@ -0,0 +1,13 @@
+package com.sub2api.monitor.widget
+
+import com.sub2api.monitor.data.AppConfig
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class WidgetStateTextTest {
+ @Test
+ fun missingConfigUsesSetupPrompt() {
+ val state = buildWidgetState(AppConfig(), null, null)
+ assertEquals("请先配置 Sub2API", state.primaryMessage)
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..70dfb63
--- /dev/null
+++ b/build.gradle.kts
@@ -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
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..9ba4441
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..1d1ef5f
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Sub2API Monitor"
+include(":app")