Add drawer menu with flat structure, unified panel styling, Room/SQLCipher setup

This commit is contained in:
Алексей Будаев 2026-04-07 19:23:52 +08:00
parent 21505aae75
commit 7eadec669c
45 changed files with 1378 additions and 18 deletions

View file

@ -0,0 +1,64 @@
package com.mistral.chat.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import net.sqlcipher.database.SupportFactory
@Database(
entities = [Profile::class, Session::class, MessageEntity::class, Setting::class],
version = 1,
exportSchema = false
)
abstract class ChatDatabase : RoomDatabase() {
abstract fun profileDao(): ProfileDao
abstract fun sessionDao(): SessionDao
abstract fun messageDao(): MessageDao
abstract fun settingDao(): SettingDao
companion object {
private const val DATABASE_NAME = "mistral_chat.db"
@Volatile
private var INSTANCE: ChatDatabase? = null
fun getInstance(context: Context): ChatDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: buildDatabase(context).also { INSTANCE = it }
}
}
private fun buildDatabase(context: Context): ChatDatabase {
val passphrase = getOrCreatePassphrase(context)
val factory = SupportFactory(passphrase)
return Room.databaseBuilder(
context.applicationContext,
ChatDatabase::class.java,
DATABASE_NAME
)
.openHelperFactory(factory)
.fallbackToDestructiveMigration()
.build()
}
private fun getOrCreatePassphrase(context: Context): ByteArray {
val prefs = context.getSharedPreferences("db_prefs", Context.MODE_PRIVATE)
val existingKey = prefs.getString("db_key", null)
return if (existingKey != null) {
existingKey.toByteArray(Charsets.UTF_8)
} else {
val newKey = generateSecureKey()
prefs.edit().putString("db_key", newKey).apply()
newKey.toByteArray(Charsets.UTF_8)
}
}
private fun generateSecureKey(): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"
return (1..32).map { chars.random() }.joinToString("")
}
}
}

View file

@ -0,0 +1,31 @@
package com.mistral.chat.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE sessionId = :sessionId ORDER BY timestamp ASC")
fun getMessagesBySession(sessionId: Long): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE sessionId = :sessionId ORDER BY timestamp ASC")
suspend fun getMessagesBySessionSync(sessionId: Long): List<MessageEntity>
@Query("SELECT COUNT(*) FROM messages WHERE sessionId = :sessionId")
suspend fun getMessageCount(sessionId: Long): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(message: MessageEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(messages: List<MessageEntity>)
@Delete
suspend fun delete(message: MessageEntity)
@Query("DELETE FROM messages WHERE sessionId = :sessionId")
suspend fun deleteBySession(sessionId: Long)
@Query("DELETE FROM messages")
suspend fun deleteAll()
}

View file

@ -0,0 +1,15 @@
package com.mistral.chat.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "profiles")
data class Profile(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
val bio: String = "",
val preferences: String = "",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)

View file

@ -0,0 +1,28 @@
package com.mistral.chat.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface ProfileDao {
@Query("SELECT * FROM profiles ORDER BY updatedAt DESC")
fun getAllProfiles(): Flow<List<Profile>>
@Query("SELECT * FROM profiles WHERE id = :id")
suspend fun getProfileById(id: Long): Profile?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(profile: Profile): Long
@Update
suspend fun update(profile: Profile)
@Delete
suspend fun delete(profile: Profile)
@Query("DELETE FROM profiles WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM profiles")
suspend fun deleteAll()
}

View file

@ -0,0 +1,29 @@
package com.mistral.chat.data
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "sessions",
foreignKeys = [
ForeignKey(
entity = Profile::class,
parentColumns = ["id"],
childColumns = ["profileId"],
onDelete = ForeignKey.SET_NULL
)
],
indices = [Index("profileId")]
)
data class Session(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val profileId: Long? = null,
val title: String = "Новая сессия",
val isManuallyRenamed: Boolean = false,
val isTitleGenerated: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)

View file

