Initial Android project setup with Compose, WebSocket, and VoiceInteractionService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- 网络 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- 语音助手核心 -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.CAPTURE_AUDIO_HOTWORD" />
|
||||
|
||||
<!-- 后台服务 -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<!-- 推送 -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- 锁屏交互 -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
|
||||
<!-- 查询其他应用(检查默认助手设置) -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".CyreneApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Cyrene"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- 全屏主界面 -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Cyrene">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- VoiceInteractionService:系统语音助手 -->
|
||||
<service
|
||||
android:name=".service.CyreneVoiceInteractionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||
|
||||
<meta-data
|
||||
android:name="android.voice_interaction"
|
||||
android:resource="@xml/voice_interaction_config" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Preferences> 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<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
|
||||
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
|
||||
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
|
||||
val themeMode: Flow<String?> = context.dataStore.data.map { it[KEY_THEME] }
|
||||
val wakeWord: Flow<String?> = context.dataStore.data.map { it[KEY_WAKE_WORD] }
|
||||
val username: Flow<String?> = context.dataStore.data.map { it[KEY_USERNAME] }
|
||||
val clientId: Flow<String?> = context.dataStore.data.map { it[KEY_CLIENT_ID] }
|
||||
val deviceName: Flow<String?> = 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() }
|
||||
}
|
||||
}
|
||||
@@ -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<List<ConversationEntity>>
|
||||
|
||||
@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()
|
||||
}
|
||||
@@ -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<List<MessageEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(message: MessageEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(messages: List<MessageEntity>)
|
||||
|
||||
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
|
||||
suspend fun deleteByConversation(conversationId: String)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<AuthResponse>
|
||||
|
||||
@POST("api/v1/auth/refresh")
|
||||
suspend fun refreshToken(@Body refreshToken: String): Response<AuthResponse>
|
||||
|
||||
// Conversations
|
||||
@GET("api/v1/conversations")
|
||||
suspend fun getConversations(): Response<List<ConversationDto>>
|
||||
|
||||
@DELETE("api/v1/conversations/{id}")
|
||||
suspend fun deleteConversation(@Path("id") id: String): Response<Unit>
|
||||
|
||||
// IoT
|
||||
@GET("api/v1/iot/devices")
|
||||
suspend fun getDevices(): Response<List<DeviceDto>>
|
||||
|
||||
@POST("api/v1/iot/devices/{id}/control")
|
||||
suspend fun controlDevice(
|
||||
@Path("id") deviceId: String,
|
||||
@Body request: IoTControlRequest,
|
||||
): Response<DeviceDto>
|
||||
|
||||
// Reminders
|
||||
@GET("api/v1/reminders")
|
||||
suspend fun getReminders(): Response<List<ReminderDto>>
|
||||
|
||||
@DELETE("api/v1/reminders/{id}")
|
||||
suspend fun deleteReminder(@Path("id") id: String): Response<Unit>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<WSReviewMessage>? = null,
|
||||
@SerializedName("messages") val messages: List<WSHistoryMessage>? = null,
|
||||
@SerializedName("multi_message") val multiMessages: List<WSMultiItem>? = 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,
|
||||
)
|
||||
@@ -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<AuthResult> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean> = _connectionState.asStateFlow()
|
||||
|
||||
private val _incomingMessages = MutableSharedFlow<Message>(extraBufferCapacity = 64)
|
||||
override fun observeMessages(): Flow<Message> = _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<List<Conversation>> {
|
||||
return conversationDao.getAll().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMessages(conversationId: String): Flow<List<Message>> {
|
||||
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<Message> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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<List<Device>>(emptyList())
|
||||
override fun getDevices(): Flow<List<Device>> = _devices.asStateFlow()
|
||||
|
||||
override suspend fun controlDevice(
|
||||
deviceId: String,
|
||||
action: String,
|
||||
value: Any?,
|
||||
): Result<Device> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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<AppDatabase>().conversationDao() }
|
||||
single { get<AppDatabase>().messageDao() }
|
||||
|
||||
// Network interceptors (no runBlocking — using @Volatile caches)
|
||||
single { AuthInterceptor() }
|
||||
single { DynamicUrlInterceptor() }
|
||||
single { RetrofitClient.provideOkHttpClient(get(), get()) }
|
||||
single { RetrofitClient.provideRetrofit(get()) }
|
||||
single { get<retrofit2.Retrofit>().create(ApiService::class.java) }
|
||||
|
||||
// WebSocket
|
||||
single { WebSocketService(get()) }
|
||||
|
||||
// Voice
|
||||
single { SpeechRecognizer() }
|
||||
single { TextToSpeechEngine(androidContext()) }
|
||||
|
||||
// Repositories
|
||||
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
|
||||
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get()) }
|
||||
single<IoTRepository> { 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()) }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package top.yeij.cyrene.domain.model
|
||||
|
||||
data class AuthResult(
|
||||
val token: String,
|
||||
val refreshToken: String?,
|
||||
val username: String,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<AuthResult>
|
||||
|
||||
suspend fun logout()
|
||||
|
||||
suspend fun isLoggedIn(): Boolean
|
||||
}
|
||||
@@ -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<Boolean>
|
||||
|
||||
fun getConversations(): Flow<List<Conversation>>
|
||||
|
||||
suspend fun getMessages(conversationId: String): Flow<List<Message>>
|
||||
|
||||
suspend fun deleteConversation(id: String)
|
||||
|
||||
suspend fun connectWebSocket(sessionId: String?)
|
||||
|
||||
suspend fun disconnectWebSocket()
|
||||
|
||||
suspend fun sendMessage(content: String, sessionId: String?)
|
||||
|
||||
fun observeMessages(): Flow<Message>
|
||||
|
||||
suspend fun loadConversationsFromServer()
|
||||
|
||||
suspend fun loadMessagesFromServer(sessionId: String): List<Message>
|
||||
}
|
||||
@@ -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<List<Device>>
|
||||
|
||||
suspend fun controlDevice(deviceId: String, action: String, value: Any? = null): Result<Device>
|
||||
|
||||
suspend fun refreshDevices()
|
||||
}
|
||||
@@ -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<List<Conversation>> = chatRepository.getConversations()
|
||||
}
|
||||
@@ -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<AuthResult> {
|
||||
if (username.isBlank() || password.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("用户名和密码不能为空"))
|
||||
}
|
||||
return authRepository.login(username, password)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
private val _incomingMessages = MutableSharedFlow<WSServerMessage>(extraBufferCapacity = 64)
|
||||
val incomingMessages: SharedFlow<WSServerMessage> = _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<String>()
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("登录")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<Boolean> = chatRepository.connectionState
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
val conversations: StateFlow<List<Conversation>> = chatRepository.getConversations()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
private val _currentMessages = MutableStateFlow<List<Message>>(emptyList())
|
||||
val currentMessages: StateFlow<List<Message>> = _currentMessages.asStateFlow()
|
||||
|
||||
private val _inputText = MutableStateFlow("")
|
||||
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||
|
||||
private val _isStreaming = MutableStateFlow(false)
|
||||
val isStreaming: StateFlow<Boolean> = _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()
|
||||
}
|
||||
}
|
||||
@@ -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<List<Device>> = ioTRepository.getDevices()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _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
|
||||
}
|
||||
}
|
||||
@@ -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<OverlayState> = _state.asStateFlow()
|
||||
|
||||
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
|
||||
|
||||
private val _recognizedText = MutableStateFlow("")
|
||||
val recognizedText: StateFlow<String> = _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()
|
||||
}
|
||||
}
|
||||
@@ -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<String> = _baseUrl.asStateFlow()
|
||||
|
||||
private val _themeMode = MutableStateFlow("auto")
|
||||
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
|
||||
|
||||
private val _wakeWord = MutableStateFlow("昔涟")
|
||||
val wakeWord: StateFlow<String> = _wakeWord.asStateFlow()
|
||||
|
||||
private val _username = MutableStateFlow("")
|
||||
val username: StateFlow<String> = _username.asStateFlow()
|
||||
|
||||
private val _isLoggedIn = MutableStateFlow(false)
|
||||
val isLoggedIn: StateFlow<Boolean> = _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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#6D3BC0"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- 昔涟首字母 C,圆形背景 -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,30 C67.255,30 78,40.745 78,54 C78,67.255 67.255,78 54,78 C40.745,78 30,67.255 30,54 C30,40.745 40.745,30 54,30 Z M54,36 C44.059,36 36,44.059 36,54 C36,63.941 44.059,72 54,72 C58.935,72 63.437,70.1 66.878,66.878 C68.523,65.301 69.761,63.394 70.505,61.289 C70.963,59.947 71.213,58.524 71.233,57.067 C71.239,55.712 71.041,54.369 70.646,53.084 L66.757,58.243 L58.243,49.729 L53.084,53.619 L44.57,45.106 L45.398,44.278 C47.881,41.795 51.235,40.233 54,40.233 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Cyrene</string>
|
||||
<string name="voice_assistant_name">昔涟</string>
|
||||
<string name="voice_assistant_description">昔涟 —— 你的智能语音助手</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="tab_chat">对话</string>
|
||||
<string name="tab_iot">设备</string>
|
||||
<string name="tab_profile">我的</string>
|
||||
|
||||
<!-- Voice -->
|
||||
<string name="hotword_listening">正在聆听唤醒词…</string>
|
||||
<string name="listening">我在听…</string>
|
||||
<string name="thinking">思考中…</string>
|
||||
<string name="speaking">正在说话…</string>
|
||||
<string name="tap_to_speak">点击说话</string>
|
||||
<string name="hold_to_speak">按住说话</string>
|
||||
|
||||
<!-- Actions -->
|
||||
<string name="login">登录</string>
|
||||
<string name="logout">退出登录</string>
|
||||
<string name="settings">设置</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="confirm">确认</string>
|
||||
<string name="save">保存</string>
|
||||
<string name="retry">重试</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="set_default_assistant">设为默认语音助手</string>
|
||||
<string name="set_default_assistant_desc">将昔涟替换为系统默认助手</string>
|
||||
<string name="appearance">外观</string>
|
||||
<string name="voice_settings">语音设置</string>
|
||||
<string name="wake_word">唤醒词</string>
|
||||
<string name="account">账号</string>
|
||||
<string name="about">关于</string>
|
||||
<string name="server_address">服务器地址</string>
|
||||
<string name="theme">主题</string>
|
||||
<string name="theme_light">浅色</string>
|
||||
<string name="theme_dark">深色</string>
|
||||
<string name="theme_auto">跟随系统</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Cyrene" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<voice-interaction-service
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:sessionService=".service.CyreneVoiceInteractionSession"
|
||||
android:recognitionService=".service.CyreneRecognitionService"
|
||||
android:supportsAssist="true"
|
||||
android:supportsLaunchVoiceAssistFromKeyguard="true"
|
||||
/>
|
||||
Reference in New Issue
Block a user