diff --git a/README.md b/README.md index d2adb83..3e59fe9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,182 @@ -# Cyrene-For-Android +# Cyrene for Android -昔涟在安卓设备上的载体。 \ No newline at end of file +昔涟在安卓设备上的载体 —— 随时随地与昔涟对话、操控 IoT 设备、接收实时通知。 + +## 概述 + +Cyrene for Android 是 [Cyrene(昔涟)](https://github.com/Cyrene/Cyrene) 项目的官方 Android 客户端。Cyrene 是一个开源的基于 LLM 平台的智能体,提供多人格对话、IoT 设备操控、记忆管理、自动化规则、知识库、语音交互等功能。 + +Android 客户端的目标是成为用户的**默认语音助手**,完全替代系统自带的助手(Google Assistant / Bixby 等),同时提供: + +- **系统级语音助手** — 注册为 `VoiceInteractionService`,可通过长按 Home / 侧滑呼出 +- **语音唤醒** — 息屏热词唤醒、免提交互(类似 "Hey Google",使用 "昔涟" 等唤醒词) +- **随时对话** — 通过文字或语音与昔涟交流 +- **IoT 控制** — 远程操控家中的智能设备 +- **实时通知** — 接收昔涟的主动消息、提醒、IoT 状态变更 +- **后台连接** — 即使 APP 在后台,仍保持通知推送与语音服务就绪 + +## 开发状态 + +> **当前阶段**:项目初始化,尚未开始正式开发。 + +根据主项目的[开发路线图](../docs/dev-plan/00-development-roadmap.md),Android 客户端计划在 **Phase 5(v1.5 → v2.0)** 开始开发,预计时间窗口为 **2027 Q2-Q3**。 + +## 技术栈 (规划) + +| 层 | 技术 | +|----|------| +| 语言 | Kotlin | +| UI 框架 | Jetpack Compose + Material Design 3 | +| 架构 | MVVM + Repository | +| 网络 | OkHttp / Retrofit + WebSocket | +| 本地存储 | Room (SQLite) + DataStore | +| 推送 | FCM (Firebase Cloud Messaging) | +| 语音 | VoiceInteractionService + 热词唤醒 + STT + TTS | +| 构建 | Gradle (Kotlin DSL) | + +## 功能路线图 + +- [ ] 用户认证与登录 +- [ ] 实时文字对话 (WebSocket) +- [ ] VoiceInteractionService 注册(替换系统语音助手) +- [ ] 间接启动悬浮窗(VoiceInteractionSession 全屏覆盖层) +- [ ] 热词唤醒(息屏 / 亮屏)+ 唤醒词自定义 +- [ ] 语音识别 (STT) +- [ ] 语音合成 (TTS) +- [ ] IoT 设备控制面板 +- [ ] 推送通知 (FCM) +- [ ] 后台连接与通知 +- [ ] 锁屏 / 息屏语音交互 +- [ ] PWA 保底方案 (短期替代) + +## 交互模式 + +APP 有两种界面呈现方式,根据启动来源自动切换: + +| 启动方式 | 界面模式 | 说明 | +|---------|---------|------| +| 桌面图标 / 最近任务 | **全屏 Activity** | 常规 APP 模式,完整功能入口 | +| 语音唤醒 / 长按 Home / 侧滑 / 长按电源键 / 耳机按键 | **悬浮窗 (VoiceInteractionSession)** | 全屏覆盖层,半透明背景透出底层 APP,不影响当前任务栈 | + +悬浮窗模式的要点: +- 借助 `VoiceInteractionSession` 系统窗口,不压入 Activity 返回栈 +- 对话结束后窗口收起,用户回到触发前的界面 +- 底层 APP 内容半透明可见(模糊遮罩),让用户保持上下文感知 +- 窗口高度自适应对话内容,类似 Google Assistant 的卡片式覆盖 + +## 设计规范 + +- **设计语言**:Material Design 3 (Material You) +- **主题**:动态配色(Dynamic Color),跟随系统壁纸或手动选择主题色 +- **暗黑模式**:支持 Light / Dark 双主题,跟随系统或手动切换 +- **组件**:全面使用 `androidx.compose.material3` 组件库 +- **图标**:Material Icons + 自定义图标 +- **动效**:遵循 MD3 动效规范(过渡动画、涟漪效果、Shared Elements) +- **字体**:系统默认字体(Roboto / Google Sans),支持动态字体缩放 + +## 项目结构 (规划) + +``` +android/ +├── app/ +│ ├── src/main/ +│ │ ├── java/com/cyrene/app/ +│ │ │ ├── ui/ # Compose UI 层 +│ │ │ ├── viewmodel/ # ViewModel 层 +│ │ │ ├── repository/ # 数据仓库层 +│ │ │ ├── data/ # 数据模型 & API 接口 +│ │ │ ├── service/ # VoiceInteractionService & WebSocket & FCM +│ │ │ ├── voice/ # 热词唤醒 (Hotword) & STT & TTS +│ │ │ └── di/ # 依赖注入 +│ │ └── res/ # 资源文件 +│ └── build.gradle.kts +├── build.gradle.kts +├── settings.gradle.kts +└── gradle.properties +``` + +## 环境要求 + +- Android Studio Hedgehog (2023.1.1) 或更高版本 +- Kotlin 2.0+ +- JDK 17+ +- Android SDK (target: 34+, min: 26+) + +## 网络配置(中国大陆用户) + +Gradle、Google Maven、Maven Central 等服务器在国内访问缓慢或不可达,首次同步前需配置镜像。 + +### Gradle Wrapper + +`gradle/wrapper/gradle-wrapper.properties`: + +```properties +# 将 services.gradle.org 替换为腾讯/阿里镜像 +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip +# 或阿里: https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-8.7-bin.zip +``` + +### 仓库镜像 + +`settings.gradle.kts` 顶部添加: + +```kotlin +pluginManagement { + repositories { + maven { url = uri("https://mirrors.cloud.tencent.com/gradle/plugins") } + maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") } + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") } + google() + mavenCentral() + } +} +``` + +### Gradle 属性 + +`gradle.properties`: + +```properties +# HTTP 代理(如使用 Clash/V2Ray,通常不需要) +# systemProp.http.proxyHost=127.0.0.1 +# systemProp.http.proxyPort=7890 +# systemProp.https.proxyHost=127.0.0.1 +# systemProp.https.proxyPort=7890 +``` + +### Android SDK Proxy + +若 Android SDK Manager 也下载缓慢,可在 Android Studio 中设置: +**Settings → Appearance & Behavior → System Settings → HTTP Proxy** + +配置为 `mirrors.cloud.tencent.com` 或 `mirrors.neusoft.edu.cn`,端口 `80`。 + +## 快速开始 + +> 项目尚未包含可构建的源码,以下为后续开发的参考步骤。 + +1. 用 Android Studio 打开 `android/` 目录 +2. 按上述"网络配置"替换国内镜像 +3. 等待 Gradle 同步完成 +4. 启动主项目后端服务(参考[主项目 README](../README.md)) +5. 在 `local.properties` 中配置 `baseUrl` 指向 Gateway 地址 +6. 选择模拟器或设备,点击 Run + +## 相关链接 + +- [Cyrene 主项目](../) — 后端服务、前端、部署文档 +- [开发路线图](../docs/dev-plan/00-development-roadmap.md) +- [多平台接入方案](../docs/dev-plan/03-multi-platform-integration.md) +- [语音系统计划](../docs/dev-plan/04-voice-system-plan.md) + +## License + +Apache-2.0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..6f463bf --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,91 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) +} + +android { + namespace = "top.yeij.cyrene" + compileSdk = 36 + + defaultConfig { + applicationId = "top.yeij.cyrene" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + debug { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } +} + +dependencies { + // Compose BOM + val composeBom = platform(libs.compose.bom) + implementation(composeBom) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons) + debugImplementation(libs.compose.ui.tooling) + + // Activity & Navigation + implementation(libs.compose.activity) + implementation(libs.compose.navigation) + + // Lifecycle + implementation(libs.lifecycle.runtime) + implementation(libs.lifecycle.viewmodel) + + // Room + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + // Network + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + + // Koin DI + implementation(libs.koin.android) + implementation(libs.koin.compose) + + // DataStore + implementation(libs.datastore) + + // Coroutines + implementation(libs.coroutines) + + // Core + implementation(libs.core.ktx) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..2d2c2df --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,27 @@ +# Cyrene ProGuard Rules + +# Retrofit +-keepattributes Signature +-keepattributes *Annotation* +-keep class top.yeij.cyrene.data.remote.dto.** { *; } +-dontwarn retrofit2.** +-keep class retrofit2.** { *; } + +# Gson +-keep class com.google.gson.** { *; } +-keepattributes EnclosingMethod + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** + +# Room +-keep class * extends androidx.room.RoomDatabase +-dontwarn androidx.room.paging.** + +# Koin +-keep class org.koin.** { *; } + +# Coroutines +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..62d63f6 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt new file mode 100644 index 0000000..423470f --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt @@ -0,0 +1,42 @@ +package top.yeij.cyrene + +import android.app.Application +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import top.yeij.cyrene.data.local.PreferencesDataStore +import top.yeij.cyrene.data.remote.AuthInterceptor +import top.yeij.cyrene.data.remote.DynamicUrlInterceptor +import top.yeij.cyrene.di.appModule +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin + +class CyreneApplication : Application() { + + private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onCreate() { + super.onCreate() + + startKoin { + androidContext(this@CyreneApplication) + modules(appModule) + } + + initScope.launch { + val koin = org.koin.core.context.GlobalContext.get() + val prefs: PreferencesDataStore = koin.get() + val urlInterceptor: DynamicUrlInterceptor = koin.get() + val authInterceptor: AuthInterceptor = koin.get() + + prefs.baseUrl.firstOrNull()?.let { url -> + if (url.isNotBlank()) urlInterceptor.baseUrl = url + } + prefs.token.firstOrNull()?.let { token -> + authInterceptor.token = token + } + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/MainActivity.kt b/app/src/main/java/top/yeij/cyrene/MainActivity.kt new file mode 100644 index 0000000..4be0779 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/MainActivity.kt @@ -0,0 +1,55 @@ +package top.yeij.cyrene + +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.navigation.compose.rememberNavController +import top.yeij.cyrene.service.CyreneVoiceInteractionService +import top.yeij.cyrene.ui.navigation.CyreneNavGraph +import top.yeij.cyrene.ui.navigation.Routes +import top.yeij.cyrene.ui.theme.CyreneTheme +import top.yeij.cyrene.util.Constants + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val isDefaultAssistant = checkIsDefaultAssistant() + + setContent { + CyreneTheme { + val navController = rememberNavController() + + CyreneNavGraph( + navController = navController, + startDestination = Routes.MAIN, + isDefaultAssistant = isDefaultAssistant, + onOpenAssistantSettings = { openAssistantSettings() }, + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + } + + private fun checkIsDefaultAssistant(): Boolean { + val componentName = ComponentName(this, CyreneVoiceInteractionService::class.java) + val intent = Intent("android.service.voice.VoiceInteractionService") + val services = packageManager.queryIntentServices(intent, 0) + return services.any { it.serviceInfo.packageName == packageName } + && CyreneVoiceInteractionService.isActive + } + + private fun openAssistantSettings() { + startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)) + } +} diff --git a/app/src/main/java/top/yeij/cyrene/data/local/AppDatabase.kt b/app/src/main/java/top/yeij/cyrene/data/local/AppDatabase.kt new file mode 100644 index 0000000..786a073 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/local/AppDatabase.kt @@ -0,0 +1,39 @@ +package top.yeij.cyrene.data.local + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import top.yeij.cyrene.data.local.dao.ConversationDao +import top.yeij.cyrene.data.local.dao.MessageDao +import top.yeij.cyrene.data.local.entity.ConversationEntity +import top.yeij.cyrene.data.local.entity.MessageEntity + +@Database( + entities = [ConversationEntity::class, MessageEntity::class], + version = 1, + exportSchema = false, +) +abstract class AppDatabase : RoomDatabase() { + + abstract fun conversationDao(): ConversationDao + abstract fun messageDao(): MessageDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getInstance(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "cyrene.db", + ) + .fallbackToDestructiveMigration() + .build() + .also { INSTANCE = it } + } + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt new file mode 100644 index 0000000..44fbe21 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt @@ -0,0 +1,71 @@ +package top.yeij.cyrene.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "cyrene_prefs") + +class PreferencesDataStore(private val context: Context) { + + companion object { + private val KEY_TOKEN = stringPreferencesKey("jwt_token") + private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token") + private val KEY_BASE_URL = stringPreferencesKey("base_url") + private val KEY_THEME = stringPreferencesKey("theme_mode") + private val KEY_WAKE_WORD = stringPreferencesKey("wake_word") + private val KEY_USERNAME = stringPreferencesKey("username") + private val KEY_CLIENT_ID = stringPreferencesKey("client_id") + private val KEY_DEVICE_NAME = stringPreferencesKey("device_name") + } + + val token: Flow = context.dataStore.data.map { it[KEY_TOKEN] } + val refreshToken: Flow = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] } + val baseUrl: Flow = context.dataStore.data.map { it[KEY_BASE_URL] } + val themeMode: Flow = context.dataStore.data.map { it[KEY_THEME] } + val wakeWord: Flow = context.dataStore.data.map { it[KEY_WAKE_WORD] } + val username: Flow = context.dataStore.data.map { it[KEY_USERNAME] } + val clientId: Flow = context.dataStore.data.map { it[KEY_CLIENT_ID] } + val deviceName: Flow = context.dataStore.data.map { it[KEY_DEVICE_NAME] } + + suspend fun saveToken(token: String) { + context.dataStore.edit { it[KEY_TOKEN] = token } + } + + suspend fun saveRefreshToken(token: String) { + context.dataStore.edit { it[KEY_REFRESH_TOKEN] = token } + } + + suspend fun saveBaseUrl(url: String) { + context.dataStore.edit { it[KEY_BASE_URL] = url } + } + + suspend fun saveThemeMode(mode: String) { + context.dataStore.edit { it[KEY_THEME] = mode } + } + + suspend fun saveWakeWord(word: String) { + context.dataStore.edit { it[KEY_WAKE_WORD] = word } + } + + suspend fun saveUsername(username: String) { + context.dataStore.edit { it[KEY_USERNAME] = username } + } + + suspend fun saveClientId(id: String) { + context.dataStore.edit { it[KEY_CLIENT_ID] = id } + } + + suspend fun saveDeviceName(name: String) { + context.dataStore.edit { it[KEY_DEVICE_NAME] = name } + } + + suspend fun clearAll() { + context.dataStore.edit { it.clear() } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt b/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt new file mode 100644 index 0000000..69ee23d --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt @@ -0,0 +1,27 @@ +package top.yeij.cyrene.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import top.yeij.cyrene.data.local.entity.ConversationEntity + +@Dao +interface ConversationDao { + + @Query("SELECT * FROM conversations ORDER BY updatedAt DESC") + fun getAll(): Flow> + + @Query("SELECT * FROM conversations WHERE id = :id") + suspend fun getById(id: String): ConversationEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(conversation: ConversationEntity) + + @Query("DELETE FROM conversations WHERE id = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM conversations") + suspend fun deleteAll() +} diff --git a/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt b/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt new file mode 100644 index 0000000..02c4173 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt @@ -0,0 +1,24 @@ +package top.yeij.cyrene.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import top.yeij.cyrene.data.local.entity.MessageEntity + +@Dao +interface MessageDao { + + @Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY timestamp ASC") + fun getByConversation(conversationId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(message: MessageEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(messages: List) + + @Query("DELETE FROM messages WHERE conversationId = :conversationId") + suspend fun deleteByConversation(conversationId: String) +} diff --git a/app/src/main/java/top/yeij/cyrene/data/local/entity/ConversationEntity.kt b/app/src/main/java/top/yeij/cyrene/data/local/entity/ConversationEntity.kt new file mode 100644 index 0000000..10fc4ee --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/local/entity/ConversationEntity.kt @@ -0,0 +1,14 @@ +package top.yeij.cyrene.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "conversations") +data class ConversationEntity( + @PrimaryKey val id: String, + val title: String, + val lastMessage: String?, + val lastMessageType: String?, + val updatedAt: Long, + val createdAt: Long, +) diff --git a/app/src/main/java/top/yeij/cyrene/data/local/entity/MessageEntity.kt b/app/src/main/java/top/yeij/cyrene/data/local/entity/MessageEntity.kt new file mode 100644 index 0000000..328aa94 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/local/entity/MessageEntity.kt @@ -0,0 +1,27 @@ +package top.yeij.cyrene.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "messages", + foreignKeys = [ + ForeignKey( + entity = ConversationEntity::class, + parentColumns = ["id"], + childColumns = ["conversationId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("conversationId")], +) +data class MessageEntity( + @PrimaryKey val id: String, + val conversationId: String, + val role: String, + val content: String, + val msgType: String, + val timestamp: Long, +) diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt b/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt new file mode 100644 index 0000000..6de0fcc --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt @@ -0,0 +1,48 @@ +package top.yeij.cyrene.data.remote + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import top.yeij.cyrene.data.remote.dto.AuthRequest +import top.yeij.cyrene.data.remote.dto.AuthResponse +import top.yeij.cyrene.data.remote.dto.ConversationDto +import top.yeij.cyrene.data.remote.dto.DeviceDto +import top.yeij.cyrene.data.remote.dto.IoTControlRequest +import top.yeij.cyrene.data.remote.dto.ReminderDto + +interface ApiService { + + // Auth + @POST("api/v1/auth/login") + suspend fun login(@Body request: AuthRequest): Response + + @POST("api/v1/auth/refresh") + suspend fun refreshToken(@Body refreshToken: String): Response + + // Conversations + @GET("api/v1/conversations") + suspend fun getConversations(): Response> + + @DELETE("api/v1/conversations/{id}") + suspend fun deleteConversation(@Path("id") id: String): Response + + // IoT + @GET("api/v1/iot/devices") + suspend fun getDevices(): Response> + + @POST("api/v1/iot/devices/{id}/control") + suspend fun controlDevice( + @Path("id") deviceId: String, + @Body request: IoTControlRequest, + ): Response + + // Reminders + @GET("api/v1/reminders") + suspend fun getReminders(): Response> + + @DELETE("api/v1/reminders/{id}") + suspend fun deleteReminder(@Path("id") id: String): Response +} diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/AuthInterceptor.kt b/app/src/main/java/top/yeij/cyrene/data/remote/AuthInterceptor.kt new file mode 100644 index 0000000..a76cd05 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/remote/AuthInterceptor.kt @@ -0,0 +1,21 @@ +package top.yeij.cyrene.data.remote + +import okhttp3.Interceptor +import okhttp3.Response + +class AuthInterceptor : Interceptor { + + @Volatile + var token: String? = null + + override fun intercept(chain: Interceptor.Chain): Response { + val request = if (!token.isNullOrEmpty()) { + chain.request().newBuilder() + .addHeader("Authorization", "Bearer $token") + .build() + } else { + chain.request() + } + return chain.proceed(request) + } +} diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/DynamicUrlInterceptor.kt b/app/src/main/java/top/yeij/cyrene/data/remote/DynamicUrlInterceptor.kt new file mode 100644 index 0000000..1a017d9 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/remote/DynamicUrlInterceptor.kt @@ -0,0 +1,37 @@ +package top.yeij.cyrene.data.remote + +import android.util.Log +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor +import okhttp3.Response + +class DynamicUrlInterceptor : Interceptor { + + companion object { + private const val TAG = "DynamicUrlInterceptor" + } + + @Volatile + var baseUrl: String = "http://10.0.2.2:8080/" + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val originalUrl = originalRequest.url + + val targetBase = baseUrl.trimEnd('/') + val parsed = targetBase.toHttpUrlOrNull() + if (parsed == null) { + Log.e(TAG, "Invalid baseUrl: '$baseUrl'") + return chain.proceed(chain.request()) + } + + val newUrl = originalUrl.newBuilder() + .scheme(parsed.scheme) + .host(parsed.host) + .port(parsed.port) + .build() + + Log.d(TAG, "Rewriting ${originalUrl} → $newUrl (base: $targetBase)") + return chain.proceed(originalRequest.newBuilder().url(newUrl).build()) + } +} diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt b/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt new file mode 100644 index 0000000..a68174a --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt @@ -0,0 +1,36 @@ +package top.yeij.cyrene.data.remote + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object RetrofitClient { + + fun provideOkHttpClient( + authInterceptor: AuthInterceptor, + dynamicUrlInterceptor: DynamicUrlInterceptor, + ): OkHttpClient { + val logging = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + return OkHttpClient.Builder() + .addInterceptor(dynamicUrlInterceptor) + .addInterceptor(authInterceptor) + .addInterceptor(logging) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + } + + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl("http://localhost/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } +} diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt new file mode 100644 index 0000000..037f509 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt @@ -0,0 +1,16 @@ +package top.yeij.cyrene.data.remote.dto + +import com.google.gson.annotations.SerializedName + +data class AuthRequest( + @SerializedName("username") val username: String, + @SerializedName("password") val password: String, +) + +data class AuthResponse( + @SerializedName("token") val token: String, + @SerializedName("refresh_token") val refreshToken: String?, + @SerializedName("username") val username: String?, + @SerializedName("user_id") val userId: String?, + @SerializedName("expires") val expires: Long? = null, +) diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt new file mode 100644 index 0000000..fffd7d5 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt @@ -0,0 +1,12 @@ +package top.yeij.cyrene.data.remote.dto + +import com.google.gson.annotations.SerializedName + +data class ConversationDto( + @SerializedName("id") val id: String, + @SerializedName("title") val title: String, + @SerializedName("last_message") val lastMessage: String?, + @SerializedName("last_message_type") val lastMessageType: String?, + @SerializedName("updated_at") val updatedAt: String, + @SerializedName("created_at") val createdAt: String, +) diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/DeviceDto.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/DeviceDto.kt new file mode 100644 index 0000000..16cc8bd --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/DeviceDto.kt @@ -0,0 +1,26 @@ +package top.yeij.cyrene.data.remote.dto + +import com.google.gson.annotations.SerializedName + +data class DeviceDto( + @SerializedName("id") val id: String, + @SerializedName("name") val name: String, + @SerializedName("type") val type: String, + @SerializedName("state") val state: DeviceState, + @SerializedName("room") val room: String?, +) + +data class DeviceState( + @SerializedName("power") val power: Boolean?, + @SerializedName("brightness") val brightness: Int?, + @SerializedName("temperature") val temperature: Float?, + @SerializedName("humidity") val humidity: Float?, + @SerializedName("colorTemp") val colorTemp: Int?, + @SerializedName("locked") val locked: Boolean?, + @SerializedName("open") val open: Boolean?, +) + +data class IoTControlRequest( + @SerializedName("action") val action: String, + @SerializedName("value") val value: Any? = null, +) diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt new file mode 100644 index 0000000..4e23e9b --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt @@ -0,0 +1,10 @@ +package top.yeij.cyrene.data.remote.dto + +import com.google.gson.annotations.SerializedName + +data class ReminderDto( + @SerializedName("id") val id: String, + @SerializedName("content") val content: String, + @SerializedName("trigger_at") val triggerAt: String, + @SerializedName("completed") val completed: Boolean, +) diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt new file mode 100644 index 0000000..67846a8 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt @@ -0,0 +1,73 @@ +package top.yeij.cyrene.data.remote.dto + +import com.google.gson.annotations.SerializedName + +// --- Client → Server --- + +data class WSClientMessage( + @SerializedName("type") val type: String, + @SerializedName("session_id") val sessionId: String? = null, + @SerializedName("mode") val mode: String? = null, + @SerializedName("content") val content: String? = null, + @SerializedName("timestamp") val timestamp: Long? = null, + @SerializedName("client_id") val clientId: String? = null, + @SerializedName("device_name") val deviceName: String? = null, + @SerializedName("user_agent") val userAgent: String? = null, +) + +// --- Server → Client --- + +data class WSClientInfo( + @SerializedName("client_id") val clientId: String? = null, + @SerializedName("device_name") val deviceName: String? = null, +) + +data class WSServerMessage( + @SerializedName("type") val type: String?, + @SerializedName("message_id") val messageId: String? = null, + @SerializedName("text") val text: String? = null, + @SerializedName("content") val content: String? = null, + @SerializedName("role") val role: String? = null, + @SerializedName("msg_type") val msgType: String? = null, + @SerializedName("session_id") val sessionId: String? = null, + @SerializedName("error") val error: String? = null, + @SerializedName("review_messages") val reviewMessages: List? = null, + @SerializedName("messages") val messages: List? = null, + @SerializedName("multi_message") val multiMessages: List? = null, + @SerializedName("tool_progress") val toolProgress: WSToolProgress? = null, + @SerializedName("thinking_status") val thinkingStatus: String? = null, + @SerializedName("thinking_content") val thinkingContent: String? = null, + @SerializedName("timestamp") val timestamp: Long? = null, + @SerializedName("client_info") val clientInfo: WSClientInfo? = null, +) + +data class WSReviewMessage( + @SerializedName("role") val role: String?, + @SerializedName("text") val text: String?, + @SerializedName("content") val content: String?, + @SerializedName("msg_type") val msgType: String?, + @SerializedName("delay_ms") val delayMs: Long? = 0, +) + +data class WSHistoryMessage( + @SerializedName("id") val id: String?, + @SerializedName("role") val role: String?, + @SerializedName("content") val content: String?, + @SerializedName("msg_type") val msgType: String?, + @SerializedName("timestamp") val timestamp: Long?, + @SerializedName("name") val name: String?, +) + +data class WSMultiItem( + @SerializedName("role") val role: String?, + @SerializedName("content") val content: String?, + @SerializedName("msg_type") val msgType: String?, +) + +data class WSToolProgress( + @SerializedName("tool_name") val toolName: String?, + @SerializedName("status") val status: String?, + @SerializedName("detail") val detail: String?, + @SerializedName("message") val message: String?, + @SerializedName("progress") val progress: Int? = null, +) diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..744ea8a --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,49 @@ +package top.yeij.cyrene.data.repository + +import top.yeij.cyrene.data.local.PreferencesDataStore +import top.yeij.cyrene.data.remote.ApiService +import top.yeij.cyrene.data.remote.AuthInterceptor +import top.yeij.cyrene.data.remote.dto.AuthRequest +import top.yeij.cyrene.domain.model.AuthResult +import top.yeij.cyrene.domain.repository.AuthRepository +import kotlinx.coroutines.flow.firstOrNull + +class AuthRepositoryImpl( + private val apiService: ApiService, + private val preferencesDataStore: PreferencesDataStore, + private val authInterceptor: AuthInterceptor, +) : AuthRepository { + + override suspend fun login(username: String, password: String): Result { + return try { + val response = apiService.login(AuthRequest(username, password)) + if (response.isSuccessful) { + val body = response.body()!! + authInterceptor.token = body.token + preferencesDataStore.saveToken(body.token) + body.refreshToken?.let { preferencesDataStore.saveRefreshToken(it) } + preferencesDataStore.saveUsername(body.username ?: body.userId ?: "开拓者") + Result.success( + AuthResult( + token = body.token, + refreshToken = body.refreshToken, + username = body.username ?: body.userId ?: "开拓者", + ) + ) + } else { + Result.failure(Exception("登录失败: ${response.code()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun logout() { + authInterceptor.token = null + preferencesDataStore.clearAll() + } + + override suspend fun isLoggedIn(): Boolean { + return preferencesDataStore.token.firstOrNull() != null + } +} diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt new file mode 100644 index 0000000..9d57603 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt @@ -0,0 +1,379 @@ +package top.yeij.cyrene.data.repository + +import android.util.Log +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import top.yeij.cyrene.data.local.dao.ConversationDao +import top.yeij.cyrene.data.local.dao.MessageDao +import top.yeij.cyrene.data.local.entity.ConversationEntity +import top.yeij.cyrene.data.local.entity.MessageEntity +import top.yeij.cyrene.data.remote.ApiService +import top.yeij.cyrene.data.remote.dto.WSServerMessage +import top.yeij.cyrene.domain.model.Conversation +import top.yeij.cyrene.domain.model.Message +import top.yeij.cyrene.domain.repository.ChatRepository +import top.yeij.cyrene.service.WebSocketService +import java.util.UUID + +class ChatRepositoryImpl( + private val conversationDao: ConversationDao, + private val messageDao: MessageDao, + private val webSocketService: WebSocketService, + private val apiService: ApiService, +) : ChatRepository { + + private val exceptionHandler = CoroutineExceptionHandler { _, e -> + Log.e("ChatRepository", "Unhandled exception", e) + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler) + + private val _connectionState = MutableStateFlow(false) + override val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) + override fun observeMessages(): Flow = _incomingMessages + + private var streamingContent = "" + private var streamingMessageId: String? = null + private var currentSessionId: String? = null + + init { + scope.launch { + webSocketService.isConnected.collect { connected -> + _connectionState.value = connected + } + } + scope.launch { + webSocketService.incomingMessages.collect { wsMsg -> + try { + handleServerMessage(wsMsg) + } catch (e: Exception) { + Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e) + } + } + } + } + + override fun getConversations(): Flow> { + return conversationDao.getAll().map { entities -> + entities.map { it.toDomain() } + } + } + + override suspend fun getMessages(conversationId: String): Flow> { + return messageDao.getByConversation(conversationId).map { entities -> + entities.map { it.toDomain() } + } + } + + override suspend fun deleteConversation(id: String) { + conversationDao.deleteById(id) + try { apiService.deleteConversation(id) } catch (_: Exception) { } + } + + override suspend fun connectWebSocket(sessionId: String?) { + currentSessionId = sessionId + webSocketService.connect(sessionId) + } + + override suspend fun disconnectWebSocket() { + webSocketService.disconnect() + } + + override suspend fun sendMessage(content: String, sessionId: String?) { + val messageId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + val sid = sessionId ?: currentSessionId ?: "default" + currentSessionId = sid + + conversationDao.upsert( + ConversationEntity( + id = sid, + title = "对话", + lastMessage = content, + lastMessageType = "chat", + updatedAt = now, + createdAt = now, + ) + ) + + messageDao.upsert( + MessageEntity( + id = messageId, + conversationId = sid, + role = "user", + content = content, + msgType = "chat", + timestamp = now, + ) + ) + + // Emit user message to UI + emitMessage( + id = messageId, + sessionId = sid, + role = "user", + content = content, + msgType = "chat", + timestamp = now, + isStreaming = false, + ) + + webSocketService.sendMessage(content, sid) + } + + override suspend fun loadConversationsFromServer() { + try { + val response = apiService.getConversations() + if (response.isSuccessful) { + response.body()?.forEach { dto -> + val timestamp = try { dto.updatedAt.toLong() } catch (_: Exception) { System.currentTimeMillis() } + conversationDao.upsert( + ConversationEntity( + id = dto.id, + title = dto.title, + lastMessage = dto.lastMessage, + lastMessageType = dto.lastMessageType, + updatedAt = timestamp, + createdAt = timestamp, + ) + ) + } + } + } catch (_: Exception) { } + } + + override suspend fun loadMessagesFromServer(sessionId: String): List { + currentSessionId = sessionId + // Send history request via WebSocket + webSocketService.requestHistory(sessionId) + return emptyList() + } + + private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") { + val existing = conversationDao.getById(sessionId) + if (existing == null) { + val now = System.currentTimeMillis() + conversationDao.upsert( + ConversationEntity( + id = sessionId, + title = "对话", + lastMessage = lastMessage, + lastMessageType = "chat", + updatedAt = now, + createdAt = now, + ) + ) + } + } + + private suspend fun handleServerMessage(wsMsg: WSServerMessage) { + when (wsMsg.type) { + "stream_start" -> { + streamingContent = "" + streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}" + } + + "stream_chunk" -> { + val delta = wsMsg.content ?: wsMsg.text ?: return + streamingContent += delta + emitMessage( + id = streamingMessageId ?: "s_${System.currentTimeMillis()}", + sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", + role = "assistant", + content = streamingContent, + msgType = "chat", + isStreaming = true, + ) + } + + "stream_end" -> { + val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}" + val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" } + streamingContent = "" + streamingMessageId = null + val sid = wsMsg.sessionId ?: currentSessionId ?: "default" + currentSessionId = sid + val ts = wsMsg.timestamp ?: System.currentTimeMillis() + + ensureConversation(sid, content) + messageDao.upsert( + MessageEntity( + id = msgId, + conversationId = sid, + role = "assistant", + content = content, + msgType = "chat", + timestamp = ts, + ) + ) + + emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false) + } + + "response" -> { + val text = wsMsg.content ?: wsMsg.text ?: return + val role = wsMsg.role ?: "assistant" + val replyMsgType = wsMsg.msgType ?: "chat" + val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}" + val sid = wsMsg.sessionId ?: currentSessionId ?: "default" + currentSessionId = sid + val ts = wsMsg.timestamp ?: System.currentTimeMillis() + + ensureConversation(sid, text) + messageDao.upsert( + MessageEntity( + id = msgId, + conversationId = sid, + role = role, + content = text, + msgType = replyMsgType, + timestamp = ts, + ) + ) + + emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false) + } + + "review" -> { + wsMsg.reviewMessages?.forEach { review -> + val text = review.content ?: review.text ?: return@forEach + val role = review.role ?: "action" + val rvMsgType = review.msgType ?: review.role ?: "action" + val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}" + + emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false) + } + } + + "thinking" -> { + val text = wsMsg.thinkingContent ?: wsMsg.content ?: wsMsg.text + if (text != null) { + emitMessage( + id = wsMsg.messageId ?: "think_${System.currentTimeMillis()}", + sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", + role = "assistant", + content = text, + msgType = "thinking", + isStreaming = false, + ) + } + } + + "tool_progress" -> { + val detail = wsMsg.toolProgress?.message + ?: wsMsg.toolProgress?.detail + ?: wsMsg.toolProgress?.toolName + ?: "正在执行操作..." + emitMessage( + id = wsMsg.messageId ?: "tool_${System.currentTimeMillis()}", + sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", + role = "system", + content = detail, + msgType = "tool_progress", + isStreaming = false, + ) + } + + "error" -> { + emitMessage( + id = "err_${System.currentTimeMillis()}", + sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", + role = "system", + content = wsMsg.error ?: "未知错误", + msgType = "system_info", + isStreaming = false, + ) + } + + "history_response" -> { + val sid = wsMsg.sessionId ?: currentSessionId ?: "default" + ensureConversation(sid) + wsMsg.messages?.forEach { hist -> + val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}" + val role = hist.role ?: "system" + val content = hist.content ?: "" + val msgType = hist.msgType ?: "chat" + val ts = hist.timestamp ?: System.currentTimeMillis() + + messageDao.upsert( + MessageEntity( + id = msgId, + conversationId = sid, + role = role, + content = content, + msgType = msgType, + timestamp = ts, + ) + ) + + emitMessage(id = msgId, sessionId = sid, role = role, content = content, msgType = msgType, timestamp = ts, isStreaming = false) + } + } + + "multi_message" -> { + wsMsg.multiMessages?.forEach { item -> + emitMessage( + id = "mm_${System.currentTimeMillis()}_${item.hashCode()}", + sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", + role = item.role ?: "assistant", + content = item.content ?: "", + msgType = item.msgType ?: "chat", + timestamp = wsMsg.timestamp ?: System.currentTimeMillis(), + isStreaming = false, + ) + } + } + } + } + + private fun emitMessage( + id: String, + sessionId: String, + role: String, + content: String, + msgType: String, + isStreaming: Boolean = false, + timestamp: Long = System.currentTimeMillis(), + ) { + // Skip messages with empty content to prevent empty bubbles + if (content.isBlank() && msgType == "chat") return + val message = Message( + id = id, + conversationId = sessionId, + role = role, + content = content, + msgType = msgType, + timestamp = timestamp, + isStreaming = isStreaming, + ) + _incomingMessages.tryEmit(message) + } + + private fun ConversationEntity.toDomain() = Conversation( + id = id, + title = title, + lastMessage = lastMessage, + lastMessageType = lastMessageType, + updatedAt = updatedAt, + createdAt = createdAt, + ) + + private fun MessageEntity.toDomain() = Message( + id = id, + conversationId = conversationId, + role = role, + content = content, + msgType = msgType, + timestamp = timestamp, + ) +} diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/IoTRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/IoTRepositoryImpl.kt new file mode 100644 index 0000000..05b8b8c --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/repository/IoTRepositoryImpl.kt @@ -0,0 +1,70 @@ +package top.yeij.cyrene.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import top.yeij.cyrene.data.remote.ApiService +import top.yeij.cyrene.data.remote.dto.IoTControlRequest +import top.yeij.cyrene.domain.model.Device +import top.yeij.cyrene.domain.model.DeviceState +import top.yeij.cyrene.domain.model.DeviceType +import top.yeij.cyrene.domain.repository.IoTRepository + +class IoTRepositoryImpl( + private val apiService: ApiService, + private val webSocketService: top.yeij.cyrene.service.WebSocketService, +) : IoTRepository { + + private val _devices = MutableStateFlow>(emptyList()) + override fun getDevices(): Flow> = _devices.asStateFlow() + + override suspend fun controlDevice( + deviceId: String, + action: String, + value: Any?, + ): Result { + return try { + val response = apiService.controlDevice(deviceId, IoTControlRequest(action, value)) + if (response.isSuccessful) { + val updated = response.body()!!.toDomain() + _devices.value = _devices.value.map { + if (it.id == deviceId) updated else it + } + Result.success(updated) + } else { + Result.failure(Exception("设备控制失败: ${response.code()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun refreshDevices() { + try { + val response = apiService.getDevices() + if (response.isSuccessful) { + _devices.value = response.body()!!.map { it.toDomain() } + } + } catch (_: Exception) { } + } + + private fun top.yeij.cyrene.data.remote.dto.DeviceDto.toDomain() = Device( + id = id, + name = name, + type = try { + DeviceType.valueOf(type.uppercase()) + } catch (_: Exception) { + DeviceType.UNKNOWN + }, + state = DeviceState( + power = state.power, + brightness = state.brightness, + temperature = state.temperature, + humidity = state.humidity, + colorTemp = state.colorTemp, + locked = state.locked, + open = state.open, + ), + room = room, + ) +} diff --git a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt new file mode 100644 index 0000000..e8b03ef --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt @@ -0,0 +1,68 @@ +package top.yeij.cyrene.di + +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module +import top.yeij.cyrene.data.local.AppDatabase +import top.yeij.cyrene.data.local.PreferencesDataStore +import top.yeij.cyrene.data.remote.ApiService +import top.yeij.cyrene.data.remote.AuthInterceptor +import top.yeij.cyrene.data.remote.DynamicUrlInterceptor +import top.yeij.cyrene.data.remote.RetrofitClient +import top.yeij.cyrene.data.repository.AuthRepositoryImpl +import top.yeij.cyrene.data.repository.ChatRepositoryImpl +import top.yeij.cyrene.data.repository.IoTRepositoryImpl +import top.yeij.cyrene.domain.repository.AuthRepository +import top.yeij.cyrene.domain.repository.ChatRepository +import top.yeij.cyrene.domain.repository.IoTRepository +import top.yeij.cyrene.domain.usecase.GetConversationsUseCase +import top.yeij.cyrene.domain.usecase.LoginUseCase +import top.yeij.cyrene.domain.usecase.SendMessageUseCase +import top.yeij.cyrene.service.WebSocketService +import top.yeij.cyrene.viewmodel.ChatViewModel +import top.yeij.cyrene.viewmodel.IoTViewModel +import top.yeij.cyrene.viewmodel.OverlayViewModel +import top.yeij.cyrene.viewmodel.SettingsViewModel +import top.yeij.cyrene.voice.stt.SpeechRecognizer +import top.yeij.cyrene.voice.tts.TextToSpeechEngine + +val appModule = module { + + // DataStore + single { PreferencesDataStore(androidContext()) } + + // Database + single { AppDatabase.getInstance(androidContext()) } + single { get().conversationDao() } + single { get().messageDao() } + + // Network interceptors (no runBlocking — using @Volatile caches) + single { AuthInterceptor() } + single { DynamicUrlInterceptor() } + single { RetrofitClient.provideOkHttpClient(get(), get()) } + single { RetrofitClient.provideRetrofit(get()) } + single { get().create(ApiService::class.java) } + + // WebSocket + single { WebSocketService(get()) } + + // Voice + single { SpeechRecognizer() } + single { TextToSpeechEngine(androidContext()) } + + // Repositories + single { AuthRepositoryImpl(get(), get(), get()) } + single { ChatRepositoryImpl(get(), get(), get(), get()) } + single { IoTRepositoryImpl(get(), get()) } + + // UseCases + factory { LoginUseCase(get()) } + factory { SendMessageUseCase(get()) } + factory { GetConversationsUseCase(get()) } + + // ViewModels + viewModel { ChatViewModel(get()) } + viewModel { IoTViewModel(get()) } + viewModel { OverlayViewModel(get()) } + single { SettingsViewModel(get(), get(), get()) } +} diff --git a/app/src/main/java/top/yeij/cyrene/domain/model/AuthResult.kt b/app/src/main/java/top/yeij/cyrene/domain/model/AuthResult.kt new file mode 100644 index 0000000..2f720ff --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/domain/model/AuthResult.kt @@ -0,0 +1,7 @@ +package top.yeij.cyrene.domain.model + +data class AuthResult( + val token: String, + val refreshToken: String?, + val username: String, +) diff --git a/app/src/main/java/top/yeij/cyrene/domain/model/Conversation.kt b/app/src/main/java/top/yeij/cyrene/domain/model/Conversation.kt new file mode 100644 index 0000000..cef744d --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/domain/model/Conversation.kt @@ -0,0 +1,10 @@ +package top.yeij.cyrene.domain.model + +data class Conversation( + val id: String, + val title: String, + val lastMessage: String?, + val lastMessageType: String?, + val updatedAt: Long, + val createdAt: Long, +) diff --git a/app/src/main/java/top/yeij/cyrene/domain/model/Device.kt b/app/src/main/java/top/yeij/cyrene/domain/model/Device.kt new file mode 100644 index 0000000..1b41b25 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/domain/model/Device.kt @@ -0,0 +1,28 @@ +package top.yeij.cyrene.domain.model + +data class Device( + val id: String, + val name: String, + val type: DeviceType, + val state: DeviceState, + val room: String?, +) + +enum class DeviceType { + LIGHT, + AC, + CURTAIN, + SENSOR, + DOOR_LOCK, + UNKNOWN, +} + +data class DeviceState( + val power: Boolean? = null, + val brightness: Int? = null, + val temperature: Float? = null, + val humidity: Float? = null, + val colorTemp: Int? = null, + val locked: Boolean? = null, + val open: Boolean? = null, +) diff --git a/app/src/main/java/top/yeij/cyrene/domain/model/Message.kt b/app/src/main/java/top/yeij/cyrene/domain/model/Message.kt new file mode 100644 index 0000000..5e874d1 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/domain/model/Message.kt @@ -0,0 +1,11 @@ +package top.yeij.cyrene.domain.model + +data class Message( + val id: String, + val conversationId: String, + val role: String, + val content: String, + val msgType: String, + val timestamp: Long, + val isStreaming: Boolean = false, +) diff --git a/app/src/main/java/top/yeij/cyrene/domain/repository/AuthRepository.kt b/app/src/main/java/top/yeij/cyrene/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..9cb5f04 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/domain/repository/AuthRepository.kt @@ -0,0 +1,12 @@ +package top.yeij.cyrene.domain.repository + +import top.yeij.cyrene.domain.model.AuthResult + +interface AuthRepository { + + suspend fun login(username: String, password: String): Result + + suspend fun logout() + + suspend fun isLoggedIn(): Boolean +} diff --git a/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt new file mode 100644 index 0000000..de77254 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt @@ -0,0 +1,29 @@ +package top.yeij.cyrene.domain.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import top.yeij.cyrene.domain.model.Conversation +import top.yeij.cyrene.domain.model.Message + +interface ChatRepository { + + val connectionState: StateFlow + + fun getConversations(): Flow> + + suspend fun getMessages(conversationId: String): Flow> + + suspend fun deleteConversation(id: String) + + suspend fun connectWebSocket(sessionId: String?) + + suspend fun disconnectWebSocket() + + suspend fun sendMessage(content: String, sessionId: String?) + + fun observeMessages(): Flow + + suspend fun loadConversationsFromServer() + + suspend fun loadMessagesFromServer(sessionId: String): List +} diff --git a/app/src/main/java/top/yeij/cyrene/domain/repository/IoTRepository.kt b/app/src/main/java/top/yeij/cyrene/domain/repository/IoTRepository.kt new file mode 100644 index 0000000..912f7f8 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/domain/repository/IoTRepository.kt @@ -0,0 +1,13 @@ +package top.yeij.cyrene.domain.repository + +import kotlinx.coroutines.flow.Flow +import top.yeij.cyrene.domain.model.Device + +interface IoTRepository { + + fun getDevices(): Flow> + + suspend fun controlDevice(deviceId: String, action: String, value: Any? = null): Result + + suspend fun refreshDevices() +} diff --git a/app/src/main/java/top/yeij/cyrene/domain/usecase/GetConversationsUseCase.kt b/app/src/main/java/top/yeij/cyrene/domain/usecase/GetConversationsUseCase.kt new file mode 100644 index 0000000..5224bcd --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/domain/usecase/GetConversationsUseCase.kt @@ -0,0 +1,11 @@ +package top.yeij.cyrene.domain.usecase + +import kotlinx.coroutines.flow.Flow +import top.yeij.cyrene.domain.model.Conversation +import top.yeij.cyrene.domain.repository.ChatRepository + +class GetConversationsUseCase( + private val chatRepository: ChatRepository, +) { + operator fun invoke(): Flow> = chatRepository.getConversations() +} diff --git a/app/src/main/java/top/yeij/cyrene/domain/usecase/LoginUseCase.kt b/app/src/main/java/top/yeij/cyrene/domain/usecase/LoginUseCase.kt new file mode 100644 index 0000000..6dfd418 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/domain/usecase/LoginUseCase.kt @@ -0,0 +1,15 @@ +package top.yeij.cyrene.domain.usecase + +import top.yeij.cyrene.domain.model.AuthResult +import top.yeij.cyrene.domain.repository.AuthRepository + +class LoginUseCase( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(username: String, password: String): Result { + if (username.isBlank() || password.isBlank()) { + return Result.failure(IllegalArgumentException("用户名和密码不能为空")) + } + return authRepository.login(username, password) + } +} diff --git a/app/src/main/java/top/yeij/cyrene/domain/usecase/SendMessageUseCase.kt b/app/src/main/java/top/yeij/cyrene/domain/usecase/SendMessageUseCase.kt new file mode 100644 index 0000000..081c26f --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/domain/usecase/SendMessageUseCase.kt @@ -0,0 +1,13 @@ +package top.yeij.cyrene.domain.usecase + +import top.yeij.cyrene.domain.repository.ChatRepository + +class SendMessageUseCase( + private val chatRepository: ChatRepository, +) { + suspend operator fun invoke(content: String, sessionId: String? = null) { + if (content.isNotBlank()) { + chatRepository.sendMessage(content, sessionId) + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneRecognitionService.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneRecognitionService.kt new file mode 100644 index 0000000..66b6ce0 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/service/CyreneRecognitionService.kt @@ -0,0 +1,33 @@ +package top.yeij.cyrene.service + +import android.service.voice.VoiceInteractionService +import android.util.Log + +/** + * Recognition service for always-on hotword detection. + * + * In API 36, AlwaysOnHotwordDetector was removed from the public SDK. + * The system manages low-level hotword detection internally and invokes + * the session service declared in voice_interaction_config.xml when triggered. + * For custom hotword models (Porcupine / openWakeWord), integrate via + * a foreground service with microphone capture instead. + */ +class CyreneRecognitionService : VoiceInteractionService() { + + override fun onReady() { + super.onReady() + isActive = true + Log.i(TAG, "Recognition service ready") + } + + override fun onShutdown() { + isActive = false + super.onShutdown() + } + + companion object { + private const val TAG = "CyreneRecognition" + var isActive: Boolean = false + private set + } +} diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt new file mode 100644 index 0000000..d783021 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt @@ -0,0 +1,43 @@ +package top.yeij.cyrene.service + +import android.content.Intent +import android.os.Bundle +import android.service.voice.VoiceInteractionService +import top.yeij.cyrene.MainActivity +import top.yeij.cyrene.util.Constants + +class CyreneVoiceInteractionService : VoiceInteractionService() { + + override fun onReady() { + super.onReady() + isActive = true + } + + override fun onPrepareToShowSession(args: Bundle, showFlags: Int) { + // Called before the session is shown — populate args for the session. + // Starting from API 36, session creation is handled by the system + // based on android:sessionService in voice_interaction_config.xml. + } + + override fun onShowSessionFailed(args: Bundle) { + // Session failed to show — could be due to permissions or system state. + } + + override fun onLaunchVoiceAssistFromKeyguard() { + val intent = Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(Constants.EXTRA_VOICE_ASSIST, true) + } + startActivity(intent) + } + + override fun onShutdown() { + isActive = false + super.onShutdown() + } + + companion object { + var isActive: Boolean = false + private set + } +} diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt new file mode 100644 index 0000000..c174523 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt @@ -0,0 +1,50 @@ +package top.yeij.cyrene.service + +import android.content.Context +import android.os.Bundle +import android.service.voice.VoiceInteractionSession +import android.view.View +import androidx.compose.ui.platform.ComposeView +import org.koin.core.context.GlobalContext +import top.yeij.cyrene.ui.overlay.OverlayContent +import top.yeij.cyrene.ui.theme.CyreneTheme +import top.yeij.cyrene.util.Constants +import top.yeij.cyrene.voice.stt.SpeechRecognizer +import top.yeij.cyrene.voice.tts.TextToSpeechEngine + +class CyreneVoiceInteractionSession(context: Context) : + VoiceInteractionSession(context) { + + private val speechRecognizer: SpeechRecognizer by lazy { + GlobalContext.get().get() + } + private val ttsEngine: TextToSpeechEngine by lazy { + GlobalContext.get().get() + } + + override fun onCreateContentView(): View { + return ComposeView(context).apply { + setContent { + CyreneTheme { + OverlayContent( + onDismiss = { finish() }, + ) + } + } + } + } + + override fun onShow(args: Bundle?, showFlags: Int) { + super.onShow(args, showFlags) + val startListening = args?.getBoolean(Constants.EXTRA_START_LISTENING, false) ?: false + if (startListening) { + speechRecognizer.startListening() + } + } + + override fun onHide() { + super.onHide() + speechRecognizer.stopListening() + ttsEngine.stop() + } +} diff --git a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt new file mode 100644 index 0000000..b32dee4 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt @@ -0,0 +1,241 @@ +package top.yeij.cyrene.service + +import android.os.Build +import android.util.Log +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import top.yeij.cyrene.data.local.PreferencesDataStore +import top.yeij.cyrene.data.remote.dto.WSClientMessage +import top.yeij.cyrene.data.remote.dto.WSServerMessage +import java.net.URLEncoder +import java.util.concurrent.TimeUnit + +class WebSocketService( + private val preferencesDataStore: PreferencesDataStore, +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val httpClient = OkHttpClient.Builder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .writeTimeout(0, TimeUnit.MILLISECONDS) + .callTimeout(0, TimeUnit.MILLISECONDS) + .pingInterval(25, TimeUnit.SECONDS) + .build() + private val gson = Gson() + + private var webSocket: WebSocket? = null + private var heartbeatJob: Job? = null + private var reconnecting = false + private var shouldReconnect = true + private var currentSessionId: String? = null + + private var clientId: String = "" + private var deviceName: String = "" + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) + val incomingMessages: SharedFlow = _incomingMessages.asSharedFlow() + + private suspend fun initClientIdentity() { + clientId = preferencesDataStore.clientId.firstOrNull() ?: run { + val id = "cl_" + System.currentTimeMillis().toString(36) + "_" + + (100000..999999).random().toString(36) + preferencesDataStore.saveClientId(id) + id + } + deviceName = preferencesDataStore.deviceName.firstOrNull() ?: run { + val name = "Android " + (Build.MODEL ?: "Device") + preferencesDataStore.saveDeviceName(name) + name + } + } + + fun getClientId(): String = clientId + fun getDeviceName(): String = deviceName + + suspend fun connect(sessionId: String? = null) { + currentSessionId = sessionId + shouldReconnect = true + reconnecting = false + + initClientIdentity() + + val baseUrl = preferencesDataStore.baseUrl.firstOrNull() + ?: "http://10.0.2.2:8080" + val token = preferencesDataStore.token.firstOrNull() ?: "" + val wsBase = baseUrl + .replace("https://", "wss://") + .replace("http://", "ws://") + .trimEnd('/') + val urlBuilder = StringBuilder("$wsBase/ws/chat") + val params = mutableListOf() + if (token.isNotBlank()) { + params.add("token=" + encode(token)) + } + sessionId?.let { + if (it.isNotBlank()) params.add("session_id=" + encode(it)) + } + if (clientId.isNotBlank()) { + params.add("client_id=" + encode(clientId)) + } + if (deviceName.isNotBlank()) { + params.add("device_name=" + encode(deviceName)) + } + if (params.isNotEmpty()) { + urlBuilder.append("?") + urlBuilder.append(params.joinToString("&")) + } + + val url = urlBuilder.toString() + Log.i(TAG, "Connecting to $url") + + val request = Request.Builder() + .url(url) + .header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}") + .build() + + cancelHeartbeat() + webSocket?.close(1000, "Reconnecting") + webSocket = httpClient.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.i(TAG, "Connected") + reconnecting = false + _isConnected.value = true + startHeartbeat() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + try { + val msg = gson.fromJson(text, WSServerMessage::class.java) + _incomingMessages.tryEmit(msg) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse message: ${e.message}") + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + Log.i(TAG, "Server closing: code=$code reason=$reason") + _isConnected.value = false + cancelHeartbeat() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.i(TAG, "Closed: code=$code reason=$reason") + _isConnected.value = false + cancelHeartbeat() + scheduleReconnect() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "Connection failure: ${t.message} (response=${response?.code})", t) + _isConnected.value = false + cancelHeartbeat() + scheduleReconnect() + } + }) + } + + private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8") + + private fun buildMessage( + type: String, + sessionId: String? = null, + mode: String? = null, + content: String? = null, + ): WSClientMessage = WSClientMessage( + type = type, + sessionId = sessionId ?: currentSessionId, + mode = mode, + content = content, + timestamp = System.currentTimeMillis(), + clientId = clientId.ifBlank { null }, + deviceName = deviceName.ifBlank { null }, + userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}", + ) + + fun sendMessage(content: String, sessionId: String? = null, mode: String = "text") { + val msg = buildMessage("message", sessionId, mode, content) + webSocket?.send(gson.toJson(msg)) + } + + fun requestHistory(sessionId: String?) { + val msg = buildMessage("history", sessionId) + webSocket?.send(gson.toJson(msg)) + } + + fun sendPing() { + val msg = buildMessage("ping") + webSocket?.send(gson.toJson(msg)) + } + + fun disconnect() { + shouldReconnect = false + reconnecting = false + cancelHeartbeat() + webSocket?.close(1000, "User disconnected") + webSocket = null + _isConnected.value = false + } + + private fun startHeartbeat() { + cancelHeartbeat() + heartbeatJob = scope.launch { + while (_isConnected.value) { + delay(30_000) + if (_isConnected.value) { + sendPing() + } + } + } + } + + private fun cancelHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = null + } + + private fun scheduleReconnect() { + if (reconnecting || !shouldReconnect) return + reconnecting = true + scope.launch { + var attempt = 0 + while (attempt < 5 && shouldReconnect && !_isConnected.value) { + val delayMs = (Math.pow(2.0, attempt.toDouble()) * 1000).toLong() + Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1}/5)") + delay(delayMs) + attempt++ + if (shouldReconnect && !_isConnected.value) { + try { + connect(currentSessionId) + } catch (e: Exception) { + Log.e(TAG, "Reconnect attempt $attempt failed: ${e.message}", e) + } + } + } + reconnecting = false + } + } + + companion object { + private const val TAG = "CyreneWS" + } +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt new file mode 100644 index 0000000..522b362 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt @@ -0,0 +1,164 @@ +package top.yeij.cyrene.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun ChatBubble( + content: String, + role: String, + msgType: String, + timestamp: Long, + modifier: Modifier = Modifier, +) { + val isUser = role == "user" + val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp)) + + when (msgType) { + "chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier) + "action" -> ActionMessage(content, modifier) + "thinking" -> ThinkingBubble(content, modifier) + "tool_progress" -> ToolProgressBubble(content, modifier) + "system_info" -> SystemInfoBubble(content, modifier) + else -> ChatMessageBubble(content, isUser, formattedTime, modifier) + } +} + +@Composable +private fun ChatMessageBubble( + content: String, + isUser: Boolean, + time: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + ) { + Column( + horizontalAlignment = if (isUser) Alignment.End else Alignment.Start, + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = if (isUser) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.widthIn(max = 300.dp), + ) { + Text( + text = content, + modifier = Modifier.padding(12.dp), + color = if (isUser) + MaterialTheme.colorScheme.onPrimary + else + MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = time, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + } +} + +@Composable +private fun ActionMessage(content: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = content, + style = MaterialTheme.typography.bodyMedium.copy( + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = MaterialTheme.shapes.medium, + ), + ) { + Text( + text = content, + modifier = Modifier.padding(10.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.Start, + ) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f), + ) { + Text( + text = content, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } +} + +@Composable +private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = content, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/DeviceCard.kt b/app/src/main/java/top/yeij/cyrene/ui/components/DeviceCard.kt new file mode 100644 index 0000000..d05447c --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/components/DeviceCard.kt @@ -0,0 +1,98 @@ +package top.yeij.cyrene.ui.components + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AcUnit +import androidx.compose.material.icons.filled.Curtains +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Sensors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import top.yeij.cyrene.domain.model.Device +import top.yeij.cyrene.domain.model.DeviceType + +@Composable +fun DeviceCard( + device: Device, + onTogglePower: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = device.type.toIcon(), + contentDescription = device.type.name, + tint = if (device.state.power == true) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column { + Text( + text = device.name, + style = MaterialTheme.typography.titleMedium, + ) + device.room?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + device.state.temperature?.let { temp -> + Text( + text = "${"%.1f".format(temp)}°C", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (device.state.power != null) { + Switch( + checked = device.state.power, + onCheckedChange = { onTogglePower() }, + ) + } + } + } +} + +private fun DeviceType.toIcon() = when (this) { + DeviceType.LIGHT -> Icons.Filled.Lightbulb + DeviceType.AC -> Icons.Filled.AcUnit + DeviceType.CURTAIN -> Icons.Filled.Curtains + DeviceType.SENSOR -> Icons.Filled.Sensors + DeviceType.DOOR_LOCK -> Icons.Filled.Lock + DeviceType.UNKNOWN -> Icons.Filled.Sensors +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/StatusIndicator.kt b/app/src/main/java/top/yeij/cyrene/ui/components/StatusIndicator.kt new file mode 100644 index 0000000..d23a0b4 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/components/StatusIndicator.kt @@ -0,0 +1,94 @@ +package top.yeij.cyrene.ui.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +enum class CyreneStatus { + ONLINE, + THINKING, + SPEAKING, + OFFLINE, +} + +@Composable +fun StatusIndicator( + status: CyreneStatus, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + when (status) { + CyreneStatus.ONLINE -> { + Icon( + Icons.Filled.Circle, + contentDescription = null, + modifier = Modifier.size(8.dp), + tint = Color(0xFF4CAF50), + ) + Text("昔涟", style = MaterialTheme.typography.labelLarge) + } + CyreneStatus.THINKING -> { + PulsingDot(Color(0xFFFFA726)) + Text("思考中…", style = MaterialTheme.typography.labelLarge) + } + CyreneStatus.SPEAKING -> { + PulsingDot(Color(0xFF42A5F5)) + Text("正在说话…", style = MaterialTheme.typography.labelLarge) + } + CyreneStatus.OFFLINE -> { + Icon( + Icons.Filled.Circle, + contentDescription = null, + modifier = Modifier.size(8.dp), + tint = Color(0xFF9E9E9E), + ) + Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge) + } + } + } +} + +@Composable +private fun PulsingDot(color: Color) { + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(600), + repeatMode = RepeatMode.Reverse, + ), + label = "pulse_alpha", + ) + + Box( + modifier = Modifier + .size(8.dp) + .alpha(alpha) + .background(color, CircleShape), + ) +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..ea73a23 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt @@ -0,0 +1,143 @@ +package top.yeij.cyrene.ui.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.DevicesOther +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import org.koin.compose.koinInject +import top.yeij.cyrene.ui.screens.chat.ChatScreen +import top.yeij.cyrene.ui.screens.iot.IoTScreen +import top.yeij.cyrene.ui.screens.login.LoginScreen +import top.yeij.cyrene.ui.screens.profile.ProfileScreen +import top.yeij.cyrene.ui.screens.settings.SettingsScreen +import top.yeij.cyrene.viewmodel.SettingsViewModel + +object Routes { + const val LOGIN = "login" + const val MAIN = "main" + const val CHAT = "chat" + const val IOT = "iot" + const val SETTINGS = "settings" +} + +@Composable +fun CyreneNavGraph( + navController: NavHostController, + startDestination: String, + isDefaultAssistant: Boolean, + onOpenAssistantSettings: () -> Unit, +) { + NavHost( + navController = navController, + startDestination = startDestination, + ) { + composable(Routes.LOGIN) { + LoginScreen( + onLoginSuccess = { + navController.navigate(Routes.MAIN) { + popUpTo(Routes.LOGIN) { inclusive = true } + } + }, + ) + } + + composable(Routes.MAIN) { + MainScreen( + navController = navController, + isDefaultAssistant = isDefaultAssistant, + onOpenAssistantSettings = onOpenAssistantSettings, + ) + } + + composable(Routes.SETTINGS) { + SettingsScreen( + onBack = { navController.popBackStack() }, + ) + } + } +} + +data class BottomNavItem( + val label: String, + val icon: @Composable () -> Unit, + val route: String, +) + +@Composable +fun MainScreen( + navController: NavHostController, + isDefaultAssistant: Boolean, + onOpenAssistantSettings: () -> Unit, +) { + val settingsViewModel: SettingsViewModel = koinInject() + + val items = listOf( + BottomNavItem( + label = "对话", + icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = "对话") }, + route = Routes.CHAT, + ), + BottomNavItem( + label = "设备", + icon = { Icon(Icons.Filled.DevicesOther, contentDescription = "设备") }, + route = Routes.IOT, + ), + BottomNavItem( + label = "我的", + icon = { Icon(Icons.Filled.Person, contentDescription = "我的") }, + route = "profile", + ), + ) + + var selectedTab by rememberSaveable { mutableIntStateOf(0) } + + Scaffold( + bottomBar = { + NavigationBar { + items.forEachIndexed { index, item -> + NavigationBarItem( + selected = selectedTab == index, + onClick = { selectedTab = index }, + icon = item.icon, + label = { Text(item.label) }, + ) + } + } + }, + ) { padding -> + Box(modifier = Modifier.padding(padding)) { + when (selectedTab) { + 0 -> ChatScreen() + 1 -> IoTScreen() + 2 -> ProfileScreen( + onNavigateToSettings = { navController.navigate(Routes.SETTINGS) }, + onLogout = { + settingsViewModel.logout() + navController.navigate(Routes.LOGIN) { + popUpTo(Routes.MAIN) { inclusive = true } + } + }, + onNavigateToLogin = { + navController.navigate(Routes.LOGIN) + }, + ) + } + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt new file mode 100644 index 0000000..33b570d --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt @@ -0,0 +1,203 @@ +package top.yeij.cyrene.ui.overlay + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +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.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject +import top.yeij.cyrene.ui.components.ChatBubble +import top.yeij.cyrene.ui.components.CyreneStatus +import top.yeij.cyrene.ui.components.StatusIndicator +import top.yeij.cyrene.viewmodel.OverlayState +import top.yeij.cyrene.viewmodel.OverlayViewModel + +@Composable +fun OverlayContent( + onDismiss: () -> Unit, + viewModel: OverlayViewModel = koinInject(), +) { + val state by viewModel.state.collectAsState() + val messages by viewModel.messages.collectAsState() + val recognizedText by viewModel.recognizedText.collectAsState() + val listState = rememberLazyListState() + + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(messages.size - 1) + } + } + + LaunchedEffect(state) { + if (state == OverlayState.IDLE) { + viewModel.finish() + onDismiss() + } + } + + AnimatedVisibility( + visible = state != OverlayState.IDLE, + enter = fadeIn() + slideInVertically { it }, + exit = fadeOut() + slideOutVertically { it }, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onDismiss() }, + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { /* consume click */ }, + shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), + shadowElevation = 8.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + StatusIndicator( + status = when (state) { + OverlayState.LISTENING -> CyreneStatus.ONLINE + OverlayState.PROCESSING -> CyreneStatus.THINKING + OverlayState.SPEAKING -> CyreneStatus.SPEAKING + OverlayState.WAITING -> CyreneStatus.ONLINE + OverlayState.IDLE -> CyreneStatus.ONLINE + }, + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { onDismiss() }) { + Icon(Icons.Filled.Close, contentDescription = "关闭") + } + } + + // Messages + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .height(200.dp), + state = listState, + ) { + items(messages, key = { it.id }) { message -> + ChatBubble( + content = message.content, + role = message.role, + msgType = message.msgType, + timestamp = message.timestamp, + ) + } + } + + // Recognized text display + if (recognizedText.isNotEmpty()) { + Text( + text = recognizedText, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + // Action button + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + ) { + Button( + onClick = { + when (state) { + OverlayState.LISTENING -> { + viewModel.onSpeechFinal(recognizedText) + } + OverlayState.WAITING -> { + viewModel.startListening() + } + else -> { } + } + }, + shape = CircleShape, + modifier = Modifier.size(64.dp), + colors = ButtonDefaults.buttonColors( + containerColor = when (state) { + OverlayState.LISTENING -> MaterialTheme.colorScheme.error + OverlayState.PROCESSING -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.primary + }, + ), + ) { + Icon( + Icons.Filled.Mic, + contentDescription = "语音", + modifier = Modifier.size(32.dp), + ) + } + } + + Text( + text = when (state) { + OverlayState.IDLE -> "" + OverlayState.LISTENING -> "我在听…" + OverlayState.PROCESSING -> "思考中…" + OverlayState.SPEAKING -> "正在说话…" + OverlayState.WAITING -> "点击继续说话" + }, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt new file mode 100644 index 0000000..869eb94 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt @@ -0,0 +1,197 @@ +package top.yeij.cyrene.ui.screens.chat + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject +import top.yeij.cyrene.ui.components.ChatBubble +import top.yeij.cyrene.ui.components.CyreneStatus +import top.yeij.cyrene.ui.components.StatusIndicator +import top.yeij.cyrene.viewmodel.ChatViewModel + +@Composable +fun ChatScreen( + viewModel: ChatViewModel = koinInject(), +) { + val messages by viewModel.currentMessages.collectAsState() + val inputText by viewModel.inputText.collectAsState() + val isStreaming by viewModel.isStreaming.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val listState = rememberLazyListState() + + LaunchedEffect(messages.size, isStreaming) { + if (messages.isNotEmpty()) { + val targetIndex = if (isStreaming) messages.size else messages.size - 1 + listState.animateScrollToItem(targetIndex) + } + } + + val status = when { + isStreaming -> CyreneStatus.THINKING + isConnected -> CyreneStatus.ONLINE + else -> CyreneStatus.OFFLINE + } + + Scaffold( + topBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + StatusIndicator(status = status) + } + }, + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = inputText, + onValueChange = { viewModel.onInputChanged(it) }, + placeholder = { Text("输入消息...") }, + modifier = Modifier.weight(1f), + maxLines = 4, + shape = MaterialTheme.shapes.medium, + ) + IconButton( + onClick = { viewModel.sendMessage() }, + enabled = inputText.isNotBlank() && !isStreaming, + ) { + if (isStreaming) { + CircularProgressIndicator( + modifier = Modifier.padding(4.dp), + strokeWidth = 2.dp, + ) + } else { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送") + } + } + } + }, + ) { padding -> + if (messages.isEmpty() && !isStreaming) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "开始和昔涟对话吧", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + state = listState, + ) { + items(messages, key = { it.id }) { message -> + ChatBubble( + content = message.content, + role = message.role, + msgType = message.msgType, + timestamp = message.timestamp, + ) + } + if (isStreaming) { + item(key = "typing_indicator") { + TypingIndicator() + } + } + } + } + } +} + +@Composable +private fun TypingIndicator(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "typing") + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.Start, + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "昔涟正在输入", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + repeat(3) { index -> + val alpha by infiniteTransition.animateFloat( + initialValue = 0.2f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 400, + delayMillis = index * 200, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "dot_$index", + ) + Box( + modifier = Modifier + .size(5.dp) + .alpha(alpha) + .background( + MaterialTheme.colorScheme.onSurfaceVariant, + CircleShape, + ), + ) + } + } + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt new file mode 100644 index 0000000..5ca770a --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt @@ -0,0 +1,57 @@ +package top.yeij.cyrene.ui.screens.iot + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject +import top.yeij.cyrene.ui.components.DeviceCard +import top.yeij.cyrene.viewmodel.IoTViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IoTScreen( + viewModel: IoTViewModel = koinInject(), +) { + val devices by viewModel.devices.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + PullToRefreshBox( + isRefreshing = isLoading, + onRefresh = { viewModel.refreshDevices() }, + ) { + if (devices.isEmpty() && !isLoading) { + Box( + modifier = Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "暂无设备数据\n请确认已连接到服务器", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn { + items(devices, key = { it.id }) { device -> + DeviceCard( + device = device, + onTogglePower = { viewModel.togglePower(device) }, + ) + } + } + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/login/LoginScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/login/LoginScreen.kt new file mode 100644 index 0000000..302404e --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/login/LoginScreen.kt @@ -0,0 +1,165 @@ +package top.yeij.cyrene.ui.screens.login + +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.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import top.yeij.cyrene.domain.usecase.LoginUseCase +import top.yeij.cyrene.viewmodel.SettingsViewModel + +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, + loginUseCase: LoginUseCase = koinInject(), + settingsViewModel: SettingsViewModel = koinInject(), +) { + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(80.dp)) + + Icon( + imageVector = Icons.Filled.Person, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Cyrene", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "昔涟", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(48.dp)) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("用户名") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("密码") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + visualTransformation = if (showPassword) + VisualTransformation.None + else + PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + singleLine = true, + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (showPassword) "隐藏密码" else "显示密码", + ) + } + }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + isLoading = true + scope.launch { + loginUseCase(username, password).fold( + onSuccess = { + isLoading = false + onLoginSuccess() + }, + onFailure = { error -> + isLoading = false + scope.launch { + snackbarHostState.showSnackbar( + error.message ?: "登录失败", + ) + } + }, + ) + } + }, + modifier = Modifier.fillMaxWidth().height(50.dp), + enabled = username.isNotBlank() && password.isNotBlank() && !isLoading, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("登录") + } + } + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt new file mode 100644 index 0000000..8549079 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt @@ -0,0 +1,134 @@ +package top.yeij.cyrene.ui.screens.profile + +import androidx.compose.foundation.clickable +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.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject +import top.yeij.cyrene.viewmodel.SettingsViewModel + +@Composable +fun ProfileScreen( + onNavigateToSettings: () -> Unit, + onLogout: () -> Unit, + onNavigateToLogin: () -> Unit, + settingsViewModel: SettingsViewModel = koinInject(), +) { + val username by settingsViewModel.username.collectAsState() + val isLoggedIn by settingsViewModel.isLoggedIn.collectAsState() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + // Profile header + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + ) { + Text( + text = if (isLoggedIn) username.ifEmpty { "开拓者" } else "未登录", + style = MaterialTheme.typography.headlineMedium, + color = if (!isLoggedIn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = if (!isLoggedIn) { + Modifier.clickable { onNavigateToLogin() } + } else { + Modifier + }, + ) + Text( + text = "与昔涟同行", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + item { HorizontalDivider() } + item { Spacer(modifier = Modifier.height(8.dp)) } + + // Settings + item { + ListItem( + headlineContent = { Text("设置") }, + leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) }, + trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) }, + modifier = Modifier.clickable { onNavigateToSettings() }, + ) + } + + // Reminders + item { + ListItem( + headlineContent = { Text("提醒") }, + leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) }, + trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) }, + ) + } + + item { HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } + + // About + item { + ListItem( + headlineContent = { Text("关于") }, + leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) }, + supportingContent = { Text("Cyrene v0.1.0") }, + ) + } + + // Help + item { + ListItem( + headlineContent = { Text("使用帮助") }, + leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) }, + ) + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + + // Logout + if (isLoggedIn) { + item { + ListItem( + headlineContent = { + Text( + text = "退出登录", + color = MaterialTheme.colorScheme.error, + ) + }, + leadingContent = { + Icon( + Icons.AutoMirrored.Filled.ExitToApp, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + modifier = Modifier.clickable { onLogout() }, + ) + } + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..efd7a50 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,208 @@ +package top.yeij.cyrene.ui.screens.settings + +import android.widget.Toast +import androidx.compose.foundation.clickable +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.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.SettingsBrightness +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import top.yeij.cyrene.viewmodel.SettingsViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onBack: () -> Unit, + viewModel: SettingsViewModel = koinInject(), +) { + val baseUrl by viewModel.baseUrl.collectAsState() + val themeMode by viewModel.themeMode.collectAsState() + val wakeWord by viewModel.wakeWord.collectAsState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + + fun sanitizeUrl(raw: String): String? { + var url = raw.trim() + if (url.isEmpty()) return null + val hasScheme = url.contains("://") + if (!hasScheme) url = "http://$url" + return try { + val parsed = java.net.URL(url) + val host = parsed.host ?: return null + if (host.isEmpty()) return null + val scheme = parsed.protocol + val port = if (parsed.port > 0) ":${parsed.port}" else "" + val path = parsed.path?.trimEnd('/') ?: "" + val query = parsed.query?.let { "?$it" } ?: "" + "$scheme://$host$port$path$query" + } catch (_: Exception) { + null + } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("设置") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + // Server + Text( + text = "服务器", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(16.dp), + ) + OutlinedTextField( + value = baseUrl, + onValueChange = { viewModel.saveBaseUrl(it) }, + label = { Text("服务器地址") }, + placeholder = { Text("http://192.168.1.x:8080") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + scope.launch { + val sanitized = sanitizeUrl(baseUrl) + if (sanitized != null) { + viewModel.saveBaseUrl(sanitized) + Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show() + } + } + }, + ), + trailingIcon = { + FilledTonalIconButton(onClick = { + val sanitized = sanitizeUrl(baseUrl) + if (sanitized != null) { + viewModel.saveBaseUrl(sanitized) + Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "地址格式无效", Toast.LENGTH_SHORT).show() + } + }) { + Icon(Icons.Filled.Check, contentDescription = "确认") + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // Appearance + Text( + text = "外观", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(16.dp), + ) + + val themeLabel = when (themeMode) { + "light" -> "浅色模式" + "dark" -> "深色模式" + else -> "跟随系统" + } + val themeIcon = when (themeMode) { + "light" -> Icons.Filled.LightMode + "dark" -> Icons.Filled.DarkMode + else -> Icons.Filled.SettingsBrightness + } + + ListItem( + headlineContent = { Text("主题") }, + supportingContent = { Text(themeLabel) }, + leadingContent = { Icon(themeIcon, contentDescription = null) }, + modifier = Modifier.clickable { + val next = when (themeMode) { + "light" -> "dark" + "dark" -> "auto" + else -> "light" + } + viewModel.saveThemeMode(next) + }, + ) + + ListItem( + headlineContent = { Text("主题色") }, + supportingContent = { Text("昔涟紫") }, + leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // Voice + Text( + text = "语音", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(16.dp), + ) + + OutlinedTextField( + value = wakeWord, + onValueChange = { viewModel.saveWakeWord(it) }, + label = { Text("唤醒词") }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = MaterialTheme.shapes.medium, + ) + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Color.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Color.kt new file mode 100644 index 0000000..8f73bc5 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Color.kt @@ -0,0 +1,58 @@ +package top.yeij.cyrene.ui.theme + +import androidx.compose.ui.graphics.Color + +// Light theme +val LightPrimary = Color(0xFF6D3BC0) +val LightOnPrimary = Color(0xFFFFFFFF) +val LightPrimaryContainer = Color(0xFFEEDCFF) +val LightOnPrimaryContainer = Color(0xFF250058) +val LightSecondary = Color(0xFF625B71) +val LightOnSecondary = Color(0xFFFFFFFF) +val LightSecondaryContainer = Color(0xFFE8DEF8) +val LightOnSecondaryContainer = Color(0xFF1E192B) +val LightTertiary = Color(0xFF7E5260) +val LightOnTertiary = Color(0xFFFFFFFF) +val LightTertiaryContainer = Color(0xFFFFD9E3) +val LightOnTertiaryContainer = Color(0xFF31101D) +val LightBackground = Color(0xFFFFFBFF) +val LightOnBackground = Color(0xFF1C1B1F) +val LightSurface = Color(0xFFFFFBFF) +val LightOnSurface = Color(0xFF1C1B1F) +val LightSurfaceVariant = Color(0xFFE7E0EC) +val LightOnSurfaceVariant = Color(0xFF49454F) +val LightError = Color(0xFFBA1A1A) +val LightOutline = Color(0xFF79747E) +val LightOutlineVariant = Color(0xFFCAC4D0) + +// Dark theme +val DarkPrimary = Color(0xFFD3BBFF) +val DarkOnPrimary = Color(0xFF3D0089) +val DarkPrimaryContainer = Color(0xFF541BA6) +val DarkOnPrimaryContainer = Color(0xFFEEDCFF) +val DarkSecondary = Color(0xFFCBC2DC) +val DarkOnSecondary = Color(0xFF332D41) +val DarkSecondaryContainer = Color(0xFF4A4458) +val DarkOnSecondaryContainer = Color(0xFFE8DEF8) +val DarkTertiary = Color(0xFFEFB8C8) +val DarkOnTertiary = Color(0xFF4A2532) +val DarkTertiaryContainer = Color(0xFF633B48) +val DarkOnTertiaryContainer = Color(0xFFFFD9E3) +val DarkBackground = Color(0xFF1C1B1F) +val DarkOnBackground = Color(0xFFE6E1E5) +val DarkSurface = Color(0xFF1C1B1F) +val DarkOnSurface = Color(0xFFE6E1E5) +val DarkSurfaceVariant = Color(0xFF49454F) +val DarkOnSurfaceVariant = Color(0xFFCAC4D0) +val DarkError = Color(0xFFFFB4AB) +val DarkOutline = Color(0xFF938F99) +val DarkOutlineVariant = Color(0xFF49454F) + +// Preset seed colors for manual theme selection +val SeedColors = mapOf( + "default" to 0xFF6D3BC0, // Lavender + "sakura" to 0xFFFFB4C8, // Pink + "ocean" to 0xFF6BA4FF, // Blue + "forest" to 0xFF6BCF7C, // Green + "sunset" to 0xFFFF9E6B, // Orange +) diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Shape.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Shape.kt new file mode 100644 index 0000000..0c84969 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Shape.kt @@ -0,0 +1,13 @@ +package top.yeij.cyrene.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val CyreneShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(32.dp), +) diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt new file mode 100644 index 0000000..ea6b3bf --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt @@ -0,0 +1,82 @@ +package top.yeij.cyrene.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val LightColorScheme = lightColorScheme( + primary = LightPrimary, + onPrimary = LightOnPrimary, + primaryContainer = LightPrimaryContainer, + onPrimaryContainer = LightOnPrimaryContainer, + secondary = LightSecondary, + onSecondary = LightOnSecondary, + secondaryContainer = LightSecondaryContainer, + onSecondaryContainer = LightOnSecondaryContainer, + tertiary = LightTertiary, + onTertiary = LightOnTertiary, + tertiaryContainer = LightTertiaryContainer, + onTertiaryContainer = LightOnTertiaryContainer, + background = LightBackground, + onBackground = LightOnBackground, + surface = LightSurface, + onSurface = LightOnSurface, + surfaceVariant = LightSurfaceVariant, + onSurfaceVariant = LightOnSurfaceVariant, + error = LightError, + outline = LightOutline, + outlineVariant = LightOutlineVariant, +) + +private val DarkColorScheme = darkColorScheme( + primary = DarkPrimary, + onPrimary = DarkOnPrimary, + primaryContainer = DarkPrimaryContainer, + onPrimaryContainer = DarkOnPrimaryContainer, + secondary = DarkSecondary, + onSecondary = DarkOnSecondary, + secondaryContainer = DarkSecondaryContainer, + onSecondaryContainer = DarkOnSecondaryContainer, + tertiary = DarkTertiary, + onTertiary = DarkOnTertiary, + tertiaryContainer = DarkTertiaryContainer, + onTertiaryContainer = DarkOnTertiaryContainer, + background = DarkBackground, + onBackground = DarkOnBackground, + surface = DarkSurface, + onSurface = DarkOnSurface, + surfaceVariant = DarkSurfaceVariant, + onSurfaceVariant = DarkOnSurfaceVariant, + error = DarkError, + outline = DarkOutline, + outlineVariant = DarkOutlineVariant, +) + +@Composable +fun CyreneTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = CyreneTypography, + shapes = CyreneShapes, + content = content, + ) +} diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Type.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Type.kt new file mode 100644 index 0000000..2822d77 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Type.kt @@ -0,0 +1,54 @@ +package top.yeij.cyrene.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val CyreneTypography = Typography( + displayLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + ), + titleLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + labelMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), +) diff --git a/app/src/main/java/top/yeij/cyrene/util/Constants.kt b/app/src/main/java/top/yeij/cyrene/util/Constants.kt new file mode 100644 index 0000000..283ba9d --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/util/Constants.kt @@ -0,0 +1,15 @@ +package top.yeij.cyrene.util + +object Constants { + const val DEFAULT_WAKE_WORD = "昔涟" + const val SILENCE_TIMEOUT_MS = 10_000L + const val WS_RECONNECT_MAX_ATTEMPTS = 5 + const val WS_PING_INTERVAL_SECONDS = 30L + const val HOTWORD_INACTIVITY_TIMEOUT_MINUTES = 10L + const val OVERLAY_ANIM_DURATION_MS = 300 + const val STT_SILENCE_THRESHOLD_MS = 1500L + + // Intent extras + const val EXTRA_VOICE_ASSIST = "voice_assist" + const val EXTRA_START_LISTENING = "start_listening" +} diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt new file mode 100644 index 0000000..c79571f --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt @@ -0,0 +1,103 @@ +package top.yeij.cyrene.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import top.yeij.cyrene.domain.model.Conversation +import top.yeij.cyrene.domain.model.Message +import top.yeij.cyrene.domain.repository.ChatRepository + +class ChatViewModel( + private val chatRepository: ChatRepository, +) : ViewModel() { + + val isConnected: StateFlow = chatRepository.connectionState + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + val conversations: StateFlow> = chatRepository.getConversations() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + private val _currentMessages = MutableStateFlow>(emptyList()) + val currentMessages: StateFlow> = _currentMessages.asStateFlow() + + private val _inputText = MutableStateFlow("") + val inputText: StateFlow = _inputText.asStateFlow() + + private val _isStreaming = MutableStateFlow(false) + val isStreaming: StateFlow = _isStreaming.asStateFlow() + + private var currentSessionId: String? = null + + init { + connectAndLoad() + } + + fun connectAndLoad(sessionId: String? = null) { + viewModelScope.launch { + chatRepository.connectWebSocket(sessionId) + chatRepository.loadConversationsFromServer() + } + viewModelScope.launch { + chatRepository.observeMessages().collect { message -> + try { + val list = _currentMessages.value.toMutableList() + val existingIdx = list.indexOfLast { it.id == message.id } + if (existingIdx >= 0) { + list[existingIdx] = message + } else { + list.add(message) + } + _currentMessages.value = list + _isStreaming.value = list.any { it.isStreaming } + } catch (e: Exception) { + Log.e("ChatViewModel", "Error processing message: ${e.message}", e) + } + } + } + } + + fun onInputChanged(text: String) { + _inputText.value = text + } + + fun sendMessage() { + val text = _inputText.value.trim() + if (text.isEmpty()) return + + _inputText.value = "" + _isStreaming.value = true + val sid = currentSessionId + + viewModelScope.launch { + chatRepository.sendMessage(text, sid) + } + } + + fun switchSession(sessionId: String) { + currentSessionId = sessionId + viewModelScope.launch { + chatRepository.disconnectWebSocket() + chatRepository.connectWebSocket(sessionId) + chatRepository.loadMessagesFromServer(sessionId) + } + } + + fun deleteConversation(id: String) { + viewModelScope.launch { + chatRepository.deleteConversation(id) + } + } + + override fun onCleared() { + viewModelScope.launch { + chatRepository.disconnectWebSocket() + } + super.onCleared() + } +} diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/IoTViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/IoTViewModel.kt new file mode 100644 index 0000000..a0a3299 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/IoTViewModel.kt @@ -0,0 +1,59 @@ +package top.yeij.cyrene.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import top.yeij.cyrene.domain.model.Device +import top.yeij.cyrene.domain.repository.IoTRepository + +class IoTViewModel( + private val ioTRepository: IoTRepository, +) : ViewModel() { + + val devices: StateFlow> = ioTRepository.getDevices() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + init { + refreshDevices() + } + + fun refreshDevices() { + viewModelScope.launch { + _isLoading.value = true + ioTRepository.refreshDevices() + _isLoading.value = false + } + } + + fun controlDevice(deviceId: String, action: String, value: Any? = null) { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + ioTRepository.controlDevice(deviceId, action, value).fold( + onSuccess = { }, + onFailure = { _error.value = it.message }, + ) + _isLoading.value = false + } + } + + fun togglePower(device: Device) { + val action = if (device.state.power == true) "off" else "on" + controlDevice(device.id, action) + } + + fun clearError() { + _error.value = null + } +} diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt new file mode 100644 index 0000000..1d31134 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt @@ -0,0 +1,115 @@ +package top.yeij.cyrene.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import top.yeij.cyrene.domain.model.Message +import top.yeij.cyrene.domain.repository.ChatRepository +import top.yeij.cyrene.util.Constants + +enum class OverlayState { + IDLE, + LISTENING, + PROCESSING, + SPEAKING, + WAITING, +} + +class OverlayViewModel( + private val chatRepository: ChatRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(OverlayState.IDLE) + val state: StateFlow = _state.asStateFlow() + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _recognizedText = MutableStateFlow("") + val recognizedText: StateFlow = _recognizedText.asStateFlow() + + private var silenceTimer: Job? = null + + init { + viewModelScope.launch { + chatRepository.connectWebSocket(null) + } + viewModelScope.launch { + chatRepository.observeMessages().collect { message -> + _messages.value = _messages.value + message + } + } + } + + fun startListening() { + _state.value = OverlayState.LISTENING + resetSilenceTimer() + } + + fun onSpeechPartial(text: String) { + _recognizedText.value = text + resetSilenceTimer() + } + + fun onSpeechFinal(text: String) { + _recognizedText.value = text + _state.value = OverlayState.PROCESSING + cancelSilenceTimer() + + viewModelScope.launch { + chatRepository.sendMessage(text, null) + _recognizedText.value = "" + } + } + + fun sendText(text: String) { + _state.value = OverlayState.PROCESSING + viewModelScope.launch { + chatRepository.sendMessage(text, null) + } + } + + fun setSpeaking() { + _state.value = OverlayState.SPEAKING + } + + fun setWaiting() { + _state.value = OverlayState.WAITING + startSilenceTimer() + } + + fun finish() { + _state.value = OverlayState.IDLE + cancelSilenceTimer() + } + + private fun startSilenceTimer() { + cancelSilenceTimer() + silenceTimer = viewModelScope.launch { + delay(Constants.SILENCE_TIMEOUT_MS) + _state.value = OverlayState.IDLE + } + } + + private fun resetSilenceTimer() { + cancelSilenceTimer() + startSilenceTimer() + } + + private fun cancelSilenceTimer() { + silenceTimer?.cancel() + silenceTimer = null + } + + override fun onCleared() { + viewModelScope.launch { + chatRepository.disconnectWebSocket() + } + super.onCleared() + } +} diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..21ebae2 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt @@ -0,0 +1,87 @@ +package top.yeij.cyrene.viewmodel + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import top.yeij.cyrene.data.local.PreferencesDataStore +import top.yeij.cyrene.data.remote.DynamicUrlInterceptor +import top.yeij.cyrene.domain.repository.AuthRepository + +class SettingsViewModel( + private val authRepository: AuthRepository, + private val preferencesDataStore: PreferencesDataStore, + private val dynamicUrlInterceptor: DynamicUrlInterceptor, +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val _baseUrl = MutableStateFlow("") + val baseUrl: StateFlow = _baseUrl.asStateFlow() + + private val _themeMode = MutableStateFlow("auto") + val themeMode: StateFlow = _themeMode.asStateFlow() + + private val _wakeWord = MutableStateFlow("昔涟") + val wakeWord: StateFlow = _wakeWord.asStateFlow() + + private val _username = MutableStateFlow("") + val username: StateFlow = _username.asStateFlow() + + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + init { + scope.launch { + _isLoggedIn.value = authRepository.isLoggedIn() + } + // Single collector for all DataStore preferences — avoids subscriber explosion + scope.launch { + combine( + preferencesDataStore.baseUrl, + preferencesDataStore.themeMode, + preferencesDataStore.wakeWord, + preferencesDataStore.username, + ) { baseUrl, themeMode, wakeWord, username -> + baseUrl?.let { url -> + if (url.isNotBlank()) { + _baseUrl.value = url + dynamicUrlInterceptor.baseUrl = url + } + } + themeMode?.let { _themeMode.value = it } + wakeWord?.let { word -> + if (word.isNotBlank()) _wakeWord.value = word + } + username?.let { _username.value = it } + }.collect { } + } + } + + fun saveBaseUrl(url: String) { + _baseUrl.value = url + dynamicUrlInterceptor.baseUrl = url + scope.launch { preferencesDataStore.saveBaseUrl(url) } + } + + fun saveThemeMode(mode: String) { + _themeMode.value = mode + scope.launch { preferencesDataStore.saveThemeMode(mode) } + } + + fun saveWakeWord(word: String) { + _wakeWord.value = word + scope.launch { preferencesDataStore.saveWakeWord(word) } + } + + fun logout() { + scope.launch { + authRepository.logout() + _isLoggedIn.value = false + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/voice/hotword/HotwordDetector.kt b/app/src/main/java/top/yeij/cyrene/voice/hotword/HotwordDetector.kt new file mode 100644 index 0000000..f388f01 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/voice/hotword/HotwordDetector.kt @@ -0,0 +1,27 @@ +package top.yeij.cyrene.voice.hotword + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class HotwordDetector { + + private val _isListening = MutableStateFlow(false) + val isListening = _isListening.asStateFlow() + + private val _onDetected = MutableStateFlow(false) + val onDetected = _onDetected.asStateFlow() + + fun startListening(wakeWord: String) { + _isListening.value = true + // Integrate system AlwaysOnHotwordDetector or Porcupine SDK here + } + + fun stopListening() { + _isListening.value = false + } + + fun updateWakeWord(newWord: String) { + stopListening() + startListening(newWord) + } +} diff --git a/app/src/main/java/top/yeij/cyrene/voice/stt/SpeechRecognizer.kt b/app/src/main/java/top/yeij/cyrene/voice/stt/SpeechRecognizer.kt new file mode 100644 index 0000000..e5b5811 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/voice/stt/SpeechRecognizer.kt @@ -0,0 +1,30 @@ +package top.yeij.cyrene.voice.stt + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class SpeechRecognizer { + + private val _isListening = MutableStateFlow(false) + val isListening = _isListening.asStateFlow() + + private val _partialResult = MutableStateFlow("") + val partialResult = _partialResult.asStateFlow() + + fun startListening() { + _isListening.value = true + // Integrate Android SpeechRecognizer or server-side Whisper API + } + + fun stopListening(): String { + _isListening.value = false + val result = _partialResult.value + _partialResult.value = "" + return result + } + + fun cancel() { + _isListening.value = false + _partialResult.value = "" + } +} diff --git a/app/src/main/java/top/yeij/cyrene/voice/tts/TextToSpeechEngine.kt b/app/src/main/java/top/yeij/cyrene/voice/tts/TextToSpeechEngine.kt new file mode 100644 index 0000000..03e58a9 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/voice/tts/TextToSpeechEngine.kt @@ -0,0 +1,97 @@ +package top.yeij.cyrene.voice.tts + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.Locale + +class TextToSpeechEngine(private val context: Context) { + + private var tts: TextToSpeech? = null + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + private val _isSpeaking = MutableStateFlow(false) + val isSpeaking = _isSpeaking.asStateFlow() + + private val _onDone = MutableStateFlow(false) + val onDone = _onDone.asStateFlow() + + fun initialize(onReady: () -> Unit) { + tts = TextToSpeech(context) { status -> + if (status == TextToSpeech.SUCCESS) { + tts?.language = Locale.CHINESE + tts?.setSpeechRate(0.9f) + tts?.setPitch(1.1f) + + tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) { + requestAudioFocus() + _isSpeaking.value = true + } + + override fun onDone(utteranceId: String?) { + _isSpeaking.value = false + _onDone.value = true + abandonAudioFocus() + } + + override fun onError(utteranceId: String?) { + _isSpeaking.value = false + abandonAudioFocus() + } + }) + onReady() + } + } + } + + fun speak(text: String, utteranceId: String = System.currentTimeMillis().toString()) { + tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId) + } + + fun stop() { + tts?.stop() + _isSpeaking.value = false + } + + fun shutdown() { + tts?.stop() + tts?.shutdown() + } + + private fun requestAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANT) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + ) + .build() + audioManager.requestAudioFocus(focusRequest) + } else { + @Suppress("DEPRECATION") + audioManager.requestAudioFocus( + null, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, + ) + } + } + + private fun abandonAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Focus released with the request + } else { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(null) + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..a4933e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..11606d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..00d0e98 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,42 @@ + + + Cyrene + 昔涟 + 昔涟 —— 你的智能语音助手 + + + 对话 + 设备 + 我的 + + + 正在聆听唤醒词… + 我在听… + 思考中… + 正在说话… + 点击说话 + 按住说话 + + + 登录 + 退出登录 + 设置 + 取消 + 确认 + 保存 + 重试 + + + 设为默认语音助手 + 将昔涟替换为系统默认助手 + 外观 + 语音设置 + 唤醒词 + 账号 + 关于 + 服务器地址 + 主题 + 浅色 + 深色 + 跟随系统 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..59e6e36 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/xml/voice_interaction_config.xml b/app/src/main/res/xml/voice_interaction_config.xml new file mode 100644 index 0000000..485253a --- /dev/null +++ b/app/src/main/res/xml/voice_interaction_config.xml @@ -0,0 +1,8 @@ + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0ae4042 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false +} diff --git a/devdocs/00-development-overview.md b/devdocs/00-development-overview.md new file mode 100644 index 0000000..feaba43 --- /dev/null +++ b/devdocs/00-development-overview.md @@ -0,0 +1,158 @@ +# 00 — 项目概述与架构 + +> 对应主项目 Phase 5(v1.5 → v2.0)Android 客户端 +> 主项目文档:`../../docs/dev-plan/04-voice-system-plan.md` + +--- + +## 1. 项目定位 + +Cyrene for Android 是昔涟的官方 Android 客户端,定位为 **可替换系统自带语音助手的智能体 APP**(替代 Google Assistant / Bixby)。 + +核心差异点: +- 不是传统 APP(以桌面图标为主要入口),而是**系统级语音助手** +- 除常规全屏 Activity 外,通过 `VoiceInteractionSession` 提供悬浮式覆盖层交互 +- 支持息屏热词唤醒、长按 Home / 电源键呼出 + +## 2. 技术栈 + +| 层 | 技术 | 版本要求 | +|----|------|---------| +| 语言 | Kotlin | 2.0+ | +| UI 框架 | Jetpack Compose + Material Design 3 | BOM 2025+ | +| 构建 | Gradle (Kotlin DSL) | 8.7+ | +| 架构模式 | MVVM + Repository | — | +| 依赖注入 | Hilt / Koin (待定) | — | +| 网络 | Retrofit + OkHttp | — | +| 实时通信 | OkHttp WebSocket | — | +| 本地存储 | Room (SQLite) + DataStore | — | +| 推送 | FCM (Firebase Cloud Messaging) | — | +| 系统语音 | VoiceInteractionService | API 23+ | +| 热词唤醒 | Always-On Hotword Detection | API 23+ | +| 语音识别 | 服务端 (Whisper API) + 本地兜底 | — | + +## 3. 最低系统要求 + +| 项目 | 要求 | +|------|------| +| minSdk | 26 (Android 8.0) | +| targetSdk | 35+ | +| compileSdk | 35+ | +| JDK | 17 | +| Gradle | 8.7+ | + +注:`VoiceInteractionService` 基础 API 要求 23,`AssistAction` 热词唤醒要求 23。minSdk 设为 26 以覆盖绝大多数活跃设备并简化兼容性。 + +## 4. 项目结构 + +``` +android/ +├── app/ +│ ├── src/main/ +│ │ ├── java/com/cyrene/app/ +│ │ │ ├── CyreneApplication.kt # Application 初始化 +│ │ │ ├── MainActivity.kt # 全屏主界面 (桌面图标入口) +│ │ │ ├── ui/ +│ │ │ │ ├── theme/ # MD3 主题 (Color / Type / Shape) +│ │ │ │ ├── screens/ # 全屏页面 (Compose) +│ │ │ │ │ ├── chat/ # 对话页 +│ │ │ │ │ ├── home/ # 首页 / IoT 面板 +│ │ │ │ │ ├── settings/ # 设置页 +│ │ │ │ │ └── login/ # 登录/注册 +│ │ │ │ ├── overlay/ # 悬浮窗对话界面 (VoiceInteractionSession) +│ │ │ │ └── components/ # 共享组件库 +│ │ │ ├── viewmodel/ # ViewModel 层 +│ │ │ ├── domain/ # 领域层 (UseCase / Repository 接口) +│ │ │ ├── data/ +│ │ │ │ ├── remote/ # API 接口定义 + DTO +│ │ │ │ ├── local/ # Room DAO + DataStore +│ │ │ │ └── repository/ # Repository 实现 +│ │ │ ├── service/ +│ │ │ │ ├── CyreneVoiceInteractionService.kt +│ │ │ │ ├── CyreneVoiceInteractionSession.kt +│ │ │ │ ├── CyreneAssistService.kt +│ │ │ │ └── WebSocketService.kt +│ │ │ ├── voice/ +│ │ │ │ ├── hotword/ # 热词唤醒引擎 +│ │ │ │ ├── stt/ # 语音识别客户端 +│ │ │ │ └── tts/ # 语音合成客户端 +│ │ │ ├── di/ # DI 模块定义 +│ │ │ └── util/ # 工具类 +│ │ ├── res/ # 资源文件 +│ │ └── AndroidManifest.xml +│ └── build.gradle.kts +├── gradle/ +│ ├── libs.versions.toml # 版本目录 +│ └── wrapper/ +├── build.gradle.kts # 根构建脚本 +├── settings.gradle.kts +└── gradle.properties +``` + +## 5. 架构分层 + +``` +┌──────────────────────────────────────────────┐ +│ UI Layer (Compose) │ +│ ├─ Screens (全屏 Activity) │ +│ └─ Overlay (VoiceInteractionSession 悬浮窗) │ +├──────────────────────────────────────────────┤ +│ ViewModel Layer │ +│ ├─ ChatViewModel │ +│ ├─ HomeViewModel (IoT) │ +│ ├─ SettingsViewModel │ +│ └─ OverlayViewModel │ +├──────────────────────────────────────────────┤ +│ Domain Layer │ +│ ├─ UseCase (SendMessage, ControlIoT, ...) │ +│ └─ Repository Interfaces │ +├──────────────────────────────────────────────┤ +│ Data Layer │ +│ ├─ Remote: Retrofit API + WebSocket │ +│ ├─ Local: Room + DataStore │ +│ └─ Repository Implementation │ +├──────────────────────────────────────────────┤ +│ Service Layer │ +│ ├─ VoiceInteractionService (系统助手) │ +│ ├─ WebSocketService (长连接) │ +│ └─ FCMMessagingService (推送) │ +└──────────────────────────────────────────────┘ +``` + +UI 层和 Service 层通过 ViewModel 解耦——全屏 Activity 和悬浮窗 Overlay 复用同一组 ViewModel,只是 UI 布局不同。 + +## 6. 与后端的关系 + +``` +Android Client + │ + ├─ HTTP REST ──────────► Gateway (:8080) # 登录、CRUD、配置 + ├─ WebSocket ──────────► Gateway (:8080) # 实时对话、IoT 状态推送 + ├─ STT Audio ──────────► Voice Service (:8093) # 语音识别 + └─ TTS Stream ◄──────── Voice Service (:8093) # 语音合成 +``` + +WebSocket 长连接是核心通信通道:对话消息、通知、IoT 状态广播均通过同一连接。HTTP 仅用于一次性操作(登录、文件上传/下载)。 + +## 7. 关键设计决策 + +| 决策 | 选择 | 理由 | +|------|------|------| +| UI 框架 | Jetpack Compose + MD3 | 声明式 UI,与悬浮窗的 ComposeView 集成简单 | +| 架构 | MVVM + Repository | Google 官方推荐,ViewModel 可在 Activity 和 Session 间复用 | +| 语音框架 | VoiceInteractionService(系统 API) | 原生支持替换系统助手,无需自定义悬浮窗权限 | +| 热词方案 | 系统 Always-On Hotword API | 息屏低功耗监听,不用自建音频采集 | +| 网络 | OkHttp WebSocket | 比 FCM 更实时,与主项目 Gateway 已有 WS Hub 对应 | +| 最低 API | 26 | 覆盖 95%+ 活跃设备,VoiceInteractionService 兼容 | + +## 8. 排期参考 + +对应主项目路线图: + +``` +2026 Q4 ─ v1.3 多平台接入(前置依赖) +2027 Q1 ─ v1.8 语音模型训练完成(后端依赖) +2027 Q2 ─ v2.0 开始 Android 客户端开发 +2027 Q3 ─ v2.3 语音助手 APP MVP 版本 +2027 Q4 ─ v3.0 APP 上架(Google Play / 国内应用商店) +``` diff --git a/devdocs/01-voice-assistant-system.md b/devdocs/01-voice-assistant-system.md new file mode 100644 index 0000000..3d8b75e --- /dev/null +++ b/devdocs/01-voice-assistant-system.md @@ -0,0 +1,256 @@ +# 01 — 系统语音助手集成规范 + +> **目标**:让昔涟成为 Android 系统级默认语音助手,替换 Google Assistant / Bixby +> **核心 API**:`VoiceInteractionService` + `VoiceInteractionSession` + +--- + +## 1. 功能目标 + +- 用户可在 **系统设置 → 默认应用 → 数字助理** 中选择昔涟 +- 长按 Home 键呼出昔涟(非全屏,悬浮覆盖层) +- 屏幕底部两角向内滑动触发昔涟 +- 长按电源键可配置为呼出昔涟 +- 息屏状态下热词唤醒昔涟 +- 有线/蓝牙耳机按键呼出昔涟 + +## 2. AndroidManifest.xml 声明 + +```xml + + + + + + + + + + + + + + + + + +``` + +## 3. 配置文件 + +### res/xml/voice_interaction_config.xml + +```xml + + +``` + +## 4. VoiceInteractionService 实现 + +```kotlin +class CyreneVoiceInteractionService : VoiceInteractionService() { + + override fun onReady() { + super.onReady() + // 服务就绪,可在此初始化 TTS 引擎等 + } + + override fun onCreateSession(args: Bundle?): VoiceInteractionSession { + return CyreneVoiceInteractionSession(this) + } + + override fun onLaunchVoiceAssistFromKeyguard() { + // 锁屏启动 → 进入简化模式,仅显示对话,IoT 控制等需先解锁 + } + + // Android 14+: AssistAction 回调 + override fun onHandleAssist( + request: AssistRequest?, + cancellationSignal: CancellationSignal?, + callback: OutcomeCallback? + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + request?.let { + val assistContent = it.assistContent + // 提取当前屏幕上下文(可选,用于后续上下文感知) + callback?.onResult(AssistResult(assistContent)) + } + } + } +} +``` + +## 5. VoiceInteractionSession 实现(悬浮窗界面) + +```kotlin +class CyreneVoiceInteractionSession(context: Context) : + VoiceInteractionSession(context) { + + override fun onCreateContentView(): View { + // 返回 ComposeView 作为悬浮窗的内容 + return ComposeView(context).apply { + setContent { + CyreneTheme { + OverlayScreen( + viewModel = overlayViewModel, + onDismiss = { finish() } + ) + } + } + } + } + + override fun onShow(args: Bundle?, showFlags: Int) { + super.onShow(args, showFlags) + // 设置窗口属性:透明背景 + 底部卡片式布局 + window?.apply { + // 半透明遮罩 + setBackgroundDrawable(ColorDrawable(0x80000000.toInt())) + // FLAG_DIM_BEHIND 可实现模糊效果 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + addSystemGestureExclusionRects(...) + } + } + } + + override fun onComputeInsets(outInsets: Insets?) { + super.onComputeInsets(outInsets) + // 控制悬浮窗内容区域 + } + + override fun onHide() { + super.onHide() + // 悬浮窗隐藏时清理状态 + } +} +``` + +### 关键窗口属性 + +| 属性 | 值 | 说明 | +|------|-----|------| +| 背景 | `ColorDrawable(0x80000000)` | 半透明黑色遮罩,透出底层 APP | +| 内容区域 | 自适应高度 | 底部弹出,类似 Google Assistant | +| 触摸外区域行为 | 关闭悬浮窗 | 用户点击遮罩区域关闭 | +| 键盘弹出 | 推高内容区域 | 文本输入时自动调整 | + +## 6. 权限清单 + +```xml + + + + + + + + + + + + + + + + + + + + + + + + +``` + +## 7. 引导用户设为默认助手 + +首次启动时检测并引导: + +```kotlin +fun checkAndPromptDefaultAssistant(context: Context) { + val isDefault = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val componentName = ComponentName(context, CyreneVoiceInteractionService::class.java) + context.packageManager + .queryIntentServices( + Intent(VoiceInteractionService.SERVICE_INTERFACE), + PackageManager.MATCH_DEFAULT_ONLY + ) + .any { it.serviceInfo.packageName == context.packageName } + } else { + false + } + + if (!isDefault) { + // 显示引导 UI → 跳转到 Settings.ACTION_VOICE_INPUT_SETTINGS + val intent = Intent(Settings.ACTION_VOICE_INPUT_SETTINGS) + context.startActivity(intent) + } +} +``` + +## 8. 热词唤醒检测 + +### 方案选型 + +| 方案 | 优点 | 缺点 | 适用场景 | +|------|------|------|---------| +| 系统 Always-On Hotword API | 低功耗、系统级支持 | 限 Android 8+,某些 ROM 不支持 | **首选** | +| Porcupine (Picovoice) | 跨平台、离线 | 商业许可,需额外集成 | 兜底 | +| 自建模型 (openWakeWord) | 完全可控、低成本 | 需要本地推理能力 | 长期方案 | + +### 唤醒词配置 + +| 优先级 | 唤醒词 | 说明 | +|--------|--------|------| +| P0 | "昔涟" (Xī Lián) | 角色名,默认唤醒词 | +| P1 | "Hey 昔涟" | 与 "Hey Google" 习惯对齐 | +| P2 | 自定义 | 用户可在设置中自定义 | + +### 息屏唤醒流程 + +``` +用户说出唤醒词 + → HotwordDetector 识别成功(<800ms) + → 系统触发 VoiceInteractionService + → CyreneVoiceInteractionSession.onCreateContentView() + → Overlay 显示,播放连接提示音 + → 用户说话 → STT → AI-Core → TTS → 语音回复 + → 对话结束 → finish() → 息屏 +``` + +## 9. Dismiss 时机 + +悬浮窗在以下情况关闭: + +| 条件 | 行为 | +|------|------| +| 用户说"再见" / "退下" | 自然对话结束,收起悬浮窗 | +| 用户点击遮罩区域 | 立即关闭 | +| 对话静默 10 秒 | 自动收起 | +| 用户主动滑动关闭 | 手势关闭,同 Google Assistant | +| 收到系统电话等中断 | 暂停语音,进入后台等待 | + +## 10. 降级策略 + +当系统不支持 `VoiceInteractionService` 或未设为默认助手时: + +- **保底方案**:PWA(利用主项目已有的 PWA 支持) +- **WebView 封装**:内嵌 H5 对话界面作为过渡 +- **通知栏常驻**:提供快速对话入口,但功能受限 diff --git a/devdocs/02-interaction-flow.md b/devdocs/02-interaction-flow.md new file mode 100644 index 0000000..9415b62 --- /dev/null +++ b/devdocs/02-interaction-flow.md @@ -0,0 +1,237 @@ +# 02 — 交互流程与导航设计 + +> **核心原则**:间接启动(语音/手势)不进入全屏 APP,而是以悬浮覆盖层呈现 +> **关联文档**:[01-voice-assistant-system.md](01-voice-assistant-system.md) + +--- + +## 1. 启动来源 → 界面模式 映射表 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 启动来源 │ +├───────────────────────┬──────────────────────────────────┤ +│ 直接启动 (Explicit) │ 间接启动 (Implicit) │ +├───────────────────────┼──────────────────────────────────┤ +│ · 桌面图标 │ · 语音唤醒 ("昔涟") │ +│ · 最近任务列表 │ · 长按 Home 键 │ +│ · 通知栏点击 │ · 底部两角向内滑动 │ +│ · Deep Link │ · 长按电源键 (配置后) │ +│ │ · 耳机按键 (单击/长按) │ +│ │ · 锁屏右滑助手 │ +├───────────────────────┼──────────────────────────────────┤ +│ ▼ │ ▼ │ +│ 全屏 Activity │ VoiceInteractionSession │ +│ (MainActivity) │ (全屏悬浮覆盖层) │ +│ · 完整导航栏 │ · 无导航栏 │ +│ · Tab 切换 │ · 仅对话卡片 │ +│ · 设置/IoT 面板 │ · 半透明遮罩透出底层 │ +│ · 压入返回栈 │ · 不压入返回栈 │ +└───────────────────────┴──────────────────────────────────┘ +``` + +## 2. 全屏 Activity 模式 + +### 2.1 导航结构 + +``` +MainActivity +├── BottomNavigation +│ ├── Tab 1: 对话 (ChatScreen) ← 默认页 +│ ├── Tab 2: IoT 面板 (IoTScreen) +│ └── Tab 3: 我的 (ProfileScreen) +├── TopAppBar +│ ├── 昔涟状态指示器 (在线/思考中/离线) +│ └── 快捷操作 (设置、通知) +└── 子页面 (通过 NavHost 导航) + ├── SettingsScreen + ├── MemoryScreen (记忆查看) + ├── KnowledgeScreen (知识库) + ├── AutomationScreen (自动化规则) + ├── ReminderScreen (提醒列表) + └── LoginScreen +``` + +### 2.2 导航图 (NavGraph) + +``` +LoginScreen ──(登录成功)──► MainScreen (带 BottomNav) + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + ChatScreen IoTScreen ProfileScreen + │ │ + ▼ ▼ + MemoryScreen SettingsScreen + KnowledgeScreen ├─ Account + AutomationScreen ├─ Appearance (主题) + ReminderScreen ├─ Voice (唤醒词/语音) + ├─ IoT Config + └─ About +``` + +### 2.3 登录流程 + +``` +APP 首次启动 + → 检查本地 Token + ├─ 有效 → 直接进入 MainScreen + └─ 无效 → LoginScreen + ├─ 输入 Gateway 地址 + 账号密码 + ├─ POST /api/v1/auth/login + ├─ 保存 Token 到 DataStore + └─ 进入 MainScreen +``` + +## 3. 悬浮覆盖层模式 (VoiceInteractionSession) + +### 3.1 视觉层级 + +``` +┌──────────────────────────────────────┐ +│ 底层 APP (半透明可见) │ +│ ┌──────────────────────────────┐ │ +│ │ 半透明黑色遮罩 (80% 不透明度) │ │ +│ │ │ │ +│ │ ┌──────────────────────┐ │ │ +│ │ │ │ │ │ +│ │ │ 对话卡片区域 │ │ │ +│ │ │ (圆角顶部 28dp) │ │ │ +│ │ │ │ │ │ +│ │ │ · 昔涟状态条 │ │ │ +│ │ │ · 对话消息流 │ │ │ +│ │ │ · 文本输入框 │ │ │ +│ │ │ · 语音输入按钮 │ │ │ +│ │ │ │ │ │ +│ │ └──────────────────────┘ │ │ +│ │ │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────────┘ +``` + +### 3.2 覆盖层生命周期 + +``` +Trigger (唤醒词/手势/按键) + │ + ▼ +VoiceInteractionSession.onCreateContentView() + │ + ▼ +onShow() → 设置窗口属性 → 播放出现动画(底部滑入) + │ + ▼ +OverlayScreen Compose 渲染 + │ + ├→ 用户说话 → STT → 显示识别文本 → 发送到 AI-Core + │ │ + │ ▼ + │ SSE 流式响应 + │ │ + ├← TTS 语音播放 ←── 流式合成 ←────────────┘ + │ + ├→ 用户打字输入 → WebSocket 发送 → 显示回复气泡 + │ + ▼ +对话结束 + ├→ 用户主动关闭 (说"再见"/点击遮罩/下滑) + └→ 超时自动关闭 (静默 10 秒) + │ + ▼ + onHide() → 播放消失动画(底部滑出) + │ + ▼ + finish() → 返回触发前界面 +``` + +### 3.3 覆盖层状态机 + +``` + ┌──────────┐ + │ IDLE │ (覆盖层不可见) + └────┬─────┘ + │ 触发 + ▼ + ┌──────────┐ + │LISTENING │ (等待语音输入,波形动画) + └────┬─────┘ + │ 检测到语音 / 用户开始打字 + ▼ + ┌──────────┐ + │PROCESSING│ (STT 识别中 / LLM 思考中) + └────┬─────┘ + │ 收到回复 + ▼ + ┌──────────┐ + │SPEAKING │ (TTS 播放中) + └────┬─────┘ + │ 播放完毕,等待下一轮 + ▼ + ┌──────────┐ + │ WAITING │ (等待用户继续或关闭) + └────┬─────┘ + │ + ┌───────┼───────┐ + │ │ + 用户继续说话 10s 静默 + │ │ + ▼ ▼ + LISTENING IDLE + (覆盖层关闭) +``` + +### 3.4 与全屏 Activity 的切换 + +``` +悬浮窗中用户点击 "打开完整 APP" + → finish() 关闭悬浮窗 + → startActivity(MainActivity) + → 用户在全屏模式下继续操作 + +全屏 APP 中用户按 Home 返回桌面 + → onStop() → 进入后台 + → WebSocket 保持连接 + → 推送/FCM 通知到达时,点击通知 → 恢复 MainActivity +``` + +## 4. 锁屏交互 + +### 4.1 锁屏唤醒 + +``` +设备锁屏 + 息屏 + │ + ├→ 说出唤醒词 "昔涟" + │ └→ onLaunchVoiceAssistFromKeyguard() + │ └→ 简化版覆盖层 (仅对话,无 IoT / 敏感操作) + │ + └→ 长按电源键 + └→ 同理 +``` + +### 4.2 锁屏安全策略 + +| 操作 | 锁屏状态 | 行为 | +|------|---------|------| +| 查询天气/时间 | 允许 | 直接回复 | +| 简单闲聊 | 允许 | 直接回复 | +| IoT 查询(状态) | 允许 | 回复设备状态 | +| IoT 控制(开关) | **禁止** | 提示"请先解锁设备" | +| 查看记忆 | **禁止** | 提示"请先解锁设备" | +| 修改设置 | **禁止** | 提示"请先解锁设备" | +| 宿主命令 | **禁止** | 提示"请先解锁设备" | + +## 5. 多窗口与分屏 + +- **分屏模式**:悬浮窗模式下不支持(本身已是覆盖层);全屏 Activity 支持分屏 +- **画中画**:语音通话场景支持画中画(PIP),显示昔涟头像 + 波形动画 + +## 6. 手势交互 + +| 手势 | 悬浮窗模式 | 全屏 Activity | +|------|-----------|---------------| +| 下滑覆盖层 | 关闭悬浮窗 | — | +| 点击遮罩区域 | 关闭悬浮窗 | — | +| 长按消息 | 复制/分享菜单 | 复制/分享/删除 | +| 左滑消息 | — | 查看消息详情/时间戳 | +| 双击昔涟头像 | 切换输入模式(语音↔文字) | 同左 | diff --git a/devdocs/03-design-system.md b/devdocs/03-design-system.md new file mode 100644 index 0000000..51344ac --- /dev/null +++ b/devdocs/03-design-system.md @@ -0,0 +1,208 @@ +# 03 — 设计系统规范 (Material Design 3) + +> **设计语言**:Material Design 3 (Material You) +> **组件库**:`androidx.compose.material3` +> **最低 API**:26(不支持 Monet 的设备回退为手动主题色) + +--- + +## 1. 色彩系统 + +### 1.1 动态配色 (Dynamic Color) + +``` +首选:androidx.compose.material3.dynamicColor + → 系统壁纸提取 Primary / Secondary / Tertiary + → 支持 Android 12+ (API 31+) + → API 26-30 回退为预设主题色 + +备选:用户在设置中手动选择 Seed Color + → 通过 MaterialTheme.colorScheme 的 lightColorScheme/darkColorScheme 生成 +``` + +### 1.2 预设主题色 + +| 主题名 | Seed Color | 氛围 | +|--------|-----------|------| +| 默认(昔涟紫) | `#9C6BFF` (Lavender) | 温柔、亲切 | +| 樱花粉 | `#FFB4C8` (Sakura) | 甜美 | +| 海洋蓝 | `#6BA4FF` (Ocean) | 清爽 | +| 森林绿 | `#6BCF7C` (Forest) | 自然 | +| 日落橙 | `#FF9E6B` (Sunset) | 温暖 | + +### 1.3 暗黑模式 + +| 属性 | Light | Dark | +|------|-------|------| +| Surface | `#FFFBFF` | `#1C1B1F` | +| Background | `#FFFBFF` | `#1C1B1F` | +| Primary | Dynamic | Dynamic (暗黑自适应) | +| OnSurface | `#1C1B1F` | `#E6E1E5` | +| SurfaceVariant | `#E7E0EC` | `#49454F` | +| 遮罩颜色 | `rgba(0,0,0,0.5)` | `rgba(0,0,0,0.7)` | + +### 1.4 悬浮窗专用色 + +``` +覆盖层背景遮罩: + Light: rgba(0, 0, 0, 0.5) // 50% 不透明度 + Dark: rgba(0, 0, 0, 0.7) // 70% 不透明度 + +对话卡片背景: + Light: MaterialTheme.colorScheme.surface + Dark: MaterialTheme.colorScheme.surface + +卡片圆角:28dp (顶部) / 0dp (底部) +卡片阴影 (Light):8dp elevation +卡片阴影 (Dark):无阴影,用 1dp outline 代替 +``` + +## 2. 字体系统 (Typography) + +| 角色 | 字号 | 字重 | 行高 | 用途 | +|------|------|------|------|------| +| displayLarge | 57sp | 400 | 64sp | 欢迎页标题 | +| headlineMedium | 28sp | 400 | 36sp | 设置页标题 | +| titleLarge | 22sp | 400 | 28sp | 对话框标题 | +| titleMedium | 16sp | 500 | 24sp | 列表标题 | +| bodyLarge | 16sp | 400 | 24sp | 对话气泡文字 | +| bodyMedium | 14sp | 400 | 20sp | 辅助文字、时间戳 | +| labelLarge | 14sp | 500 | 20sp | 按钮文字 | +| labelMedium | 12sp | 500 | 16sp | Tab 标签 | +| labelSmall | 11sp | 500 | 16sp | 状态标签 | + +字体家族:`system-ui`(默认),不支持自定义字体以保证加载速度和系统一致性。 + +## 3. 形状系统 (Shapes) + +| 角色 | 圆角 | 用途 | +|------|------|------| +| extraSmall | 4dp | 小标签、芯片 | +| small | 8dp | 输入框、按钮 | +| medium | 12dp | 卡片、对话框 | +| large | 16dp | 大卡片 | +| extraLarge | 28dp | 底部弹出卡片、Sheet | + +## 4. 组件规范 + +### 4.1 对话气泡 + +``` +昔涟消息 (左侧): +┌─────────────────────────────┐ +│ 🤖 昔涟 10:32 │ +│ ┌─────────────────────────┐ │ +│ │ 开拓者,今天心情怎么样? │ │ ← 圆角: 12dp (top-start 4dp) +│ └─────────────────────────┘ │ 背景: PrimaryContainer +│ │ 文字: OnPrimaryContainer +└─────────────────────────────┘ + +用户消息 (右侧): +┌─────────────────────────────┐ +│ 你 10:33 │ +│ ┌─────────────────────┐ │ +│ │ 还不错!你呢? │ │ ← 圆角: 12dp (top-end 4dp) +│ └─────────────────────┘ │ 背景: Primary +│ │ 文字: OnPrimary +└─────────────────────────────┘ +``` + +### 4.2 消息类型样式 + +| 类型 | 样式 | 示例 | +|------|------|------| +| `chat` | 普通气泡 | 对话内容 | +| `action` | 居中斜体、灰色 | *昔涟正在查看客厅灯光状态* | +| `thinking` | 折叠面板、虚线边框 | 可展开/折叠 | +| `system_info` | Toast 样式 | 服务状态告知 | +| `tool_progress` | 进度条 + 图标 | IoT 操作进行中 | + +### 4.3 语音输入按钮 (悬浮窗核心组件) + +``` +┌─────────────────────────────────┐ +│ │ +│ 🎤 波形动画 │ (LISTENING 状态) +│ "我在听..." │ +│ │ +│ ┌───────────────────────────┐ │ +│ │ 输入文字或直接说话... │ │ (IDLE 状态,点击切换语音) +│ └───────────────────────────┘ │ +│ │ +│ ┌────┐ ┌──┐ │ +│ │ 🎤 │ (按住说话) │⌨️│ │ (WAITING 状态) +│ └────┘ └──┘ │ +└─────────────────────────────────┘ +``` + +### 4.4 昔涟状态指示器 + +``` +在线 (绿色点 + "昔涟"): +● 昔涟 + +思考中 (黄色脉冲 + "思考中..."): +◉ 思考中... + +离线 (灰色 + "离线"): +○ 昔涟 · 离线 + +说话中 (蓝色波纹 + "正在说话..."): +〰 正在说话... +``` + +### 4.5 IoT 设备卡片 + +``` +┌──────────────────────────┐ +│ 💡 客厅灯 ● ON │ +│ ┌──────────────────────┐ │ +│ │ 亮度 ████████░░ 80% │ │ +│ │ 色温 ████░░░░░░ 4000K│ │ +│ └──────────────────────┘ │ +│ [💡 开关] │ +└──────────────────────────┘ +``` + +## 5. 动效规范 + +| 动效 | 时长 | 曲线 | 说明 | +|------|------|------|------| +| 覆盖层出现 | 300ms | `FastOutSlowInEasing` | 底部滑入 | +| 覆盖层消失 | 250ms | `FastOutLinearInEasing` | 底部滑落 | +| 气泡出现 | 200ms | `LinearOutSlowInEasing` | 淡入 + 微上移 | +| 涟漪效果 | 400ms | `LinearEasing` | 标准 MD3 ripple | +| 页面切换 | 300ms | `FastOutSlowInEasing` | 淡入淡出 | +| 波形动画 | 循环 | — | 录制时音频可视化 | +| 状态指示脉冲 | 2s 循环 | — | 思考中 / 说话中的呼吸灯效果 | + +## 6. 图标系统 + +| 来源 | 用途 | +|------|------| +| `Icons.Filled` | 导航栏、主要操作按钮 | +| `Icons.Outlined` | 列表项、辅助操作 | +| `Icons.Rounded` | 芯片、标签 | +| 自定义 Lottie | 昔涟头像动画、情感表达 | +| 自定义 Vector | 品牌 LOGO、IoT 设备图标 | + +## 7. 悬浮窗 vs 全屏 布局差异 + +| 元素 | 全屏 Activity | 悬浮窗 Overlay | +|------|-------------|---------------| +| TopAppBar | 显示(标题 + 操作) | 不显示 | +| BottomNav | 显示(三 Tab) | 不显示 | +| 对话区域 | 全屏滚动 | 自适应高度,最大 70% 屏幕 | +| 背景 | Surface 纯色 | 半透明遮罩 + 卡片 | +| 圆角 | 无 | 顶部 28dp | +| 导航返回 | 系统返回键 | 关闭覆盖层 | +| IoT 面板 | 完整功能 | 仅限查询,无控制 | +| 设置入口 | 完整 | 无(需打开 APP) | + +## 8. 无障碍规范 + +- 所有可交互元素提供 `contentDescription` +- 语音按钮提供大点击区域(最小 48dp × 48dp) +- 支持 TalkBack 导航 +- 字体缩放:支持系统字体大小设置(最大 200%) +- 色彩对比度:满足 WCAG AA 标准(正文 ≥ 4.5:1,大文字 ≥ 3:1) diff --git a/devdocs/04-feature-spec.md b/devdocs/04-feature-spec.md new file mode 100644 index 0000000..a3ed26a --- /dev/null +++ b/devdocs/04-feature-spec.md @@ -0,0 +1,272 @@ +# 04 — 功能规格说明书 + +> **版本**:MVP v0.1 → Stable v1.0 +> **优先级定义**:P0 = 不可缺失 | P1 = 首个正式版必需 | P2 = 后续版本 + +--- + +## 1. 功能总览 + +### MVP (v0.1) — 核心语音助手 + +| # | 功能 | 优先级 | 说明 | +|---|------|--------|------| +| F01 | VoiceInteractionService 注册 | P0 | 系统可识别并设为默认助手 | +| F02 | 语音唤醒(热词"昔涟") | P0 | 息屏/亮屏唤醒 | +| F03 | 悬浮覆盖层对话 | P0 | VoiceInteractionSession 界面 | +| F04 | STT 语音识别 | P0 | 实时语音转文字 | +| F05 | TTS 语音合成 | P0 | 文字转语音回复 | +| F06 | 实时文字对话 | P0 | WebSocket 双向通信 | +| F07 | 用户认证与登录 | P0 | Token 持久化 | + +### v0.5 — 功能完善 + +| # | 功能 | 优先级 | 说明 | +|---|------|--------|------| +| F08 | IoT 设备状态查询 | P1 | 只读查询设备状态 | +| F09 | IoT 设备控制 | P1 | 开关/调节设备 | +| F10 | 推送通知 (FCM) | P1 | 昔涟主动消息、提醒 | +| F11 | 多会话历史 | P1 | 查看历史对话记录 | +| F12 | 提醒管理 | P1 | 创建/查看/删除提醒 | +| F13 | 全屏 Activity 模式 | P1 | 桌面图标入口、完整功能 | +| F14 | 暗黑模式 | P1 | 跟随系统 / 手动切换 | +| F15 | 自定义唤醒词 | P2 | 用户可修改唤醒词 | + +### v1.0 — 正式版 + +| # | 功能 | 优先级 | 说明 | +|---|------|--------|------| +| F16 | 记忆查看 | P1 | 浏览昔涟的记忆 | +| F17 | 自动化规则 | P2 | 查看/触发自动化场景 | +| F18 | 知识库查询 | P2 | 检索知识文档 | +| F19 | 文件上传 | P2 | 图片上传与分析 | +| F20 | 后台思考展示 | P2 | 查看昔涟的思考内容 | +| F21 | 多设备同步 | P2 | Web 端和 Android 端对话同步 | +| F22 | 画中画语音通话 | P2 | 持续语音对话的 PIP 模式 | +| F23 | 主题自定义 | P2 | 预设主题色切换 | +| F24 | 离线兜底 | P2 | 无网络时的本地回复 | + +--- + +## 2. P0 功能详细规格 + +### F01 · VoiceInteractionService 注册 + +**用户故事**:作为用户,我可以在系统设置中将昔涟设为默认语音助手。 + +**验收标准**: +- [ ] AndroidManifest.xml 正确声明 `VoiceInteractionService` +- [ ] 系统 **设置 → 默认应用 → 数字助理** 列表中可见 "昔涟" +- [ ] 选中后,长按 Home 键可触发昔涟 +- [ ] 选中后,底部两角滑动可触发昔涟 +- [ ] 未设为默认时,APP 内显示引导卡片并一键跳转设置页 + +**技术依赖**:无 + +--- + +### F02 · 语音唤醒 + +**用户故事**:作为用户,我可以在息屏或使用其他 APP 时说"昔涟"直接唤起助手。 + +**验收标准**: +- [ ] 息屏状态下说出"昔涟"可唤醒(成功率 ≥ 95%,安静环境) +- [ ] 亮屏使用其他 APP 时说出"昔涟"可唤醒 +- [ ] 唤醒后显示悬浮覆盖层,播放提示音 +- [ ] 10 分钟无交互自动停止热词监听以省电 +- [ ] 用户可在设置中开启/关闭息屏唤醒 +- [ ] 误唤醒率 ≤ 5 次/天(正常使用环境) + +**技术依赖**:F01 (VoiceInteractionService),`CAPTURE_AUDIO_HOTWORD` 权限 + +--- + +### F03 · 悬浮覆盖层对话 + +**用户故事**:作为用户,语音唤醒昔涟后看到半透明覆盖层,不影响当前使用的 APP。 + +**验收标准**: +- [ ] 底部滑入动画 300ms,覆盖层显示 +- [ ] 半透明黑色遮罩透出底层 APP 内容 +- [ ] 对话卡片顶部圆角 28dp,自适应高度(最大 70% 屏幕) +- [ ] 点击遮罩区域关闭覆盖层 +- [ ] 下滑卡片关闭覆盖层 +- [ ] 静默 10 秒自动收起 +- [ ] 用户说"再见"/"退下"自然结束对话 +- [ ] 关闭后回到触发前状态,不压入任何 Activity 栈 + +**技术依赖**:F01 (VoiceInteractionService) + +--- + +### F04 · STT 语音识别 + +**用户故事**:作为用户,我可以对昔涟说话,她会实时将我的语音转成文字。 + +**验收标准**: +- [ ] 覆盖层显示时自动开始监听 +- [ ] 实时显示识别中间结果(流式 STT) +- [ ] 语音结束(静默 1.5s)后自动提交识别结果 +- [ ] 识别结果以用户气泡形式显示在对话中 +- [ ] 安静环境识别准确率 ≥ 95%(中文普通话) +- [ ] 支持噪音环境降噪 + +**技术依赖**:后端 Whisper API (voice-service :8093),`RECORD_AUDIO` 权限 + +--- + +### F05 · TTS 语音合成 + +**用户故事**:作为用户,昔涟可以用自然的声音读出她的回复。 + +**验收标准**: +- [ ] 流式 TTS:收到 LLM 第一个 token 即开始合成 +- [ ] 语音自然流畅,无机械感(使用后端 Edge-TTS 或训练模型) +- [ ] 播放完毕自动进入下一轮监听 +- [ ] 播放期间自动降低其他音频音量(Audio Focus) +- [ ] 用户可在设置中调整语速、音量 + +**技术依赖**:后端 TTS API (voice-service :8093) + +--- + +### F06 · 实时文字对话 + +**用户故事**:作为用户,我可以打字与昔涟交流,并实时看到她的回复。 + +**验收标准**: +- [ ] WebSocket 连接建立后保持心跳 (30s ping) +- [ ] 文本消息发送后 200ms 内显示用户气泡 +- [ ] 昔涟回复流式显示(逐字/逐 token 渲染) +- [ ] 正确处理 `chat`、`action`、`thinking` 三种消息类型 +- [ ] 断线自动重连(指数退避,最多 5 次) +- [ ] 连接状态在界面实时指示 +- [ ] 多条消息按时间顺序排列 +- [ ] 支持消息滚动到顶部加载历史 + +**技术依赖**:后端 Gateway WebSocket (:8080) + +--- + +### F07 · 用户认证与登录 + +**用户故事**:作为用户,我可以登录我的 Cyrene 账号以同步数据。 + +**验收标准**: +- [ ] 首次启动显示登录页 +- [ ] 支持输入 Gateway 地址 + 账号 + 密码 +- [ ] 登录成功保存 JWT Token 到 EncryptedDataStore +- [ ] Token 过期自动刷新(refresh token) +- [ ] 再次启动自动登录(skip login page) +- [ ] 登录失败显示明确错误信息 +- [ ] 支持退出登录并清除本地数据 + +**技术依赖**:后端 Gateway Auth API (:8080) + +--- + +## 3. P1 功能详细规格 + +### F08 · IoT 设备状态查询 + +**验收标准**: +- [ ] 全屏 Activity 中 IoT Tab 显示所有设备卡片 +- [ ] 实时显示设备状态(开/关、亮度、温度等) +- [ ] 通过 WebSocket 接收状态变更推送 +- [ ] 覆盖层模式下支持语音查询("灯开着吗?") +- [ ] 覆盖层模式下 IoT 查询结果以文字+卡片形式展示 +- [ ] 锁屏状态下仅允许查询 + +### F09 · IoT 设备控制 + +**验收标准**: +- [ ] 设备卡片上可直接开关/调节 +- [ ] 支持语音控制("打开客厅灯") +- [ ] 控制结果实时反馈(成功/失败) +- [ ] 锁屏状态下禁止控制,提示解锁 +- [ ] 支持设备白名单(每用户可控制的设备不同) + +### F10 · 推送通知 (FCM) + +**验收标准**: +- [ ] 接收昔涟主动消息推送 +- [ ] 接收提醒到期推送 +- [ ] 接收 IoT 状态变更推送 +- [ ] 点击通知打开对应界面 +- [ ] 通知渠道分组(对话 / 提醒 / IoT / 系统) +- [ ] 用户可独立控制各渠道开关 + +### F11 · 多会话历史 + +**验收标准**: +- [ ] 对话列表页展示所有历史会话 +- [ ] 每条会话显示标题、最后一条消息预览、时间 +- [ ] 点击进入对应会话详情 +- [ ] 支持删除会话 +- [ ] 与 Web 端历史同步 + +### F12 · 提醒管理 + +**验收标准**: +- [ ] 可以语音创建提醒("提醒我下午三点开会") +- [ ] 列表展示所有活跃提醒 +- [ ] 支持删除/标记完成 +- [ ] 到期时 FCM 推送 + 覆盖层显示 + +### F13 · 全屏 Activity 模式 + +**验收标准**: +- [ ] 桌面图标启动进入全屏界面 +- [ ] BottomNav 三 Tab(对话 / IoT / 我的) +- [ ] 完整的设置页 +- [ ] 与覆盖层共享同一 WebSocket 连接和 ViewModel + +### F14 · 暗黑模式 + +**验收标准**: +- [ ] 跟随系统暗黑模式自动切换 +- [ ] 用户可在设置中手动选择 Light / Dark / Auto +- [ ] 覆盖层同步使用当前主题 +- [ ] 对话气泡、卡片、输入框颜色正确适配 + +--- + +## 4. P2 功能概要 + +| # | 功能 | 关键验收标准 | +|---|------|------------| +| F15 | 自定义唤醒词 | 设置页可输入自定义词,验证唯一性,测试唤醒效果 | +| F16 | 记忆查看 | 时间线展示昔涟记忆,支持搜索过滤 | +| F17 | 自动化规则 | 查看规则列表,手动触发,查看执行日志 | +| F18 | 知识库查询 | 搜索文档,查看内容,语音问答 | +| F19 | 文件上传 | 图片选择/拍照,缩略图预览,AI 分析结果 | +| F20 | 后台思考 | 展示昔涟后台思考片段,可折叠面板 | +| F21 | 多设备同步 | Web 端和 Android 端对话实时同步 | +| F22 | PIP 语音通话 | 切换到 PIP 窗口进行持续语音对话 | +| F23 | 主题自定义 | 从预设色中选择,实时预览 | +| F24 | 离线兜底 | 无网络时显示离线提示,缓存本地回复模板 | + +--- + +## 5. 按开发阶段分组 + +### Sprint 1 (MVP):F01 → F07 (P0 全部) +目标:可设为系统默认助手,能语音唤醒并对话 + +### Sprint 2:F08 → F14 (P1 全部) +目标:IoT 控制、全屏界面、推送通知上线 + +### Sprint 3+:F15 → F24 (P2 逐个) +目标:体验完善、高级功能 + +## 6. 非功能需求 + +| 类别 | 需求 | 指标 | +|------|------|------| +| 性能 | 覆盖层冷启动 | < 500ms | +| 性能 | 语音识别端到端延迟 | < 2s (STT + LLM + TTS) | +| 性能 | WebSocket 消息延迟 | < 100ms | +| 稳定性 | 崩溃率 | < 0.5% | +| 电量 | 热词监听功耗 | < 3% 电池/小时 (息屏) | +| 网络 | 支持弱网 | 切换到低码率 TTS | +| 兼容性 | 国内 ROM 适配 | MIUI / ColorOS / OriginOS / HarmonyOS | diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..1e8e761 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,14 @@ +# Gradle +org.gradle.parallel=true +org.gradle.daemon=true +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.internal.http.connectionTimeout=60000 +org.gradle.internal.http.socketTimeout=60000 + +# Kotlin +kotlin.code.style=official + +# Android +android.useAndroidX=true +android.nonTransitiveRClass=true +android.suppressUnsupportedCompileSdk=36 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..3ce25f7 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,63 @@ +[versions] +agp = "8.7.3" +kotlin = "2.0.21" +ksp = "2.0.21-1.0.27" +composeBom = "2024.12.01" +composeActivity = "1.9.3" +composeNavigation = "2.8.5" +lifecycle = "2.8.7" +room = "2.6.1" +retrofit = "2.11.0" +okhttp = "4.12.0" +koin = "4.0.0" +datastore = "1.1.1" +coroutines = "1.9.0" +material3 = "1.3.1" + +[libraries] +# Compose BOM +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } + +# Activity & Navigation +compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "composeActivity" } +compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation" } + +# Lifecycle +lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } + +# Room +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } + +# Network +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } + +# Koin DI +koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } +koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } + +# DataStore +datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + +# Coroutines +coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } + +# Core +core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..85b9a26 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e093824 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + repositories { + maven { url = uri("https://maven.aliyun.com/repository/public") } + maven { url = uri("https://maven.aliyun.com/repository/google") } + maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") } + maven { url = uri("https://maven.aliyun.com/repository/central") } + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + maven { url = uri("https://maven.aliyun.com/repository/public") } + maven { url = uri("https://maven.aliyun.com/repository/google") } + maven { url = uri("https://maven.aliyun.com/repository/central") } + google() + mavenCentral() + } +} + +rootProject.name = "Cyrene" +include(":app")