@ -0,0 +1,40 @@
package com.mistral.chat.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface SessionDao {
@Query("SELECT * FROM sessions ORDER BY updatedAt DESC")
fun getAllSessions(): Flow<List<Session>>
@Query("SELECT * FROM sessions WHERE profileId = :profileId ORDER BY updatedAt DESC")
fun getSessionsByProfile(profileId: Long): Flow<List<Session>>
@Query("SELECT * FROM sessions WHERE profileId IS NULL ORDER BY updatedAt DESC")
fun getSessionsWithoutProfile(): Flow<List<Session>>
@Query("SELECT * FROM sessions WHERE id = :id")
suspend fun getSessionById(id: Long): Session?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(session: Session): Long
@Update
suspend fun update(session: Session)
@Delete
suspend fun delete(session: Session)
@Query("DELETE FROM sessions WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM sessions")
suspend fun deleteAll()
@Query("UPDATE sessions SET updatedAt = :timestamp WHERE id = :sessionId")
suspend fun updateTimestamp(sessionId: Long, timestamp: Long = System.currentTimeMillis())
@Query("UPDATE sessions SET title = :title, isManuallyRenamed = 1 WHERE id = :sessionId")
suspend fun updateTitle(sessionId: Long, title: String)
}

View file

@ -0,0 +1,11 @@
package com.mistral.chat.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "settings")
data class Setting(
@PrimaryKey
val key: String,
val value: String
)

View file

@ -0,0 +1,22 @@
package com.mistral.chat.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface SettingDao {
@Query("SELECT * FROM settings WHERE `key` = :key")
suspend fun getSetting(key: String): Setting?
@Query("SELECT value FROM settings WHERE `key` = :key")
suspend fun getValue(key: String): String?
@Query("SELECT value FROM settings WHERE `key` = :key")
fun getValueFlow(key: String): Flow<String?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(setting: Setting)
@Query("DELETE FROM settings WHERE `key` = :key")
suspend fun delete(key: String)
}

View file

@ -0,0 +1,54 @@
package com.mistral.chat.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.mistral.chat.R
class DrawerSubmenuAdapter(
private val items: List<DrawerMenuItem>,
private val onItemClick: (DrawerMenuItem) -> Unit
) : RecyclerView.Adapter<DrawerSubmenuAdapter.ViewHolder>() {
data class DrawerMenuItem(
val id: String,
val title: String,
val icon: Int? = null,
val isSelected: Boolean = false
)
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val icon: ImageView = itemView.findViewById(R.id.itemIcon)
val title: TextView = itemView.findViewById(R.id.itemTitle)
val check: ImageView = itemView.findViewById(R.id.itemCheck)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_drawer, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.title.text = item.title
if (item.icon != null) {
holder.icon.setImageResource(item.icon)
holder.icon.visibility = View.VISIBLE
} else {
holder.icon.visibility = View.GONE
}
holder.check.visibility = if (item.isSelected) View.VISIBLE else View.GONE
holder.itemView.setOnClickListener {
onItemClick(item)
}
}
override fun getItemCount(): Int = items.size
}

View file

