Clean up unused code and fix profile selection

- Removed unused UserProfile.kt, ApiModels.kt
- Fixed profile checkmark showing for all profiles (using lambda)
- Increased text bubble width to 350dp
- Auto-scroll to beginning of AI messages
- Auto-select profile on creation
- Model selection persists across restarts
- Removed null-unsafe !! operators
- Added Russian language support for UI strings
This commit is contained in:
Алексей Будаев 2026-04-06 20:14:32 +08:00
parent a5fe4bc29e
commit 21505aae75
10 changed files with 1070 additions and 332 deletions

View file

@ -5,35 +5,60 @@ import com.google.gson.Gson
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.mistral.chat.data.Message import com.mistral.chat.data.Message
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Call import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class MistralClient(private val apiKey: String) { class MistralClient(private val apiKey: String) {
private val client = OkHttpClient.Builder() private var client = createNewClient()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS) private fun createNewClient(): OkHttpClient {
.writeTimeout(60, TimeUnit.SECONDS) return OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(25, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build() .build()
}
private val gson = Gson() private val gson = Gson()
private val jsonMediaType = "application/json".toMediaType() private val jsonMediaType = "application/json".toMediaType()
private var currentCall: Call? = null private var currentCall: Call? = null
private var currentContinuation: Continuation<Result<Pair<String, String>>>? = null
companion object { companion object {
private const val BASE_URL = "https://api.mistral.ai/v1" private const val BASE_URL = "https://api.mistral.ai/v1"
private val SUPPORTED_MODELS = setOf(
"mistral-small-latest",
"mistral-medium-latest",
"mistral-large-latest",
"mistral-small",
"mistral-medium",
"mistral-large",
"codestral-latest",
"codestral",
"pixtral-large-latest",
"pixtral-12b-2409"
)
val AVAILABLE_MODELS = listOf( val AVAILABLE_MODELS = listOf(
"mistral-small-latest" to "Mistral Small", "mistral-small-latest" to "Mistral Small",
"mistral-medium-latest" to "Mistral Medium", "mistral-medium-latest" to "Mistral Medium",
"mistral-large-latest" to "Mistral Large", "mistral-large-latest" to "Mistral Large",
"codestral-latest" to "Codestral" "codestral-latest" to "Codestral",
"pixtral-large-latest" to "Pixtral Large"
) )
} }
@ -45,8 +70,7 @@ import java.util.concurrent.TimeUnit
.get() .get()
.build() .build()
val response = client.newCall(request).execute() client.newCall(request).execute().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
return@withContext Result.failure(Exception("API error: ${response.code}")) return@withContext Result.failure(Exception("API error: ${response.code}"))
} }
@ -57,28 +81,47 @@ import java.util.concurrent.TimeUnit
val models = responseJson val models = responseJson
.getAsJsonArray("data") .getAsJsonArray("data")
?.mapNotNull { obj -> ?.mapNotNull { obj ->
try {
val jsonObj = obj.asJsonObject val jsonObj = obj.asJsonObject
val id = jsonObj.get("id")?.asString val id = jsonObj.get("id")?.asString ?: return@mapNotNull null
val created = jsonObj.get("created")?.asLong ?: 0L if (id in SUPPORTED_MODELS) {
if (id != null && created > 0 && id.endsWith("-latest")) {
val displayName = id val displayName = id
.replace("-latest", "") .replace("-latest", "")
.replace("-12b-2409", "")
.replace("-", " ") .replace("-", " ")
.split(" ") .split(" ")
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } } .joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
id to displayName id to displayName
} else null } else null
} ?: emptyList() } catch (e: Exception) {
null
}
}
?.distinctBy { it.first }
?: emptyList()
Result.success(models) Result.success(models)
}
} catch (e: Exception) { } catch (e: Exception) {
if (e is java.io.IOException && !e.message.orEmpty().contains("cancel", ignoreCase = true)) {
Result.failure(e) Result.failure(e)
} else {
Result.failure(Exception("Request cancelled"))
}
} }
} }
fun cancelRequest() { fun cancelRequest() {
currentCall?.cancel() val call = currentCall
val continuation = currentContinuation
call?.cancel()
currentCall = null currentCall = null
continuation?.resume(Result.failure(Exception("Request cancelled")))
currentContinuation = null
client = createNewClient()
} }
suspend fun chat( suspend fun chat(
@ -90,7 +133,7 @@ import java.util.concurrent.TimeUnit
val jsonObject = JsonObject() val jsonObject = JsonObject()
jsonObject.addProperty("model", model) jsonObject.addProperty("model", model)
jsonObject.addProperty("temperature", 0.7) jsonObject.addProperty("temperature", 0.7)
jsonObject.addProperty("stream", onChunk != null) jsonObject.addProperty("stream", false)
val messagesArray = JsonArray() val messagesArray = JsonArray()
messages.forEach { msg -> messages.forEach { msg ->
@ -112,15 +155,40 @@ import java.util.concurrent.TimeUnit
.build() .build()
currentCall = client.newCall(request) currentCall = client.newCall(request)
val response = currentCall!!.execute()
if (response.code == 0 || response.code == -1) { suspendCancellableCoroutine { continuation ->
return@withContext Result.failure(Exception("Request cancelled")) currentContinuation = continuation
currentCall?.enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
val cont = currentContinuation
currentCall = null
currentContinuation = null
if (cont != null) {
if (call.isCanceled()) {
cont.resume(Result.failure(Exception("Request cancelled")))
} else {
cont.resume(Result.failure(e))
}
}
} }
override fun onResponse(call: okhttp3.Call, response: Response) {
val cont = currentContinuation
if (cont == null) {
response.close()
currentCall = null
return
}
try {
if (!response.isSuccessful) { if (!response.isSuccessful) {
val errorBody = response.body?.string() ?: "Unknown error" val errorBody = response.body?.string() ?: "Unknown error"
return@withContext Result.failure(Exception("API error: ${response.code} - $errorBody")) currentCall = null
currentContinuation = null
cont.resume(Result.failure(Exception("API error: ${response.code} - $errorBody")))
return
} }
val responseBody = response.body?.string() ?: "" val responseBody = response.body?.string() ?: ""
@ -129,24 +197,54 @@ import java.util.concurrent.TimeUnit
onChunk(responseBody) onChunk(responseBody)
} }
val responseJson = gson.fromJson(responseBody, JsonObject::class.java) val responseJson = try {
gson.fromJson(responseBody, JsonObject::class.java)
} catch (e: Exception) {
currentCall = null
currentContinuation = null
cont.resume(Result.failure(Exception("Invalid JSON response")))
return
}
val choices = responseJson.getAsJsonArray("choices") val choices = responseJson.getAsJsonArray("choices")
if (choices == null || choices.size() == 0) { if (choices == null || choices.size() == 0) {
return@withContext Result.failure(Exception("No response from API")) currentCall = null
currentContinuation = null
cont.resume(Result.failure(Exception("No response from API")))
return
} }
val content = choices val firstChoice = choices.get(0)?.asJsonObject
.get(0) if (firstChoice == null) {
?.asJsonObject currentCall = null
?.getAsJsonObject("message") currentContinuation = null
?.get("content") cont.resume(Result.failure(Exception("Empty choice")))
?.asString ?: "" return
}
val message = firstChoice.getAsJsonObject("message")
val content = message?.get("content")?.asString ?: ""
val usedModel = responseJson.get("model")?.asString ?: model val usedModel = responseJson.get("model")?.asString ?: model
currentCall = null currentCall = null
Result.success(content to usedModel) currentContinuation = null
cont.resume(Result.success(content to usedModel))
} catch (e: Exception) {
currentCall = null
currentContinuation = null
cont.resume(Result.failure(e))
}
}
})
continuation.invokeOnCancellation {
currentCall?.cancel()
currentCall = null
currentContinuation = null
client = createNewClient()
}
}
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }

View file

@ -1,27 +1,52 @@
package com.mistral.chat.data package com.mistral.chat.data
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "messages",
foreignKeys = [
ForeignKey(
entity = Session::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("sessionId")]
)
data class MessageEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val sessionId: Long,
val content: String,
val isUser: Boolean,
val timestamp: Long = System.currentTimeMillis()
)
data class Message( data class Message(
val id: String = System.currentTimeMillis().toString(), val id: Long = 0,
val sessionId: Long = 0,
val content: String, val content: String,
val isUser: Boolean, val isUser: Boolean,
val timestamp: Long = System.currentTimeMillis(), val timestamp: Long = System.currentTimeMillis(),
val senderName: String? = null val senderName: String? = null
) )
data class ChatRequest( fun MessageEntity.toMessage(): Message = Message(
val model: String, id = id,
val messages: List<Message>, sessionId = sessionId,
val temperature: Double = 0.7, content = content,
val stream: Boolean = false isUser = isUser,
timestamp = timestamp
) )
data class ChatResponse( fun Message.toEntity(): MessageEntity = MessageEntity(
val id: String, id = id,
val choices: List<Choice>, sessionId = sessionId,
val model: String content = content,
) isUser = isUser,
timestamp = timestamp
data class Choice(
val index: Int,
val message: Message
) )

View file

@ -1,18 +0,0 @@
package com.mistral.chat.data
data class UserProfile(
val name: String = "",
val bio: String = "",
val preferences: String = ""
) {
fun isEmpty(): Boolean = name.isBlank() && bio.isBlank() && preferences.isBlank()
fun toContextString(): String {
return buildString {
append("[User Profile]\n")
if (name.isNotBlank()) append("Name: $name\n")
if (bio.isNotBlank()) append("Bio: $bio\n")
if (preferences.isNotBlank()) append("Preferences: $preferences\n")
}
}
}

View file

@ -8,14 +8,17 @@ import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.view.MenuItem
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.PopupMenu import android.widget.PopupMenu
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import android.widget.EditText
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.view.GravityCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -23,17 +26,29 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import com.google.android.material.navigation.NavigationView
import com.google.android.material.progressindicator.LinearProgressIndicator import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.button.MaterialButton
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.mistral.chat.R import com.mistral.chat.R
import com.mistral.chat.api.MistralClient import com.mistral.chat.api.MistralClient
import com.mistral.chat.data.ChatDatabase
import com.mistral.chat.data.Message import com.mistral.chat.data.Message
import com.mistral.chat.data.UserProfile import com.mistral.chat.data.MessageEntity
import com.mistral.chat.data.Profile
import com.mistral.chat.data.Session
import com.mistral.chat.data.toMessage
import com.mistral.chat.data.toEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
private lateinit var recyclerView: RecyclerView private lateinit var recyclerView: RecyclerView
private lateinit var adapter: MessageAdapter private lateinit var adapter: MessageAdapter
@ -43,6 +58,10 @@ class MainActivity : AppCompatActivity() {
private lateinit var logoButton: ImageView private lateinit var logoButton: ImageView
private lateinit var menuButton: ImageButton private lateinit var menuButton: ImageButton
private lateinit var toolbarTitle: TextView private lateinit var toolbarTitle: TextView
private lateinit var hamburgerButton: ImageButton
private lateinit var drawerLayout: androidx.drawerlayout.widget.DrawerLayout
private lateinit var navigationView: NavigationView
private lateinit var rightPanel: View
private lateinit var gson: Gson private lateinit var gson: Gson
private var currentJob: kotlinx.coroutines.Job? = null private var currentJob: kotlinx.coroutines.Job? = null
@ -52,15 +71,26 @@ class MainActivity : AppCompatActivity() {
private var selectedModelName: String = "mistral-small-latest" private var selectedModelName: String = "mistral-small-latest"
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
private lateinit var encryptedPrefs: SharedPreferences private lateinit var encryptedPrefs: SharedPreferences
private lateinit var database: ChatDatabase
private val profiles = mutableListOf<Profile>()
private val sessions = mutableListOf<Session>()
private var currentProfileId: Long? = null
private var currentSessionId: Long? = null
private var profilesAdapter: ProfilesAdapter? = null
private var isRightPanelVisible = false
private var isLeftDrawerOpen = false
private var userMessageCount = 0
private var titleGenerationJob: kotlinx.coroutines.Job? = null
private var isFirstLoad = true
companion object { companion object {
private const val PREFS_NAME = "mistral_chat_prefs" private const val PREFS_NAME = "mistral_chat_prefs"
private const val KEY_USER_NAME = "user_name"
private const val KEY_USER_BIO = "user_bio"
private const val KEY_USER_PREFS = "user_preferences"
private const val KEY_MESSAGES = "chat_messages"
private const val KEY_PROFILE_HASH = "profile_hash"
private const val KEY_API_KEY = "api_key" private const val KEY_API_KEY = "api_key"
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 MAX_PROFILES = 10
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -74,6 +104,11 @@ class MainActivity : AppCompatActivity() {
gson = Gson() gson = Gson()
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val savedModel = prefs.getString(KEY_SELECTED_MODEL, null)
if (savedModel != null) {
selectedModelName = savedModel
}
val masterKey = MasterKey.Builder(this) val masterKey = MasterKey.Builder(this)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
@ -91,20 +126,27 @@ class MainActivity : AppCompatActivity() {
client = MistralClient(getApiKey()) client = MistralClient(getApiKey())
loadMessages()
logoButton = findViewById(R.id.logoButton) logoButton = findViewById(R.id.logoButton)
menuButton = findViewById(R.id.menuButton) menuButton = findViewById(R.id.menuButton)
hamburgerButton = findViewById(R.id.hamburgerButton)
toolbarTitle = findViewById(R.id.toolbarTitle) toolbarTitle = findViewById(R.id.toolbarTitle)
recyclerView = findViewById(R.id.recyclerView) recyclerView = findViewById(R.id.recyclerView)
inputField = findViewById(R.id.inputField) inputField = findViewById(R.id.inputField)
sendButton = findViewById(R.id.sendButton) sendButton = findViewById(R.id.sendButton)
progressIndicator = findViewById(R.id.progressIndicator) progressIndicator = findViewById(R.id.progressIndicator)
drawerLayout = findViewById(R.id.drawerLayout)
navigationView = findViewById(R.id.navigationView)
rightPanel = findViewById(R.id.rightPanelContainer)
database = ChatDatabase.getInstance(this)
setupToolbar() setupToolbar()
setupRecyclerView() setupRecyclerView()
setupDrawer()
setupRightPanel()
loadModels() loadModels()
setupInput() setupInput()
loadProfilesAndSessions()
inputField.postDelayed({ inputField.postDelayed({
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
@ -113,48 +155,409 @@ class MainActivity : AppCompatActivity() {
}, 300) }, 300)
} }
private fun setupDrawer() {
navigationView.setNavigationItemSelectedListener(this)
hamburgerButton.setOnClickListener {
drawerLayout.openDrawer(GravityCompat.START)
}
drawerLayout.addDrawerListener(object : androidx.drawerlayout.widget.DrawerLayout.DrawerListener {
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {}
override fun onDrawerOpened(drawerView: View) {
if (drawerView == navigationView) {
isLeftDrawerOpen = true
} else if (drawerView == rightPanel) {
isRightPanelVisible = true
}
}
override fun onDrawerClosed(drawerView: View) {
if (drawerView == navigationView) {
isLeftDrawerOpen = false
} else if (drawerView == rightPanel) {
isRightPanelVisible = false
}
}
override fun onDrawerStateChanged(newState: Int) {}
})
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_profiles -> {
showProfilesManager()
}
R.id.action_api_key -> {
showApiKeyDialog()
}
R.id.action_clear_all -> {
showClearAllDialog()
}
R.id.action_settings -> {
showSettingsDialog()
}
R.id.action_about -> {
showAboutDialog()
}
}
drawerLayout.closeDrawer(GravityCompat.START)
return true
}
private fun showProfilesManager() {
if (profiles.isEmpty()) {
showCreateProfileDialog()
} else {
val options = profiles.map { it.name }.toMutableList()
if (profiles.size < MAX_PROFILES) {
options.add("Создать новый профиль")
}
AlertDialog.Builder(this)
.setTitle(R.string.manage_profiles)
.setItems(options.toTypedArray()) { _, which ->
if (which < profiles.size) {
showEditProfileDialog(profiles[which])
} else if (profiles.size < MAX_PROFILES) {
showCreateProfileDialog()
}
}
.show()
}
}
private fun showCreateProfileDialog() {
showProfileDialog(null)
}
private fun showEditProfileDialog(profile: Profile) {
showProfileDialog(profile)
}
private fun showClearAllDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_clear_all, null)
val deleteProfilesCheckbox = dialogView.findViewById<android.widget.CheckBox>(R.id.deleteProfilesCheckbox)
AlertDialog.Builder(this)
.setTitle(R.string.clear_all_history)
.setView(dialogView)
.setPositiveButton(R.string.yes) { _, _ ->
val deleteProfiles = deleteProfilesCheckbox.isChecked
lifecycleScope.launch {
database.sessionDao().deleteAll()
// Wait for transaction to complete
kotlinx.coroutines.delay(100)
if (deleteProfiles) {
database.profileDao().deleteAll()
currentProfileId = null
prefs.edit().remove(KEY_LAST_PROFILE_ID).apply()
profiles.clear()
}
messages.clear()
adapter.notifyDataSetChanged()
// Create fresh session with null profileId
val newSession = Session(
profileId = null,
title = "Новая сессия"
)
val sessionId = database.sessionDao().insert(newSession)
currentSessionId = sessionId
userMessageCount = 0
messages.clear()
adapter.notifyDataSetChanged()
sessions.clear()
sessions.add(newSession.copy(id = sessionId))
updateRightPanel()
Toast.makeText(this@MainActivity, R.string.history_cleared, Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton(R.string.no, null)
.show()
}
private fun showSettingsDialog() {
val currentSetting = prefs.getBoolean(KEY_NEW_SESSION_ON_START, false)
val options = arrayOf("Открывать последнюю сессию", "Начинать новую сессию")
val selectedIndex = if (currentSetting) 1 else 0
AlertDialog.Builder(this)
.setTitle(R.string.settings)
.setSingleChoiceItems(options, selectedIndex) { dialog, which ->
val newValue = which == 1
prefs.edit().putBoolean(KEY_NEW_SESSION_ON_START, newValue).apply()
dialog.dismiss()
Toast.makeText(this, "Настройка сохранена", Toast.LENGTH_SHORT).show()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun showAboutDialog() {
AlertDialog.Builder(this)
.setTitle(R.string.about)
.setMessage(R.string.about_text)
.setPositiveButton(R.string.ok, null)
.show()
}
private fun setupToolbar() { private fun setupToolbar() {
hamburgerButton.isVisible = true
logoButton.setOnClickListener { logoButton.setOnClickListener {
showModelSelectorDialog() showModelSelectorDialog()
} }
menuButton.setOnClickListener { view -> menuButton.setOnClickListener { view ->
val popup = PopupMenu(this, view) showRightPanelMenu(view)
popup.menuInflater.inflate(R.menu.main_menu, popup.menu)
popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_api_key -> {
showApiKeyDialog()
true
}
R.id.action_profile -> {
showProfileDialog()
true
}
R.id.action_clear -> {
showClearChatDialog()
true
}
R.id.action_about -> {
showAboutDialog()
true
}
else -> false
} }
} }
popup.show()
private fun showRightPanelMenu(view: View) {
if (isRightPanelVisible) {
drawerLayout.closeDrawer(GravityCompat.END)
} else {
if (isLeftDrawerOpen) {
drawerLayout.closeDrawer(GravityCompat.START)
}
drawerLayout.openDrawer(GravityCompat.END)
}
}
private fun setupRightPanel() {
val includedPanel = rightPanel.findViewById<View>(R.id.panelRightContent)
val profilesRecyclerView = includedPanel.findViewById<RecyclerView>(R.id.profilesRecyclerView)
val sessionsRecyclerView = includedPanel.findViewById<RecyclerView>(R.id.sessionsRecyclerView)
val newSessionButton = includedPanel.findViewById<MaterialButton>(R.id.newSessionButton)
profilesAdapter = ProfilesAdapter(
profiles = profiles,
onProfileClick = { profile -> selectProfile(profile) },
onProfileLongClick = { profile -> showProfileOptions(profile) },
getSelectedProfileId = { currentProfileId }
)
profilesRecyclerView.layoutManager = LinearLayoutManager(this)
profilesRecyclerView.adapter = profilesAdapter
val sessionsAdapter = SessionsAdapter(
sessions = sessions,
getCurrentSessionId = { currentSessionId },
onSessionClick = { session -> selectSession(session) },
onSessionLongClick = { session -> showSessionOptions(session) }
)
sessionsRecyclerView.layoutManager = LinearLayoutManager(this)
sessionsRecyclerView.adapter = sessionsAdapter
newSessionButton.setOnClickListener { createNewSession() }
}
private fun loadProfilesAndSessions() {
lifecycleScope.launch {
database.profileDao().getAllProfiles().collect { profileList ->
profiles.clear()
profiles.addAll(profileList)
val lastProfileId = prefs.getLong(KEY_LAST_PROFILE_ID, -1L)
val profileExists = profiles.any { it.id == lastProfileId }
val currentStillValid = currentProfileId != null && profiles.any { it.id == currentProfileId }
if (!currentStillValid) {
currentProfileId = if (lastProfileId > 0 && profileExists) {
lastProfileId
} else if (profiles.isNotEmpty()) {
profiles.first().id
} else {
null
}
val profileId = currentProfileId
if (profileId != null) {
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profileId).apply()
}
}
profilesAdapter?.refresh()
updateRightPanel()
}
}
lifecycleScope.launch {
database.sessionDao().getAllSessions().collect { sessionList ->
sessions.clear()
sessions.addAll(sessionList)
updateRightPanel()
val lastSessionId = prefs.getLong("last_session_id", -1L)
if (currentSessionId == null && sessions.isNotEmpty() && isFirstLoad) {
val newSessionOnStart = prefs.getBoolean(KEY_NEW_SESSION_ON_START, false)
if (newSessionOnStart) {
createNewSession()
} else if (lastSessionId > 0 && sessions.any { it.id == lastSessionId }) {
val session = sessions.find { it.id == lastSessionId }
if (session != null) {
selectSession(session)
} else {
selectSession(sessions.first())
}
} else {
selectSession(sessions.first())
}
isFirstLoad = false
} else if (currentSessionId != null && !sessions.any { it.id == currentSessionId }) {
if (sessions.isNotEmpty()) {
selectSession(sessions.first())
}
}
}
}
}
private fun updateRightPanel() {
try {
profilesAdapter?.refresh()
val includedPanel = rightPanel.findViewById<View>(R.id.panelRightContent) ?: return
val sessionsRecyclerView = includedPanel.findViewById<RecyclerView>(R.id.sessionsRecyclerView) ?: return
(sessionsRecyclerView.adapter as? SessionsAdapter)?.notifyDataSetChanged()
} catch (e: Exception) {
// Panel not initialized yet
}
}
private fun selectProfile(profile: Profile) {
currentProfileId = profile.id
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profile.id).apply()
profilesAdapter?.refresh()
val profileName = profiles.find { it.id == currentProfileId }?.name ?: profile.name
Toast.makeText(this, getString(R.string.profile_info, profileName), Toast.LENGTH_SHORT).show()
}
private fun showProfileOptions(profile: Profile) {
val options = arrayOf("Редактировать", "Удалить")
AlertDialog.Builder(this)
.setTitle(profile.name)
.setItems(options) { _, which ->
when (which) {
0 -> editProfile(profile)
1 -> deleteProfile(profile)
}
}
.show()
}
private fun editProfile(profile: Profile) {
showProfileDialog(profile)
}
private fun deleteProfile(profile: Profile) {
AlertDialog.Builder(this)
.setTitle(R.string.delete)
.setMessage("Удалить профиль ${profile.name}?")
.setPositiveButton(R.string.yes) { _, _ ->
lifecycleScope.launch {
database.profileDao().delete(profile)
}
}
.setNegativeButton(R.string.no, null)
.show()
}
private fun selectSession(session: Session) {
currentSessionId = session.id
userMessageCount = 0
prefs.edit().putLong("last_session_id", session.id).apply()
loadSessionMessages(session.id)
updateRightPanel()
drawerLayout.closeDrawer(GravityCompat.END)
}
private fun loadSessionMessages(sessionId: Long) {
lifecycleScope.launch {
val dbMessages = database.messageDao().getMessagesBySessionSync(sessionId)
messages.clear()
messages.addAll(dbMessages.map { it.toMessage() })
adapter.notifyDataSetChanged()
}
}
private fun showSessionOptions(session: Session) {
val options = arrayOf("Переименовать", "Удалить")
AlertDialog.Builder(this)
.setTitle(session.title)
.setItems(options) { _, which ->
when (which) {
0 -> renameSession(session)
1 -> deleteSession(session)
}
}
.show()
}
private fun renameSession(session: Session) {
val input = EditText(this)
input.setText(session.title)
AlertDialog.Builder(this)
.setTitle("Переименовать")
.setView(input)
.setPositiveButton(R.string.save) { _, _ ->
val newTitle = input.text.toString().trim()
if (newTitle.isNotEmpty()) {
lifecycleScope.launch {
database.sessionDao().updateTitle(session.id, newTitle)
}
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun deleteSession(session: Session) {
AlertDialog.Builder(this)
.setTitle(R.string.delete)
.setMessage("Удалить сессию ${session.title}?")
.setPositiveButton(R.string.yes) { _, _ ->
lifecycleScope.launch {
database.sessionDao().delete(session)
}
}
.setNegativeButton(R.string.no, null)
.show()
}
private fun createNewSession() {
lifecycleScope.launch {
val session = Session(
profileId = if (currentProfileId != null && profiles.any { it.id == currentProfileId }) currentProfileId else null,
title = "Новая сессия"
)
val sessionId = database.sessionDao().insert(session)
currentSessionId = sessionId
userMessageCount = 0
messages.clear()
adapter.notifyDataSetChanged()
updateRightPanel()
Toast.makeText(this@MainActivity, R.string.new_session, Toast.LENGTH_SHORT).show()
drawerLayout.closeDrawer(GravityCompat.END)
} }
} }
private fun showModelSelectorDialog() { private fun showModelSelectorDialog() {
val modelNames = availableModels.map { it.second }.toTypedArray() val uniqueModels = availableModels.distinctBy { it.second }
val currentModelId = availableModels.find { it.first == selectedModelName }?.second ?: modelNames.firstOrNull() ?: "" val modelNames = uniqueModels.map { it.second }.toTypedArray()
val currentIndex = modelNames.indexOf(currentModelId).coerceAtLeast(0) val currentModelId = uniqueModels.find { it.first == selectedModelName }?.second
?: uniqueModels.firstOrNull()?.second
?: ""
val currentIndex = uniqueModels.indexOfFirst { it.second == currentModelId }.coerceAtLeast(0)
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.select_model) .setTitle(R.string.select_model)
.setSingleChoiceItems(modelNames, currentIndex) { dialog, which -> .setSingleChoiceItems(modelNames, currentIndex) { dialog, which ->
selectedModelName = availableModels[which].first selectedModelName = uniqueModels[which].first
prefs.edit().putString(KEY_SELECTED_MODEL, selectedModelName).apply()
dialog.dismiss() dialog.dismiss()
} }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
@ -192,17 +595,96 @@ class MainActivity : AppCompatActivity() {
val userInput = inputField.text?.toString()?.trim() val userInput = inputField.text?.toString()?.trim()
if (userInput.isNullOrEmpty()) return if (userInput.isNullOrEmpty()) return
addMessage(Message(content = userInput, isUser = true)) if (currentSessionId == null) {
inputField.text?.clear() createNewSessionAndSend(userInput)
return
}
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
inputField.text?.clear()
sendMessage(userInput) sendMessage(userInput)
} }
private fun createNewSessionAndSend(userInput: String) {
lifecycleScope.launch {
val session = Session(
profileId = if (currentProfileId != null && profiles.any { it.id == currentProfileId }) currentProfileId else null,
title = "Новая сессия"
)
val sessionId = database.sessionDao().insert(session)
currentSessionId = sessionId
userMessageCount = 0
messages.clear()
adapter.notifyDataSetChanged()
updateRightPanel()
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
inputField.text?.clear()
sendMessage(userInput)
}
}
private fun generateSessionTitle(): kotlinx.coroutines.Job {
val sessionId = currentSessionId ?: return kotlinx.coroutines.Job()
return lifecycleScope.launch(Dispatchers.IO) {
var attempts = 0
val maxAttempts = 2
while (attempts < maxAttempts && isActive) {
attempts++
try {
val recentMessages = messages.takeLast(4).map { msg ->
if (msg.isUser) "User: ${msg.content}" else "AI: ${msg.content}"
}.joinToString("\n")
val prompt = "Кратко озаглавь этот чат в 3-5 слов. Отвечай только названием, без кавычек."
val fullPrompt = "$prompt\n\n$recentMessages"
val result = withTimeoutOrNull(10000L) {
client?.chat(selectedModelName, listOf(
Message(content = fullPrompt, isUser = true)
))
}
if (!isActive) return@launch
if (result != null) {
result.onSuccess { (response, _) ->
val title = response.trim().take(50)
if (title.isNotEmpty() && currentSessionId == sessionId) {
database.sessionDao().updateTitle(sessionId, title)
runOnUiThread {
sessions.find { it.id == sessionId }?.let { session ->
val index = sessions.indexOf(session)
if (index >= 0) {
sessions[index] = session.copy(title = title)
updateRightPanel()
}
}
}
}
}
return@launch
}
} catch (e: Exception) {
if (!isActive) return@launch
}
if (attempts < maxAttempts && isActive) {
kotlinx.coroutines.delay(1000L)
}
}
}
}
private fun loadModels() { private fun loadModels() {
lifecycleScope.launch { lifecycleScope.launch {
val result = client?.getModels() val result = client?.getModels()
result?.onSuccess { models -> result?.onSuccess { models ->
availableModels = models availableModels = models
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
if (!hasUserSelectedModel) {
runOnUiThread { runOnUiThread {
val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) } val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) }
if (codestralIndex >= 0) { if (codestralIndex >= 0) {
@ -211,44 +693,71 @@ class MainActivity : AppCompatActivity() {
selectedModelName = models[0].first selectedModelName = models[0].first
} }
} }
}
}?.onFailure { }?.onFailure {
availableModels = MistralClient.AVAILABLE_MODELS availableModels = MistralClient.AVAILABLE_MODELS
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
if (!hasUserSelectedModel) {
runOnUiThread { runOnUiThread {
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest" selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest"
} }
} }
} }
} }
}
private fun addMessage(message: Message) { private fun addMessage(message: Message) {
val isAssistantMessage = !message.isUser
val newPosition = messages.size - 1
messages.add(message) messages.add(message)
adapter.notifyItemInserted(messages.size - 1) adapter.notifyItemInserted(newPosition)
saveMessages()
// 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)
}
}
}
val sessionId = currentSessionId
if (sessionId != null) {
lifecycleScope.launch {
val entity = MessageEntity(
sessionId = sessionId,
content = message.content,
isUser = message.isUser,
timestamp = message.timestamp
)
database.messageDao().insert(entity)
database.sessionDao().updateTimestamp(sessionId)
}
}
recyclerView.postDelayed({ recyclerView.postDelayed({
recyclerView.scrollToPosition(messages.size - 1) recyclerView.scrollToPosition(messages.size - 1)
}, 100) }, 100)
} }
private fun loadMessages() { private fun saveMessageToDatabase(sessionId: Long?, content: String, isUser: Boolean, senderName: String?) {
val json = prefs.getString(KEY_MESSAGES, null) if (sessionId != null) {
if (json != null) { lifecycleScope.launch {
try { database.messageDao().insert(MessageEntity(
val type = object : TypeToken<List<Message>>() {}.type sessionId = sessionId,
val loaded: List<Message> = gson.fromJson(json, type) content = content,
messages.clear() isUser = isUser,
messages.addAll(loaded) timestamp = System.currentTimeMillis()
} catch (e: Exception) { ))
// Ignore parse errors database.sessionDao().updateTimestamp(sessionId)
} }
} }
} }
private fun saveMessages() {
val json = gson.toJson(messages)
prefs.edit().putString(KEY_MESSAGES, json).apply()
}
private fun getApiKey(): String { private fun getApiKey(): String {
return encryptedPrefs.getString(KEY_API_KEY, null) ?: "" return encryptedPrefs.getString(KEY_API_KEY, null) ?: ""
} }
@ -320,11 +829,7 @@ class MainActivity : AppCompatActivity() {
currentJob = lifecycleScope.launch { currentJob = lifecycleScope.launch {
try { try {
val userProfile = loadUserProfile() val profileContext = getSelectedProfileContext()
val profileContext = if (!userProfile.isEmpty()) {
userProfile.toContextString()
} else ""
val apiMessages = messages.map { msg -> val apiMessages = messages.map { msg ->
Message( Message(
@ -337,18 +842,46 @@ class MainActivity : AppCompatActivity() {
apiMessages.add(0, Message(content = profileContext, isUser = true)) apiMessages.add(0, Message(content = profileContext, isUser = true))
} }
val result = client?.chat(selectedModel, apiMessages) val result = withTimeout(15000L) {
client?.chat(selectedModel, apiMessages) ?: Result.failure(Exception("Client not initialized"))
}
result?.onSuccess { (response, usedModel) -> if (!isActive) return@launch
addMessage(Message(content = response, isUser = false, senderName = usedModel))
}?.onFailure { error -> result.onSuccess { (response, usedModel) ->
val displayModel = usedModel.ifEmpty { "Assistant" }
addMessage(Message(content = response, isUser = false, senderName = displayModel))
lifecycleScope.launch {
saveMessageToDatabase(currentSessionId, response, false, displayModel)
}
val count = userMessageCount + 1
userMessageCount = count
if (count == 2 && titleGenerationJob?.isActive != true) {
titleGenerationJob = generateSessionTitle()
}
}.onFailure { error ->
if (!isActive) return@launch
val errorMessage = error.message ?: "Unknown error" val errorMessage = error.message ?: "Unknown error"
if (!errorMessage.contains("cancelled", ignoreCase = true)) {
val userFriendlyMessage = getUserFriendlyError(errorMessage) val userFriendlyMessage = getUserFriendlyError(errorMessage)
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error")) addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
} }
}
sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false
} catch (e: kotlinx.coroutines.CancellationException) { } catch (e: kotlinx.coroutines.CancellationException) {
addMessage(Message(content = "❌ Отменено пользователем", isUser = false, senderName = "Cancelled")) if (!isActive) return@launch
} finally { addMessage(Message(content = "Запрос отменён", isUser = false, senderName = "System"))
sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false
} catch (e: Exception) {
if (!isActive) return@launch
val userFriendlyMessage = getUserFriendlyError(e.message ?: "Unknown error")
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
sendButton.isEnabled = true sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo) sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false progressIndicator.isVisible = false
@ -358,62 +891,92 @@ class MainActivity : AppCompatActivity() {
private fun cancelRequest() { private fun cancelRequest() {
currentJob?.cancel() currentJob?.cancel()
titleGenerationJob?.cancel()
client?.cancelRequest() client?.cancelRequest()
sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false
} }
private fun loadUserProfile(): UserProfile { private fun getSelectedProfileContext(): String {
return UserProfile( if (currentProfileId == null) return ""
name = prefs.getString(KEY_USER_NAME, "") ?: "",
bio = prefs.getString(KEY_USER_BIO, "") ?: "", val profile = profiles.find { it.id == currentProfileId }
preferences = prefs.getString(KEY_USER_PREFS, "") ?: "" if (profile == null) return ""
)
return buildString {
append("[Profile: ${profile.name}]\n")
if (profile.bio.isNotBlank()) append("Bio: ${profile.bio}\n")
if (profile.preferences.isNotBlank()) append("Preferences: ${profile.preferences}\n")
}
} }
private fun saveUserProfile(profile: UserProfile) { private fun getCurrentProfileName(): String {
prefs.edit() if (currentProfileId == null) return "Вы"
.putString(KEY_USER_NAME, profile.name) val profileName = profiles.find { it.id == currentProfileId }?.name
.putString(KEY_USER_BIO, profile.bio) return if (profileName.isNullOrBlank()) "Вы" else profileName
.putString(KEY_USER_PREFS, profile.preferences)
.remove(KEY_PROFILE_HASH)
.apply()
} }
private fun deleteUserProfile() { private fun showProfileDialog(existingProfile: Profile? = null) {
prefs.edit() if (existingProfile == null && profiles.size >= MAX_PROFILES) {
.remove(KEY_USER_NAME) Toast.makeText(this, "Максимум $MAX_PROFILES профилей", Toast.LENGTH_SHORT).show()
.remove(KEY_USER_BIO) return
.remove(KEY_USER_PREFS)
.remove(KEY_PROFILE_HASH)
.apply()
} }
private fun showProfileDialog() {
val profile = loadUserProfile()
val dialogView = layoutInflater.inflate(R.layout.dialog_profile, null) val dialogView = layoutInflater.inflate(R.layout.dialog_profile, null)
val nameInput = dialogView.findViewById<TextInputEditText>(R.id.nameInput) val nameInput = dialogView.findViewById<TextInputEditText>(R.id.nameInput)
val bioInput = dialogView.findViewById<TextInputEditText>(R.id.bioInput) val bioInput = dialogView.findViewById<TextInputEditText>(R.id.bioInput)
val preferencesInput = dialogView.findViewById<TextInputEditText>(R.id.preferencesInput) val preferencesInput = dialogView.findViewById<TextInputEditText>(R.id.preferencesInput)
nameInput.setText(profile.name) existingProfile?.let {
bioInput.setText(profile.bio) nameInput.setText(it.name)
preferencesInput.setText(profile.preferences) bioInput.setText(it.bio)
preferencesInput.setText(it.preferences)
}
AlertDialog.Builder(this) val dialog = AlertDialog.Builder(this)
.setTitle(if (existingProfile != null) R.string.profile_title else R.string.new_profile)
.setView(dialogView) .setView(dialogView)
.setPositiveButton(R.string.save) { _, _ -> .setPositiveButton(R.string.save) { _, _ ->
val newProfile = UserProfile( val name = nameInput.text?.toString()?.trim() ?: ""
name = nameInput.text?.toString() ?: "", if (name.isNotEmpty()) {
lifecycleScope.launch {
if (existingProfile != null) {
database.profileDao().update(existingProfile.copy(
name = name,
bio = bioInput.text?.toString() ?: "",
preferences = preferencesInput.text?.toString() ?: "",
updatedAt = System.currentTimeMillis()
))
} else {
val newId = database.profileDao().insert(Profile(
name = name,
bio = bioInput.text?.toString() ?: "", bio = bioInput.text?.toString() ?: "",
preferences = preferencesInput.text?.toString() ?: "" preferences = preferencesInput.text?.toString() ?: ""
) ))
saveUserProfile(newProfile) if (currentProfileId == null) {
currentProfileId = newId
}
}
}
}
} }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.setNeutralButton(R.string.delete) { _, _ ->
deleteUserProfile() if (existingProfile != null) {
dialog.setNeutralButton(R.string.delete) { _, _ ->
lifecycleScope.launch {
database.profileDao().delete(existingProfile)
if (currentProfileId == existingProfile.id) {
currentProfileId = null
} }
.show() }
}
}
dialog.show()
} }
private fun showClearChatDialog() { private fun showClearChatDialog() {
@ -422,19 +985,11 @@ class MainActivity : AppCompatActivity() {
.setPositiveButton(R.string.yes) { _, _ -> .setPositiveButton(R.string.yes) { _, _ ->
messages.clear() messages.clear()
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
prefs.edit().remove(KEY_MESSAGES).apply()
} }
.setNegativeButton(R.string.no, null) .setNegativeButton(R.string.no, null)
.show() .show()
} }
private fun showAboutDialog() {
AlertDialog.Builder(this)
.setMessage(R.string.about_text)
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun getUserFriendlyError(error: String): String { private fun getUserFriendlyError(error: String): String {
return when { return when {
error.contains("timeout", ignoreCase = true) || error.contains("timeout", ignoreCase = true) ||

View file

@ -6,6 +6,7 @@ import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -46,6 +47,11 @@ class MessageAdapter(private val messages: List<Message>) : RecyclerView.Adapter
class UserMessageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { class UserMessageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(message: Message) { fun bind(message: Message) {
val textView = itemView.findViewById<TextView>(R.id.messageText) val textView = itemView.findViewById<TextView>(R.id.messageText)
val senderNameView = itemView.findViewById<TextView>(R.id.senderName)
val senderIconView = itemView.findViewById<ImageView>(R.id.senderIcon)
senderIconView.visibility = View.VISIBLE
senderNameView.text = message.senderName ?: "Вы"
textView.text = message.content textView.text = message.content
textView.setBackgroundResource(R.drawable.bg_message_user) textView.setBackgroundResource(R.drawable.bg_message_user)
textView.setTextColor(ContextCompat.getColor(itemView.context, R.color.user_message_text)) textView.setTextColor(ContextCompat.getColor(itemView.context, R.color.user_message_text))

View file

@ -1,13 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<RelativeLayout <androidx.drawerlayout.widget.DrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawerLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fitsSystemWindows="true" android:fitsSystemWindows="true"
tools:context=".ui.MainActivity"> tools:context=".ui.MainActivity">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout" android:id="@+id/appBarLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -19,9 +24,19 @@
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingStart="16dp" android:paddingStart="4dp"
android:paddingEnd="16dp"> android:paddingEnd="16dp">
<ImageButton
android:id="@+id/hamburgerButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_menu_hamburger"
android:contentDescription="@string/settings"
android:clickable="true"
android:focusable="true" />
<ImageView <ImageView
android:id="@+id/logoButton" android:id="@+id/logoButton"
android:layout_width="36dp" android:layout_width="36dp"
@ -107,7 +122,7 @@
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:hint="@string/enter_message" android:hint="@string/enter_message"
android:imeOptions="actionSend" android:imeOptions="actionSend"
android:inputType="textMultiLine" android:inputType="textMultiLine|textCapSentences"
android:maxLines="5" android:maxLines="5"
android:minHeight="56dp" android:minHeight="56dp"
android:paddingStart="8dp" android:paddingStart="8dp"
@ -133,3 +148,25 @@
</LinearLayout> </LinearLayout>
</RelativeLayout> </RelativeLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigationView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header"
app:menu="@menu/drawer_menu" />
<FrameLayout
android:id="@+id/rightPanelContainer"
android:layout_width="300dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="?attr/colorSurface">
<include layout="@layout/panel_right" />
</FrameLayout>
</androidx.drawerlayout.widget.DrawerLayout>

View file

@ -24,7 +24,7 @@
android:id="@+id/nameInput" android:id="@+id/nameInput"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="textPersonName" android:inputType="textPersonName|textCapSentences"
android:maxLines="1" /> android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
@ -41,7 +41,7 @@
android:id="@+id/bioInput" android:id="@+id/bioInput"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="textMultiLine" android:inputType="textMultiLine|textCapSentences"
android:minLines="3" android:minLines="3"
android:maxLines="5" /> android:maxLines="5" />
@ -59,7 +59,7 @@
android:id="@+id/preferencesInput" android:id="@+id/preferencesInput"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="textMultiLine" android:inputType="textMultiLine|textCapSentences"
android:minLines="2" android:minLines="2"
android:maxLines="4" /> android:maxLines="4" />

View file

@ -31,7 +31,7 @@
android:id="@+id/messageText" android:id="@+id/messageText"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:maxWidth="280dp" android:maxWidth="350dp"
android:padding="12dp" android:padding="12dp"
android:textSize="16sp" android:textSize="16sp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"

View file

@ -7,13 +7,33 @@
android:padding="8dp"> android:padding="8dp">
<TextView <TextView
android:id="@+id/messageText" android:id="@+id/senderName"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:maxWidth="280dp" android:layout_marginEnd="4dp"
android:padding="12dp" android:textSize="12sp"
android:textSize="16sp" android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintEnd_toStartOf="@id/senderIcon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/senderIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/ic_person"
android:contentDescription="@string/profile"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/messageText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="350dp"
android:layout_marginTop="2dp"
android:padding="12dp"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/senderIcon" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -34,4 +34,19 @@
<string name="api_key_current">Текущий ключ: %s</string> <string name="api_key_current">Текущий ключ: %s</string>
<string name="enter_api_key">Введите API ключ</string> <string name="enter_api_key">Введите API ключ</string>
<string name="api_key_required">Требуется API ключ Mistral</string> <string name="api_key_required">Требуется API ключ Mistral</string>
<string name="clear_all_history">Очистить всю историю</string>
<string name="clear_all_confirm">Удалить все сессии и сообщения?</string>
<string name="history_cleared">История очищена</string>
<string name="sessions">Сессии</string>
<string name="new_session">Новая сессия</string>
<string name="no_sessions">Нет сессий</string>
<string name="ok">OK</string>
<string name="profiles">Профили</string>
<string name="profiles_uppercase">ПРОФИЛИ</string>
<string name="new_profile">Новый профиль</string>
<string name="manage_profiles">Управление профилями</string>
<string name="edit">Редактировать</string>
<string name="selected">Выбрано</string>
<string name="clear_all_delete_profiles">Удалить все профили</string>
<string name="profile_info">Профиль: %s</string>
</resources> </resources>