@ -90,6 +90,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private const val KEY_NEW_SESSION_ON_START = "new_session_on_start"
private const val KEY_LAST_PROFILE_ID = "last_profile_id"
private const val KEY_SELECTED_MODEL = "selected_model"
private const val KEY_THEME_MODE = "theme_mode"
private const val MAX_PROFILES = 10
}
@ -193,7 +194,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
R.id.action_clear_all -> {
showClearAllDialog()
}
R.id.action_settings -> {
R.id.action_appearance -> {
showThemeDialog()
}
R.id.action_session -> {
showSettingsDialog()
}
R.id.action_about -> {
@ -298,6 +302,31 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
.show()
}
private fun showThemeDialog() {
val currentTheme = prefs.getInt(KEY_THEME_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
val options = arrayOf("Системная", "Светлая", "Тёмная")
val selectedIndex = when (currentTheme) {
AppCompatDelegate.MODE_NIGHT_NO -> 1
AppCompatDelegate.MODE_NIGHT_YES -> 2
else -> 0
}
AlertDialog.Builder(this)
.setTitle(R.string.appearance_settings)
.setSingleChoiceItems(options, selectedIndex) { dialog, which ->
val mode = when (which) {
1 -> AppCompatDelegate.MODE_NIGHT_NO
2 -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
prefs.edit().putInt(KEY_THEME_MODE, mode).apply()
AppCompatDelegate.setDefaultNightMode(mode)
dialog.dismiss()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun showAboutDialog() {
AlertDialog.Builder(this)
.setTitle(R.string.about)
@ -707,22 +736,16 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
}
private fun addMessage(message: Message) {
val isAssistantMessage = !message.isUser
val newPosition = messages.size - 1
messages.add(message)
adapter.notifyItemInserted(newPosition)
// Scroll to beginning of assistant messages so user sees the sender name first
if (isAssistantMessage) {
recyclerView.post {
recyclerView.scrollToPosition(0)
// Then scroll to show the new message from beginning
val scrollAmount = (recyclerView.computeVerticalScrollExtent() - 200).coerceAtLeast(0)
recyclerView.post {
recyclerView.scrollBy(0, scrollAmount)
}
}
// Scroll to show new AI response
if (!message.isUser) {
recyclerView.postDelayed({
recyclerView.scrollToPosition(newPosition)
}, 150)
}
val sessionId = currentSessionId
@ -738,10 +761,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
database.sessionDao().updateTimestamp(sessionId)
}
}
recyclerView.postDelayed({
recyclerView.scrollToPosition(messages.size - 1)
}, 100)
}
private fun saveMessageToDatabase(sessionId: Long?, content: String, isUser: Boolean, senderName: String?) {

View file

@ -0,0 +1,55 @@
package com.mistral.chat.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.mistral.chat.R
import com.mistral.chat.data.Profile
class ProfilesAdapter(
private val profiles: List<Profile>,
private val onProfileClick: (Profile) -> Unit,
private val onProfileLongClick: (Profile) -> Unit,
private val getSelectedProfileId: () -> Long?
) : RecyclerView.Adapter<ProfilesAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val icon: ImageView = view.findViewById(R.id.profileIcon)
val name: TextView = view.findViewById(R.id.profileName)
val checkmark: ImageView = view.findViewById(R.id.profileCheckmark)
}
fun refresh() {
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_profile, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val profile = profiles[position]
val selectedId = getSelectedProfileId()
holder.name.text = if (profile.name.length > 12) {
profile.name.take(12) + "..."
} else {
profile.name
}
holder.name.alpha = if (profile.id == selectedId) 1.0f else 0.7f
holder.checkmark.visibility = if (profile.id == selectedId) View.VISIBLE else View.GONE
holder.itemView.setOnClickListener { onProfileClick(profile) }
holder.itemView.setOnLongClickListener {
onProfileLongClick(profile)
true
}
}
override fun getItemCount(): Int = profiles.size
}

View file

@ -0,0 +1,45 @@
package com.mistral.chat.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.mistral.chat.R
import com.mistral.chat.data.Session
class SessionsAdapter(
private val sessions: List<Session>,
private val getCurrentSessionId: () -> Long?,
private val onSessionClick: (Session) -> Unit,
private val onSessionLongClick: (Session) -> Unit
) : RecyclerView.Adapter<SessionsAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.sessionTitle)
val checkmark: ImageView = view.findViewById(R.id.sessionCheckmark)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_session, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val session = sessions[position]
val currentId = getCurrentSessionId()
holder.title.text = session.title
holder.title.alpha = if (session.id == currentId) 1.0f else 0.7f
holder.checkmark.visibility = if (session.id == currentId) View.VISIBLE else View.GONE
holder.itemView.setOnClickListener { onSessionClick(session) }
holder.itemView.setOnLongClickListener {
onSessionLongClick(session)
true
}
}
override fun getItemCount(): Int = sessions.size
}