This commit is contained in:
Viktoria Polyakova
2026-01-25 15:57:18 +03:00
parent 9626c9f3be
commit 0a9c3f5bc4
2809 changed files with 182961 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- For Android 11+ (API 30+) - Scoped Storage -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
android:minSdkVersion="30" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CRMChat"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<!-- WorkManager initialization -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
</provider>
<activity
android:name=".ui.auth.AuthActivity"
android:exported="true"
android:theme="@style/Theme.CRMChat.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.main.MainActivity"
android:exported="false"
android:theme="@style/Theme.CRMChat.NoActionBar" />
<activity
android:name=".ui.chat.ChatActivity"
android:exported="false"
android:theme="@style/Theme.CRMChat.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.main.CreateChatActivity"
android:exported="false"
android:theme="@style/Theme.CRMChat.NoActionBar" />
<activity
android:name=".ui.main.UserSelectionActivity"
android:exported="false"
android:theme="@style/Theme.CRMChat.NoActionBar" />
<activity
android:name=".ui.chat.ImageViewerActivity"
android:exported="false"
android:theme="@style/Theme.CRMChat.NoActionBar" />
<activity
android:name=".ui.profile.ProfileActivity"
android:exported="false"
android:theme="@style/Theme.CRMChat.NoActionBar" />
<activity
android:name=".ui.settings.SettingsActivity"
android:exported="false"
android:theme="@style/Theme.CRMChat.NoActionBar" />
</application>
</manifest>

View File

@@ -0,0 +1,6 @@
package com.crm.chat.data
object AppConstants {
const val API_KEY = "KxBBliOsuVkfRSQzmWNYFJ"
const val SERVER_URL = "https://crm.mcmed.ru"
}

View File

@@ -0,0 +1,83 @@
package com.crm.chat.data.api
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
class ApiClient(private val baseUrl: String, private val apiKey: String? = null, private val token: String? = null) {
private val okHttpClient: OkHttpClient by lazy {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val builder = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
// Add API key interceptor if API key is provided
if (!apiKey.isNullOrBlank()) {
val apiKeyInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("x-api-key", apiKey)
.build()
chain.proceed(request)
}
builder.addInterceptor(apiKeyInterceptor)
}
// Add auth token interceptor if token is provided
if (!token.isNullOrBlank()) {
val authInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
chain.proceed(request)
}
builder.addInterceptor(authInterceptor)
}
builder.build()
}
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val authApiService: AuthApiService by lazy {
retrofit.create(AuthApiService::class.java)
}
val chatApiService: ChatApiService by lazy {
retrofit.create(ChatApiService::class.java)
}
val userApiService: UserApiService by lazy {
retrofit.create(UserApiService::class.java)
}
val userProfileApiService: UserProfileApiService by lazy {
retrofit.create(UserProfileApiService::class.java)
}
// Expose OkHttpClient for file uploads
fun getOkHttpClientInstance(): OkHttpClient = okHttpClient
// Expose baseUrl for file uploads
fun getBaseUrl(): String = baseUrl
// Expose apiKey for file uploads
fun getApiKey(): String? = apiKey
// Expose token for file downloads
fun getToken(): String? = token
}

View File

@@ -0,0 +1,19 @@
package com.crm.chat.data.api
import com.crm.chat.data.model.AuthResponse
import com.crm.chat.data.model.LoginRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface AuthApiService {
@POST("api/iam/auth/login")
suspend fun login(@Body request: LoginRequest): Response<AuthResponse>
@POST("api/iam/auth/login-ext")
suspend fun loginExtension(@Body request: LoginRequest): Response<AuthResponse>
@POST("api/iam/auth/login-site")
suspend fun loginSite(@Body request: LoginRequest): Response<AuthResponse>
}

View File

@@ -0,0 +1,171 @@
package com.crm.chat.data.api
import com.crm.chat.data.model.*
import retrofit2.Response
import retrofit2.http.*
interface ChatApiService {
// Chat Providers
@GET("api/chat/providers")
suspend fun getChatProviders(@Query("offset") offset: Int = 0): Response<List<ChatProvider>>
// Chats
@GET("api/chat/chats")
suspend fun getChats(
@Query("limit") limit: Int = 50,
@Query("offset") offset: Int = 0
): Response<List<Chat>>
@GET("api/chat/chats/{chatId}")
suspend fun getChat(@Path("chatId") chatId: Long): Response<Chat>
@DELETE("api/chat/chats/{chatId}")
suspend fun deleteChat(@Path("chatId") chatId: Long): Response<Unit>
// Create chats
@POST("api/chat/chats/personal")
suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): Response<Chat>
@POST("api/chat/chats/group")
suspend fun createGroupChat(@Body request: CreateGroupChatRequest): Response<Chat>
@POST("api/chat/chats/external")
suspend fun createExternalChat(@Body request: CreateExternalChatRequest): Response<Chat>
// Chat search
@GET("api/chat/chats/find")
suspend fun findChats(@QueryMap filters: Map<String, String>): Response<List<Chat>>
@GET("api/chat/chats/find/full")
suspend fun findChatsFull(@QueryMap filters: Map<String, String>): Response<List<Chat>>
@GET("api/chat/chats/find/full/personal")
suspend fun findChatsFullPersonal(@QueryMap filters: Map<String, String>): Response<List<Chat>>
@GET("api/chat/chats/find/full/by-message-content")
suspend fun findChatsByMessageContent(@QueryMap filters: Map<String, String>): Response<List<Chat>>
// Chat management
@PATCH("api/chat/chats/group/{chatId}")
suspend fun updateGroupChat(
@Path("chatId") chatId: Long,
@Body request: UpdateGroupChatRequest
): Response<Chat>
@PUT("api/chat/chats/{chatId}/pin/{messageId}")
suspend fun pinMessage(@Path("chatId") chatId: Long, @Path("messageId") messageId: Long): Response<Chat>
@PUT("api/chat/chats/{chatId}/unpin/{messageId}")
suspend fun unpinMessage(@Path("chatId") chatId: Long, @Path("messageId") messageId: Long): Response<Chat>
@POST("api/chat/chats/{chatId}/contact")
suspend fun createContactLead(
@Path("chatId") chatId: Long,
@Body request: CreateContactLeadRequest
): Response<EntityInfo>
// Chat messages
@GET("api/chat/chats/{chatId}/messages")
suspend fun getChatMessages(
@Path("chatId") chatId: Long,
@Query("limit") limit: Int = 50,
@Query("offset") offset: Int = 0
): Response<ChatMessagesResponse>
@POST("api/chat/chats/{chatId}/messages")
suspend fun sendMessage(
@Path("chatId") chatId: Long,
@Body request: SendMessageRequest
): Response<ChatMessage>
@PUT("api/chat/chats/{chatId}/messages/{messageId}")
suspend fun updateMessage(
@Path("chatId") chatId: Long,
@Path("messageId") messageId: Long,
@Body request: UpdateMessageRequest
): Response<ChatMessage>
@DELETE("api/chat/chats/{chatId}/messages/{messageId}")
suspend fun deleteMessage(@Path("chatId") chatId: Long, @Path("messageId") messageId: Long): Response<Unit>
@PUT("api/chat/chats/{chatId}/messages/{messageId}/status/{status}")
suspend fun updateMessageStatus(
@Path("chatId") chatId: Long,
@Path("messageId") messageId: Long,
@Path("status") status: String
): Response<Unit>
@POST("api/chat/chats/{chatId}/messages/status/{status}")
suspend fun updateMessagesStatus(
@Path("chatId") chatId: Long,
@Path("status") status: String,
@Body messageIds: List<Long>
): Response<List<ChatMessage>>
@PUT("api/chat/chats/{chatId}/messages/{messageId}/react/{reaction}")
suspend fun reactToMessage(
@Path("chatId") chatId: Long,
@Path("messageId") messageId: Long,
@Path("reaction") reaction: String
): Response<ChatMessage>
@PUT("api/chat/chats/{chatId}/messages/{messageId}/unreact/{reactionId}")
suspend fun unreactToMessage(
@Path("chatId") chatId: Long,
@Path("messageId") messageId: Long,
@Path("reactionId") reactionId: Long
): Response<ChatMessage>
// Mark all messages as read
@PUT("api/chat/chats/{chatId}/status/read")
suspend fun markAllMessagesAsRead(@Path("chatId") chatId: Long): Response<Unit>
@POST("api/chat/chats/{chatId}/messages/status/seen")
suspend fun markMessagesAsRead(@Path("chatId") chatId: Long, @Body request: MarkMessagesReadRequest): Response<List<ChatMessage>>
// Unseen count
@GET("api/chat/unseen-count")
suspend fun getUnseenCount(): Response<Int>
// Users for chat creation
@GET("api/iam/users")
suspend fun getUsers(
@Query("limit") limit: Int = 50,
@Query("offset") offset: Int = 0,
@Query("search") search: String? = null
): Response<List<User>>
}
// User models for address book
data class User(
val id: Long,
val firstName: String,
val lastName: String,
val email: String,
val phone: String? = null,
val role: String,
val isActive: Boolean,
val departmentId: Long? = null,
val position: String? = null,
val avatarUrl: String? = null,
val analyticsId: String? = null,
val objectPermissions: List<ObjectPermission>? = null,
val accessibleUserIds: List<Long>? = null,
val isPlatformAdmin: Boolean
) {
// Computed property for full name
val name: String
get() = "$firstName $lastName"
}
data class ObjectPermission(
val objectType: String,
val objectId: Long? = null,
val createPermission: String,
val viewPermission: String,
val editPermission: String,
val deletePermission: String,
val reportPermission: String,
val dashboardPermission: String
)

View File

@@ -0,0 +1,27 @@
package com.crm.chat.data.api
import com.crm.chat.data.model.UpdateUserRequest
import com.crm.chat.data.model.User
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.*
interface UserApiService {
@GET("api/iam/users/{id}")
suspend fun getUserById(@Path("id") userId: Long): Response<User>
@PUT("api/iam/users/{id}")
suspend fun updateUser(@Path("id") userId: Long, @Body request: UpdateUserRequest): Response<User>
@Multipart
@POST("api/iam/users/{id}/avatar")
suspend fun uploadUserAvatar(
@Path("id") userId: Long,
@Part file: MultipartBody.Part
): Response<User>
@DELETE("api/iam/users/{id}/avatar")
suspend fun removeUserAvatar(@Path("id") userId: Long): Response<User>
}

View File

@@ -0,0 +1,15 @@
package com.crm.chat.data.api
import com.crm.chat.data.model.UpdateUserProfileRequest
import com.crm.chat.data.model.UserProfile
import retrofit2.Response
import retrofit2.http.*
interface UserProfileApiService {
@GET("api/iam/users/{id}/profile")
suspend fun getUserProfile(@Path("id") userId: Long): Response<UserProfile>
@PATCH("api/iam/users/{id}/profile")
suspend fun updateUserProfile(@Path("id") userId: Long, @Body request: UpdateUserProfileRequest): Response<Unit>
}

View File

@@ -0,0 +1,21 @@
package com.crm.chat.data.model
data class AuthRequest(
val email: String,
val password: String
)
data class AuthResponse(
val token: String,
val userId: Long,
val subdomain: String,
val accountId: Long,
val isPartner: Boolean? = null,
val refreshToken: String? = null
)
data class LoginRequest(
val email: String,
val password: String,
val subdomain: String? = null
)

View File

@@ -0,0 +1,144 @@
package com.crm.chat.data.model
// Chat Provider Models
data class ChatProvider(
val id: Long,
val name: String,
val type: ChatProviderType,
val status: ChatProviderStatus,
val transport: ChatProviderTransport
)
enum class ChatProviderType {
TWILIO, FACEBOOK_MESSENGER, WAZZUP
}
enum class ChatProviderStatus {
ACTIVE, INACTIVE
}
enum class ChatProviderTransport {
SMS, WHATSAPP, MESSENGER
}
// Chat Models
data class Chat(
val id: Long,
val title: String? = null,
val type: String, // Using String instead of enum to match API
val providerId: Long,
val createdBy: Long? = null,
val externalId: String? = null,
val entityId: Long? = null,
val createdAt: String? = null,
val users: List<ChatUserItem>? = null,
val pinnedMessages: List<Long>? = null,
val lastMessage: ChatMessage? = null,
val unseenCount: Int = 0,
val updatedAt: String? = null,
val hasAccess: Boolean = true
)
// Additional chat-related models
data class ChatUserItem(
val id: Long,
val userId: Long,
val role: String,
val name: String? = null,
val externalUser: ChatUserExternal? = null
)
enum class ChatType {
PERSONAL, GROUP, EXTERNAL
}
// Chat Message Models
data class ChatMessage(
val id: Long,
val chatId: Long,
val chatUserId: Long,
val text: String,
val statuses: List<ChatMessageStatusItem>? = null,
val files: List<ChatMessageFile>? = null,
val replyTo: ChatMessage? = null, // Changed from Long to ChatMessage object
val reactions: List<ChatMessageReaction>? = null,
val createdAt: String
)
// Additional message-related models
data class ChatMessageStatusItem(
val chatUserId: Long,
val status: String, // "sent", "delivered", "seen", etc.
val createdAt: String
)
data class ChatMessageFile(
val id: Long,
val fileId: String,
val fileName: String,
val fileSize: Long? = null,
val fileType: String? = null,
val downloadUrl: String,
val createdAt: String
)
data class ChatMessageReaction(
val id: Long,
val chatUserId: Long,
val reaction: String,
val createdAt: String
)
enum class ChatMessageStatus {
SENT, DELIVERED, READ, FAILED
}
enum class ChatMessageType {
TEXT, IMAGE, FILE, AUDIO
}
// API Response Models
data class ChatsResponse(
val chats: List<Chat>,
val totalCount: Int
)
data class ChatMessagesResponse(
val messages: List<ChatMessage>,
val totalCount: Int,
val hasMore: Boolean
)
data class SendMessageRequest(
val text: String,
val messageType: String = "text",
val fileUrl: String? = null,
val replyToId: Long? = null,
val fileIds: List<String>? = null
)
// File Upload Models
data class FileUploadResponse(
val key: String,
val id: String,
val fileName: String,
val fileSize: Long,
val mimeType: String,
val createdAt: String,
val downloadUrl: String
)
data class FileUploadListResponse(
val files: List<FileUploadResponse>
)
// WebSocket Event Models
data class ChatEvent(
val type: String,
val chatId: Long,
val data: Any
)
data class NewMessageEvent(
val message: ChatMessage
)

View File

@@ -0,0 +1,59 @@
package com.crm.chat.data.model
// Request models for chat creation
data class CreatePersonalChatRequest(
val providerId: Long,
val companionId: Long
)
data class CreateGroupChatRequest(
val providerId: Long,
val participantIds: List<Long>,
val title: String,
val entityId: Long? = null
)
data class CreateExternalChatRequest(
val title: String,
val entityId: Long? = null,
val externalUser: ChatUserExternal? = null
)
// External user data class
data class ChatUserExternal(
val externalId: String,
val firstName: String? = null,
val lastName: String? = null,
val avatarUrl: String? = null,
val phone: String? = null,
val email: String? = null,
val link: String? = null
)
// Request models for chat management
data class UpdateGroupChatRequest(
val title: String,
val participantIds: List<Long>? = null
)
data class CreateContactLeadRequest(
val contactName: String,
val leadName: String? = null,
val responsibleUserId: Long? = null
)
// Request models for message management
data class UpdateMessageRequest(
val text: String
)
data class MarkMessagesReadRequest(
val messageIds: List<Long>
)
// Response models
data class EntityInfo(
val id: Long,
val type: String,
val name: String
)

View File

@@ -0,0 +1,10 @@
package com.crm.chat.data.model
import com.google.gson.annotations.SerializedName
data class UpdateUserProfileRequest(
@SerializedName("birthDate") val birthDate: String?,
@SerializedName("employmentDate") val employmentDate: String?,
@SerializedName("workingTimeFrom") val workingTimeFrom: String?,
@SerializedName("workingTimeTo") val workingTimeTo: String?
)

View File

@@ -0,0 +1,11 @@
package com.crm.chat.data.model
import com.google.gson.annotations.SerializedName
data class UpdateUserRequest(
@SerializedName("firstName") val firstName: String,
@SerializedName("lastName") val lastName: String?,
@SerializedName("email") val email: String,
@SerializedName("phone") val phone: String?,
@SerializedName("position") val position: String?
)

View File

@@ -0,0 +1,19 @@
package com.crm.chat.data.model
import com.google.gson.annotations.SerializedName
data class User(
@SerializedName("id") val id: Long,
@SerializedName("firstName") val firstName: String,
@SerializedName("lastName") val lastName: String?,
@SerializedName("email") val email: String,
@SerializedName("phone") val phone: String?,
@SerializedName("avatarUrl") val avatarUrl: String?,
@SerializedName("position") val position: String?,
@SerializedName("role") val role: String = "USER",
@SerializedName("isActive") val isActive: Boolean = true,
@SerializedName("departmentId") val departmentId: Long? = null
) {
val fullName: String
get() = "${firstName} ${lastName ?: ""}".trim()
}

View File

@@ -0,0 +1,11 @@
package com.crm.chat.data.model
import com.google.gson.annotations.SerializedName
data class UserProfile(
@SerializedName("userId") val userId: Long,
@SerializedName("birthDate") val birthDate: String?,
@SerializedName("employmentDate") val employmentDate: String?,
@SerializedName("workingTimeFrom") val workingTimeFrom: String?,
@SerializedName("workingTimeTo") val workingTimeTo: String?
)

View File

@@ -0,0 +1,99 @@
package com.crm.chat.data.repository
import com.crm.chat.data.api.ApiClient
import com.crm.chat.data.model.AuthResponse
import com.crm.chat.data.model.LoginRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import android.util.Log
class AuthRepository(private val apiClient: ApiClient) {
suspend fun login(email: String, password: String): Result<AuthResponse> {
return withContext(Dispatchers.IO) {
try {
Log.d("AuthRepository", "Начало авторизации для email: $email")
val request = LoginRequest(email = email, password = password)
Log.d("AuthRepository", "Отправка запроса на авторизацию")
val response = apiClient.authApiService.loginExtension(request)
Log.d("AuthRepository", "Получен ответ: код=${response.code()}, успешный=${response.isSuccessful}")
if (response.isSuccessful) {
val authResponse = response.body()
Log.d("AuthRepository", "Тело ответа: $authResponse")
if (authResponse != null) {
Log.d("AuthRepository", "Токен: ${authResponse.token}")
if (authResponse.token.isNotBlank()) {
Log.d("AuthRepository", "Авторизация успешна")
Result.success(authResponse)
} else {
Log.e("AuthRepository", "Сервер вернул пустой токен")
Result.failure(Exception("Сервер вернул пустой токен. Проверьте правильность email и пароля."))
}
} else {
Log.e("AuthRepository", "Сервер вернул null в теле ответа")
Result.failure(Exception("Сервер вернул пустой ответ. Проверьте подключение к серверу и правильность URL."))
}
} else {
val errorBody = response.errorBody()?.string()
Log.e("AuthRepository", "Ошибка HTTP: код=${response.code()}, сообщение=${response.message()}, тело=${errorBody}")
val errorMessage = when (response.code()) {
400 -> "Некорректный запрос (400). Проверьте введенные данные и формат email."
401 -> "Неверный email или пароль (401). Проверьте правильность введенных данных."
403 -> "Доступ запрещен (403). Проверьте API ключ и права доступа."
404 -> "Сервер не найден (404). Проверьте URL сервера и подключение к сети."
405 -> "Метод не разрешен (405). Эндпоинт не существует или недоступен."
422 -> "Некорректные данные (422). Проверьте формат email и пароля."
500 -> "Ошибка сервера (500). Сервер временно недоступен, попробуйте позже."
502, 503, 504 -> "Проблемы с сервером (${response.code()}). Проверьте подключение к серверу."
else -> "Ошибка входа: ${response.code()} ${response.message()}\nДетали: ${errorBody ?: "Нет дополнительной информации"}"
}
Result.failure(Exception(errorMessage))
}
} catch (e: Exception) {
Log.e("AuthRepository", "Исключение при авторизации", e)
val errorMessage = when (e) {
is java.net.UnknownHostException -> "Нет подключения к серверу. Проверьте интернет-соединение и правильность URL."
is java.net.SocketTimeoutException -> "Таймаут соединения. Проверьте подключение к серверу и попробуйте снова."
is java.io.IOException -> "Ошибка сети. Проверьте подключение к интернету."
else -> "Неожиданная ошибка: ${e.message}"
}
Result.failure(Exception(errorMessage))
}
}
}
suspend fun loginExtension(email: String, password: String): Result<AuthResponse> {
return withContext(Dispatchers.IO) {
try {
val request = LoginRequest(email = email, password = password)
val response = apiClient.authApiService.loginExtension(request)
if (response.isSuccessful) {
val authResponse = response.body()
if (authResponse != null) {
Result.success(authResponse)
} else {
Result.failure(Exception("Empty response body"))
}
} else {
val errorMessage = when (response.code()) {
401 -> "Неверный email или пароль"
403 -> "Доступ запрещен"
404 -> "Сервер не найден"
500 -> "Ошибка сервера"
else -> "Ошибка входа: ${response.code()} ${response.message()}"
}
Result.failure(Exception(errorMessage))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
}

View File

@@ -0,0 +1,529 @@
package com.crm.chat.data.repository
import com.crm.chat.data.api.ApiClient
import com.crm.chat.data.api.User
import com.crm.chat.data.model.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import android.util.Log
import okhttp3.MediaType.Companion.toMediaType
class ChatRepository(private val apiClient: ApiClient) {
// Expose base URL for download URL construction
fun getBaseUrl(): String = apiClient.getBaseUrl()
suspend fun getChatProviders(): Result<List<ChatProvider>> {
return withContext(Dispatchers.IO) {
try {
// TODO: Implement chat providers API when available
Result.success(emptyList())
} catch (e: Exception) {
Log.e("ChatRepository", "Error getting chat providers", e)
Result.failure(e)
}
}
}
suspend fun getChats(limit: Int = 50, offset: Int = 0): Result<List<Chat>> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.getChats(limit, offset)
if (response.isSuccessful) {
val chats = response.body()
if (chats != null) {
Result.success(chats)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error getting chats", e)
Result.failure(e)
}
}
}
suspend fun getChat(chatId: Long): Result<Chat> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.getChat(chatId)
if (response.isSuccessful) {
val chat = response.body()
if (chat != null) {
Result.success(chat)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error getting chat", e)
Result.failure(e)
}
}
}
suspend fun deleteChat(chatId: Long): Result<Unit> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.deleteChat(chatId)
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error deleting chat", e)
Result.failure(e)
}
}
}
// Chat creation methods
suspend fun createPersonalChat(request: CreatePersonalChatRequest): Result<Chat> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.createPersonalChat(request)
if (response.isSuccessful) {
val chat = response.body()
if (chat != null) {
Result.success(chat)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error creating personal chat", e)
Result.failure(e)
}
}
}
suspend fun createGroupChat(request: CreateGroupChatRequest): Result<Chat> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.createGroupChat(request)
if (response.isSuccessful) {
val chat = response.body()
if (chat != null) {
Result.success(chat)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error creating group chat", e)
Result.failure(e)
}
}
}
suspend fun createExternalChat(request: CreateExternalChatRequest): Result<Chat> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.createExternalChat(request)
if (response.isSuccessful) {
val chat = response.body()
if (chat != null) {
Result.success(chat)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error creating external chat", e)
Result.failure(e)
}
}
}
// Chat search methods
suspend fun findChats(filters: Map<String, String>): Result<List<Chat>> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.findChats(filters)
if (response.isSuccessful) {
val chats = response.body()
if (chats != null) {
Result.success(chats)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error finding chats", e)
Result.failure(e)
}
}
}
suspend fun findChatsFull(filters: Map<String, String>): Result<List<Chat>> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.findChatsFull(filters)
if (response.isSuccessful) {
val chats = response.body()
if (chats != null) {
Result.success(chats)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error finding chats full", e)
Result.failure(e)
}
}
}
// Message management methods
suspend fun sendMessage(chatId: Long, request: SendMessageRequest): Result<ChatMessage> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.sendMessage(chatId, request)
if (response.isSuccessful) {
val message = response.body()
if (message != null) {
Result.success(message)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error sending message", e)
Result.failure(e)
}
}
}
suspend fun updateMessage(chatId: Long, messageId: Long, request: UpdateMessageRequest): Result<ChatMessage> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.updateMessage(chatId, messageId, request)
if (response.isSuccessful) {
val message = response.body()
if (message != null) {
Result.success(message)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error updating message", e)
Result.failure(e)
}
}
}
suspend fun deleteMessage(chatId: Long, messageId: Long): Result<Unit> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.deleteMessage(chatId, messageId)
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error deleting message", e)
Result.failure(e)
}
}
}
suspend fun reactToMessage(chatId: Long, messageId: Long, reaction: String): Result<ChatMessage> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.reactToMessage(chatId, messageId, reaction)
if (response.isSuccessful) {
val message = response.body()
if (message != null) {
Result.success(message)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error reacting to message", e)
Result.failure(e)
}
}
}
suspend fun unreactToMessage(chatId: Long, messageId: Long, reactionId: Long): Result<ChatMessage> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.unreactToMessage(chatId, messageId, reactionId)
if (response.isSuccessful) {
val message = response.body()
if (message != null) {
Result.success(message)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error unreacting to message", e)
Result.failure(e)
}
}
}
// Message methods
suspend fun getChatMessages(chatId: Long, limit: Int = 50, offset: Int = 0): Result<ChatMessagesResponse> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.getChatMessages(chatId, limit, offset)
if (response.isSuccessful) {
val messagesResponse = response.body()
if (messagesResponse != null) {
Result.success(messagesResponse)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error getting chat messages", e)
Result.failure(e)
}
}
}
suspend fun markMessagesAsRead(chatId: Long, messageIds: List<Long>): Result<List<ChatMessage>> {
return withContext(Dispatchers.IO) {
try {
val request = com.crm.chat.data.model.MarkMessagesReadRequest(messageIds)
val response = apiClient.chatApiService.markMessagesAsRead(chatId, request)
if (response.isSuccessful) {
val messages = response.body()
if (messages != null) {
Result.success(messages)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error marking messages as read", e)
Result.failure(e)
}
}
}
suspend fun markAllMessagesAsRead(chatId: Long): Result<Unit> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.markAllMessagesAsRead(chatId)
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error marking messages as read", e)
Result.failure(e)
}
}
}
// Utility methods
suspend fun getUnseenCount(): Result<Int> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.getUnseenCount()
if (response.isSuccessful) {
val count = response.body()
if (count != null) {
Result.success(count)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error getting unseen count", e)
Result.failure(e)
}
}
}
suspend fun getUsers(search: String? = null, limit: Int = 50, offset: Int = 0): Result<List<User>> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.chatApiService.getUsers(limit, offset, search)
if (response.isSuccessful) {
val users = response.body()
if (users != null) {
Result.success(users)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error getting users", e)
Result.failure(e)
}
}
}
@Suppress("DEPRECATION")
suspend fun uploadFile(fileUri: android.net.Uri, contentResolver: android.content.ContentResolver): Result<List<FileUploadResponse>> {
return withContext(Dispatchers.IO) {
try {
// Read file data
val fileName = getFileName(fileUri, contentResolver)
val mimeType = contentResolver.getType(fileUri) ?: "application/octet-stream"
val inputStream = contentResolver.openInputStream(fileUri)
?: return@withContext Result.failure(Exception("Cannot open file"))
val fileData = inputStream.use { it.readBytes() }
// Create multipart request
val requestBody = okhttp3.MultipartBody.Builder()
.setType(okhttp3.MultipartBody.FORM)
.addFormDataPart(fileName, fileName,
okhttp3.RequestBody.create(mimeType.toMediaType(), fileData))
.build()
val response = apiClient.getOkHttpClientInstance().newCall(
okhttp3.Request.Builder()
.url("${apiClient.getBaseUrl()}/api/storage/upload")
.post(requestBody)
.addHeader("X-Api-Key", apiClient.getApiKey() ?: "")
.build()
).execute()
if (response.isSuccessful) {
val responseBody = response.body?.string()
if (responseBody != null) {
val gson = com.google.gson.Gson()
val fileResponse = gson.fromJson(
responseBody,
Array<FileUploadResponse>::class.java
)
Result.success(fileResponse.toList())
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error uploading file", e)
Result.failure(e)
}
}
}
private fun getFileName(uri: android.net.Uri, contentResolver: android.content.ContentResolver): String {
var fileName = "file"
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex >= 0) {
fileName = cursor.getString(nameIndex)
}
}
return fileName
}
@Suppress("DEPRECATION")
suspend fun downloadFile(downloadUrl: String, fileName: String, context: android.content.Context): Result<java.io.File> {
return withContext(Dispatchers.IO) {
try {
// Log the request details
Log.d("ChatRepository", "Download request URL: $downloadUrl")
Log.d("ChatRepository", "Download file name: $fileName")
// Create a request WITHOUT Authorization header (like web version)
// Only add X-Api-Key if it's needed for the specific endpoint
val request = okhttp3.Request.Builder()
.url(downloadUrl)
.addHeader("X-Api-Key", apiClient.getApiKey() ?: "")
// Remove Authorization header that was causing 400 errors
.build()
Log.d("ChatRepository", "Request headers: X-Api-Key=${apiClient.getApiKey()}")
val response = apiClient.getOkHttpClientInstance().newCall(request).execute()
Log.d("ChatRepository", "Response code: ${response.code}")
Log.d("ChatRepository", "Response message: ${response.message}")
if (response.isSuccessful) {
val inputStream = response.body?.byteStream()
if (inputStream != null) {
// Save to cache directory first
val cacheDir = context.cacheDir
val cacheFile = java.io.File(cacheDir, "temp_$fileName")
cacheFile.outputStream().use { output ->
inputStream.copyTo(output)
}
Log.d("ChatRepository", "File saved to cache: ${cacheFile.absolutePath}, size: ${cacheFile.length()} bytes")
Result.success(cacheFile)
} else {
Log.e("ChatRepository", "Empty response body")
Result.failure(Exception("Empty response body"))
}
} else {
Log.e("ChatRepository", "Download failed with code: ${response.code}, message: ${response.message}")
val errorBody = response.body?.string()
Log.e("ChatRepository", "Error response body: $errorBody")
Result.failure(Exception("Download failed: ${response.code} - ${response.message}"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Error downloading file", e)
Result.failure(e)
}
}
}
}

View File

@@ -0,0 +1,163 @@
package com.crm.chat.data.repository
import android.content.ContentResolver
import android.net.Uri
import android.util.Log
import com.crm.chat.data.api.ApiClient
import com.crm.chat.data.model.UpdateUserRequest
import com.crm.chat.data.model.UpdateUserProfileRequest
import com.crm.chat.data.model.User
import com.crm.chat.data.model.UserProfile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
class UserRepository(private val apiClient: ApiClient) {
suspend fun getUserById(userId: Long): Result<User> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.userApiService.getUserById(userId)
if (response.isSuccessful) {
val user = response.body()
if (user != null) {
Result.success(user)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("UserRepository", "Error getting user by id", e)
Result.failure(e)
}
}
}
suspend fun updateUser(userId: Long, request: UpdateUserRequest): Result<User> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.userApiService.updateUser(userId, request)
if (response.isSuccessful) {
val user = response.body()
if (user != null) {
Result.success(user)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("UserRepository", "Error updating user", e)
Result.failure(e)
}
}
}
suspend fun uploadUserAvatar(userId: Long, imageUri: Uri, contentResolver: ContentResolver): Result<User> {
return withContext(Dispatchers.IO) {
try {
// Get file from URI
val inputStream = contentResolver.openInputStream(imageUri)
?: return@withContext Result.failure(Exception("Cannot open image file"))
val tempFile = File.createTempFile("avatar", ".jpg").apply {
deleteOnExit()
outputStream().use { output ->
inputStream.copyTo(output)
}
}
val requestBody = tempFile.asRequestBody("image/*".toMediaTypeOrNull())
val multipartBody = MultipartBody.Part.createFormData("file", tempFile.name, requestBody)
val response = apiClient.userApiService.uploadUserAvatar(userId, multipartBody)
// Clean up temp file
tempFile.delete()
if (response.isSuccessful) {
val user = response.body()
if (user != null) {
Result.success(user)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("UserRepository", "Error uploading avatar", e)
Result.failure(e)
}
}
}
suspend fun removeUserAvatar(userId: Long): Result<User> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.userApiService.removeUserAvatar(userId)
if (response.isSuccessful) {
val user = response.body()
if (user != null) {
Result.success(user)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("UserRepository", "Error removing avatar", e)
Result.failure(e)
}
}
}
suspend fun getUserProfile(userId: Long): Result<UserProfile> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.userProfileApiService.getUserProfile(userId)
if (response.isSuccessful) {
val userProfile = response.body()
if (userProfile != null) {
Result.success(userProfile)
} else {
Result.failure(Exception("Empty response"))
}
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("UserRepository", "Error getting user profile", e)
Result.failure(e)
}
}
}
suspend fun updateUserProfile(userId: Long, request: UpdateUserProfileRequest): Result<Unit> {
return withContext(Dispatchers.IO) {
try {
val response = apiClient.userProfileApiService.updateUserProfile(userId, request)
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e("UserRepository", "Error updating user profile", e)
Result.failure(e)
}
}
}
}

View File

@@ -0,0 +1,223 @@
package com.crm.chat.ui.auth
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.doOnTextChanged
import com.crm.chat.R
import com.crm.chat.databinding.ActivityAuthBinding
import com.crm.chat.ui.main.MainActivity
class AuthActivity : AppCompatActivity() {
private lateinit var binding: ActivityAuthBinding
private val viewModel: AuthViewModel by viewModels()
private var lastEmail: String? = null
private var lastPassword: String? = null
private var isAutoLoginAttempt = false
companion object {
private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1001
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize notification channel
com.crm.chat.utils.NotificationHelper.createNotificationChannel(this)
// Request notification permission on Android 13+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
if (androidx.core.content.ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.POST_NOTIFICATIONS
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
androidx.core.app.ActivityCompat.requestPermissions(
this,
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
)
}
}
binding = ActivityAuthBinding.inflate(layoutInflater)
setContentView(binding.root)
setupViews()
// Initialize default settings on first launch
val prefs: SharedPreferences = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
initializeDefaultSettings(prefs)
// Try automatic login on app startup (including after phone restart)
val token = prefs.getString("auth_token", null)
val serverUrl = prefs.getString("server_url", null)
val apiKey = prefs.getString("api_key", null)
val savedEmail = prefs.getString("saved_email", null)
val savedPassword = prefs.getString("saved_password", null)
if (token != null && serverUrl != null && apiKey != null && savedEmail != null && savedPassword != null) {
// Try automatic login with saved credentials
attemptAutoLogin(savedEmail, savedPassword, serverUrl, apiKey)
} else {
// Show login form if no saved credentials or incomplete data
showLoginForm()
}
}
private fun attemptAutoLogin(email: String, password: String, serverUrl: String, apiKey: String) {
isAutoLoginAttempt = true
lastEmail = email
lastPassword = password
// Show loading state for auto-login
binding.progressBar.visibility = View.VISIBLE
binding.titleTextView.text = "Автоматический вход..."
binding.descriptionTextView.visibility = View.GONE
binding.emailInputLayout.visibility = View.GONE
binding.passwordInputLayout.visibility = View.GONE
binding.loginButton.visibility = View.GONE
// Attempt login with custom server
viewModel.loginWithCustomServer(serverUrl, apiKey, email, password)
}
private fun showLoginForm() {
isAutoLoginAttempt = false
binding.progressBar.visibility = View.GONE
binding.titleTextView.text = getString(R.string.login_title)
binding.descriptionTextView.visibility = View.VISIBLE
binding.emailInputLayout.visibility = View.VISIBLE
binding.passwordInputLayout.visibility = View.VISIBLE
binding.loginButton.visibility = View.VISIBLE
binding.loginButton.isEnabled = true
binding.loginButton.text = "Войти"
}
private fun setupViews() {
observeViewModel()
binding.loginButton.setOnClickListener {
val email = binding.emailEditText.text.toString().trim()
val password = binding.passwordEditText.text.toString().trim()
if (email.isBlank()) {
binding.errorTextView.text = "Введите email"
binding.errorTextView.visibility = View.VISIBLE
return@setOnClickListener
}
if (password.isBlank()) {
binding.errorTextView.text = "Введите пароль"
binding.errorTextView.visibility = View.VISIBLE
return@setOnClickListener
}
isAutoLoginAttempt = false
lastEmail = email
lastPassword = password
viewModel.login(email, password)
}
// Clear error when user starts typing
binding.emailEditText.doOnTextChanged { _, _, _, _ ->
binding.errorTextView.visibility = View.GONE
}
binding.passwordEditText.doOnTextChanged { _, _, _, _ ->
binding.errorTextView.visibility = View.GONE
}
binding.additionalSettingsButton.setOnClickListener {
openAdditionalSettings()
}
}
private fun observeViewModel() {
viewModel.loginState.observe(this) { state ->
when (state) {
is AuthViewModel.LoginState.Loading -> {
binding.progressBar.visibility = View.VISIBLE
binding.loginButton.isEnabled = false
binding.errorTextView.visibility = View.GONE
binding.loginButton.text = "Подключение..."
}
is AuthViewModel.LoginState.Success -> {
isAutoLoginAttempt = false
binding.progressBar.visibility = View.GONE
binding.loginButton.isEnabled = true
binding.loginButton.text = "Войти"
val token = state.authResponse.token
if (!token.isNullOrBlank()) {
// Save auth data including credentials for auto-login
saveAuthData(token, state.serverUrl, state.authResponse.userId, lastEmail ?: "", lastPassword ?: "")
// Navigate to main activity
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
} else {
binding.errorTextView.text = "Ошибка: сервер вернул пустой токен. Проверьте правильность email и пароля."
binding.errorTextView.visibility = View.VISIBLE
}
}
is AuthViewModel.LoginState.Error -> {
binding.progressBar.visibility = View.GONE
binding.loginButton.isEnabled = true
binding.loginButton.text = "Войти"
if (isAutoLoginAttempt) {
// Auto-login failed, show login form with error
isAutoLoginAttempt = false
showLoginForm()
binding.errorTextView.text = "Автоматический вход не удался. Введите данные вручную."
binding.errorTextView.visibility = View.VISIBLE
} else {
binding.errorTextView.text = state.message
binding.errorTextView.visibility = View.VISIBLE
Toast.makeText(this, state.message, Toast.LENGTH_LONG).show()
}
}
}
}
}
private fun openAdditionalSettings() {
val intent = Intent(this, com.crm.chat.ui.settings.SettingsActivity::class.java)
startActivity(intent)
}
private fun saveAuthData(token: String, serverUrl: String, userId: Long, email: String, password: String) {
val prefs: SharedPreferences = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
prefs.edit()
.putString("auth_token", token)
.putString("server_url", serverUrl)
.putString("api_key", com.crm.chat.data.AppConstants.API_KEY)
.putLong("current_user_id", userId)
.putString("saved_email", email)
.putString("saved_password", password)
.apply()
}
private fun initializeDefaultSettings(prefs: SharedPreferences) {
// Check if this is first launch by checking if server_url is set
val serverUrl = prefs.getString("server_url", null)
val apiKey = prefs.getString("api_key", null)
if (serverUrl.isNullOrEmpty() || apiKey.isNullOrEmpty()) {
// First launch - set default settings
prefs.edit()
.putString("server_url", com.crm.chat.data.AppConstants.SERVER_URL)
.putString("api_key", com.crm.chat.data.AppConstants.API_KEY)
.apply()
}
}
}

View File

@@ -0,0 +1,116 @@
package com.crm.chat.ui.auth
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.crm.chat.data.AppConstants
import com.crm.chat.data.api.ApiClient
import com.crm.chat.data.model.AuthResponse
import com.crm.chat.data.repository.AuthRepository
import kotlinx.coroutines.launch
class AuthViewModel : ViewModel() {
private val _loginState = MutableLiveData<LoginState>()
val loginState: LiveData<LoginState> = _loginState
private val apiClient = ApiClient("") // Will be updated with server URL
private val authRepository = AuthRepository(apiClient)
fun login(email: String?, password: String?) {
// Валидация входных данных
val validationError = validateInput(email, password)
if (validationError != null) {
_loginState.value = LoginState.Error(validationError)
return
}
_loginState.value = LoginState.Loading
viewModelScope.launch {
try {
// Используем константы из AppConstants
val updatedApiClient = ApiClient(AppConstants.SERVER_URL, AppConstants.API_KEY)
val updatedRepository = AuthRepository(updatedApiClient)
val result = updatedRepository.login(email!!, password!!)
result.fold(
onSuccess = { authResponse ->
if (!authResponse.token.isNullOrBlank()) {
_loginState.value = LoginState.Success(authResponse, AppConstants.SERVER_URL)
} else {
_loginState.value = LoginState.Error("Сервер вернул пустой токен. Проверьте правильность email и пароля.")
}
},
onFailure = { exception ->
_loginState.value = LoginState.Error(exception.message ?: "Ошибка входа. Проверьте подключение к серверу и правильность введенных данных.")
}
)
} catch (e: Exception) {
_loginState.value = LoginState.Error("Неожиданная ошибка: ${e.message}. Попробуйте перезапустить приложение.")
}
}
}
// Старый метод для совместимости (если понадобится)
fun loginWithCustomServer(serverUrl: String?, apiKey: String?, email: String?, password: String?) {
// Валидация входных данных
val validationError = validateInput(serverUrl, apiKey, email, password)
if (validationError != null) {
_loginState.value = LoginState.Error(validationError)
return
}
_loginState.value = LoginState.Loading
viewModelScope.launch {
try {
// Update API client with server URL and API key
val updatedApiClient = ApiClient(serverUrl!!, apiKey!!)
val updatedRepository = AuthRepository(updatedApiClient)
val result = updatedRepository.login(email!!, password!!)
result.fold(
onSuccess = { authResponse ->
if (!authResponse.token.isNullOrBlank()) {
_loginState.value = LoginState.Success(authResponse, serverUrl)
} else {
_loginState.value = LoginState.Error("Сервер вернул пустой токен. Проверьте правильность email и пароля.")
}
},
onFailure = { exception ->
_loginState.value = LoginState.Error(exception.message ?: "Ошибка входа. Проверьте подключение к серверу и правильность введенных данных.")
}
)
} catch (e: Exception) {
_loginState.value = LoginState.Error("Неожиданная ошибка: ${e.message}. Попробуйте перезапустить приложение.")
}
}
}
private fun validateInput(email: String?, password: String?): String? {
return when {
email.isNullOrBlank() -> "Электронная почта обязательна для авторизации"
password.isNullOrBlank() -> "Пароль обязателен для авторизации"
else -> null
}
}
private fun validateInput(serverUrl: String?, apiKey: String?, email: String?, password: String?): String? {
return when {
apiKey.isNullOrBlank() -> "API ключ обязателен для подключения к серверу"
email.isNullOrBlank() -> "Электронная почта обязательна для авторизации"
password.isNullOrBlank() -> "Пароль обязателен для авторизации"
serverUrl.isNullOrBlank() -> "URL сервера обязателен для подключения"
!serverUrl.startsWith("http://") && !serverUrl.startsWith("https://") -> "URL сервера должен начинаться с http:// или https://"
else -> null
}
}
sealed class LoginState {
object Loading : LoginState()
data class Success(val authResponse: AuthResponse, val serverUrl: String) : LoginState()
data class Error(val message: String) : LoginState()
}
}

View File

@@ -0,0 +1,815 @@
package com.crm.chat.ui.chat
import android.content.SharedPreferences
import android.os.Bundle
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.toColorInt
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide
import com.crm.chat.R
import com.crm.chat.databinding.ActivityChatBinding
import com.crm.chat.utils.NotificationHelper
class ChatActivity : AppCompatActivity() {
private lateinit var binding: ActivityChatBinding
private val viewModel: ChatViewModel by viewModels()
private lateinit var messageAdapter: MessageAdapter
// Access to shared main view model for user data
private val mainViewModel: com.crm.chat.ui.main.MainViewModel = com.crm.chat.ui.main.MainViewModel.getInstance()
// Store chat users for message adapter
private var chatUsers: List<com.crm.chat.data.model.ChatUserItem>? = null
// Track loading states
private var isChatLoaded = false
private var areMessagesLoaded = false
private var shouldScrollAfterRefresh = false
// Reply functionality
private var replyingToMessage: com.crm.chat.data.model.ChatMessage? = null
private var isEditingMessage: Boolean = false
// File attachment functionality
private var selectedFileUri: android.net.Uri? = null
private var selectedFileName: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityChatBinding.inflate(layoutInflater)
setContentView(binding.root)
val chatId = intent.getLongExtra("chat_id", -1)
if (chatId == -1L) {
finish()
return
}
// Get auth data
val prefs: SharedPreferences = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
val token = prefs.getString("auth_token", null)
val serverUrl = prefs.getString("server_url", null)
val apiKey = prefs.getString("api_key", null)
if (token.isNullOrEmpty() || serverUrl.isNullOrEmpty() || apiKey.isNullOrEmpty()) {
finish()
return
}
// Initialize main view model first
// mainViewModel is already initialized as a property
viewModel.initialize(serverUrl, token, apiKey, chatId)
// Notification callback removed - notifications are handled by BackgroundSyncWorker
// viewModel.notificationCallback = { message -> showNotification(message) }
// Don't call refreshMessages() immediately - let initial load complete first
setupViews()
observeViewModel()
observeAddressBook()
}
private fun setupViews() {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayShowTitleEnabled(false) // Hide default title
supportActionBar?.setDisplayHomeAsUpEnabled(false) // We'll handle back button in custom view
// Inflate custom toolbar view
val customToolbarView = layoutInflater.inflate(R.layout.toolbar_chat, binding.toolbar, false)
binding.toolbar.addView(customToolbarView)
// Setup back button
val backButton = customToolbarView.findViewById<android.widget.ImageButton>(R.id.backButton)
backButton.setOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
// Setup RecyclerView - adapter will be set when chat data is loaded
binding.messagesRecyclerView.layoutManager = LinearLayoutManager(this@ChatActivity).apply {
stackFromEnd = true
}
// Add scroll listener to mark messages as read when scrolling to bottom and control FAB visibility
binding.messagesRecyclerView.addOnScrollListener(object : androidx.recyclerview.widget.RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: androidx.recyclerview.widget.RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
checkAndMarkMessagesAsRead()
updateScrollToBottomFabVisibility()
}
})
// Setup send button
binding.sendButton.setOnClickListener {
val message = binding.messageEditText.text.toString().trim()
// Check if we're editing a message
if (isEditingMessage && replyingToMessage != null) {
if (message.isNotEmpty()) {
// Edit the message
viewModel.editMessage(replyingToMessage!!.id, message)
binding.messageEditText.text?.clear()
// Clear edit state after editing
cancelReply()
} else {
Toast.makeText(this, "Пожалуйста, введите сообщение", Toast.LENGTH_SHORT).show()
}
return@setOnClickListener
}
// Check if we have text, file, or both
when {
message.isNotEmpty() && selectedFileUri != null -> {
// Upload file first, then send message with both text and file
uploadFileAndSendMessage(message)
}
message.isNotEmpty() -> {
// Send text-only message (with reply if replying)
viewModel.sendMessage(message, replyingToMessage)
binding.messageEditText.text?.clear()
// Clear reply state after sending
cancelReply()
}
selectedFileUri != null -> {
// Send file-only message (with reply if replying)
viewModel.sendFileMessage(selectedFileUri!!, contentResolver)
// Clear file selection after sending
clearFileSelection()
}
else -> {
// No content to send
Toast.makeText(this, "Пожалуйста, введите сообщение или выберите файл", Toast.LENGTH_SHORT).show()
}
}
}
// Setup reply cancel button
binding.replyCancelButton.setOnClickListener {
cancelReply()
}
// Setup file attachment cancel button
binding.fileCancelButton.setOnClickListener {
clearFileSelection()
}
// Setup attach file button
binding.attachButton.setOnClickListener {
openFilePicker()
}
// Setup scroll to bottom FAB
binding.scrollToBottomFab.setOnClickListener {
scrollToBottom()
}
}
private fun setupMessageAdapter() {
// Get API credentials from preferences
val prefs: SharedPreferences = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
val serverUrl = prefs.getString("server_url", null)
val apiKey = prefs.getString("api_key", null)
val token = prefs.getString("auth_token", null)
messageAdapter = MessageAdapter(
getUserName = { userId -> mainViewModel.getUserName(userId) },
chatUsers = chatUsers,
onScrollToMessage = { messageId -> scrollToMessage(messageId) },
onReplyToMessage = { message -> startReplyToMessage(message) },
onEditMessage = { message -> startEditMessage(message) },
onDeleteMessage = { message -> deleteMessage(message) },
serverUrl = serverUrl,
token = token,
apiKey = apiKey
)
binding.messagesRecyclerView.adapter = messageAdapter
}
private fun observeViewModel() {
viewModel.chatState.observe(this) { state ->
when (state) {
is com.crm.chat.ui.chat.ChatState.Loading -> {
isChatLoaded = false
updateLoadingState()
}
is com.crm.chat.ui.chat.ChatState.Success -> {
isChatLoaded = true
// Store chat users for message adapter
chatUsers = state.chat.users
// Setup message adapter now that we have chat data
setupMessageAdapter()
// Set chat name and avatar in custom toolbar
val chatName = when {
state.chat.title != null -> state.chat.title
state.chat.type == "personal" && state.chat.users != null && state.chat.users.size >= 2 -> {
// For personal chats, show the other user's name (same as chat list)
val currentUserId = 12033923L // Hardcoded for demo, should be from auth
val otherUser = state.chat.users?.find { it.userId != currentUserId }
otherUser?.let { user ->
// First try cached user data from address book
val cachedName = mainViewModel.getUserName(user.userId)
if (cachedName != "User ${user.userId}") {
cachedName
} else {
// Fallback to name from chat data, format as Last Name First Name
user.name?.let { name ->
// Assume name is "First Last", split and rearrange to "Last First"
val parts = name.trim().split(" ")
if (parts.size >= 2) {
"${parts[1]} ${parts[0]}"
} else {
name
}
} ?: "Personal Chat"
}
} ?: "Personal Chat"
}
state.chat.type == "personal" -> "Личный чат"
else -> "Чат ${state.chat.id}"
}
// Set name in custom toolbar
val customToolbarView = binding.toolbar.getChildAt(0)
val chatNameTextView = customToolbarView.findViewById<android.widget.TextView>(R.id.chatNameTextView)
chatNameTextView.text = chatName
// Load avatar for personal chats
if (state.chat.type == "personal" && state.chat.users != null && state.chat.users.size >= 2) {
val currentUserId = 12033923L // Should be from auth store
val otherUser = state.chat.users.find { it.userId != currentUserId }
if (otherUser != null) {
loadChatAvatar(otherUser.userId, customToolbarView)
}
} else {
// For group chats or other types, use group chat icon
val chatAvatarImageView = customToolbarView.findViewById<android.widget.ImageView>(R.id.chatAvatarImageView)
chatAvatarImageView.setImageResource(R.drawable.ic_group_chat)
}
updateLoadingState()
}
is com.crm.chat.ui.chat.ChatState.Error -> {
isChatLoaded = false
updateLoadingState()
Toast.makeText(this, state.message, Toast.LENGTH_SHORT).show()
}
}
}
viewModel.messagesState.observe(this) { state ->
when (state) {
is MessagesState.Success -> {
areMessagesLoaded = true
// Only update if messageAdapter is initialized
if (::messageAdapter.isInitialized) {
// Check if user is currently at the bottom before updating
val layoutManager = binding.messagesRecyclerView.layoutManager as LinearLayoutManager
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
val totalItemCount = messageAdapter.itemCount
val isAtBottom = lastVisibleItemPosition >= totalItemCount - 1 // User is at or near the bottom
messageAdapter.submitList(state.messages)
// Handle scrolling logic
if (shouldScrollAfterRefresh) {
// Scroll to bottom after sending a message
shouldScrollAfterRefresh = false
// Use post to ensure layout is complete before scrolling
binding.messagesRecyclerView.post {
scrollToBottom()
}
} else if (isAtBottom) {
// Only auto-scroll to bottom if user was already at the bottom (for regular updates)
binding.messagesRecyclerView.post {
binding.messagesRecyclerView.scrollToPosition(state.messages.size - 1)
// Mark messages as read when opening chat and user is at bottom
viewModel.markMessagesAsRead()
}
}
}
updateLoadingState()
}
else -> {
areMessagesLoaded = false
updateLoadingState()
}
}
}
viewModel.sendMessageState.observe(this) { state ->
when (state) {
is SendMessageState.Success -> {
// Message sent successfully - notify main view model to refresh chat list
mainViewModel.notifyChatUpdated(intent.getLongExtra("chat_id", -1))
// Refresh messages to show the new message in the chat
shouldScrollAfterRefresh = true
viewModel.refreshMessages()
}
is SendMessageState.Error -> {
Toast.makeText(this, "Не удалось отправить сообщение: ${state.message}", Toast.LENGTH_SHORT).show()
}
else -> {} // Handle other states if needed
}
}
viewModel.editMessageState.observe(this) { state ->
when (state) {
is EditMessageState.Success -> {
// Message edited successfully
Toast.makeText(this, "Сообщение отредактировано", Toast.LENGTH_SHORT).show()
// Messages are already refreshed by ViewModel
}
is EditMessageState.Error -> {
Toast.makeText(this, "Не удалось отредактировать сообщение: ${state.message}", Toast.LENGTH_SHORT).show()
}
else -> {} // Handle loading state if needed
}
}
viewModel.deleteMessageState.observe(this) { state ->
when (state) {
is DeleteMessageState.Success -> {
// Message deleted successfully
Toast.makeText(this, "Сообщение удалено", Toast.LENGTH_SHORT).show()
// Messages are already refreshed by ViewModel
}
is DeleteMessageState.Error -> {
Toast.makeText(this, "Не удалось удалить сообщение: ${state.message}", Toast.LENGTH_SHORT).show()
}
else -> {} // Handle loading state if needed
}
}
}
private fun loadChatAvatar(userId: Long, customToolbarView: android.view.View) {
val avatarUrl = mainViewModel.getUserAvatarUrl(userId)
val userName = mainViewModel.getUserName(userId)
val chatAvatarImageView = customToolbarView.findViewById<android.widget.ImageView>(R.id.chatAvatarImageView)
if (avatarUrl != null && avatarUrl.isNotEmpty()) {
// Load actual avatar image from URL with Glide
Glide.with(this)
.load(avatarUrl)
.circleCrop()
.placeholder(getUserColor(userId)) // Show color while loading
.error(object : com.bumptech.glide.request.target.CustomTarget<android.graphics.drawable.Drawable>() {
override fun onResourceReady(resource: android.graphics.drawable.Drawable, transition: com.bumptech.glide.request.transition.Transition<in android.graphics.drawable.Drawable>?) {
// Avatar loaded successfully
chatAvatarImageView.setImageDrawable(resource)
}
override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) {
// Show initials when avatar fails to load
showInitialsAvatar(userId, userName, chatAvatarImageView)
}
})
.into(chatAvatarImageView)
print("Loading toolbar avatar for $userName: $avatarUrl")
} else {
// No avatar URL, show initials
showInitialsAvatar(userId, userName, chatAvatarImageView)
print("No toolbar avatar for $userName, showing initials")
}
}
private fun showInitialsAvatar(userId: Long, userName: String, imageView: android.widget.ImageView) {
// Generate initials from user name
val initials = userName.split(" ").let { parts ->
when (parts.size) {
1 -> parts[0].take(1).uppercase()
2 -> "${parts[0].take(1)}${parts[1].take(1)}".uppercase()
else -> parts.firstOrNull()?.take(1)?.uppercase() ?: "U"
}
}
// Background color for the avatar
val backgroundColor = getUserColor(userId)
// Create circular bitmap with initials
val size = 192
val bitmap = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bitmap)
// Draw circle background
val circlePaint = android.graphics.Paint().apply {
color = backgroundColor
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
}
canvas.drawCircle(size / 2f, size / 2f, size / 2f, circlePaint)
// Draw initials text
val textPaint = android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
textSize = size * 0.5f // Larger text for better visibility
textAlign = android.graphics.Paint.Align.CENTER
isAntiAlias = true
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
val xPos = size / 2f
val yPos = size / 2f - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(initials, xPos, yPos, textPaint)
// Clear any background color from ImageView (let the bitmap be fully circular)
imageView.setBackgroundColor(android.graphics.Color.TRANSPARENT)
imageView.setImageBitmap(bitmap)
}
private fun getUserColor(userId: Long): Int {
// Generate a color based on user ID for consistency
val colors = intArrayOf(
"#E53935".toColorInt(), // Red
"#1E88E5".toColorInt(), // Blue
"#43A047".toColorInt(), // Green
"#FB8C00".toColorInt(), // Orange
"#8E24AA".toColorInt(), // Purple
"#00ACC1".toColorInt(), // Cyan
"#FDD835".toColorInt(), // Yellow
"#6D4C41".toColorInt(), // Brown
)
return colors[(userId % colors.size).toInt()]
}
private fun observeAddressBook() {
// Refresh message display when address book loads
mainViewModel.addressBookState.observe(this) { state ->
when (state) {
is com.crm.chat.ui.main.MainViewModel.AddressBookState.Success -> {
// Address book loaded, refresh message display to show proper names
if (::messageAdapter.isInitialized) {
messageAdapter.notifyDataSetChanged()
println("DEBUG: Address book loaded, refreshing messages")
}
}
else -> {} // Handle other states if needed
}
}
}
private fun scrollToMessage(messageId: Long) {
// Find the position of the message with the given ID
val messages = messageAdapter.currentList
val position = messages.indexOfFirst { it.id == messageId }
if (position >= 0) {
// Scroll to the message position
binding.messagesRecyclerView.smoothScrollToPosition(position)
// Optional: Highlight the message temporarily
// You could add a highlight animation here if desired
} else {
// Message not found in current list
Toast.makeText(this, "Сообщение не найдено", Toast.LENGTH_SHORT).show()
}
}
private fun startReplyToMessage(message: com.crm.chat.data.model.ChatMessage) {
replyingToMessage = message
isEditingMessage = false // We're replying, not editing
// Show reply indicator above input field
binding.replyIndicator.visibility = android.view.View.VISIBLE
// Set reply sender name and message text
val senderName = getUserNameFromChatUsers(message.chatUserId)
binding.replySenderText.text = senderName
binding.replyMessageText.text = message.text
// Focus on input field
binding.messageEditText.requestFocus()
}
private fun getUserNameFromChatUsers(chatUserId: Long): String {
// Get user name from chat users data, similar to MessageAdapter logic
val chatUserItem = chatUsers?.find { it.id == chatUserId }
val actualUserId = chatUserItem?.userId ?: chatUserId
// If this is the current user, show "Я" instead of their name
val currentUserId = 12033923L // Hardcoded for demo, should be from auth
if (actualUserId == currentUserId) {
return "Я"
}
// Try to get name from chat user data first
val nameFromChatUser = chatUserItem?.name
if (!nameFromChatUser.isNullOrBlank()) {
return formatNameForReply(nameFromChatUser)
}
// Fallback to main view model
val nameFromViewModel = mainViewModel.getUserName(actualUserId)
if (nameFromViewModel != "User $actualUserId") {
return formatNameForReply(nameFromViewModel)
}
// Final fallback
return "User $actualUserId"
}
private fun formatNameForReply(name: String): String {
// Format name as "Last Name First Name" for replies
val parts = name.trim().split(" ")
return if (parts.size >= 2) {
"${parts[1]} ${parts[0]}"
} else {
name
}
}
private fun cancelReply() {
replyingToMessage = null
isEditingMessage = false
binding.replyIndicator.visibility = android.view.View.GONE
}
private fun startEditMessage(message: com.crm.chat.data.model.ChatMessage) {
// Set the message text in the input field
binding.messageEditText.setText(message.text)
// Store the message being edited and set editing flag
replyingToMessage = message
isEditingMessage = true // We're editing, not replying
// Show edit indicator above input field
binding.replyIndicator.visibility = android.view.View.VISIBLE
binding.replySenderText.text = "Редактирование"
binding.replyMessageText.text = message.text
// Focus on input field and move cursor to end
binding.messageEditText.requestFocus()
binding.messageEditText.setSelection(message.text.length)
}
private fun deleteMessage(message: com.crm.chat.data.model.ChatMessage) {
viewModel.deleteMessage(message.id)
}
private fun openFilePicker() {
val intent = android.content.Intent(android.content.Intent.ACTION_GET_CONTENT).apply {
type = "*/*" // Allow all file types
addCategory(android.content.Intent.CATEGORY_OPENABLE)
}
try {
startActivityForResult(android.content.Intent.createChooser(intent, "Выберите файл"), FILE_PICKER_REQUEST_CODE)
} catch (e: android.content.ActivityNotFoundException) {
Toast.makeText(this, "Файловый менеджер не найден", Toast.LENGTH_SHORT).show()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == FILE_PICKER_REQUEST_CODE && resultCode == RESULT_OK) {
data?.data?.let { uri ->
handleFileSelection(uri)
}
}
}
private fun handleFileSelection(uri: android.net.Uri) {
try {
// Get file information
val fileName = getFileName(uri)
val fileSize = getFileSize(uri)
// Store selected file temporarily
selectedFileUri = uri
selectedFileName = fileName
// Show file attachment indicator
updateFileAttachmentUI()
// Show confirmation
Toast.makeText(this, "Файл выбран: $fileName (${formatFileSize(fileSize)})", Toast.LENGTH_LONG).show()
} catch (e: Exception) {
Toast.makeText(this, "Ошибка выбора файла: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
private fun updateFileAttachmentUI() {
if (selectedFileUri != null && selectedFileName != null) {
// Show file attachment indicator
binding.fileAttachmentIndicator.visibility = android.view.View.VISIBLE
binding.fileAttachmentText.text = "Отправка: $selectedFileName"
} else {
// Hide file attachment indicator
binding.fileAttachmentIndicator.visibility = android.view.View.GONE
}
}
private fun clearFileSelection() {
selectedFileUri = null
selectedFileName = null
updateFileAttachmentUI()
}
private fun uploadFileAndSendMessage(message: String) {
if (selectedFileUri == null) return
// Show progress
Toast.makeText(this, "Загрузка файла...", Toast.LENGTH_SHORT).show()
// Upload file first
lifecycleScope.launch {
try {
val uploadResult = com.crm.chat.data.repository.ChatRepository(
com.crm.chat.data.api.ApiClient(
getSharedPreferences("crm_chat_prefs", MODE_PRIVATE).getString("server_url", "") ?: "",
getSharedPreferences("crm_chat_prefs", MODE_PRIVATE).getString("api_key", ""),
getSharedPreferences("crm_chat_prefs", MODE_PRIVATE).getString("auth_token", "")
)
).uploadFile(selectedFileUri!!, contentResolver)
uploadResult.fold(
onSuccess = { uploadedFiles ->
if (uploadedFiles.isNotEmpty()) {
val fileIds = uploadedFiles.map { it.id }
// Send message with both text and file IDs
viewModel.sendMessage(message, replyingToMessage, fileIds)
binding.messageEditText.text?.clear()
// Clear reply state and file selection after sending
cancelReply()
clearFileSelection()
} else {
Toast.makeText(this@ChatActivity, "Не удалось загрузить файл", Toast.LENGTH_SHORT).show()
}
},
onFailure = { exception ->
Toast.makeText(this@ChatActivity, "Ошибка загрузки файла: ${exception.message}", Toast.LENGTH_SHORT).show()
}
)
} catch (e: Exception) {
Toast.makeText(this@ChatActivity, "Ошибка: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
private fun getFileName(uri: android.net.Uri): String {
var fileName = "Unknown"
if (uri.scheme == "content") {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0) {
fileName = cursor.getString(nameIndex)
}
}
}
} else if (uri.scheme == "file") {
fileName = java.io.File(uri.path!!).name
}
return fileName
}
private fun getFileSize(uri: android.net.Uri): Long {
var fileSize: Long = 0
if (uri.scheme == "content") {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (sizeIndex >= 0) {
fileSize = cursor.getLong(sizeIndex)
}
}
}
} else if (uri.scheme == "file") {
fileSize = java.io.File(uri.path!!).length()
}
return fileSize
}
private fun formatFileSize(size: Long): String {
if (size < 1024) return "$size B"
val kb = size / 1024.0
if (kb < 1024) return "%.1f KB".format(kb)
val mb = kb / 1024.0
if (mb < 1024) return "%.1f MB".format(mb)
val gb = mb / 1024.0
return "%.1f GB".format(gb)
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
return true
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
STORAGE_PERMISSION_REQUEST_CODE -> {
if (grantResults.isNotEmpty() && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
// Permission granted
android.widget.Toast.makeText(
this,
"Разрешение на хранение получено. Попробуйте скачать файл снова.",
android.widget.Toast.LENGTH_LONG
).show()
} else {
// Permission denied
android.widget.Toast.makeText(
this,
"Разрешение на хранение отклонено. Файлы будут сохранены в хранилище приложения.",
android.widget.Toast.LENGTH_LONG
).show()
}
}
}
}
override fun onResume() {
super.onResume()
viewModel.setChatActive(true)
}
override fun onPause() {
super.onPause()
viewModel.setChatActive(false)
}
private fun updateScrollToBottomFabVisibility() {
// Show FAB when user is not at the bottom, hide when at bottom
val layoutManager = binding.messagesRecyclerView.layoutManager as? LinearLayoutManager ?: return
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
val totalItemCount = messageAdapter.itemCount
val isAtBottom = lastVisibleItemPosition >= totalItemCount - 1
binding.scrollToBottomFab.visibility = if (isAtBottom) {
android.view.View.GONE
} else {
android.view.View.VISIBLE
}
}
private fun scrollToBottom() {
if (::messageAdapter.isInitialized) {
// Use post to ensure the layout is updated
binding.messagesRecyclerView.post {
if (messageAdapter.itemCount > 0) {
// Scroll to the last position with a small offset to ensure it's fully visible
val layoutManager = binding.messagesRecyclerView.layoutManager as LinearLayoutManager
layoutManager.scrollToPositionWithOffset(messageAdapter.itemCount - 1, 0)
// Also try smooth scroll as a fallback
binding.messagesRecyclerView.smoothScrollToPosition(messageAdapter.itemCount - 1)
}
}
}
}
private fun checkAndMarkMessagesAsRead() {
// Check if user is at the bottom of the chat (last message is visible)
val layoutManager = binding.messagesRecyclerView.layoutManager as? LinearLayoutManager ?: return
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
val totalItemCount = messageAdapter.itemCount
// Only mark as read when the very last message is visible
val isAtBottom = lastVisibleItemPosition == totalItemCount - 1
if (isAtBottom && totalItemCount > 0) {
// Mark all messages in this chat as read
viewModel.markMessagesAsRead()
// Badge notification will be updated automatically by MainViewModel
}
}
private fun updateLoadingState() {
// Show loading screen until both chat data and messages are loaded
val isFullyLoaded = isChatLoaded && areMessagesLoaded
if (isFullyLoaded) {
// Hide loading screen and show chat content
binding.loadingProgressBar.visibility = android.view.View.GONE
binding.messagesRecyclerView.visibility = android.view.View.VISIBLE
binding.messageInputLayout.visibility = android.view.View.VISIBLE
binding.toolbar.visibility = android.view.View.VISIBLE
} else {
// Show loading screen and hide chat content
binding.loadingProgressBar.visibility = android.view.View.VISIBLE
binding.messagesRecyclerView.visibility = android.view.View.GONE
binding.messageInputLayout.visibility = android.view.View.GONE
binding.toolbar.visibility = android.view.View.GONE
}
}
companion object {
private const val FILE_PICKER_REQUEST_CODE = 1001
private const val STORAGE_PERMISSION_REQUEST_CODE = 2001
}
}

View File

@@ -0,0 +1,34 @@
package com.crm.chat.ui.chat
import com.crm.chat.data.model.Chat
import com.crm.chat.data.model.ChatMessage
sealed class ChatState {
object Loading : ChatState()
data class Success(val chat: Chat) : ChatState()
data class Error(val message: String) : ChatState()
}
sealed class MessagesState {
object Loading : MessagesState()
data class Success(val messages: List<ChatMessage>) : MessagesState()
data class Error(val message: String) : MessagesState()
}
sealed class SendMessageState {
object Loading : SendMessageState()
data class Success(val message: ChatMessage) : SendMessageState()
data class Error(val message: String) : SendMessageState()
}
sealed class EditMessageState {
object Loading : EditMessageState()
data class Success(val message: ChatMessage) : EditMessageState()
data class Error(val message: String) : EditMessageState()
}
sealed class DeleteMessageState {
object Loading : DeleteMessageState()
object Success : DeleteMessageState()
data class Error(val message: String) : DeleteMessageState()
}

View File

@@ -0,0 +1,383 @@
package com.crm.chat.ui.chat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.crm.chat.data.api.ApiClient
import com.crm.chat.data.model.Chat
import com.crm.chat.data.model.ChatMessage
import com.crm.chat.data.model.SendMessageRequest
import com.crm.chat.data.repository.ChatRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class ChatViewModel : ViewModel() {
private val _chatState = MutableLiveData<ChatState>()
val chatState: LiveData<ChatState> = _chatState
private val _messagesState = MutableLiveData<MessagesState>()
val messagesState: LiveData<MessagesState> = _messagesState
private val _sendMessageState = MutableLiveData<SendMessageState>()
val sendMessageState: LiveData<SendMessageState> = _sendMessageState
private val _editMessageState = MutableLiveData<EditMessageState>()
val editMessageState: LiveData<EditMessageState> = _editMessageState
private val _deleteMessageState = MutableLiveData<DeleteMessageState>()
val deleteMessageState: LiveData<DeleteMessageState> = _deleteMessageState
private lateinit var chatRepository: ChatRepository
private var chatId: Long = -1
private var pollingJob: Job? = null
private var isChatActive: Boolean = false
private var previousMessages: List<ChatMessage> = emptyList()
fun initialize(serverUrl: String, token: String, apiKey: String, chatId: Long) {
this.chatId = chatId
val apiClient = ApiClient(serverUrl, apiKey, token)
chatRepository = ChatRepository(apiClient)
loadChat()
loadMessages()
startPolling()
}
private fun loadChat() {
viewModelScope.launch {
val result = chatRepository.getChat(chatId)
result.fold(
onSuccess = { chat ->
// Only update UI if chat information has changed
val currentChat = (chatState.value as? ChatState.Success)?.chat
if (currentChat == null || hasChatChanged(currentChat, chat)) {
_chatState.value = ChatState.Success(chat)
}
// If no changes, don't update the UI
},
onFailure = { exception ->
// Only update to error state if we're not already in error state
if (chatState.value !is ChatState.Error) {
_chatState.value = ChatState.Error(exception.message ?: "Failed to load chat")
}
}
)
}
}
private fun loadMessages() {
viewModelScope.launch {
val result = chatRepository.getChatMessages(chatId)
result.fold(
onSuccess = { messagesResponse ->
// Sort messages by createdAt in ascending order (oldest first)
val sortedMessages = messagesResponse.messages.sortedBy { it.createdAt }
// Check if this is the initial load (no previous success state)
val isInitialLoad = messagesState.value !is MessagesState.Success
val currentMessages = (messagesState.value as? MessagesState.Success)?.messages ?: emptyList()
if (isInitialLoad || hasMessagesChanged(currentMessages, sortedMessages)) {
println("DEBUG: Messages changed or initial load, updating UI (${sortedMessages.size} messages)")
previousMessages = sortedMessages
_messagesState.value = MessagesState.Success(sortedMessages)
// If there are new messages and user is not active in chat, update chat list
if (!isInitialLoad && !isChatActive && sortedMessages.size > currentMessages.size) {
com.crm.chat.ui.main.MainViewModel.getInstance().notifyChatUpdated(chatId)
}
} else {
println("DEBUG: Messages unchanged, skipping UI update")
}
},
onFailure = { exception ->
// For initial load failures, still set success with empty list to avoid hanging
if (messagesState.value !is MessagesState.Success) {
println("DEBUG: Initial messages load failed, setting empty list to avoid hanging")
_messagesState.value = MessagesState.Success(emptyList())
} else if (messagesState.value !is MessagesState.Error) {
_messagesState.value = MessagesState.Error(exception.message ?: "Failed to load messages")
}
}
)
}
}
fun setChatActive(active: Boolean) {
isChatActive = active
}
fun sendMessage(text: String, replyToMessage: com.crm.chat.data.model.ChatMessage? = null, fileIds: List<String>? = null) {
_sendMessageState.value = SendMessageState.Loading
viewModelScope.launch {
val request = SendMessageRequest(
text = text,
replyToId = replyToMessage?.id,
fileIds = fileIds
)
val result = chatRepository.sendMessage(chatId, request)
result.fold(
onSuccess = { message ->
_sendMessageState.value = SendMessageState.Success(message)
// Reload messages to show the new message
loadMessages()
},
onFailure = { exception ->
_sendMessageState.value = SendMessageState.Error(exception.message ?: "Failed to send message")
}
)
}
}
fun sendFileMessage(fileUri: android.net.Uri, contentResolver: android.content.ContentResolver) {
_sendMessageState.value = SendMessageState.Loading
viewModelScope.launch {
try {
// Upload file first
val uploadResult = chatRepository.uploadFile(fileUri, contentResolver)
uploadResult.fold(
onSuccess = { uploadedFiles ->
if (uploadedFiles.isNotEmpty()) {
val fileIds = uploadedFiles.map { it.id }
// Send message with file IDs
val request = SendMessageRequest(
text = "",
fileIds = fileIds
)
val messageResult = chatRepository.sendMessage(chatId, request)
messageResult.fold(
onSuccess = { message ->
_sendMessageState.value = SendMessageState.Success(message)
// Reload messages to show the new message
loadMessages()
},
onFailure = { exception ->
_sendMessageState.value = SendMessageState.Error(exception.message ?: "Failed to send file message")
}
)
} else {
_sendMessageState.value = SendMessageState.Error("No files uploaded")
}
},
onFailure = { exception ->
_sendMessageState.value = SendMessageState.Error(exception.message ?: "Failed to upload file")
}
)
} catch (e: Exception) {
_sendMessageState.value = SendMessageState.Error(e.message ?: "Unexpected error")
}
}
}
fun markMessagesAsRead() {
// Get current messages to find unread ones
val currentMessages = messagesState.value
if (currentMessages is MessagesState.Success) {
val currentUserId = 12033923L // Hardcoded, should be from auth
// Find unread messages (messages from other users that don't have "seen" status)
val unreadMessageIds = currentMessages.messages
.filter { message ->
message.chatUserId != currentUserId && // Not from current user
(message.statuses?.none { status ->
status.chatUserId == currentUserId && status.status == "seen"
} ?: true) // No "seen" status from current user (or no statuses at all)
}
.map { it.id }
if (unreadMessageIds.isNotEmpty()) {
viewModelScope.launch {
val result = chatRepository.markMessagesAsRead(chatId, unreadMessageIds)
result.fold(
onSuccess = {
// Messages marked as read, notify main view model to refresh chat list
// This will update the unseenCount in the chat list
com.crm.chat.ui.main.MainViewModel.getInstance().notifyChatUpdated(chatId)
},
onFailure = { exception ->
println("DEBUG: Failed to mark messages as read: ${exception.message}")
}
)
}
}
}
}
fun refreshMessages() {
// Force refresh messages by clearing previous messages cache
previousMessages = emptyList()
// Force reload by setting loading state and then loading
_messagesState.value = MessagesState.Loading
loadMessages()
}
private fun hasChatChanged(currentChat: Chat, newChat: Chat): Boolean {
// Compare basic chat properties
if (currentChat.id != newChat.id ||
currentChat.title != newChat.title ||
currentChat.type != newChat.type ||
currentChat.createdAt != newChat.createdAt ||
currentChat.updatedAt != newChat.updatedAt ||
currentChat.unseenCount != newChat.unseenCount ||
currentChat.hasAccess != newChat.hasAccess) {
return true
}
// Compare users
val currentUsers = currentChat.users ?: emptyList()
val newUsers = newChat.users ?: emptyList()
if (currentUsers.size != newUsers.size) {
return true
}
for (i in 0 until currentUsers.size) {
val currentUser = currentUsers[i]
val newUser = newUsers.getOrNull(i) ?: return true
if (currentUser.id != newUser.id ||
currentUser.userId != newUser.userId ||
currentUser.role != newUser.role) {
return true
}
}
// Compare last message if it exists
val currentLastMessage = currentChat.lastMessage
val newLastMessage = newChat.lastMessage
if ((currentLastMessage == null) != (newLastMessage == null)) {
return true // One has last message, other doesn't
}
if (currentLastMessage != null && newLastMessage != null) {
if (currentLastMessage.id != newLastMessage.id ||
currentLastMessage.text != newLastMessage.text ||
currentLastMessage.chatUserId != newLastMessage.chatUserId ||
currentLastMessage.createdAt != newLastMessage.createdAt) {
return true
}
}
// No changes detected
return false
}
private fun hasMessagesChanged(currentMessages: List<ChatMessage>, newMessages: List<ChatMessage>): Boolean {
// Check if the number of messages changed
if (currentMessages.size != newMessages.size) {
return true
}
// Check if any message content changed (ignore status updates as they don't affect display)
for (i in currentMessages.indices) {
val current = currentMessages[i]
val new = newMessages[i]
// Compare basic properties that affect message display
if (current.id != new.id ||
current.text != new.text ||
current.chatUserId != new.chatUserId ||
current.createdAt != new.createdAt) {
return true
}
// Compare files (attachments)
val currentFiles = current.files ?: emptyList()
val newFiles = new.files ?: emptyList()
if (currentFiles.size != newFiles.size) {
return true
}
for (j in currentFiles.indices) {
val currentFile = currentFiles[j]
val newFile = newFiles[j]
if (currentFile.id != newFile.id ||
currentFile.fileName != newFile.fileName ||
currentFile.fileType != newFile.fileType) {
return true
}
}
// Compare replyTo if exists
val currentReplyTo = current.replyTo
val newReplyTo = new.replyTo
if ((currentReplyTo == null) != (newReplyTo == null)) {
return true // One has reply, other doesn't
}
if (currentReplyTo != null && newReplyTo != null) {
if (currentReplyTo.id != newReplyTo.id ||
currentReplyTo.text != newReplyTo.text) {
return true
}
}
// Note: We ignore status changes (seen/unseen) as they don't affect message display
// Reactions could be added here if needed
}
// No content changes detected
return false
}
private fun startPolling() {
pollingJob = viewModelScope.launch {
while (true) {
delay(1000)
loadMessages()
}
}
}
override fun onCleared() {
super.onCleared()
pollingJob?.cancel()
}
fun editMessage(messageId: Long, newText: String) {
_editMessageState.value = EditMessageState.Loading
viewModelScope.launch {
val request = com.crm.chat.data.model.UpdateMessageRequest(text = newText)
val result = chatRepository.updateMessage(chatId, messageId, request)
result.fold(
onSuccess = { updatedMessage ->
_editMessageState.value = EditMessageState.Success(updatedMessage)
// Reload messages to show the updated message
// Note: The backend should handle marking the message as unread for other users
loadMessages()
},
onFailure = { exception ->
_editMessageState.value = EditMessageState.Error(exception.message ?: "Failed to edit message")
}
)
}
}
fun deleteMessage(messageId: Long) {
_deleteMessageState.value = DeleteMessageState.Loading
viewModelScope.launch {
val result = chatRepository.deleteMessage(chatId, messageId)
result.fold(
onSuccess = {
_deleteMessageState.value = DeleteMessageState.Success
// Reload messages to reflect the deletion
loadMessages()
},
onFailure = { exception ->
_deleteMessageState.value = DeleteMessageState.Error(exception.message ?: "Failed to delete message")
}
)
}
}
}

View File

@@ -0,0 +1,275 @@
package com.crm.chat.ui.chat
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.bumptech.glide.Glide
import com.crm.chat.databinding.ActivityImageViewerBinding
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class ImageViewerActivity : AppCompatActivity() {
private lateinit var binding: ActivityImageViewerBinding
private var imageUrl: String = ""
private var fileName: String = ""
private var serverUrl: String? = null
private var token: String? = null
private var apiKey: String? = null
private var cachedImageFile: java.io.File? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Get SharedPreferences
val prefs = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
// Apply saved theme preference
val savedThemeMode = prefs.getInt("theme_mode", androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode(savedThemeMode)
super.onCreate(savedInstanceState)
binding = ActivityImageViewerBinding.inflate(layoutInflater)
setContentView(binding.root)
// Get data from intent
imageUrl = intent.getStringExtra(EXTRA_IMAGE_URL) ?: ""
fileName = intent.getStringExtra(EXTRA_FILE_NAME) ?: "image"
// Get credentials from SharedPreferences
serverUrl = prefs.getString("server_url", null)
token = prefs.getString("auth_token", null)
apiKey = prefs.getString("api_key", null)
// Set up custom toolbar
binding.toolbarTitle.text = fileName
// Load image
loadImage()
// Set up back button
binding.backButton.setOnClickListener {
finish()
}
// Set up download button in toolbar
binding.downloadButton.setOnClickListener {
downloadImage()
}
}
private fun loadImage() {
if (imageUrl.isNotEmpty()) {
// Check if we already have the image cached
if (cachedImageFile != null && cachedImageFile!!.exists()) {
displayImageFromCache(cachedImageFile!!)
return
}
// Download image to cache first
downloadImageToCache()
}
}
private fun downloadImageToCache() {
if (serverUrl.isNullOrEmpty() || apiKey.isNullOrEmpty()) {
// Fallback: try to load without auth (might work for some servers)
Glide.with(this)
.load(imageUrl)
.into(binding.imageView)
return
}
val repository = com.crm.chat.data.api.ApiClient(serverUrl!!, apiKey!!, token ?: "")
.let { com.crm.chat.data.repository.ChatRepository(it) }
GlobalScope.launch {
try {
val result = repository.downloadFile(imageUrl, fileName, this@ImageViewerActivity)
runOnUiThread {
result.fold(
onSuccess = { cacheFile ->
cachedImageFile = cacheFile
displayImageFromCache(cacheFile)
},
onFailure = { exception ->
// Fallback: try to load directly (might work for some images)
android.util.Log.w("ImageViewer", "Failed to download image to cache: ${exception.message}")
Glide.with(this@ImageViewerActivity)
.load(imageUrl)
.into(binding.imageView)
}
)
}
} catch (e: Exception) {
runOnUiThread {
android.util.Log.e("ImageViewer", "Error downloading image to cache", e)
// Fallback: try to load directly
Glide.with(this@ImageViewerActivity)
.load(imageUrl)
.into(binding.imageView)
}
}
}
}
private fun displayImageFromCache(cacheFile: java.io.File) {
Glide.with(this)
.load(cacheFile)
.into(binding.imageView)
}
private fun downloadImage() {
// Use cached image if available
cachedImageFile?.let { cacheFile ->
if (cacheFile.exists()) {
showSaveDialog(cacheFile, fileName)
return
}
}
if (serverUrl.isNullOrEmpty() || token.isNullOrEmpty() || apiKey.isNullOrEmpty()) {
Toast.makeText(this, "Невозможно скачать: отсутствуют учетные данные", Toast.LENGTH_SHORT).show()
return
}
val repository = com.crm.chat.data.api.ApiClient(serverUrl!!, apiKey!!, token!!)
.let { com.crm.chat.data.repository.ChatRepository(it) }
// Show progress
binding.downloadButton.isEnabled = false
Toast.makeText(this, "Скачивание $fileName...", Toast.LENGTH_SHORT).show()
GlobalScope.launch {
try {
val result = repository.downloadFile(imageUrl, fileName, this@ImageViewerActivity)
runOnUiThread {
result.fold(
onSuccess = { cacheFile ->
cachedImageFile = cacheFile // Update cached file reference
showSaveDialog(cacheFile, fileName)
},
onFailure = { exception ->
Toast.makeText(
this@ImageViewerActivity,
"Скачивание не удалось: ${exception.message}",
Toast.LENGTH_SHORT
).show()
resetDownloadButton()
}
)
}
} catch (e: Exception) {
runOnUiThread {
Toast.makeText(
this@ImageViewerActivity,
"Ошибка скачивания: ${e.message}",
Toast.LENGTH_SHORT
).show()
resetDownloadButton()
}
}
}
}
private fun showSaveDialog(cacheFile: java.io.File, fileName: String) {
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
saveFileUsingMediaStore(cacheFile, fileName)
} else {
saveFileDirectly(cacheFile, fileName)
}
} catch (e: Exception) {
android.util.Log.e("ImageSave", "Failed to save image", e)
Toast.makeText(
this,
"Не удалось сохранить изображение: ${e.message}",
Toast.LENGTH_SHORT
).show()
resetDownloadButton()
}
}
@Suppress("DEPRECATION")
private fun saveFileDirectly(cacheFile: java.io.File, fileName: String) {
try {
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
val destFile = java.io.File(downloadsDir, fileName)
cacheFile.inputStream().use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
android.media.MediaScannerConnection.scanFile(
this,
arrayOf(destFile.absolutePath),
null,
null
)
Toast.makeText(
this,
"Изображение сохранено в Загрузки: $fileName",
Toast.LENGTH_LONG
).show()
resetDownloadButton()
} catch (e: Exception) {
throw e
}
}
private fun saveFileUsingMediaStore(cacheFile: java.io.File, fileName: String) {
try {
val contentResolver = contentResolver
val contentValues = android.content.ContentValues().apply {
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(android.provider.MediaStore.MediaColumns.MIME_TYPE, "image/*")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, android.os.Environment.DIRECTORY_DOWNLOADS)
}
}
val collectionUri = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI
} else {
android.provider.MediaStore.Files.getContentUri("external")
}
val uri = contentResolver.insert(collectionUri, contentValues)
if (uri != null) {
contentResolver.openOutputStream(uri)?.use { outputStream ->
cacheFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
Toast.makeText(
this,
"Изображение сохранено в Загрузки: $fileName",
Toast.LENGTH_LONG
).show()
} else {
throw Exception("Failed to create file in Downloads")
}
resetDownloadButton()
} catch (e: Exception) {
throw e
}
}
private fun resetDownloadButton() {
binding.downloadButton.isEnabled = true
}
companion object {
const val EXTRA_IMAGE_URL = "image_url"
const val EXTRA_FILE_NAME = "file_name"
const val EXTRA_SERVER_URL = "server_url"
const val EXTRA_TOKEN = "token"
const val EXTRA_API_KEY = "api_key"
}
}

View File

@@ -0,0 +1,743 @@
package com.crm.chat.ui.chat
import android.content.Intent
import android.net.Uri
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.crm.chat.data.model.ChatMessage
import com.crm.chat.data.model.ChatMessageFile
import com.crm.chat.databinding.ItemMessageBinding
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
class MessageAdapter(
private val getUserName: (Long) -> String,
private val chatUsers: List<com.crm.chat.data.model.ChatUserItem>? = null,
private val onScrollToMessage: ((Long) -> Unit)? = null,
private val onReplyToMessage: ((ChatMessage) -> Unit)? = null,
private val onEditMessage: ((ChatMessage) -> Unit)? = null,
private val onDeleteMessage: ((ChatMessage) -> Unit)? = null,
serverUrl: String? = null,
token: String? = null,
apiKey: String? = null
) : ListAdapter<ChatMessage, MessageAdapter.MessageViewHolder>(MessageDiffCallback()) {
private val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault())
// For file downloads
private val chatRepository: com.crm.chat.data.repository.ChatRepository? = if (serverUrl != null && token != null && apiKey != null) {
val apiClient = com.crm.chat.data.api.ApiClient(serverUrl, apiKey, token)
com.crm.chat.data.repository.ChatRepository(apiClient)
} else {
null
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
val binding = ItemMessageBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return MessageViewHolder(binding, dateFormat, getUserName, chatUsers, onScrollToMessage, onReplyToMessage, chatRepository, onEditMessage, onDeleteMessage)
}
override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
holder.bind(getItem(position))
}
class MessageViewHolder(
private val binding: ItemMessageBinding,
private val dateFormat: SimpleDateFormat,
private val getUserName: (Long) -> String,
private val chatUsers: List<com.crm.chat.data.model.ChatUserItem>?,
private val onScrollToMessage: ((Long) -> Unit)?,
private val onReplyToMessage: ((ChatMessage) -> Unit)?,
private val chatRepository: com.crm.chat.data.repository.ChatRepository?,
private val onEditMessage: ((ChatMessage) -> Unit)?,
private val onDeleteMessage: ((ChatMessage) -> Unit)?
) : RecyclerView.ViewHolder(binding.root) {
// Cache for downloaded images
private val imageCache = mutableMapOf<String, java.io.File>()
private fun loadImageToCache(imageUrl: String, fileName: String, onSuccess: (java.io.File) -> Unit, onFailure: (() -> Unit)? = null) {
// Check if already cached
imageCache[imageUrl]?.let { cachedFile ->
if (cachedFile.exists()) {
onSuccess(cachedFile)
return
} else {
imageCache.remove(imageUrl)
}
}
val repository = chatRepository ?: run {
onFailure?.invoke()
return
}
kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.Main) {
try {
val result = repository.downloadFile(imageUrl, fileName, binding.root.context)
result.fold(
onSuccess = { cacheFile ->
imageCache[imageUrl] = cacheFile
onSuccess(cacheFile)
},
onFailure = { exception ->
android.util.Log.w("MessageAdapter", "Failed to download image to cache: ${exception.message}")
onFailure?.invoke()
}
)
} catch (e: Exception) {
android.util.Log.e("MessageAdapter", "Error downloading image to cache", e)
onFailure?.invoke()
}
}
}
// Utility function to check if a file is an image
private fun isImageFile(file: ChatMessageFile): Boolean {
val fileName = file.fileName?.lowercase() ?: ""
val fileType = file.fileType?.lowercase() ?: ""
// Check by file extension
val imageExtensions = listOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
val extension = fileName.substringAfterLast(".", "")
// Check by MIME type
val imageMimeTypes = listOf("image/jpeg", "image/png", "image/gif", "image/bmp", "image/webp")
return imageExtensions.contains(extension) || imageMimeTypes.contains(fileType)
}
fun bind(message: ChatMessage) {
// Resolve chatUserId to actual userId
val chatUserItem = chatUsers?.find { it.id == message.chatUserId }
val actualUserId = chatUserItem?.userId ?: message.chatUserId
// For now, assume messages from current user (simplified logic)
// In a real app, you'd get current user ID from preferences/session
val currentUserId = 12033923L // Hardcoded for demo, should be from auth
val isSentMessage = actualUserId == currentUserId
val timestamp = formatTimestamp(message.createdAt)
// Handle reply indicator - different for sent vs received messages
if (message.replyTo != null) {
// Resolve original sender
val originalChatUserItem = chatUsers?.find { it.id == message.replyTo.chatUserId }
val originalUserId = originalChatUserItem?.userId ?: message.replyTo.chatUserId
// If original sender is current user, show "Я" instead of their name
val originalSenderName = if (originalUserId == currentUserId) "Я" else getUserName(originalUserId)
if (isSentMessage) {
// Show reply indicator inside sent message bubble
binding.sentReplyIndicator.visibility = android.view.View.VISIBLE
binding.sentReplySenderText.text = originalSenderName
binding.sentReplyMessageText.text = message.replyTo.text
// Make reply indicator clickable to scroll to original message
binding.sentReplyIndicator.setOnClickListener {
onScrollToMessage?.invoke(message.replyTo.id)
}
} else {
// Show reply indicator inside received message bubble
binding.receivedReplyIndicator.visibility = android.view.View.VISIBLE
binding.receivedReplySenderText.text = originalSenderName
binding.receivedReplyMessageText.text = message.replyTo.text
// Make reply indicator clickable to scroll to original message
binding.receivedReplyIndicator.setOnClickListener {
onScrollToMessage?.invoke(message.replyTo.id)
}
}
} else {
// Hide both reply indicators
binding.sentReplyIndicator.visibility = android.view.View.GONE
binding.receivedReplyIndicator.visibility = android.view.View.GONE
}
if (isSentMessage) {
// Show sent message
binding.sentMessageCard.visibility = android.view.View.VISIBLE
binding.receivedMessageCard.visibility = android.view.View.GONE
binding.senderNameText.visibility = android.view.View.GONE
// Handle files
if (!message.files.isNullOrEmpty()) {
val file = message.files.first() // Show first file
if (isImageFile(file)) {
// Show image preview
binding.sentImagePreview.visibility = android.view.View.VISIBLE
binding.sentFileLayout.visibility = android.view.View.GONE
// Load image to cache first, then display
val imageUrl = "${chatRepository?.getBaseUrl()}/api/storage/file/${file.fileId}"
loadImageToCache(imageUrl, file.fileName ?: "image",
onSuccess = { cacheFile ->
Glide.with(binding.sentImagePreview.context)
.load(cacheFile)
.override(400, 400) // Limit size for preview
.centerCrop()
.into(binding.sentImagePreview)
},
onFailure = {
// Fallback: try to load directly (might work for some servers)
Glide.with(binding.sentImagePreview.context)
.load(imageUrl)
.override(400, 400) // Limit size for preview
.centerCrop()
.into(binding.sentImagePreview)
}
)
// Click listener to open full-size image
binding.sentImagePreview.setOnClickListener {
openImageViewer(imageUrl, file.fileName ?: "image")
}
} else {
// Show regular file layout
binding.sentFileLayout.visibility = android.view.View.VISIBLE
binding.sentImagePreview.visibility = android.view.View.GONE
binding.sentFileNameText.text = file.fileName ?: "Unknown file"
binding.sentFileNameText.setOnClickListener {
downloadFile(file.fileId, file.fileName ?: "file")
}
// Make paperclip clickable too - find the first TextView (paperclip)
val sentFileLayout = binding.sentFileLayout
if (sentFileLayout is android.widget.LinearLayout && sentFileLayout.childCount > 0) {
val paperclipView = sentFileLayout.getChildAt(0) as? android.widget.TextView
paperclipView?.setOnClickListener {
downloadFile(file.fileId, file.fileName ?: "file")
}
}
}
} else {
binding.sentFileLayout.visibility = android.view.View.GONE
binding.sentImagePreview.visibility = android.view.View.GONE
}
// Handle text - show below files if files exist, otherwise normal padding
binding.sentMessageText.text = message.text
if (!message.files.isNullOrEmpty() && message.text.isNotBlank()) {
// Files present and text exists - adjust padding
binding.sentMessageText.setPadding(12, 0, 12, 12) // No top padding since files are above
} else {
binding.sentMessageText.setPadding(12, 12, 12, 12) // Normal padding
}
// Hide text if no text and no files (shouldn't happen but safety check)
if (message.text.isBlank() && message.files.isNullOrEmpty()) {
binding.sentMessageText.visibility = android.view.View.GONE
} else {
binding.sentMessageText.visibility = android.view.View.VISIBLE
}
// Add click listener for replying to sent messages
binding.sentMessageCard.setOnClickListener {
onReplyToMessage?.invoke(message)
}
// Add long-press listener for edit/delete actions on sent messages
binding.sentMessageCard.setOnLongClickListener {
showMessageActionsDialog(message)
true
}
// Show read status if message has been seen by others
val hasBeenRead = message.statuses?.any { status ->
status.status == "seen" && status.chatUserId != message.chatUserId
} ?: false
// Include checkmark outside parentheses if read
val readIndicator = if (hasBeenRead) "" else ""
binding.timestampText.text = "Я ($timestamp)$readIndicator"
binding.timestampText.visibility = android.view.View.VISIBLE
} else {
// Show received message
binding.sentMessageCard.visibility = android.view.View.GONE
binding.receivedMessageCard.visibility = android.view.View.VISIBLE
binding.timestampText.visibility = android.view.View.GONE
val userName = getUserName(actualUserId)
// Handle files
if (!message.files.isNullOrEmpty()) {
val file = message.files.first() // Show first file
if (isImageFile(file)) {
// Show image preview
binding.receivedImagePreview.visibility = android.view.View.VISIBLE
binding.receivedFileLayout.visibility = android.view.View.GONE
// Load image to cache first, then display
val imageUrl = "${chatRepository?.getBaseUrl()}/api/storage/file/${file.fileId}"
loadImageToCache(imageUrl, file.fileName ?: "image",
onSuccess = { cacheFile ->
Glide.with(binding.receivedImagePreview.context)
.load(cacheFile)
.override(400, 400) // Limit size for preview
.centerCrop()
.into(binding.receivedImagePreview)
},
onFailure = {
// Fallback: try to load directly (might work for some servers)
Glide.with(binding.receivedImagePreview.context)
.load(imageUrl)
.override(400, 400) // Limit size for preview
.centerCrop()
.into(binding.receivedImagePreview)
}
)
// Click listener to open full-size image
binding.receivedImagePreview.setOnClickListener {
openImageViewer(imageUrl, file.fileName ?: "image")
}
} else {
// Show regular file layout
binding.receivedFileLayout.visibility = android.view.View.VISIBLE
binding.receivedImagePreview.visibility = android.view.View.GONE
binding.receivedFileNameText.text = file.fileName ?: "Unknown file"
binding.receivedFileNameText.setOnClickListener {
downloadFile(file.fileId, file.fileName ?: "file")
}
// Make paperclip clickable too - find the first TextView (paperclip)
val receivedFileLayout = binding.receivedFileLayout
if (receivedFileLayout is android.widget.LinearLayout && receivedFileLayout.childCount > 0) {
val paperclipView = receivedFileLayout.getChildAt(0) as? android.widget.TextView
paperclipView?.setOnClickListener {
downloadFile(file.fileId, file.fileName ?: "file")
}
}
}
} else {
binding.receivedFileLayout.visibility = android.view.View.GONE
binding.receivedImagePreview.visibility = android.view.View.GONE
}
// Handle text - show below files if files exist, otherwise normal padding
binding.receivedMessageText.text = message.text
if (!message.files.isNullOrEmpty() && message.text.isNotBlank()) {
// Files present and text exists - adjust padding
binding.receivedMessageText.setPadding(12, 0, 12, 12) // No top padding since files are above
} else {
binding.receivedMessageText.setPadding(12, 12, 12, 12) // Normal padding
}
// Hide text if no text and no files (shouldn't happen but safety check)
if (message.text.isBlank() && message.files.isNullOrEmpty()) {
binding.receivedMessageText.visibility = android.view.View.GONE
} else {
binding.receivedMessageText.visibility = android.view.View.VISIBLE
}
println("DEBUG: Message chatUserId=${message.chatUserId} -> userId=${actualUserId} -> name: $userName")
// Show "Last Name First Name (time)" for received messages
binding.senderNameText.text = "$userName ($timestamp)"
binding.senderNameText.visibility = android.view.View.VISIBLE
// Add click listener for replying to received messages
binding.receivedMessageCard.setOnClickListener {
onReplyToMessage?.invoke(message)
}
}
}
private fun downloadFile(fileId: String, fileName: String) {
val context = binding.root.context
val repository = chatRepository ?: return
// Show downloading progress
android.widget.Toast.makeText(
context,
"Скачивание $fileName...",
android.widget.Toast.LENGTH_SHORT
).show()
// Try the correct endpoint pattern based on HAR file analysis
// For images: /api/storage/image/{id}/{uuid}
// For other files: /api/storage/file/{fileId}
// Since we only have fileId, try both patterns
// First try: /api/storage/file/{fileId} (current approach)
val downloadUrl = "${repository.getBaseUrl()}/api/storage/file/$fileId"
// Log the URL for debugging
android.util.Log.d("FileDownload", "Download URL: $downloadUrl")
android.util.Log.d("FileDownload", "File ID: $fileId")
// Download file to cache first
kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.Main) {
try {
val result = repository.downloadFile(downloadUrl, fileName, context)
result.fold(
onSuccess = { cacheFile ->
// File downloaded to cache successfully
// Now offer to save it to phone storage
showSaveFileDialog(cacheFile, fileName)
},
onFailure = { exception ->
// If /api/storage/file/ fails, try alternative endpoint
android.util.Log.w("FileDownload", "Primary endpoint failed, trying alternative", exception)
// Try alternative: /api/storage/download/{fileId}
val altDownloadUrl = "${repository.getBaseUrl()}/api/storage/download/$fileId"
android.util.Log.d("FileDownload", "Trying alternative URL: $altDownloadUrl")
tryAlternativeDownload(altDownloadUrl, fileName, context)
}
)
} catch (e: Exception) {
android.widget.Toast.makeText(
context,
"Ошибка скачивания: ${e.message}",
android.widget.Toast.LENGTH_SHORT
).show()
android.util.Log.e("FileDownload", "Download error", e)
}
}
}
private fun tryAlternativeDownload(altDownloadUrl: String, fileName: String, context: android.content.Context) {
val repository = chatRepository ?: return
kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.Main) {
try {
val result = repository.downloadFile(altDownloadUrl, fileName, context)
result.fold(
onSuccess = { cacheFile ->
showSaveFileDialog(cacheFile, fileName)
},
onFailure = { exception ->
android.widget.Toast.makeText(
context,
"Скачивание не удалось: ${exception.message}",
android.widget.Toast.LENGTH_SHORT
).show()
android.util.Log.e("FileDownload", "Alternative download also failed", exception)
}
)
} catch (e: Exception) {
android.widget.Toast.makeText(
context,
"Ошибка скачивания: ${e.message}",
android.widget.Toast.LENGTH_SHORT
).show()
android.util.Log.e("FileDownload", "Alternative download error", e)
}
}
}
private fun showSaveFileDialog(cacheFile: java.io.File, fileName: String) {
val context = binding.root.context
val activity = context as? android.app.Activity ?: return
android.app.AlertDialog.Builder(activity)
.setTitle("Файл загружен")
.setMessage("Сохранить '$fileName' на телефон?")
.setPositiveButton("Сохранить") { _, _ ->
saveFileToPhone(cacheFile, fileName)
}
.setNegativeButton("Отмена", null)
.show()
}
private fun saveFileToPhone(cacheFile: java.io.File, fileName: String) {
val context = binding.root.context
val activity = context as? android.app.Activity ?: run {
android.widget.Toast.makeText(
context,
"Невозможно сохранить файл: активность недоступна",
android.widget.Toast.LENGTH_SHORT
).show()
return
}
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
// Android 10+ (API 29+) - Use MediaStore API
if (checkStoragePermission(activity)) {
saveFileUsingMediaStore(cacheFile, fileName, context)
} else {
requestStoragePermission(activity, cacheFile, fileName)
}
} else {
// Android 9 and below - Direct file access
if (checkStoragePermission(activity)) {
saveFileDirectly(cacheFile, fileName, context)
} else {
requestStoragePermission(activity, cacheFile, fileName)
}
}
} catch (e: Exception) {
android.util.Log.e("FileSave", "Failed to save file", e)
android.widget.Toast.makeText(
context,
"Не удалось сохранить файл: ${e.message}",
android.widget.Toast.LENGTH_SHORT
).show()
}
}
@Suppress("DEPRECATION")
private fun saveFileDirectly(cacheFile: java.io.File, fileName: String, context: android.content.Context) {
try {
// Get the Downloads directory
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
// Create destination file
val destFile = java.io.File(downloadsDir, fileName)
// Copy file from cache to downloads
cacheFile.inputStream().use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
// Notify media scanner
android.media.MediaScannerConnection.scanFile(
context,
arrayOf(destFile.absolutePath),
null,
null
)
android.widget.Toast.makeText(
context,
"Файл сохранен в Загрузки: $fileName",
android.widget.Toast.LENGTH_LONG
).show()
} catch (e: Exception) {
throw e // Re-throw to be handled by caller
}
}
private fun saveFileUsingMediaStore(cacheFile: java.io.File, fileName: String, context: android.content.Context) {
try {
val contentResolver = context.contentResolver
val contentValues = android.content.ContentValues().apply {
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(android.provider.MediaStore.MediaColumns.MIME_TYPE, getMimeType(fileName))
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, android.os.Environment.DIRECTORY_DOWNLOADS)
}
}
// Use appropriate URI based on API level
val collectionUri = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
// Android 10+ - Use Downloads collection
android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI
} else {
// Android 9 and below - Use generic external storage
android.provider.MediaStore.Files.getContentUri("external")
}
val uri = contentResolver.insert(collectionUri, contentValues)
if (uri != null) {
contentResolver.openOutputStream(uri)?.use { outputStream ->
cacheFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
android.widget.Toast.makeText(
context,
"File saved to Downloads: $fileName",
android.widget.Toast.LENGTH_LONG
).show()
} else {
throw Exception("Failed to create file in Downloads")
}
} catch (e: Exception) {
// Fallback: Try to save in app's private Downloads directory
saveFileToAppPrivateStorage(cacheFile, fileName, context)
}
}
private fun saveFileToAppPrivateStorage(cacheFile: java.io.File, fileName: String, context: android.content.Context) {
try {
// Create app-private Downloads directory
val appDownloadsDir = java.io.File(context.getExternalFilesDir(null), "Downloads")
if (!appDownloadsDir.exists()) {
appDownloadsDir.mkdirs()
}
val destFile = java.io.File(appDownloadsDir, fileName)
// Copy file from cache to app downloads
cacheFile.inputStream().use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
android.widget.Toast.makeText(
context,
"Файл сохранен в хранилище приложения: $fileName",
android.widget.Toast.LENGTH_LONG
).show()
} catch (e: Exception) {
throw Exception("Failed to save file to any location: ${e.message}")
}
}
private fun checkStoragePermission(activity: android.app.Activity): Boolean {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// Android 11+ (API 30+) - Check MANAGE_EXTERNAL_STORAGE
android.os.Environment.isExternalStorageManager()
} else {
// Android 6-10 - Check WRITE_EXTERNAL_STORAGE
androidx.core.content.ContextCompat.checkSelfPermission(
activity,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
}
private fun requestStoragePermission(activity: android.app.Activity, cacheFile: java.io.File, fileName: String) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// Android 11+ - Request MANAGE_EXTERNAL_STORAGE via settings
android.widget.Toast.makeText(
activity,
"Пожалуйста, предоставьте разрешение на хранение в Настройках > Приложения > ${activity.packageManager.getApplicationLabel(activity.applicationInfo)} > Хранилище",
android.widget.Toast.LENGTH_LONG
).show()
// Try to open settings
try {
val intent = android.content.Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
data = android.net.Uri.parse("package:${activity.packageName}")
}
activity.startActivity(intent)
} catch (e: Exception) {
android.util.Log.e("Permission", "Failed to open settings", e)
}
// Fallback to app storage
saveFileToAppPrivateStorage(cacheFile, fileName, activity)
} else {
// Android 6-10 - Request WRITE_EXTERNAL_STORAGE permission
androidx.core.app.ActivityCompat.requestPermissions(
activity,
arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE),
STORAGE_PERMISSION_REQUEST_CODE
)
// Store file info for later retry
pendingFileSave = Pair(cacheFile, fileName)
}
}
private fun getMimeType(fileName: String): String {
return when (fileName.substringAfterLast(".", "")) {
"pdf" -> "application/pdf"
"doc" -> "application/msword"
"docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
"xls" -> "application/vnd.ms-excel"
"xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
"txt" -> "text/plain"
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"mp4" -> "video/mp4"
"mp3" -> "audio/mpeg"
"zip" -> "application/zip"
else -> "application/octet-stream"
}
}
companion object {
private const val STORAGE_PERMISSION_REQUEST_CODE = 2001
private var pendingFileSave: Pair<java.io.File, String>? = null
}
private fun openImageViewer(imageUrl: String, fileName: String) {
val context = binding.root.context
try {
val intent = Intent(context, ImageViewerActivity::class.java).apply {
putExtra(ImageViewerActivity.EXTRA_IMAGE_URL, imageUrl)
putExtra(ImageViewerActivity.EXTRA_FILE_NAME, fileName)
// Pass server credentials if available
chatRepository?.let { repo ->
putExtra(ImageViewerActivity.EXTRA_SERVER_URL, repo.getBaseUrl())
// For now, we'll get credentials from preferences in the activity
// In a production app, these should be securely stored and passed
}
}
context.startActivity(intent)
} catch (e: Exception) {
android.util.Log.e("ImageViewer", "Failed to open image viewer", e)
android.widget.Toast.makeText(
context,
"Невозможно открыть просмотр изображений",
android.widget.Toast.LENGTH_SHORT
).show()
}
}
private fun showMessageActionsDialog(message: ChatMessage) {
val context = binding.root.context
val activity = context as? android.app.Activity ?: return
val options = arrayOf("Ответить", "Редактировать", "Удалить")
android.app.AlertDialog.Builder(activity)
.setTitle("Действия с сообщением")
.setItems(options) { _, which ->
when (which) {
0 -> { // Reply
onReplyToMessage?.invoke(message)
}
1 -> { // Edit
onEditMessage?.invoke(message)
}
2 -> { // Delete
// Show confirmation dialog for delete
android.app.AlertDialog.Builder(activity)
.setTitle("Удалить сообщение")
.setMessage("Вы уверены, что хотите удалить это сообщение?")
.setPositiveButton("Удалить") { _, _ ->
onDeleteMessage?.invoke(message)
}
.setNegativeButton("Отмена", null)
.show()
}
}
}
.setNegativeButton("Отмена", null)
.show()
}
private fun formatTimestamp(timestamp: String): String {
return try {
// Assuming timestamp is in ISO format
val date = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
.parse(timestamp)
dateFormat.format(date ?: Date())
} catch (e: Exception) {
dateFormat.format(Date())
}
}
}
class MessageDiffCallback : DiffUtil.ItemCallback<ChatMessage>() {
override fun areItemsTheSame(oldItem: ChatMessage, newItem: ChatMessage): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ChatMessage, newItem: ChatMessage): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -0,0 +1,286 @@
package com.crm.chat.ui.main
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.crm.chat.data.model.Chat
import com.crm.chat.databinding.ItemChatBinding
import androidx.core.graphics.toColorInt
import java.text.SimpleDateFormat
import java.util.*
class ChatAdapter(
private val onChatClick: (Chat) -> Unit,
private val getUserName: (Long) -> String,
private val getUserAvatarUrl: (Long) -> String?
) :
ListAdapter<Chat, ChatAdapter.ChatViewHolder>(ChatDiffCallback()) {
private val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault())
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatViewHolder {
val binding = ItemChatBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ChatViewHolder(binding, onChatClick, getUserName, getUserAvatarUrl, dateFormat)
}
override fun onBindViewHolder(holder: ChatViewHolder, position: Int) {
holder.bind(getItem(position))
}
class ChatViewHolder(
private val binding: ItemChatBinding,
private val onChatClick: (Chat) -> Unit,
private val getUserName: (Long) -> String,
private val getUserAvatarUrl: (Long) -> String?,
private val dateFormat: SimpleDateFormat
) : RecyclerView.ViewHolder(binding.root) {
fun bind(chat: Chat) {
// Determine chat display name
val displayName = when {
chat.title != null -> chat.title
chat.type == "personal" && chat.users != null && chat.users.size >= 2 -> {
// For personal chats, show the other user's name
val currentUserId = 12033923L // Should be from auth store
val otherUser = chat.users.find { it.userId != currentUserId }
otherUser?.let { user ->
// Use cached user data from address book (firstname + lastname)
getUserName(user.userId)
} ?: "Personal Chat"
}
chat.type == "group" -> chat.title ?: "Group Chat"
else -> "Chat ${chat.id}"
}
binding.chatNameTextView.text = displayName
binding.lastMessageTextView.text = getLastMessageSnippet(chat)
binding.lastMessageTimeTextView.text = formatTimestamp(chat.lastMessage?.createdAt)
// Load avatar for personal chats
if (chat.type == "personal" && chat.users != null && chat.users.size >= 2) {
val currentUserId = 12033923L // Should be from auth store
val otherUser = chat.users.find { it.userId != currentUserId }
if (otherUser != null) {
// Try to get user name from cache or API
val userName = getUserName(otherUser.userId)
loadUserAvatar(otherUser.userId, userName)
} else {
// Fallback to default avatar
loadDefaultAvatar()
}
} else {
// For group chats and other types, use group icon
android.util.Log.d("ChatAdapter", "Loading group avatar for chat ${chat.id}, type: ${chat.type}")
loadGroupAvatar(chat.title ?: "Group Chat")
}
// Handle unread count
if (chat.unseenCount > 0) {
binding.unreadCountBadge.text = chat.unseenCount.toString()
binding.unreadCountBadge.visibility = android.view.View.VISIBLE
} else {
binding.unreadCountBadge.visibility = android.view.View.GONE
}
// Handle click
binding.root.setOnClickListener {
onChatClick(chat)
}
}
private fun getLastMessageSnippet(chat: Chat): String {
val lastMessage = chat.lastMessage ?: return ""
// Check if message has files
if (!lastMessage.files.isNullOrEmpty()) {
val fileType = lastMessage.files[0].fileType
val fileEmoji = when {
fileType?.contains("image") == true -> "🖼️"
fileType?.contains("audio") == true -> "🎧"
else -> "📎"
}
return if (lastMessage.text.isNotEmpty()) {
"$fileEmoji ${lastMessage.text}"
} else {
lastMessage.files[0].fileName
}
}
// Regular text message
return lastMessage.text.ifEmpty {
"💬"
}
}
private fun loadUserAvatar(userId: Long, userName: String) {
val avatarUrl = getUserAvatarUrl(userId)
if (!avatarUrl.isNullOrEmpty()) {
// Clear the circular background for actual avatar images
binding.chatAvatarImageView.background = null
// Load actual avatar image from URL with Glide
Glide.with(binding.chatAvatarImageView.context)
.load(avatarUrl)
.circleCrop()
.placeholder(getUserColor(userId)) // Show color while loading
.error(object : com.bumptech.glide.request.target.CustomTarget<android.graphics.drawable.Drawable>() {
override fun onResourceReady(resource: android.graphics.drawable.Drawable, transition: com.bumptech.glide.request.transition.Transition<in android.graphics.drawable.Drawable>?) {
// Avatar loaded successfully
binding.chatAvatarImageView.setImageDrawable(resource)
}
override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) {
// Show initials when avatar fails to load
showInitialsAvatar(userId, userName)
}
})
.into(binding.chatAvatarImageView)
print("Loading avatar for $userName: $avatarUrl")
} else {
// No avatar URL, show initials
showInitialsAvatar(userId, userName)
print("No avatar for $userName, showing initials")
}
}
private fun showInitialsAvatar(userId: Long, userName: String) {
// Generate initials from user name
val initials = userName.split(" ").let { parts ->
when (parts.size) {
1 -> parts[0].take(1).uppercase()
2 -> "${parts[0].take(1)}${parts[1].take(1)}".uppercase()
else -> parts.firstOrNull()?.take(1)?.uppercase() ?: "U"
}
}
// Background color for the avatar
val backgroundColor = getUserColor(userId)
// Create circular bitmap with initials
val size = 192
val bitmap = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bitmap)
// Draw circle background
val circlePaint = android.graphics.Paint().apply {
color = backgroundColor
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
}
canvas.drawCircle(size / 2f, size / 2f, size / 2f, circlePaint)
// Draw initials text
val textPaint = android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
textSize = size * 0.5f // Larger text for better visibility
textAlign = android.graphics.Paint.Align.CENTER
isAntiAlias = true
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
val xPos = size / 2f
val yPos = size / 2f - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(initials, xPos, yPos, textPaint)
// Clear any background color from ImageView (let the bitmap be fully circular)
binding.chatAvatarImageView.setBackgroundColor(android.graphics.Color.TRANSPARENT)
binding.chatAvatarImageView.setImageBitmap(bitmap)
}
private fun loadGroupAvatar(groupName: String) {
// Load the custom group chat avatar drawable
val drawable = androidx.core.content.ContextCompat.getDrawable(
binding.chatAvatarImageView.context,
com.crm.chat.R.drawable.ic_group_chat
)
// Clear the circular background and set the drawable as image source
binding.chatAvatarImageView.background = null
binding.chatAvatarImageView.setImageDrawable(drawable)
}
private fun loadDefaultAvatar() {
// Default avatar color
val color = "#9E9E9E".toColorInt() // Grey
binding.chatAvatarImageView.setBackgroundColor(color)
}
private fun formatTimestamp(timestamp: String?): String {
if (timestamp.isNullOrEmpty()) return ""
return try {
// Assuming timestamp is in ISO format
val date = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
.parse(timestamp)
dateFormat.format(date ?: Date())
} catch (e: Exception) {
dateFormat.format(Date())
}
}
private fun getUserColor(userId: Long): Int {
// Generate a color based on user ID for consistency
val colors = intArrayOf(
"#E53935".toColorInt(), // Red
"#1E88E5".toColorInt(), // Blue
"#43A047".toColorInt(), // Green
"#FB8C00".toColorInt(), // Orange
"#8E24AA".toColorInt(), // Purple
"#00ACC1".toColorInt(), // Cyan
"#FDD835".toColorInt(), // Yellow
"#6D4C41".toColorInt(), // Brown
)
return colors[(userId % colors.size).toInt()]
}
}
class ChatDiffCallback : DiffUtil.ItemCallback<Chat>() {
override fun areItemsTheSame(oldItem: Chat, newItem: Chat): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Chat, newItem: Chat): Boolean {
// Compare only fields that affect UI display
return oldItem.title == newItem.title &&
oldItem.type == newItem.type &&
oldItem.unseenCount == newItem.unseenCount &&
oldItem.hasAccess == newItem.hasAccess &&
areUsersTheSame(oldItem.users, newItem.users) &&
areLastMessagesTheSame(oldItem.lastMessage, newItem.lastMessage)
}
private fun areUsersTheSame(oldUsers: List<com.crm.chat.data.model.ChatUserItem>?, newUsers: List<com.crm.chat.data.model.ChatUserItem>?): Boolean {
if (oldUsers == null && newUsers == null) return true
if (oldUsers == null || newUsers == null) return false
if (oldUsers.size != newUsers.size) return false
// For chat display, we mainly care about user IDs and roles
return oldUsers.all { oldUser ->
newUsers.any { newUser ->
oldUser.id == newUser.id && oldUser.userId == newUser.userId && oldUser.role == newUser.role
}
}
}
private fun areLastMessagesTheSame(oldMsg: com.crm.chat.data.model.ChatMessage?, newMsg: com.crm.chat.data.model.ChatMessage?): Boolean {
if (oldMsg == null && newMsg == null) return true
if (oldMsg == null || newMsg == null) return false
// Compare only display-relevant fields, ignore timestamps and statuses
return oldMsg.id == newMsg.id &&
oldMsg.text == newMsg.text &&
oldMsg.chatUserId == newMsg.chatUserId &&
oldMsg.files?.map { it.id to it.fileName } == newMsg.files?.map { it.id to it.fileName }
}
}
}

View File

@@ -0,0 +1,315 @@
package com.crm.chat.ui.main
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.doOnTextChanged
import com.crm.chat.R
import com.crm.chat.data.model.*
import com.crm.chat.databinding.ActivityCreateChatBinding
import com.crm.chat.ui.chat.ChatActivity
class CreateChatActivity : AppCompatActivity() {
private lateinit var binding: ActivityCreateChatBinding
private val viewModel: MainViewModel = MainViewModel.getInstance()
private var selectedUserId: Long? = null
private var selectedUserName: String? = null
private var selectedUserIds: LongArray? = null
private var selectedUserNames: Array<String>? = null
private var isCreatingChat = false
companion object {
private const val REQUEST_SELECT_USER = 1001
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCreateChatBinding.inflate(layoutInflater)
setContentView(binding.root)
// Initialize with auth data
val prefs = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
val serverUrl = prefs.getString("server_url", "") ?: ""
val token = prefs.getString("auth_token", "") ?: ""
val apiKey = prefs.getString("api_key", "") ?: ""
if (token.isNotEmpty() && serverUrl.isNotEmpty()) {
viewModel.initialize(this, serverUrl, token, apiKey)
}
setupViews()
observeViewModel()
// Check if we received group chat data from intent (after views are set up)
handleIntentData()
}
private fun handleIntentData() {
val chatType = intent.getStringExtra("chat_type")
if (chatType == "group") {
selectedUserIds = intent.getLongArrayExtra("selected_user_ids")
selectedUserNames = intent.getStringArrayExtra("selected_user_names")
val userIds = selectedUserIds
val userNames = selectedUserNames
if (userIds != null && userIds.isNotEmpty() && userNames != null && userNames.isNotEmpty()) {
// Hide chat type selection for pre-selected group chat
binding.chatTypeLabel.visibility = View.GONE
binding.chatTypeRadioGroup.visibility = View.GONE
// Show group chat fields directly
showGroupChatFields()
binding.participantsEditText.setText(userIds.joinToString(", "))
binding.participantsEditText.isEnabled = false // Make it read-only since users are pre-selected
binding.groupTitleEditText.requestFocus() // Focus on group name input
// Update title to indicate group chat creation
binding.titleTextView.text = "Создать групповой чат"
}
}
}
private fun setupViews() {
// Show personal chat fields by default
showPersonalChatFields()
// Chat type selection
binding.chatTypeRadioGroup.setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.radioPersonal -> showPersonalChatFields()
R.id.radioGroup -> showGroupChatFields()
R.id.radioExternal -> showExternalChatFields()
}
}
// Select user button click
binding.selectUserButton.setOnClickListener {
val intent = Intent(this, UserSelectionActivity::class.java)
startActivityForResult(intent, REQUEST_SELECT_USER)
}
// Create button click
binding.createButton.setOnClickListener {
createChat()
}
// Clear error when user starts typing
binding.groupTitleEditText.doOnTextChanged { _, _, _, _ ->
binding.errorTextView.visibility = View.GONE
}
binding.participantsEditText.doOnTextChanged { _, _, _, _ ->
binding.errorTextView.visibility = View.GONE
}
binding.externalTitleEditText.doOnTextChanged { _, _, _, _ ->
binding.errorTextView.visibility = View.GONE
}
binding.entityEditText.doOnTextChanged { _, _, _, _ ->
binding.errorTextView.visibility = View.GONE
}
}
private fun showPersonalChatFields() {
binding.companionLabel.visibility = View.VISIBLE
binding.selectUserButton.visibility = View.VISIBLE
binding.selectedUserTextView.visibility = View.VISIBLE
binding.groupTitleLabel.visibility = View.GONE
binding.groupTitleInputLayout.visibility = View.GONE
binding.participantsLabel.visibility = View.GONE
binding.participantsInputLayout.visibility = View.GONE
binding.externalTitleLabel.visibility = View.GONE
binding.externalTitleInputLayout.visibility = View.GONE
}
private fun showGroupChatFields() {
binding.companionLabel.visibility = View.GONE
binding.selectUserButton.visibility = View.GONE
binding.selectedUserTextView.visibility = View.GONE
binding.groupTitleLabel.visibility = View.VISIBLE
binding.groupTitleInputLayout.visibility = View.VISIBLE
binding.participantsLabel.visibility = View.GONE // Hide participant IDs for simplified UI
binding.participantsInputLayout.visibility = View.GONE
binding.externalTitleLabel.visibility = View.GONE
binding.externalTitleInputLayout.visibility = View.GONE
// For pre-selected group chats, hide entity ID as well
if (selectedUserIds != null && selectedUserIds!!.isNotEmpty()) {
binding.entityLabel.visibility = View.GONE
binding.entityInputLayout.visibility = View.GONE
}
}
private fun showExternalChatFields() {
binding.companionLabel.visibility = View.GONE
binding.selectUserButton.visibility = View.GONE
binding.selectedUserTextView.visibility = View.GONE
binding.groupTitleLabel.visibility = View.GONE
binding.groupTitleInputLayout.visibility = View.GONE
binding.participantsLabel.visibility = View.GONE
binding.participantsInputLayout.visibility = View.GONE
binding.externalTitleLabel.visibility = View.VISIBLE
binding.externalTitleInputLayout.visibility = View.VISIBLE
}
private fun createChat() {
// Check if this is a pre-selected group chat (radio group is hidden)
if (selectedUserIds != null && selectedUserIds!!.isNotEmpty()) {
createGroupChat()
return
}
// Otherwise determine from radio button
val chatType = when (binding.chatTypeRadioGroup.checkedRadioButtonId) {
R.id.radioPersonal -> "personal"
R.id.radioGroup -> "group"
R.id.radioExternal -> "external"
else -> "personal"
}
when (chatType) {
"personal" -> createPersonalChat()
"group" -> createGroupChat()
"external" -> createExternalChat()
}
}
private fun createPersonalChat() {
val companionId = selectedUserId
val entityIdText = binding.entityEditText.text?.toString()?.trim()
if (companionId == null) {
binding.errorTextView.text = "Выберите собеседника"
binding.errorTextView.visibility = View.VISIBLE
return
}
val entityId = entityIdText?.toLongOrNull()
val providerId = 994L // From API logs
isCreatingChat = true
viewModel.createPersonalChat(providerId, companionId)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SELECT_USER && resultCode == RESULT_OK) {
selectedUserId = data?.getLongExtra("selected_user_id", -1)
selectedUserName = data?.getStringExtra("selected_user_name")
if (selectedUserId != null && selectedUserId != -1L) {
binding.selectedUserTextView.text = "Выбран: $selectedUserName (ID: $selectedUserId)"
binding.selectedUserTextView.visibility = View.VISIBLE
binding.selectUserButton.text = "Изменить выбор"
}
}
}
private fun createGroupChat() {
val title = binding.groupTitleEditText.text?.toString()?.trim()
if (title.isNullOrBlank()) {
binding.errorTextView.text = "Введите название группы"
binding.errorTextView.visibility = View.VISIBLE
return
}
// Use selectedUserIds if available (from user selection), otherwise parse from text field
val participantIds = selectedUserIds?.toList() ?: run {
val participantsText = binding.participantsEditText.text?.toString()?.trim()
if (participantsText.isNullOrBlank()) {
emptyList()
} else {
try {
participantsText.split(",").map { it.trim().toLong() }
} catch (e: NumberFormatException) {
binding.errorTextView.text = "Некорректные ID участников"
binding.errorTextView.visibility = View.VISIBLE
return
}
}
}
if (participantIds.isEmpty()) {
binding.errorTextView.text = "Не выбраны участники группы"
binding.errorTextView.visibility = View.VISIBLE
return
}
// For pre-selected group chats, don't use entityId (field is hidden)
val entityId = if (selectedUserIds != null && selectedUserIds!!.isNotEmpty()) {
null
} else {
binding.entityEditText.text?.toString()?.trim()?.toLongOrNull()
}
// Use provider ID from HAR file (994)
val providerId = 994L
isCreatingChat = true
viewModel.createGroupChat(providerId, participantIds, title, entityId)
}
private fun createExternalChat() {
val title = binding.externalTitleEditText.text?.toString()?.trim()
val entityIdText = binding.entityEditText.text?.toString()?.trim()
if (title.isNullOrBlank()) {
binding.errorTextView.text = "Введите название чата"
binding.errorTextView.visibility = View.VISIBLE
return
}
val entityId = entityIdText?.toLongOrNull()
isCreatingChat = true
viewModel.createExternalChat(title, entityId)
}
private fun observeViewModel() {
viewModel.chatsState.observe(this) { state ->
when (state) {
is MainViewModel.ChatsState.Loading -> {
if (isCreatingChat) {
binding.progressBar.visibility = View.VISIBLE
binding.createButton.isEnabled = false
binding.errorTextView.visibility = View.GONE
}
}
is MainViewModel.ChatsState.Success -> {
if (isCreatingChat) {
binding.progressBar.visibility = View.GONE
binding.createButton.isEnabled = true
Toast.makeText(this, "Чат успешно создан", Toast.LENGTH_SHORT).show()
finish()
}
}
is MainViewModel.ChatsState.Error -> {
if (isCreatingChat) {
binding.progressBar.visibility = View.GONE
binding.createButton.isEnabled = true
binding.errorTextView.text = state.message
binding.errorTextView.visibility = View.VISIBLE
Toast.makeText(this, state.message, Toast.LENGTH_LONG).show()
// Reset flag on error to allow retry
isCreatingChat = false
}
}
}
}
}
}

View File

@@ -0,0 +1,531 @@
package com.crm.chat.ui.main
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.recyclerview.widget.LinearLayoutManager
import com.crm.chat.R
import com.crm.chat.data.model.Chat
import com.crm.chat.databinding.ActivityMainBinding
import com.crm.chat.ui.auth.AuthActivity
import androidx.core.content.edit
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val viewModel: MainViewModel = MainViewModel.getInstance()
private lateinit var chatAdapter: ChatAdapter
private var currentChats: List<com.crm.chat.data.model.Chat> = emptyList()
// Track if we're waiting for chat creation result
private var isWaitingForChatCreation = false
private var pendingUserIdForChatCreation: Long? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize notification channel
com.crm.chat.utils.NotificationHelper.createNotificationChannel(this)
// Request notification permission on Android 13+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
if (androidx.core.content.ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.POST_NOTIFICATIONS
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
androidx.core.app.ActivityCompat.requestPermissions(
this,
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
)
}
}
// Apply saved theme preference
val prefs: SharedPreferences = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
val savedThemeMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
AppCompatDelegate.setDefaultNightMode(savedThemeMode)
// Check if opened from notification
val openChatId = intent.getLongExtra("open_chat_id", -1)
if (openChatId != -1L) {
openChatFromNotification(openChatId)
}
// Check if user is authenticated
val token = prefs.getString("auth_token", null)
val serverUrl = prefs.getString("server_url", null)
val apiKey = prefs.getString("api_key", null)
if (token.isNullOrEmpty() || serverUrl.isNullOrEmpty() || apiKey.isNullOrEmpty()) {
// Not authenticated, go back to auth
val intent = Intent(this, AuthActivity::class.java)
startActivity(intent)
finish()
return
}
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Initialize chat functionality (this will start polling)
viewModel.initialize(this, serverUrl, token, apiKey)
// Set badge update callback
viewModel.badgeUpdateCallback = { unreadCount, senderName, latestMessage, userAvatar ->
com.crm.chat.utils.NotificationHelper.updateAppIconBadge(this, unreadCount, senderName, latestMessage, userAvatar)
}
// Schedule background sync for when app is not running
com.crm.chat.worker.BackgroundSyncManager.scheduleBackgroundSync(this)
setupViews()
observeViewModel()
// Setup menu button click listener
binding.menuButton.setOnClickListener {
showPopupMenu()
}
// Initial refresh to ensure we have latest data
viewModel.refreshChats()
}
override fun onResume() {
super.onResume()
// Immediately refresh chats when returning to foreground
viewModel.refreshChats()
// Ensure polling is active
viewModel.ensurePollingIsActive()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_USER_SELECTION && resultCode == RESULT_OK && data != null) {
val chatType = data.getStringExtra("chat_type")
when (chatType) {
"personal" -> {
val userId = data.getLongExtra("selected_user_id", -1)
val userName = data.getStringExtra("selected_user_name")
if (userId != -1L) {
createPersonalChat(userId, userName)
}
}
"group" -> {
val userIds = data.getLongArrayExtra("selected_user_ids")
val userNames = data.getStringArrayExtra("selected_user_names")
if (userIds != null && userIds.isNotEmpty() && userNames != null && userNames.isNotEmpty()) {
openCreateChatWithUsers(userIds, userNames)
}
}
}
}
}
private fun setupViews() {
// Setup toolbar
setSupportActionBar(binding.toolbar)
// Setup RecyclerView
chatAdapter = ChatAdapter(
onChatClick = { chat ->
openChat(chat)
},
getUserName = { userId ->
viewModel.getUserName(userId)
},
getUserAvatarUrl = { userId ->
viewModel.getUserAvatarUrl(userId)
}
)
binding.chatsRecyclerView.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = chatAdapter
}
// Add swipe-to-delete functionality
val itemTouchHelper = androidx.recyclerview.widget.ItemTouchHelper(object : androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback(
0, androidx.recyclerview.widget.ItemTouchHelper.LEFT or androidx.recyclerview.widget.ItemTouchHelper.RIGHT
) {
override fun onMove(
recyclerView: androidx.recyclerview.widget.RecyclerView,
viewHolder: androidx.recyclerview.widget.RecyclerView.ViewHolder,
target: androidx.recyclerview.widget.RecyclerView.ViewHolder
): Boolean {
return false // No drag and drop
}
override fun onSwiped(viewHolder: androidx.recyclerview.widget.RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
val chat = chatAdapter.currentList[position]
showDeleteConfirmationDialog(chat)
}
override fun onChildDraw(
c: android.graphics.Canvas,
recyclerView: androidx.recyclerview.widget.RecyclerView,
viewHolder: androidx.recyclerview.widget.RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
val itemView = viewHolder.itemView
val icon = getDrawable(android.R.drawable.ic_delete)
if (icon != null) {
val iconMargin = (itemView.height - icon.intrinsicHeight) / 2
val iconTop = itemView.top + (itemView.height - icon.intrinsicHeight) / 2
val iconBottom = iconTop + icon.intrinsicHeight
if (dX > 0) { // Swiping right
val iconLeft = itemView.left + iconMargin
val iconRight = itemView.left + iconMargin + icon.intrinsicWidth
icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
} else { // Swiping left
val iconRight = itemView.right - iconMargin
val iconLeft = itemView.right - iconMargin - icon.intrinsicWidth
icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
}
icon.draw(c)
}
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
}
})
itemTouchHelper.attachToRecyclerView(binding.chatsRecyclerView)
// Setup swipe refresh
binding.swipeRefreshLayout.setOnRefreshListener {
viewModel.refreshChats()
}
// Setup retry button
binding.retryButton.setOnClickListener {
viewModel.refreshChats()
}
// Setup FAB for creating new chat
binding.createChatFab.setOnClickListener {
openUserSelection()
}
}
private fun observeViewModel() {
viewModel.chatsState.observe(this) { state ->
binding.swipeRefreshLayout.isRefreshing = false
when (state) {
is MainViewModel.ChatsState.Loading -> {
// Only show loading if we're not waiting for chat creation
if (!isWaitingForChatCreation) {
showLoadingState()
}
}
is MainViewModel.ChatsState.Success -> {
if (state.chats.isEmpty()) {
showEmptyState()
} else {
showChats(state.chats)
// Load user names for personal chats
loadUserNamesForChats(state.chats)
// Handle chat creation result
handleChatCreationResult(state.chats)
}
}
is MainViewModel.ChatsState.Error -> {
// Handle chat creation error
if (isWaitingForChatCreation) {
Toast.makeText(this, "Ошибка создания чата: ${state.message}", Toast.LENGTH_LONG).show()
isWaitingForChatCreation = false
pendingUserIdForChatCreation = null
} else {
showErrorState(state.message)
}
}
}
}
// Observe address book state to refresh chat names when user data loads
viewModel.addressBookState.observe(this) { state ->
when (state) {
is MainViewModel.AddressBookState.Success -> {
// User data has loaded, refresh the chat list to show proper names
if (viewModel.chatsState.value is MainViewModel.ChatsState.Success) {
chatAdapter.notifyDataSetChanged()
}
}
else -> {} // Handle other states if needed
}
}
}
private fun handleChatCreationResult(chats: List<com.crm.chat.data.model.Chat>) {
if (isWaitingForChatCreation && pendingUserIdForChatCreation != null) {
// Check if this is a new chat creation (look for recent chat with the user)
val newChat = chats.find { chat ->
chat.type == "personal" && chat.users?.any { it.userId == pendingUserIdForChatCreation } == true
}
if (newChat != null) {
Toast.makeText(this, "Личный чат успешно создан", Toast.LENGTH_SHORT).show()
openChat(newChat)
// Reset flags
isWaitingForChatCreation = false
pendingUserIdForChatCreation = null
}
}
}
private fun loadUserNamesForChats(chats: List<Chat>) {
// User names are now loaded from the address book cache
// No need to load individual users since we have all user data
}
private fun showLoadingState() {
binding.loadingProgressBar.visibility = android.view.View.VISIBLE
binding.chatsRecyclerView.visibility = android.view.View.GONE
binding.emptyStateLayout.visibility = android.view.View.GONE
binding.errorStateLayout.visibility = android.view.View.GONE
}
private fun showChats(chats: List<Chat>) {
binding.loadingProgressBar.visibility = android.view.View.GONE
binding.chatsRecyclerView.visibility = android.view.View.VISIBLE
binding.emptyStateLayout.visibility = android.view.View.GONE
binding.errorStateLayout.visibility = android.view.View.GONE
// Simply submit the list - DiffUtil will handle efficient updates
currentChats = chats.toList()
chatAdapter.submitList(currentChats)
}
private fun hasChatContentChanged(oldChat: Chat, newChat: Chat): Boolean {
return oldChat.unseenCount != newChat.unseenCount ||
oldChat.lastMessage?.id != newChat.lastMessage?.id ||
oldChat.lastMessage?.text != newChat.lastMessage?.text ||
oldChat.lastMessage?.createdAt != newChat.lastMessage?.createdAt
}
private fun showEmptyState() {
binding.loadingProgressBar.visibility = android.view.View.GONE
binding.chatsRecyclerView.visibility = android.view.View.GONE
binding.emptyStateLayout.visibility = android.view.View.VISIBLE
binding.errorStateLayout.visibility = android.view.View.GONE
}
private fun showErrorState(message: String) {
binding.loadingProgressBar.visibility = android.view.View.GONE
binding.chatsRecyclerView.visibility = android.view.View.GONE
binding.emptyStateLayout.visibility = android.view.View.GONE
binding.errorStateLayout.visibility = android.view.View.VISIBLE
binding.errorTextView.text = message
}
private fun openChat(chat: Chat) {
val intent = Intent(this, com.crm.chat.ui.chat.ChatActivity::class.java)
intent.putExtra("chat_id", chat.id)
startActivity(intent)
}
private fun openUserSelection() {
val intent = Intent(this, UserSelectionActivity::class.java)
startActivityForResult(intent, REQUEST_USER_SELECTION)
}
private fun openCreateChat() {
val intent = Intent(this, CreateChatActivity::class.java)
startActivity(intent)
}
private fun toggleTheme() {
// Read current theme preference
val prefs = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
val currentMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
// Toggle between light and dark
val newNightMode = when (currentMode) {
AppCompatDelegate.MODE_NIGHT_YES -> AppCompatDelegate.MODE_NIGHT_NO
else -> AppCompatDelegate.MODE_NIGHT_YES
}
// Apply new theme
AppCompatDelegate.setDefaultNightMode(newNightMode)
// Save the preference
prefs.edit { putInt("theme_mode", newNightMode) }
// Recreate activity to apply theme change immediately
recreate()
}
private fun createPersonalChat(userId: Long, userName: String?) {
// First check if chat already exists
val existingChat = currentChats.find { chat ->
chat.type == "personal" && chat.users?.any { it.userId == userId } == true
}
if (existingChat != null) {
// Chat already exists, just open it
Toast.makeText(this, "Открыт существующий чат", Toast.LENGTH_SHORT).show()
openChat(existingChat)
return
}
// Create personal chat directly using the API
// Use provider ID from HAR file (994)
viewModel.createPersonalChat(994L, userId)
// Set flags to track chat creation
isWaitingForChatCreation = true
pendingUserIdForChatCreation = userId
// Show loading state immediately
showLoadingState()
}
private fun openCreateChatWithUsers(userIds: LongArray, userNames: Array<String>) {
val intent = Intent(this, CreateChatActivity::class.java).apply {
putExtra("selected_user_ids", userIds)
putExtra("selected_user_names", userNames)
putExtra("chat_type", "group")
}
startActivity(intent)
}
private fun showDeleteConfirmationDialog(chat: com.crm.chat.data.model.Chat) {
val chatName = when (chat.type) {
"personal" -> {
val otherUser = chat.users?.find { it.userId != getCurrentUserId() }
if (otherUser != null) {
viewModel.getUserName(otherUser.userId)
} else {
"Личный чат"
}
}
"group" -> chat.title ?: "Групповой чат"
else -> chat.title ?: "Чат"
}
androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("Удалить чат")
.setMessage("Вы действительно хотите удалить чат \"$chatName\"? Это действие нельзя отменить.")
.setPositiveButton("Удалить") { _, _ ->
deleteChat(chat)
}
.setNegativeButton("Отмена") { dialog, _ ->
dialog.dismiss()
// Refresh the list to restore the swiped item
chatAdapter.notifyDataSetChanged()
}
.setOnCancelListener {
// Refresh the list to restore the swiped item
chatAdapter.notifyDataSetChanged()
}
.show()
}
private fun deleteChat(chat: com.crm.chat.data.model.Chat) {
viewModel.deleteChat(chat.id)
Toast.makeText(this, "Чат удален", Toast.LENGTH_SHORT).show()
}
private fun getCurrentUserId(): Long {
val prefs = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
return prefs.getLong("current_user_id", 12033923L) // Default from HAR file
}
private fun showPopupMenu() {
val popupMenu = androidx.appcompat.widget.PopupMenu(this, binding.menuButton)
popupMenu.menuInflater.inflate(R.menu.main_menu, popupMenu.menu)
popupMenu.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.menu_profile -> {
openProfile()
true
}
R.id.menu_settings -> {
openSettings()
true
}
R.id.menu_logout -> {
showLogoutConfirmation()
true
}
else -> false
}
}
popupMenu.show()
}
private fun openProfile() {
val intent = Intent(this, com.crm.chat.ui.profile.ProfileActivity::class.java)
startActivity(intent)
}
private fun openSettings() {
val intent = Intent(this, com.crm.chat.ui.settings.SettingsActivity::class.java)
startActivity(intent)
}
private fun showLogoutConfirmation() {
androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("Выход")
.setMessage("Вы действительно хотите выйти из аккаунта?")
.setPositiveButton("Выйти") { _, _ ->
logout()
}
.setNegativeButton("Отмена", null)
.show()
}
private fun logout() {
// Clear authentication data
val prefs = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
prefs.edit()
.remove("auth_token")
.remove("server_url")
.remove("api_key")
.remove("current_user_id")
.apply()
// Stop background sync
com.crm.chat.worker.BackgroundSyncManager.cancelBackgroundSync(this)
// Navigate back to auth activity
val intent = Intent(this, AuthActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
}
private fun openChatFromNotification(chatId: Long) {
// Wait for chats to load, then open the specific chat
viewModel.chatsState.observe(this) { state ->
if (state is MainViewModel.ChatsState.Success) {
val chat = state.chats.find { it.id == chatId }
if (chat != null) {
openChat(chat)
// Remove observer after opening
viewModel.chatsState.removeObservers(this)
}
}
}
}
companion object {
private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1001
private const val REQUEST_USER_SELECTION = 1002
}
}

View File

@@ -0,0 +1,739 @@
package com.crm.chat.ui.main
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.crm.chat.data.api.ApiClient
import com.crm.chat.data.model.*
import com.crm.chat.data.repository.ChatRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainViewModel : ViewModel() {
companion object {
private var instance: MainViewModel? = null
fun getInstance(): MainViewModel {
if (instance == null) {
instance = MainViewModel()
println("DEBUG: Created new MainViewModel singleton instance")
} else {
println("DEBUG: Reusing existing MainViewModel singleton instance")
}
return instance!!
}
}
private val _chatsState = MutableLiveData<ChatsState>()
val chatsState: LiveData<ChatsState> = _chatsState
private var lastChatsData: List<com.crm.chat.data.model.Chat>? = null
private val _providersState = MutableLiveData<ProvidersState>()
val providersState: LiveData<ProvidersState> = _providersState
private val _unseenCountState = MutableLiveData<UnseenCountState>()
val unseenCountState: LiveData<UnseenCountState> = _unseenCountState
private lateinit var apiClient: ApiClient
private lateinit var chatRepository: ChatRepository
private lateinit var applicationContext: android.content.Context
// User cache for displaying names in chats
private val userCache = mutableMapOf<Long, com.crm.chat.data.api.User>()
// Address book - all users
private val _addressBookState = MutableLiveData<AddressBookState>()
val addressBookState: LiveData<AddressBookState> = _addressBookState
private var lastAddressBookData: List<com.crm.chat.data.api.User>? = null
// Polling job tracking
private var pollingJob: kotlinx.coroutines.Job? = null
var badgeUpdateCallback: ((Int, String?, String?, android.graphics.Bitmap?) -> Unit)? = null
fun loadChats() {
println("DEBUG: MainViewModel.loadChats() called")
viewModelScope.launch {
try {
val result = chatRepository.getChats()
result.fold(
onSuccess = { chats ->
println("DEBUG: MainViewModel loaded ${chats.size} chats")
// Sort chats by last message date (newest first)
val sortedChats = chats.sortedByDescending { chat ->
chat.lastMessage?.createdAt ?: ""
}
// Only update if data has changed
val dataChanged = hasChatsDataChanged(sortedChats)
println("DEBUG: loadChats - dataChanged: $dataChanged, oldSize: ${lastChatsData?.size}, newSize: ${sortedChats.size}")
if (dataChanged) {
println("DEBUG: loadChats - updating UI with ${sortedChats.size} chats")
_chatsState.value = ChatsState.Success(sortedChats)
lastChatsData = sortedChats.toList()
// Update app icon badge with total unread count
// Delay badge update slightly to ensure address book is loaded
kotlinx.coroutines.delay(100)
updateAppIconBadge(sortedChats.sumOf { it.unseenCount })
} else {
println("DEBUG: loadChats - no changes detected")
}
},
onFailure = { exception ->
println("DEBUG: MainViewModel failed to load chats: ${exception.message}")
// Only set error state if we're not already in an error state
if (_chatsState.value !is ChatsState.Error) {
_chatsState.value = ChatsState.Error(exception.message ?: "Ошибка загрузки чатов")
}
}
)
} catch (e: Exception) {
println("DEBUG: MainViewModel exception in loadChats: ${e.message}")
// Only set error state if we're not already in an error state
if (_chatsState.value !is ChatsState.Error) {
_chatsState.value = ChatsState.Error("Неожиданная ошибка: ${e.message}")
}
}
}
}
private fun hasChatsDataChanged(newChats: List<com.crm.chat.data.model.Chat>): Boolean {
val oldChats = lastChatsData ?: run {
println("DEBUG: hasChatsDataChanged - first load (oldChats is null)")
return true // First load
}
println("DEBUG: hasChatsDataChanged - comparing oldSize=${oldChats.size} vs newSize=${newChats.size}")
// Check if sizes differ (covers additions and deletions)
if (oldChats.size != newChats.size) {
println("DEBUG: hasChatsDataChanged - size changed from ${oldChats.size} to ${newChats.size}")
return true
}
// Check if any chat data has changed
for (newChat in newChats) {
val oldChat = oldChats.find { it.id == newChat.id } ?: run {
println("DEBUG: hasChatsDataChanged - new chat ${newChat.id} not found in old chats (addition)")
return true
}
if (hasChatChanged(oldChat, newChat)) {
println("DEBUG: hasChatsDataChanged - chat ${newChat.id} has changed")
return true
}
}
// Check if any old chats are missing from new chats (deletions)
for (oldChat in oldChats) {
val newChat = newChats.find { it.id == oldChat.id }
if (newChat == null) {
println("DEBUG: hasChatsDataChanged - old chat ${oldChat.id} missing from new chats (deletion)")
return true // Chat was deleted
}
}
println("DEBUG: hasChatsDataChanged - no changes detected")
return false
}
private fun hasChatChanged(oldChat: com.crm.chat.data.model.Chat, newChat: com.crm.chat.data.model.Chat): Boolean {
// Compare properties that affect chat display
if (oldChat.unseenCount != newChat.unseenCount) return true
if (oldChat.title != newChat.title) return true
if (oldChat.type != newChat.type) return true
// Compare last message content (but not timestamps that change on every poll)
val oldMsg = oldChat.lastMessage
val newMsg = newChat.lastMessage
if ((oldMsg == null) != (newMsg == null)) return true // One has message, other doesn't
if (oldMsg != null && newMsg != null) {
if (oldMsg.id != newMsg.id) return true
if (oldMsg.text != newMsg.text) return true
if (oldMsg.chatUserId != newMsg.chatUserId) return true
// Note: We don't compare createdAt as it may change on every poll
}
return false
}
fun loadProviders() {
_providersState.value = ProvidersState.Loading
viewModelScope.launch {
try {
val result = chatRepository.getChatProviders()
result.fold(
onSuccess = { providers ->
_providersState.value = ProvidersState.Success(providers)
},
onFailure = { exception ->
_providersState.value = ProvidersState.Error(exception.message ?: "Ошибка загрузки провайдеров")
}
)
} catch (e: Exception) {
_providersState.value = ProvidersState.Error("Неожиданная ошибка: ${e.message}")
}
}
}
fun loadUnseenCount() {
_unseenCountState.value = UnseenCountState.Loading
viewModelScope.launch {
try {
val result = chatRepository.getUnseenCount()
result.fold(
onSuccess = { count ->
_unseenCountState.value = UnseenCountState.Success(count)
},
onFailure = { exception ->
_unseenCountState.value = UnseenCountState.Error(exception.message ?: "Ошибка загрузки счетчика")
}
)
} catch (e: Exception) {
_unseenCountState.value = UnseenCountState.Error("Неожиданная ошибка: ${e.message}")
}
}
}
fun deleteChat(chatId: Long) {
viewModelScope.launch {
try {
val result = chatRepository.deleteChat(chatId)
result.fold(
onSuccess = {
println("DEBUG: Chat deleted successfully, forcing complete refresh")
// Clear cached data to force UI update
lastChatsData = null
// Stop current polling
pollingJob?.cancel()
pollingJob = null
// Force immediate refresh
loadChats()
// Restart polling to get fresh data
startForegroundPolling()
},
onFailure = { exception ->
_chatsState.value = ChatsState.Error(exception.message ?: "Ошибка удаления чата")
}
)
} catch (e: Exception) {
_chatsState.value = ChatsState.Error("Неожиданная ошибка: ${e.message}")
}
}
}
fun createPersonalChat(providerId: Long, companionId: Long) {
viewModelScope.launch {
try {
// First check if personal chat already exists with this user
val existingChat = lastChatsData?.find { chat ->
chat.type == "personal" && chat.users?.any { user ->
user.userId == companionId
} == true
}
if (existingChat != null) {
// Chat already exists, just return it
_chatsState.value = ChatsState.Success(lastChatsData ?: emptyList())
return@launch
}
// Create new chat if it doesn't exist
val request = CreatePersonalChatRequest(providerId, companionId)
val result = chatRepository.createPersonalChat(request)
result.fold(
onSuccess = { chat ->
// Refresh chats list
loadChats()
},
onFailure = { exception ->
_chatsState.value = ChatsState.Error(exception.message ?: "Ошибка создания личного чата")
}
)
} catch (e: Exception) {
_chatsState.value = ChatsState.Error("Неожиданная ошибка: ${e.message}")
}
}
}
fun createGroupChat(providerId: Long, participantIds: List<Long>, title: String, entityId: Long? = null) {
val request = CreateGroupChatRequest(providerId, participantIds, title, entityId)
viewModelScope.launch {
try {
val result = chatRepository.createGroupChat(request)
result.fold(
onSuccess = { chat ->
// Refresh chats list
loadChats()
},
onFailure = { exception ->
_chatsState.value = ChatsState.Error(exception.message ?: "Ошибка создания группового чата")
}
)
} catch (e: Exception) {
_chatsState.value = ChatsState.Error("Неожиданная ошибка: ${e.message}")
}
}
}
fun createExternalChat(title: String, entityId: Long? = null, externalUser: ChatUserExternal? = null) {
val request = CreateExternalChatRequest(title, entityId, externalUser)
viewModelScope.launch {
try {
val result = chatRepository.createExternalChat(request)
result.fold(
onSuccess = { chat ->
// Refresh chats list
loadChats()
},
onFailure = { exception ->
_chatsState.value = ChatsState.Error(exception.message ?: "Ошибка создания внешнего чата")
}
)
} catch (e: Exception) {
_chatsState.value = ChatsState.Error("Неожиданная ошибка: ${e.message}")
}
}
}
fun searchChats(query: String) {
if (query.isBlank()) {
loadChats() // If empty query, just reload all chats
return
}
val filters = mapOf("search" to query)
_chatsState.value = ChatsState.Loading
viewModelScope.launch {
try {
val result = chatRepository.findChatsFull(filters)
result.fold(
onSuccess = { chats ->
_chatsState.value = ChatsState.Success(chats)
},
onFailure = { exception ->
_chatsState.value = ChatsState.Error(exception.message ?: "Ошибка поиска чатов")
}
)
} catch (e: Exception) {
_chatsState.value = ChatsState.Error("Неожиданная ошибка: ${e.message}")
}
}
}
fun initialize(context: android.content.Context, serverUrl: String, token: String, apiKey: String) {
// Store application context
applicationContext = context.applicationContext
// Create API client with authentication token
apiClient = ApiClient(serverUrl, apiKey, token)
chatRepository = ChatRepository(apiClient)
// Load address book first, then chats and background sync
loadAddressBookFirst()
}
private fun loadAddressBookFirst() {
println("DEBUG: Loading address book first")
viewModelScope.launch {
try {
val result = chatRepository.getUsers(null) // Load all users without search filter
result.fold(
onSuccess = { users ->
// Cache all users and avatars asynchronously
users.forEach { user ->
cacheUser(user)
// Cache avatar asynchronously to avoid blocking UI
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
try {
com.crm.chat.utils.NotificationHelper.cacheUserAvatar(applicationContext, user)
} catch (e: Exception) {
println("DEBUG: Error caching avatar for user ${user.id}: ${e.message}")
}
}
}
_addressBookState.value = AddressBookState.Success(users)
lastAddressBookData = users.toList()
println("DEBUG: Address book loaded first, now loading chats")
// Now load chats and start polling
loadChats()
startForegroundPolling()
},
onFailure = { exception ->
_addressBookState.value = AddressBookState.Error(exception.message ?: "Ошибка загрузки адресной книги")
println("DEBUG: Address book load failed: ${exception.message}")
// Still try to load chats even if address book fails
loadChats()
startForegroundPolling()
}
)
} catch (e: Exception) {
_addressBookState.value = AddressBookState.Error("Неожиданная ошибка: ${e.message}")
// Still try to load chats even if address book fails
loadChats()
startForegroundPolling()
}
}
}
fun ensurePollingIsActive() {
// Check if polling coroutine is still active, restart if needed
if (pollingJob == null || !pollingJob!!.isActive) {
println("DEBUG: Restarting chat polling")
startForegroundPolling()
}
}
private fun startForegroundPolling() {
println("DEBUG: MainViewModel.startForegroundPolling() called")
pollingJob = viewModelScope.launch {
println("DEBUG: MainViewModel polling job started")
// Address book polling every 10 seconds
launch {
while (true) {
kotlinx.coroutines.delay(10000) // 10 seconds
println("DEBUG: MainViewModel address book polling tick")
loadAddressBook()
}
}
// Chats polling every 1 second
launch {
while (true) {
kotlinx.coroutines.delay(1000) // 1 second
println("DEBUG: MainViewModel chats polling tick")
loadChats()
}
}
}
}
fun refreshChats() {
// Force immediate refresh by clearing cached data
lastChatsData = null
loadChats()
}
fun notifyChatUpdated(chatId: Long) {
// Trigger an immediate refresh of chats when a specific chat is updated
loadChats()
}
fun getUsers(search: String? = null): androidx.lifecycle.LiveData<Result<List<com.crm.chat.data.api.User>>> {
val liveData = androidx.lifecycle.MutableLiveData<Result<List<com.crm.chat.data.api.User>>>()
viewModelScope.launch {
try {
val result = chatRepository.getUsers(search)
liveData.value = result
} catch (e: Exception) {
liveData.value = Result.failure(e)
}
}
return liveData
}
fun getUserName(userId: Long): String {
val user = userCache[userId]
val result = if (user != null) {
"${user.lastName} ${user.firstName}"
} else {
"User $userId"
}
return result
}
fun getUserAvatarUrl(userId: Long): String? {
return userCache[userId]?.avatarUrl
}
fun cacheUser(user: com.crm.chat.data.api.User) {
userCache[user.id] = user
}
fun loadAddressBook() {
println("DEBUG: Starting address book load")
viewModelScope.launch {
try {
val result = chatRepository.getUsers(null) // Load all users without search filter
result.fold(
onSuccess = { users ->
// Only update if data has changed
if (hasAddressBookDataChanged(users)) {
// Cache all users for future use
users.forEach { user ->
cacheUser(user)
// Cache user avatar for notifications
com.crm.chat.utils.NotificationHelper.cacheUserAvatar(applicationContext, user)
}
_addressBookState.value = AddressBookState.Success(users)
lastAddressBookData = users.toList()
println("DEBUG: Address book loaded successfully with ${users.size} users")
}
// Notify that user data has been updated (chat names will refresh automatically)
// Don't refresh the entire chat list, just the display names
},
onFailure = { exception ->
// Only set error state if we're not already in an error state
if (_addressBookState.value !is AddressBookState.Error) {
_addressBookState.value = AddressBookState.Error(exception.message ?: "Ошибка загрузки адресной книги")
println("DEBUG: Address book load failed: ${exception.message}")
}
}
)
} catch (e: Exception) {
// Only set error state if we're not already in an error state
if (_addressBookState.value !is AddressBookState.Error) {
_addressBookState.value = AddressBookState.Error("Неожиданная ошибка: ${e.message}")
println("DEBUG: Address book load exception: ${e.message}")
}
}
}
}
private fun hasAddressBookDataChanged(newUsers: List<com.crm.chat.data.api.User>): Boolean {
val oldUsers = lastAddressBookData ?: return true // First load
// Check if sizes differ
if (oldUsers.size != newUsers.size) return true
// Check if any user data has changed
for (newUser in newUsers) {
val oldUser = oldUsers.find { it.id == newUser.id } ?: return true
if (hasUserChanged(oldUser, newUser)) return true
}
return false
}
private fun hasUserChanged(oldUser: com.crm.chat.data.api.User, newUser: com.crm.chat.data.api.User): Boolean {
return oldUser.firstName != newUser.firstName ||
oldUser.lastName != newUser.lastName ||
oldUser.avatarUrl != newUser.avatarUrl
}
private fun updateAppIconBadge(unreadCount: Int) {
// Only show detailed notifications after address book is loaded
if (userCache.isNotEmpty() && unreadCount > 0) {
val (senderName, latestMessage, senderAvatar) = findLatestMessageWithAvatar()
badgeUpdateCallback?.invoke(unreadCount, senderName, latestMessage, senderAvatar)
} else {
// Show basic notification if address book not loaded yet
badgeUpdateCallback?.invoke(unreadCount, null, null, null)
}
}
private fun findLatestMessageWithAvatar(): Triple<String?, String?, android.graphics.Bitmap?> {
val chats = lastChatsData ?: return Triple(null, null, null)
// Find the chat with the most recent message
val chatWithLatestMessage = chats
.filter { it.lastMessage != null }
.maxByOrNull { it.lastMessage?.createdAt ?: "" }
return chatWithLatestMessage?.let { chat ->
val message = chat.lastMessage ?: return@let Triple(null, null, null)
// Map chatUserId to actual userId using chat.users if available
val senderUserId = chat.users?.find { it.id == message.chatUserId }?.userId ?: message.chatUserId
val senderName = getUserName(senderUserId)
val messageText = message.text ?: "Новое сообщение"
// Create chat avatar (not sender avatar)
val chatAvatar = createChatAvatar(chat, senderUserId, senderName)
Triple(senderName, messageText, chatAvatar)
} ?: Triple(null, null, null)
}
private fun createChatAvatar(chat: com.crm.chat.data.model.Chat, senderUserId: Long, senderName: String): android.graphics.Bitmap? {
return when {
// For personal chats, show the OTHER user's avatar (not the sender)
chat.type == "personal" && chat.users != null && chat.users.size >= 2 -> {
val currentUserId = applicationContext.getSharedPreferences("crm_chat_prefs", android.content.Context.MODE_PRIVATE)
.getLong("current_user_id", 12033923L)
val otherUser = chat.users.find { it.userId != currentUserId }
if (otherUser != null) {
createUserAvatar(otherUser.userId, getUserName(otherUser.userId))
} else {
createUserAvatar(senderUserId, senderName) // fallback
}
}
// For group chats, use sender avatar or create group avatar
chat.title != null -> {
// Group chat - could create a group avatar here, but for now use sender
createUserAvatar(senderUserId, senderName)
}
// Default fallback
else -> {
createUserAvatar(senderUserId, senderName)
}
}
}
private fun createUserAvatar(userId: Long, userName: String): android.graphics.Bitmap? {
// First try to get cached avatar from NotificationHelper
val cacheDir = applicationContext.cacheDir
val cacheFile = java.io.File(cacheDir, "user_avatars/avatar_$userId.png")
println("DEBUG: MainViewModel.createUserAvatar - Looking for cached avatar at: ${cacheFile.absolutePath}, exists: ${cacheFile.exists()}")
if (cacheFile.exists()) {
println("DEBUG: Cache file exists, size: ${cacheFile.length()}")
return try {
val cachedBitmap = android.graphics.BitmapFactory.decodeFile(cacheFile.absolutePath)
println("DEBUG: Loaded cached bitmap for user $userId: ${cachedBitmap != null}")
if (cachedBitmap != null) {
println("DEBUG: Bitmap size: ${cachedBitmap.width}x${cachedBitmap.height}")
// Resize to notification size and make circular
val circularAvatar = createCircularAvatar(cachedBitmap, 192)
println("DEBUG: Created circular avatar for user $userId: ${circularAvatar != null}")
circularAvatar
} else {
println("DEBUG: Bitmap decode returned null")
createGeneratedAvatar(userId, userName)
}
} catch (e: Exception) {
println("DEBUG: Exception loading cached avatar for user $userId: ${e.message}")
e.printStackTrace()
createGeneratedAvatar(userId, userName)
}
}
// If no cached avatar, create generated one
println("DEBUG: No cached avatar for user $userId, using generated avatar")
return createGeneratedAvatar(userId, userName)
}
private fun createGeneratedAvatar(userId: Long, userName: String): android.graphics.Bitmap? {
// Generate avatar with user initials
return try {
val size = 192
val bitmap = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bitmap)
// Get initials from full name (Фамилия Имя)
val initials = userName.split(" ").let { parts ->
when (parts.size) {
1 -> parts[0].take(1).uppercase()
2 -> "${parts[0].take(1)}${parts[1].take(1)}".uppercase()
else -> parts.firstOrNull()?.take(1)?.uppercase() ?: "U"
}
}
// Generate color based on user ID
val colors = arrayOf(
android.graphics.Color.parseColor("#E53935"),
android.graphics.Color.parseColor("#1E88E5"),
android.graphics.Color.parseColor("#43A047"),
android.graphics.Color.parseColor("#FB8C00"),
android.graphics.Color.parseColor("#8E24AA")
)
val backgroundColor = colors[(userId % colors.size).toInt()]
// Draw circle background
val paint = android.graphics.Paint().apply {
color = backgroundColor
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
}
val rect = android.graphics.RectF(0f, 0f, size.toFloat(), size.toFloat())
canvas.drawOval(rect, paint)
// Draw initials text
paint.apply {
color = android.graphics.Color.WHITE
textSize = size * 0.4f
textAlign = android.graphics.Paint.Align.CENTER
style = android.graphics.Paint.Style.FILL
}
val xPos = size / 2f
val yPos = size / 2f - (paint.descent() + paint.ascent()) / 2f
canvas.drawText(initials, xPos, yPos, paint)
bitmap
} catch (e: Exception) {
null // Return null if avatar creation fails
}
}
private fun createCircularAvatar(sourceBitmap: android.graphics.Bitmap, diameter: Int): android.graphics.Bitmap {
val output = android.graphics.Bitmap.createBitmap(diameter, diameter, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(output)
val paint = android.graphics.Paint().apply {
isAntiAlias = true
}
// Calculate scaling to fit the bitmap in the circle
val minEdge = minOf(sourceBitmap.width, sourceBitmap.height)
val scale = diameter.toFloat() / minEdge
val matrix = android.graphics.Matrix()
matrix.postScale(scale, scale)
// Center the bitmap
val xOffset = (diameter - sourceBitmap.width * scale) / 2
val yOffset = (diameter - sourceBitmap.height * scale) / 2
matrix.postTranslate(xOffset, yOffset)
// Create circular mask
val radius = diameter / 2f
canvas.drawCircle(radius, radius, radius, paint)
// Apply Porter-Duff mode to clip the bitmap to circle
paint.xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.SRC_IN)
canvas.drawBitmap(sourceBitmap, matrix, paint)
sourceBitmap.recycle()
return output
}
fun updateApiClient(serverUrl: String, token: String) {
val updatedApiClient = ApiClient(serverUrl, com.crm.chat.data.AppConstants.API_KEY)
// Note: We need to create a new ChatRepository with the updated ApiClient
// This is a limitation of the current architecture
// In a real app, we would use dependency injection to handle this properly
}
sealed class ChatsState {
object Loading : ChatsState()
data class Success(val chats: List<Chat>) : ChatsState()
data class Error(val message: String) : ChatsState()
}
sealed class ProvidersState {
object Loading : ProvidersState()
data class Success(val providers: List<ChatProvider>) : ProvidersState()
data class Error(val message: String) : ProvidersState()
}
sealed class UnseenCountState {
object Loading : UnseenCountState()
data class Success(val count: Int) : UnseenCountState()
data class Error(val message: String) : UnseenCountState()
}
sealed class AddressBookState {
object Loading : AddressBookState()
data class Success(val users: List<com.crm.chat.data.api.User>) : AddressBookState()
data class Error(val message: String) : AddressBookState()
}
}

View File

@@ -0,0 +1,377 @@
package com.crm.chat.ui.main
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.core.widget.doOnTextChanged
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.crm.chat.R
import com.crm.chat.data.api.User
import com.crm.chat.data.repository.ChatRepository
import com.crm.chat.databinding.ActivityUserSelectionBinding
import com.crm.chat.databinding.ItemUserBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class UserSelectionActivity : AppCompatActivity() {
private lateinit var binding: ActivityUserSelectionBinding
private val viewModel: MainViewModel by viewModels()
private lateinit var userAdapter: UserAdapter
private var searchJob: Job? = null
private val selectedUsers = mutableListOf<User>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityUserSelectionBinding.inflate(layoutInflater)
setContentView(binding.root)
// Initialize with auth data
val prefs = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
val serverUrl = prefs.getString("server_url", "") ?: ""
val token = prefs.getString("auth_token", "") ?: ""
val apiKey = prefs.getString("api_key", "") ?: ""
if (token.isNotEmpty() && serverUrl.isNotEmpty()) {
viewModel.initialize(this, serverUrl, token, apiKey)
}
setupViews()
loadUsers()
}
private fun setupViews() {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = "Выберите пользователей"
// Add Done button to toolbar
binding.toolbar.inflateMenu(R.menu.menu_user_selection)
binding.toolbar.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.action_done -> {
onDoneClicked()
true
}
else -> false
}
}
// Update toolbar subtitle with selection count
updateToolbarSubtitle()
// Setup RecyclerView
userAdapter = UserAdapter(
onUserClick = { user -> toggleUserSelection(user) },
isUserSelected = { user -> selectedUsers.contains(user) }
)
binding.usersRecyclerView.apply {
layoutManager = LinearLayoutManager(this@UserSelectionActivity)
adapter = userAdapter
}
// Setup search
binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
searchUsers(query)
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
// Debounce search
searchJob?.cancel()
searchJob = CoroutineScope(Dispatchers.Main).launch {
delay(300)
searchUsers(newText)
}
return true
}
})
// Clear search on back press
binding.searchView.setOnCloseListener {
loadUsers()
false
}
// Setup done button at the bottom
binding.doneButton.setOnClickListener {
onDoneClicked()
}
// Update button visibility based on selection
updateDoneButtonVisibility()
}
private fun loadUsers() {
binding.progressBar.visibility = android.view.View.VISIBLE
binding.emptyTextView.visibility = android.view.View.GONE
CoroutineScope(Dispatchers.Main).launch {
viewModel.getUsers(null).observe(this@UserSelectionActivity) { result ->
binding.progressBar.visibility = android.view.View.GONE
result.fold(
onSuccess = { users ->
if (users.isEmpty()) {
binding.emptyTextView.visibility = android.view.View.VISIBLE
binding.emptyTextView.text = "Пользователи не найдены"
} else {
binding.emptyTextView.visibility = android.view.View.GONE
userAdapter.submitList(users)
}
},
onFailure = { exception ->
binding.emptyTextView.visibility = android.view.View.VISIBLE
binding.emptyTextView.text = "Ошибка загрузки пользователей"
Toast.makeText(this@UserSelectionActivity, exception.message, Toast.LENGTH_SHORT).show()
}
)
}
}
}
private fun searchUsers(query: String?) {
if (query.isNullOrBlank()) {
loadUsers()
return
}
binding.progressBar.visibility = android.view.View.VISIBLE
binding.emptyTextView.visibility = android.view.View.GONE
CoroutineScope(Dispatchers.Main).launch {
viewModel.getUsers(query).observe(this@UserSelectionActivity) { result ->
binding.progressBar.visibility = android.view.View.GONE
result.fold(
onSuccess = { users ->
if (users.isEmpty()) {
binding.emptyTextView.visibility = android.view.View.VISIBLE
binding.emptyTextView.text = "Пользователи не найдены по запросу \"$query\""
} else {
binding.emptyTextView.visibility = android.view.View.GONE
userAdapter.submitList(users)
}
},
onFailure = { exception ->
binding.emptyTextView.visibility = android.view.View.VISIBLE
binding.emptyTextView.text = "Ошибка поиска"
Toast.makeText(this@UserSelectionActivity, exception.message, Toast.LENGTH_SHORT).show()
}
)
}
}
}
private fun toggleUserSelection(user: User) {
if (selectedUsers.contains(user)) {
selectedUsers.remove(user)
} else {
selectedUsers.add(user)
}
updateToolbarSubtitle()
updateDoneButtonVisibility()
userAdapter.notifyDataSetChanged()
}
private fun updateToolbarSubtitle() {
val count = selectedUsers.size
supportActionBar?.subtitle = when {
count == 0 -> "Никто не выбран"
count == 1 -> "Выбран 1 пользователь"
count < 5 -> "Выбрано $count пользователя"
else -> "Выбрано $count пользователей"
}
updateDoneButtonVisibility()
}
private fun updateDoneButtonVisibility() {
val count = selectedUsers.size
if (count > 0) {
binding.doneButton.visibility = android.view.View.VISIBLE
binding.doneButton.text = when {
count == 1 -> "Создать личный чат"
else -> "Создать групповой чат ($count)"
}
} else {
binding.doneButton.visibility = android.view.View.GONE
}
}
private fun onDoneClicked() {
when (selectedUsers.size) {
0 -> {
Toast.makeText(this, "Выберите хотя бы одного пользователя", Toast.LENGTH_SHORT).show()
}
1 -> {
// Single user selection - return immediately for personal chat
val user = selectedUsers[0]
val resultIntent = Intent().apply {
putExtra("selected_user_id", user.id)
putExtra("selected_user_name", user.name)
putExtra("chat_type", "personal")
}
setResult(Activity.RESULT_OK, resultIntent)
finish()
}
else -> {
// Multiple users selection - return for group chat
val userIds = selectedUsers.map { it.id }.toLongArray()
val userNames = selectedUsers.map { it.name }.toTypedArray()
val resultIntent = Intent().apply {
putExtra("selected_user_ids", userIds)
putExtra("selected_user_names", userNames)
putExtra("chat_type", "group")
}
setResult(Activity.RESULT_OK, resultIntent)
finish()
}
}
}
override fun onSupportNavigateUp(): Boolean {
setResult(Activity.RESULT_CANCELED)
onBackPressedDispatcher.onBackPressed()
return true
}
class UserAdapter(
private val onUserClick: (User) -> Unit,
private val isUserSelected: (User) -> Boolean
) : ListAdapter<User, UserAdapter.UserViewHolder>(UserDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
val binding = ItemUserBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return UserViewHolder(binding, onUserClick, isUserSelected)
}
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
holder.bind(getItem(position))
}
class UserViewHolder(
private val binding: ItemUserBinding,
private val onUserClick: (User) -> Unit,
private val isUserSelected: (User) -> Boolean
) : RecyclerView.ViewHolder(binding.root) {
fun bind(user: User) {
binding.userNameTextView.text = user.name
binding.userIdTextView.text = "ID: ${user.id}"
binding.userEmailTextView.text = user.email ?: ""
// Load user avatar or show initials
if (!user.avatarUrl.isNullOrBlank()) {
Glide.with(binding.userAvatarImageView)
.load(user.avatarUrl)
.circleCrop()
.placeholder(android.R.drawable.ic_menu_camera)
.error(createInitialsBitmap(user.name))
.into(binding.userAvatarImageView)
} else {
binding.userAvatarImageView.setImageBitmap(createInitialsBitmap(user.name))
}
// Show selection state
binding.root.isSelected = isUserSelected(user)
binding.root.setBackgroundResource(
if (isUserSelected(user)) R.color.selected_item_background
else android.R.color.transparent
)
binding.root.setOnClickListener {
onUserClick(user)
}
}
private fun createInitialsBitmap(userName: String): android.graphics.Bitmap {
// Generate initials from user name
val initials = userName.split(" ").let { parts ->
when (parts.size) {
1 -> parts[0].take(1).uppercase()
2 -> "${parts[0].take(1)}${parts[1].take(1)}".uppercase()
else -> parts.firstOrNull()?.take(1)?.uppercase() ?: "U"
}
}
// Get user color based on user ID (assuming we have access to user ID)
val userId = userName.hashCode().toLong() // Simple hash for color consistency
val backgroundColor = getUserColor(userId)
// Create circular bitmap with initials
val size = 192
val bitmap = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bitmap)
// Draw circle background
val circlePaint = android.graphics.Paint().apply {
color = backgroundColor
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
}
canvas.drawCircle(size / 2f, size / 2f, size / 2f, circlePaint)
// Draw initials text
val textPaint = android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
textSize = size * 0.5f
textAlign = android.graphics.Paint.Align.CENTER
isAntiAlias = true
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
val xPos = size / 2f
val yPos = size / 2f - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(initials, xPos, yPos, textPaint)
return bitmap
}
private fun getUserColor(userId: Long): Int {
// Generate a color based on user ID for consistency
val colors = arrayOf(
"#E53935", // Red
"#1E88E5", // Blue
"#43A047", // Green
"#FB8C00", // Orange
"#8E24AA", // Purple
"#00ACC1", // Cyan
"#FDD835", // Yellow
"#6D4C41" // Brown
)
// Ensure positive index to avoid ArrayIndexOutOfBoundsException with negative hash codes
val index = kotlin.math.abs(userId.toInt()) % colors.size
return android.graphics.Color.parseColor(colors[index])
}
}
class UserDiffCallback : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem == newItem
}
}
}
}

View File

@@ -0,0 +1,231 @@
package com.crm.chat.ui.profile
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.crm.chat.databinding.ActivityProfileBinding
import com.bumptech.glide.Glide
import com.crm.chat.data.api.ApiClient
import com.crm.chat.data.model.UpdateUserRequest
import com.crm.chat.data.model.User
import com.crm.chat.data.repository.UserRepository
import kotlinx.coroutines.launch
class ProfileActivity : AppCompatActivity() {
private lateinit var binding: ActivityProfileBinding
private lateinit var prefs: SharedPreferences
private lateinit var apiClient: ApiClient
private lateinit var userRepository: UserRepository
private var currentUser: User? = null
private var selectedAvatarUri: Uri? = null
private val pickImageLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri?.let { handleImageSelection(it) }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityProfileBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
// Initialize API client and repository
val baseUrl = prefs.getString("server_url", "") ?: ""
val apiKey = prefs.getString("api_key", "") ?: ""
val token = prefs.getString("auth_token", "") ?: ""
apiClient = ApiClient(baseUrl, apiKey, token)
userRepository = UserRepository(apiClient)
setupToolbar()
loadCurrentUserData()
setupListeners()
}
private fun setupToolbar() {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = "Профиль"
}
private fun loadCurrentUserData() {
binding.progressBar.visibility = android.view.View.VISIBLE
val userId = prefs.getLong("current_user_id", 0L)
if (userId > 0) {
lifecycleScope.launch {
try {
val result = userRepository.getUserById(userId)
if (result.isSuccess) {
currentUser = result.getOrNull()
populateUI()
} else {
// Fallback to preferences data
loadFromPreferences()
}
} catch (e: Exception) {
// Fallback to preferences data
loadFromPreferences()
} finally {
binding.progressBar.visibility = android.view.View.GONE
}
}
} else {
loadFromPreferences()
}
}
private fun loadFromPreferences() {
val userId = prefs.getLong("current_user_id", 0L)
val userName = prefs.getString("user_name", "")
val userEmail = prefs.getString("user_email", "")
val userPhone = prefs.getString("user_phone", "")
val userAvatarUrl = prefs.getString("user_avatar_url", "")
// Parse full name into first and last name
val nameParts = userName?.split(" ", limit = 2) ?: emptyList()
val firstName = nameParts.getOrNull(0) ?: ""
val lastName = nameParts.getOrNull(1)
// Create user object from preferences
currentUser = User(
id = userId,
firstName = firstName,
lastName = lastName,
email = userEmail ?: "",
phone = userPhone,
avatarUrl = userAvatarUrl,
position = prefs.getString("user_position", null)
)
populateUI()
binding.progressBar.visibility = android.view.View.GONE
}
private fun populateUI() {
currentUser?.let { user ->
binding.firstNameEditText.setText(user.firstName)
binding.lastNameEditText.setText(user.lastName)
binding.emailEditText.setText(user.email)
binding.phoneEditText.setText(user.phone)
if (!user.avatarUrl.isNullOrEmpty()) {
Glide.with(this)
.load(user.avatarUrl)
.circleCrop()
.placeholder(com.crm.chat.R.mipmap.ic_launcher)
.into(binding.avatarImageView)
} else {
binding.avatarImageView.setImageResource(com.crm.chat.R.mipmap.ic_launcher)
}
}
}
private fun setupListeners() {
binding.avatarImageView.setOnClickListener {
openImagePicker()
}
binding.saveButton.setOnClickListener {
saveProfile()
}
}
private fun openImagePicker() {
pickImageLauncher.launch("image/*")
}
private fun handleImageSelection(uri: Uri) {
selectedAvatarUri = uri
// Display selected image
Glide.with(this)
.load(uri)
.circleCrop()
.into(binding.avatarImageView)
Toast.makeText(this, "Изображение выбрано", Toast.LENGTH_SHORT).show()
}
private fun saveProfile() {
val firstName = binding.firstNameEditText.text.toString().trim()
val lastName = binding.lastNameEditText.text.toString().trim()
val email = binding.emailEditText.text.toString().trim()
val phone = binding.phoneEditText.text.toString().trim()
if (firstName.isEmpty()) {
binding.errorTextView.text = "Введите имя"
binding.errorTextView.visibility = android.view.View.VISIBLE
return
}
if (email.isEmpty()) {
binding.errorTextView.text = "Введите email"
binding.errorTextView.visibility = android.view.View.VISIBLE
return
}
binding.progressBar.visibility = android.view.View.VISIBLE
binding.saveButton.isEnabled = false
lifecycleScope.launch {
try {
currentUser?.let { user ->
// First upload avatar if selected
if (selectedAvatarUri != null) {
val avatarResult = userRepository.uploadUserAvatar(user.id, selectedAvatarUri!!, contentResolver)
if (avatarResult.isSuccess) {
// Update current user with new avatar URL
currentUser = avatarResult.getOrNull()
}
}
// Update user profile
val updateRequest = UpdateUserRequest(
firstName = firstName,
lastName = lastName,
email = email,
phone = phone,
position = user.position
)
val result = userRepository.updateUser(user.id, updateRequest)
if (result.isSuccess) {
// Save to preferences
val fullName = "${firstName} ${lastName}".trim()
prefs.edit()
.putString("user_name", fullName)
.putString("user_email", email)
.putString("user_phone", phone)
.apply()
binding.errorTextView.visibility = android.view.View.GONE
Toast.makeText(this@ProfileActivity, "Профиль сохранен", Toast.LENGTH_SHORT).show()
finish()
} else {
binding.errorTextView.text = "Ошибка сохранения профиля"
binding.errorTextView.visibility = android.view.View.VISIBLE
}
}
} catch (e: Exception) {
binding.errorTextView.text = "Ошибка: ${e.localizedMessage}"
binding.errorTextView.visibility = android.view.View.VISIBLE
} finally {
binding.progressBar.visibility = android.view.View.GONE
binding.saveButton.isEnabled = true
}
}
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
return true
}
}

View File

@@ -0,0 +1,98 @@
package com.crm.chat.ui.settings
import android.content.SharedPreferences
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import com.crm.chat.databinding.ActivitySettingsBinding
class SettingsActivity : AppCompatActivity() {
private lateinit var binding: ActivitySettingsBinding
private lateinit var prefs: SharedPreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = getSharedPreferences("crm_chat_prefs", MODE_PRIVATE)
setupToolbar()
loadCurrentSettings()
setupListeners()
}
private fun setupToolbar() {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = "Настройки"
}
private fun loadCurrentSettings() {
val serverUrl = prefs.getString("server_url", "")
val apiKey = prefs.getString("api_key", "")
val currentThemeMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
binding.serverUrlEditText.setText(serverUrl)
binding.apiKeyEditText.setText(apiKey)
// Set theme switch based on current mode
binding.themeSwitch.isChecked = when (currentThemeMode) {
AppCompatDelegate.MODE_NIGHT_YES -> true
else -> false
}
}
private fun setupListeners() {
binding.saveButton.setOnClickListener {
saveSettings()
}
binding.themeSwitch.setOnCheckedChangeListener { _, isChecked ->
// Immediately apply theme change
val newMode = if (isChecked) {
AppCompatDelegate.MODE_NIGHT_YES
} else {
AppCompatDelegate.MODE_NIGHT_NO
}
AppCompatDelegate.setDefaultNightMode(newMode)
// Save preference
prefs.edit().putInt("theme_mode", newMode).apply()
}
}
private fun saveSettings() {
val serverUrl = binding.serverUrlEditText.text.toString().trim()
val apiKey = binding.apiKeyEditText.text.toString().trim()
if (serverUrl.isEmpty()) {
binding.errorTextView.text = "Введите адрес сервера"
binding.errorTextView.visibility = android.view.View.VISIBLE
return
}
if (apiKey.isEmpty()) {
binding.errorTextView.text = "Введите API-ключ"
binding.errorTextView.visibility = android.view.View.VISIBLE
return
}
// Save to preferences
prefs.edit()
.putString("server_url", serverUrl)
.putString("api_key", apiKey)
.apply()
binding.errorTextView.visibility = android.view.View.GONE
Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show()
finish()
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
return true
}
}

View File

@@ -0,0 +1,348 @@
package com.crm.chat.utils
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.*
import android.media.AudioManager
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import android.os.Vibrator
import android.os.VibrationEffect
import androidx.core.app.NotificationCompat
import com.crm.chat.R
import com.crm.chat.ui.main.MainActivity
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.net.URL
object NotificationHelper {
private const val BADGE_CHANNEL_ID = "badge_notifications"
private const val BADGE_CHANNEL_NAME = "Badge Notifications"
private const val BADGE_CHANNEL_DESCRIPTION = "Persistent notifications for unread message badges"
private const val BADGE_NOTIFICATION_ID = 999999
fun createNotificationChannel(context: Context) {
// Create badge notification channel only
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val importance = NotificationManager.IMPORTANCE_LOW
val channel = NotificationChannel(BADGE_CHANNEL_ID, BADGE_CHANNEL_NAME, importance).apply {
description = BADGE_CHANNEL_DESCRIPTION
// Badge notifications don't need sound or vibration
enableVibration(false)
enableLights(true)
}
val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
private fun getMessageCountText(count: Int): String {
return when {
count % 10 == 1 && count % 100 != 11 -> "$count непрочитанное сообщение"
count % 10 in 2..4 && count % 100 !in 12..14 -> "$count непрочитанных сообщения"
else -> "$count непрочитанных сообщений"
}
}
private fun getAvatarCacheFile(context: Context, userId: Long): File {
val cacheDir = File(context.cacheDir, "user_avatars")
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}
return File(cacheDir, "avatar_$userId.png")
}
fun cacheUserAvatar(context: Context, userInfo: com.crm.chat.data.api.User) {
println("DEBUG: cacheUserAvatar called for user ${userInfo.id}, avatarUrl=${userInfo.avatarUrl}")
userInfo.avatarUrl?.let { avatarUrl ->
try {
val cacheFile = getAvatarCacheFile(context, userInfo.id)
println("DEBUG: Cache file path: ${cacheFile.absolutePath}")
// Skip if already cached
if (cacheFile.exists()) {
println("DEBUG: Avatar already cached for user ${userInfo.id}")
return
}
println("DEBUG: Downloading avatar from $avatarUrl")
val url = URL(avatarUrl)
val connection = url.openConnection() as java.net.HttpURLConnection
connection.connectTimeout = 15000 // 15 seconds timeout
connection.readTimeout = 15000
connection.requestMethod = "GET"
connection.setRequestProperty("User-Agent", "Android Chat App")
connection.setRequestProperty("Accept", "image/*")
// Add authentication headers if available
val prefs = context.getSharedPreferences("crm_chat_prefs", Context.MODE_PRIVATE)
val token = prefs.getString("auth_token", null)
val apiKey = prefs.getString("api_key", null)
if (token != null) {
connection.setRequestProperty("Authorization", "Bearer $token")
}
if (apiKey != null) {
connection.setRequestProperty("x-api-key", apiKey)
}
connection.connect()
val responseCode = connection.responseCode
println("DEBUG: HTTP response code: $responseCode")
if (responseCode == java.net.HttpURLConnection.HTTP_OK) {
val inputStream = connection.inputStream
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream.close()
connection.disconnect()
if (bitmap != null) {
// Ensure cache directory exists
cacheFile.parentFile?.mkdirs()
FileOutputStream(cacheFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 90, out)
}
bitmap.recycle()
println("DEBUG: Successfully cached avatar for user ${userInfo.id}, file size: ${cacheFile.length()}")
} else {
println("DEBUG: Failed to decode bitmap for user ${userInfo.id}")
}
} else {
println("DEBUG: HTTP error $responseCode for user ${userInfo.id}")
connection.disconnect()
}
} catch (e: IOException) {
println("DEBUG: Failed to cache avatar for user ${userInfo.id}: ${e.message}")
e.printStackTrace()
} catch (e: Exception) {
println("DEBUG: Unexpected error caching avatar for user ${userInfo.id}: ${e.message}")
e.printStackTrace()
}
} ?: println("DEBUG: No avatar URL for user ${userInfo.id}")
}
private fun createUserAvatarBitmap(context: Context, userInfo: com.crm.chat.data.api.User): Bitmap {
val size = 192 // Standard notification icon size
// Try to load cached avatar first
val cacheFile = getAvatarCacheFile(context, userInfo.id)
println("DEBUG: createUserAvatarBitmap - checking cache file: ${cacheFile.absolutePath}, exists: ${cacheFile.exists()}")
if (cacheFile.exists()) {
println("DEBUG: Cache file exists, size: ${cacheFile.length()}")
try {
val cachedBitmap = BitmapFactory.decodeFile(cacheFile.absolutePath)
if (cachedBitmap != null) {
println("DEBUG: Successfully decoded cached bitmap for user ${userInfo.id}: ${cachedBitmap.width}x${cachedBitmap.height}")
return createCircularBitmap(cachedBitmap, size)
} else {
println("DEBUG: Failed to decode cached bitmap for user ${userInfo.id}")
}
} catch (e: Exception) {
println("DEBUG: Exception loading cached avatar for user ${userInfo.id}: ${e.message}")
// Delete corrupted cache file
cacheFile.delete()
}
} else {
println("DEBUG: Cache file does not exist for user ${userInfo.id}")
}
// Try to download and cache avatar from URL if available
userInfo.avatarUrl?.let { avatarUrl ->
try {
val url = URL(avatarUrl)
val connection = url.openConnection()
connection.connectTimeout = 5000 // 5 seconds timeout
connection.readTimeout = 5000
connection.connect()
val inputStream = connection.getInputStream()
val originalBitmap = BitmapFactory.decodeStream(inputStream)
inputStream.close()
if (originalBitmap != null) {
// Cache the downloaded avatar
try {
FileOutputStream(cacheFile).use { out ->
originalBitmap.compress(Bitmap.CompressFormat.PNG, 90, out)
}
} catch (e: IOException) {
println("Failed to cache downloaded avatar for user ${userInfo.id}: ${e.message}")
}
// Resize and crop to circle
return createCircularBitmap(originalBitmap, size)
}
} catch (e: IOException) {
// Fall back to generated avatar if image loading fails
println("Failed to load avatar from URL: $avatarUrl, error: ${e.message}")
}
}
// Fallback: Generate avatar with initials
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
// Get user name for initials
val fullName = userInfo.name
val initials = if (fullName.isNotBlank()) {
val parts = fullName.split(" ")
when (parts.size) {
1 -> parts[0].take(1).uppercase()
else -> "${parts[0].take(1)}${parts[1].take(1)}".uppercase()
}
} else {
"U"
}
// Generate color based on user ID
val colors = arrayOf(
Color.parseColor("#E53935"), // Red
Color.parseColor("#1E88E5"), // Blue
Color.parseColor("#43A047"), // Green
Color.parseColor("#FB8C00"), // Orange
Color.parseColor("#8E24AA"), // Purple
Color.parseColor("#00ACC1"), // Cyan
Color.parseColor("#FDD835"), // Yellow
Color.parseColor("#6D4C41") // Brown
)
val backgroundColor = colors[(userInfo.id % colors.size).toInt()]
// Draw circle background
val paint = Paint().apply {
color = backgroundColor
isAntiAlias = true
style = Paint.Style.FILL
}
val rect = RectF(0f, 0f, size.toFloat(), size.toFloat())
canvas.drawOval(rect, paint)
// Draw initials text
paint.apply {
color = Color.WHITE
textSize = size * 0.4f
textAlign = Paint.Align.CENTER
style = Paint.Style.FILL
}
val xPos = size / 2f
val yPos = size / 2f - (paint.descent() + paint.ascent()) / 2f
canvas.drawText(initials, xPos, yPos, paint)
return bitmap
}
private fun createCircularBitmap(sourceBitmap: Bitmap, diameter: Int): Bitmap {
val output = Bitmap.createBitmap(diameter, diameter, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val paint = Paint().apply {
isAntiAlias = true
}
// Calculate scaling to fit the bitmap in the circle
val minEdge = minOf(sourceBitmap.width, sourceBitmap.height)
val scale = diameter.toFloat() / minEdge
val matrix = android.graphics.Matrix()
matrix.postScale(scale, scale)
// Center the bitmap
val xOffset = (diameter - sourceBitmap.width * scale) / 2
val yOffset = (diameter - sourceBitmap.height * scale) / 2
matrix.postTranslate(xOffset, yOffset)
// Create circular mask
val radius = diameter / 2f
canvas.drawCircle(radius, radius, radius, paint)
// Apply Porter-Duff mode to clip the bitmap to circle
paint.xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.SRC_IN)
canvas.drawBitmap(sourceBitmap, matrix, paint)
sourceBitmap.recycle()
return output
}
private fun createBadgeNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val importance = NotificationManager.IMPORTANCE_LOW
val channel = NotificationChannel(BADGE_CHANNEL_ID, BADGE_CHANNEL_NAME, importance).apply {
description = BADGE_CHANNEL_DESCRIPTION
}
val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
fun updateAppIconBadge(context: Context, unreadCount: Int, senderName: String? = null, latestMessage: String? = null, userAvatar: Bitmap? = null) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (unreadCount > 0) {
// Ensure badge notification channel exists
createBadgeNotificationChannel(context)
// Create intent to open MainActivity
val intent = Intent(context, com.crm.chat.ui.main.MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
BADGE_NOTIFICATION_ID,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Use sender avatar as large icon
val baseAvatar = userAvatar ?: android.graphics.BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)
// Use sender name as title if available, otherwise generic title
val title = senderName ?: "Непрочитанные сообщения"
val contentText = if (senderName != null && latestMessage != null) {
latestMessage
} else {
val messageText = getMessageCountText(unreadCount)
"У вас $messageText"
}
// Create ongoing notification for badge with avatar as large icon
val notificationBuilder = NotificationCompat.Builder(context, BADGE_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher) // Use app icon as small icon
.setLargeIcon(baseAvatar) // Use user avatar as large icon
.setContentTitle(title)
.setContentText(contentText)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setOngoing(true) // Makes it persistent
.setOnlyAlertOnce(true) // Don't make sound/vibration for updates
.setNumber(unreadCount) // This sets the badge count on supported launchers
.setContentIntent(pendingIntent) // Opens app when tapped
.setAutoCancel(false)
val badgeNotification = notificationBuilder.build()
notificationManager.notify(BADGE_NOTIFICATION_ID, badgeNotification)
} else {
// Remove badge notification when no unread messages
notificationManager.cancel(BADGE_NOTIFICATION_ID)
}
}
}

View File

@@ -0,0 +1,47 @@
package com.crm.chat.worker
import android.content.Context
import androidx.work.*
import java.util.concurrent.TimeUnit
object BackgroundSyncManager {
private const val SYNC_WORK_NAME = "background_sync_work"
fun scheduleBackgroundSync(context: Context) {
val workManager = WorkManager.getInstance(context)
// Cancel any existing work
workManager.cancelUniqueWork(SYNC_WORK_NAME)
// Create constraints - only run when connected to network
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
// Create periodic work request - run every 10 minutes
val syncWorkRequest = PeriodicWorkRequestBuilder<BackgroundSyncWorker>(
10, TimeUnit.MINUTES, // Repeat interval
5, TimeUnit.MINUTES // Flex interval
)
.setConstraints(constraints)
.build()
// Enqueue the work
workManager.enqueueUniquePeriodicWork(
SYNC_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
syncWorkRequest
)
}
fun cancelBackgroundSync(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(SYNC_WORK_NAME)
}
fun isBackgroundSyncScheduled(context: Context): Boolean {
val workManager = WorkManager.getInstance(context)
val workInfos = workManager.getWorkInfosForUniqueWork(SYNC_WORK_NAME).get()
return workInfos.any { it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.RUNNING }
}
}

View File

@@ -0,0 +1,139 @@
package com.crm.chat.worker
import android.content.Context
import android.content.SharedPreferences
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.crm.chat.data.api.ApiClient
import com.crm.chat.data.repository.ChatRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class BackgroundSyncWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
// Get stored credentials
val prefs: SharedPreferences = applicationContext.getSharedPreferences("crm_chat_prefs", Context.MODE_PRIVATE)
val token = prefs.getString("auth_token", null)
val serverUrl = prefs.getString("server_url", null)
val apiKey = prefs.getString("api_key", null)
val currentUserId = prefs.getLong("current_user_id", 12033923L) // Default to hardcoded value
if (token.isNullOrEmpty() || serverUrl.isNullOrEmpty() || apiKey.isNullOrEmpty()) {
// Not authenticated, skip
return@withContext Result.success()
}
// Create repository
val apiClient = ApiClient(serverUrl, apiKey, token)
val chatRepository = ChatRepository(apiClient)
// Load chats first to get chat information
val chatsResult = chatRepository.getChats()
if (chatsResult.isFailure) {
println("Background sync: Failed to load chats: ${chatsResult.exceptionOrNull()?.message}")
return@withContext Result.retry()
}
val chats = chatsResult.getOrNull() ?: emptyList()
println("Background sync: Loaded ${chats.size} chats")
// Load address book (users) for name resolution
val usersResult = chatRepository.getUsers(null)
val users = usersResult.getOrNull() ?: emptyList()
println("Background sync: Loaded ${users.size} users")
if (users.isNotEmpty()) {
println("Background sync: Sample user: ${users.first().id} - ${users.first().firstName} ${users.first().lastName}")
}
// Cache user avatars for faster notification display (only download if files don't match)
println("DEBUG: BackgroundSyncWorker caching ${users.size} user avatars")
users.forEach { user ->
// Check if avatar needs to be downloaded (only if URL exists and file doesn't match)
if (user.avatarUrl != null) {
val cacheFile = getAvatarCacheFile(applicationContext, user.id)
val needsDownload = !cacheFile.exists() || !isAvatarUpToDate(cacheFile, user.avatarUrl)
if (needsDownload) {
println("DEBUG: Downloading avatar for user ${user.id}")
com.crm.chat.utils.NotificationHelper.cacheUserAvatar(applicationContext, user)
} else {
println("DEBUG: Avatar already cached and up-to-date for user ${user.id}")
}
}
}
// Check each chat for new messages
var totalNewMessages = 0
val chatsWithNewMessages = mutableListOf<Pair<com.crm.chat.data.model.Chat, List<com.crm.chat.data.model.ChatMessage>>>()
for (chat in chats) {
try {
// Get recent messages for this chat (last 10 messages)
val messagesResult = chatRepository.getChatMessages(chat.id, limit = 10)
if (messagesResult.isSuccess) {
val messagesResponse = messagesResult.getOrNull()
val messages = messagesResponse?.messages ?: emptyList()
// Filter messages that are not from current user and not already seen
val newMessages = messages.filter { message ->
val isFromOtherUser = message.chatUserId != currentUserId
val isNew = isMessageNew(message, chat.id, prefs)
isFromOtherUser && isNew
}
if (newMessages.isNotEmpty()) {
chatsWithNewMessages.add(chat to newMessages)
totalNewMessages += newMessages.size
// Mark these messages as notified to avoid duplicate notifications
markMessagesAsNotified(newMessages, chat.id, prefs)
}
}
} catch (e: Exception) {
println("Background sync: Error checking chat ${chat.id}: ${e.message}")
}
}
// Badge notifications are handled by MainViewModel when chats are loaded
// No need to show individual notifications here
println("Background sync: Found $totalNewMessages new messages across ${chatsWithNewMessages.size} chats")
Result.success()
} catch (e: Exception) {
println("Background sync: Unexpected error: ${e.message}")
Result.retry()
}
}
private fun isMessageNew(message: com.crm.chat.data.model.ChatMessage, chatId: Long, prefs: SharedPreferences): Boolean {
val lastNotifiedId = prefs.getLong("last_notified_message_${chatId}", 0)
return message.id > lastNotifiedId
}
private fun markMessagesAsNotified(messages: List<com.crm.chat.data.model.ChatMessage>, chatId: Long, prefs: SharedPreferences) {
val latestMessageId = messages.maxOfOrNull { it.id } ?: return
prefs.edit().putLong("last_notified_message_${chatId}", latestMessageId).apply()
}
private fun getAvatarCacheFile(context: Context, userId: Long): java.io.File {
val cacheDir = java.io.File(context.cacheDir, "user_avatars")
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}
return java.io.File(cacheDir, "avatar_$userId.png")
}
private fun isAvatarUpToDate(cacheFile: java.io.File, avatarUrl: String): Boolean {
// For now, we'll just check if the file exists
// In a more sophisticated implementation, we could check file timestamps or ETags
// But for simplicity, we'll assume cached avatars are up-to-date unless the URL changes
return cacheFile.exists()
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/secondary" />
</shape>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/primary_light" />
<stroke
android:width="1dp"
android:color="@color/primary" />
</shape>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

View File

@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnPrimary">
<path
android:fillColor="#00000000"
android:pathData="M20,11H7.83l5.59-5.59L12,4l-8,8 8,8 1.41-1.41L7.83,13H20v-2z"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:strokeLineJoin="round"/>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12c0,1.54 0.36,3.05 1.05,4.42L2,22l5.58,-1.05C9.95,21.64 11.46,22 13,22h7c1.1,0 2,-0.9 2,-2V12C22,6.48 17.52,2 12,2zM13,17h-2v-6h2V17zM13,9h-2V7h2V9z"/>
</vector>

View File

@@ -0,0 +1,84 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="468dp"
android:height="468dp"
android:viewportWidth="468"
android:viewportHeight="468">
<path
android:fillColor="#f5f5f5"
android:pathData="M456.475 247.424c-4.333-8.608-4.341-18.794-.034-27.416 15.98-31.991 15.654-72.457-4.287-107.242-21.874-38.156-61.426-59.142-100.407-57.43-9.922.436-19.497-4.081-25.505-11.99-19.844-26.122-53.825-43.346-92.419-43.346-38.682 0-72.73 17.303-92.553 43.526-6.003 7.941-15.591 12.48-25.538 12.077-38.869-1.573-78.253 19.404-100.062 57.447-20 34.888-20.27 75.491-4.145 107.527 4.333 8.608 4.341 18.794.034 27.416-15.98 31.991-15.654 72.457 4.287 107.242 21.874 38.156 61.426 59.142 100.407 57.43 9.922-.436 19.497 4.081 25.505 11.99 19.843 26.121 53.825 43.345 92.419 43.345 38.682 0 72.73-17.303 92.553-43.526 6.003-7.941 15.591-12.48 25.538-12.077 38.87 1.573 78.253-19.404 100.062-57.447 20-34.888 20.27-75.491 4.145-107.526z" />
<path
android:fillColor="#bfc8fb"
android:pathData="m241.253 42.358 7.747 1.642-8.959 2.541c-4.718 1.338-8.321 5.159-9.38 9.948l-1.661 7.511-1.661-7.511c-1.059-4.789-4.662-8.61-9.38-9.948l-8.959-2.541 7.747-1.642c5.016-1.063 8.977-4.907 10.19-9.889l2.063-8.469 2.063 8.47c1.213 4.981 5.175 8.825 10.19 9.888z" />
<path
android:fillColor="#ffb1d1"
android:pathData="M436 186.02a7 7 0 1 1-14 0 7 7 0 1 1 14 0z" />
<path
android:fillColor="#ffb1d1"
android:pathData="M46 186.02a7 7 0 1 1-14 0 7 7 0 1 1 14 0z" />
<path
android:fillColor="#9cb1ff"
android:pathData="M7 387.652v-95.833c0-47.558 38.379-86.112 85.722-86.112h41.982c47.343 0 85.722 38.554 85.722 86.112v95.833z" />
<path
android:fillColor="#c4d0ff"
android:pathData="M112.722 205.707h-20c-47.343 0-85.722 38.553-85.722 86.111v95.833h20v-95.833c0-47.558 38.379-86.111 85.722-86.111z" />
<path
android:fillColor="#fcdeca"
android:pathData="M7 315.027h49.906v72.625H7z" />
<path
android:fillColor="#f9cdaf"
android:pathData="M113.713 247.805c14.935 0 27.042-12.132 27.042-27.098v-75.633h-54.084v75.633c0 14.966 12.107 27.098 27.042 27.098z" />
<path
android:fillColor="#fcdeca"
android:pathData="M114.262 191.161c-34.701 0-62.832-28.189-62.832-62.962v-62.289h125.663v62.289c0 34.773-28.13 62.962-62.831 62.962z" />
<path
android:fillColor="#a4a8b2"
android:pathData="M117.634 18h-37.753c-6.815 0-13.546 1.91-19.158 5.776-17.834 12.286-11.58 32.979-11.58 32.979-32.393 14.395 2.288 74.095 2.288 74.095l23.387-60.542s42.236 27.807 79.236 0l23.038 60.542c6.083-5.235 10.385-12.292 12.148-20.159 10.933-48.78-11.45-72.138-32.282-83.165-12.09-6.399-25.645-9.526-39.324-9.526z" />
<path
android:fillColor="#c5c7ce"
android:pathData="M69.144 56.755s-6.254-20.693 11.58-32.979c5.611-3.866 12.342-5.776 19.157-5.776h-20c-6.815 0-13.546 1.91-19.158 5.776-17.833 12.286-11.58 32.979-11.58 32.979-32.393 14.395 2.288 74.095 2.288 74.095l9.193-23.797c-6.49-18.345-10.275-41.946 8.52-50.298z" />
<path
android:fillColor="#9cb1ff"
android:pathData="M247.575 387.652v-95.833c0-47.558 38.379-86.112 85.722-86.112h41.982c47.343 0 85.722 38.554 85.722 86.112v95.833z" />
<path
android:fillColor="#fcdeca"
android:pathData="M411.094 315.027h49.906v72.625h-49.906z" />
<path
android:fillColor="#f9cdaf"
android:pathData="M354.287 247.805c14.935 0 27.042-12.132 27.042-27.098v-75.633h-54.084v75.633c0 14.966 12.108 27.098 27.042 27.098z" />
<path
android:fillColor="#fcdeca"
android:pathData="M354.837 191.161c-34.701 0-62.832-28.189-62.832-62.962v-62.289h125.663v62.289c0 34.773-28.13 62.962-62.831 62.962z" />
<path
android:fillColor="#a4a8b2"
android:pathData="M358.209 18h-37.753c-6.815 0-13.546 1.91-19.158 5.776-17.834 12.286-11.58 32.979-11.58 32.979-32.393 14.395 2.288 74.095 2.288 74.095l23.387-60.542s42.236 27.807 79.236 0l23.038 60.542c6.083-5.235 10.385-12.292 12.148-20.159 10.933-48.78-11.45-72.138-32.282-83.165-12.09-6.399-25.645-9.526-39.324-9.526z" />
<path
android:fillColor="#c5c7ce"
android:pathData="M309.718 56.755s-6.254-20.693 11.58-32.979c5.612-3.866 12.343-5.776 19.158-5.776h-20c-6.815 0-13.546 1.91-19.158 5.776-17.833 12.286-11.58 32.979-11.58 32.979-32.393 14.395 2.288 74.094 2.288 74.094l9.193-23.797c-6.49-18.344-10.275-41.945 8.519-50.297z" />
<path
android:fillColor="#ffe67b"
android:pathData="M341.301 450v-96.315c0-47.797-38.544-86.544-86.091-86.544h-42.163c-47.547 0-86.091 38.747-86.091 86.544v96.315z" />
<path
android:fillColor="#fff5ca"
android:pathData="M233.047 267.141h-20c-47.547 0-86.091 38.747-86.091 86.544v96.315h20v-96.314c0-47.798 38.544-86.545 86.091-86.545z" />
<path
android:fillColor="#fcdeca"
android:pathData="M291.18 377.011h50.121v72.989h-50.121z" />
<path
android:fillColor="#fcdeca"
android:pathData="M126.956 377.011h50.433v72.989h-50.433z" />
<path
android:fillColor="#f9cdaf"
android:pathData="M234.129 309.696c14.935 0 27.042-12.132 27.042-27.098v-75.633h-54.084v75.633c0 14.966 12.107 27.098 27.042 27.098z" />
<path
android:fillColor="#fcdeca"
android:pathData="M234.678 253.053c-34.701 0-62.832-28.189-62.832-62.962v-62.289h125.664v62.289c0 34.772-28.131 62.962-62.832 62.962z" />
<path
android:fillColor="#a4a8b2"
android:pathData="M238.05 79.891h-37.753c-6.815 0-13.546 1.91-19.158 5.776-17.834 12.286-11.58 32.979-11.58 32.979-32.393 14.395 2.288 74.095 2.288 74.095l23.387-60.542s42.236 27.807 79.236 0l23.038 60.542c6.083-5.235 10.385-12.292 12.148-20.159 10.933-48.78-11.45-72.138-32.282-83.165-12.09-6.399-25.644-9.526-39.324-9.526z" />
<path
android:fillColor="#c5c7ce"
android:pathData="M189.56 118.646s-6.254-20.693 11.58-32.979c5.612-3.866 12.342-5.776 19.157-5.776h-19.999c-6.815 0-13.545 1.91-19.157 5.776-17.834 12.286-11.58 32.979-11.58 32.979-32.393 14.395 2.288 74.095 2.288 74.095l9.193-23.797c-6.492-18.345-10.276-41.946 8.518-50.298z" />
<path
android:fillColor="#333"
android:pathData="M388.329 199.704v-10.027c20.006-10.917 34.054-31.352 36.07-55.495 6.082-5.985 10.394-13.666 12.246-21.927 15.716-55.513-23.73-101.935-78.436-101.212h-37.754c-24.343-1.225-42.391 23.07-38.565 42.109-6.438 4.359-12.222 11.922-12.921 25.016-9.751-3.441-20.243-5.239-30.918-5.239h-37.754c-.76 0-1.514.026-2.265.065-9.896-45.65-42.405-59.833-80.397-61.951h-37.754c-24.355-1.226-42.4 23.088-38.564 42.125-5.298 3.625-9.108 8.75-11.154 15.065-7.514 23.201 10.022 56.878 14.449 64.811 1.633 24.192 15.326 44.788 35.06 56.021v10.636c-45.17 6.423-79.672 45.67-79.672 92.134v95.817.007c0 3.866 3.134 7 7 7 .042 0 .083-.006.126-.006h112.83v55.347.001c0 3.383 2.399 6.204 5.589 6.857.456.093.927.142 1.411.142h214.345c.483 0 .955-.049 1.411-.142 3.19-.653 5.589-3.475 5.589-6.857v-.001-55.348h112.573c.042.001.083.006.126.006 3.866 0 7-3.134 7-7v-.007-95.817c0-46.463-34.502-85.71-79.671-92.13zm-33.492-15.516c-14 0-27.152-5.124-37.433-14.48 4.462-24.478 2.07-44.144-9.051-61.716l10.689-27.667c20.506 10.914 52.229 13.239 72.137.636l19.346 50.837c-1.839 29.484-26.037 52.39-55.688 52.39zm19.492 11.257v25.285c0 11.081-8.991 20.096-20.042 20.096s-20.041-9.015-20.041-20.096v-25.633c6.579 2.026 13.496 3.089 20.591 3.089 6.767.001 13.307-.958 19.492-2.741zm-54.083 4.199c-5.891.838-11.604 2.232-17.087 4.131.493-2.521.861-5.089 1.079-7.705 3.528-3.472 6.456-7.516 8.658-11.911 2.356 1.784 4.812 3.402 7.35 4.86zm-27.684-136.452c3.192-1.419 4.836-5.021 3.871-8.378-3.766-8.694 3.034-30.449 24.022-29.771h37.754c45.354.25 78.058 34.093 64.775 84.151-.624 2.783-1.658 5.473-3.047 7.976l-18.767-49.313c-1.499-4.262-7.195-5.918-10.747-3.106-32.518 24.435-69.631.761-71.182-.251-3.561-2.471-8.918-.754-10.38 3.324l-10.757 27.848c-4.38-4.155-9.393-7.856-15.035-11.07-.004-.003-.01-.008-.014-.011-1.373-16.564 6.729-20.165 9.507-21.399zm-92.265 23.737h37.754c45.354.25 78.058 34.094 64.773 84.152-.623 2.782-1.657 5.472-3.045 7.975l-18.768-49.313c-1.499-4.262-7.195-5.917-10.747-3.106-32.518 24.435-69.63.76-71.181-.251-3.56-2.471-8.919-.754-10.379 3.324l-17.646 45.674c-5.487-12.798-10.75-29.891-7.159-40.961 1.44-4.439 4.143-7.409 8.502-9.346 3.192-1.419 4.836-5.021 3.871-8.378-3.764-8.691 3.035-30.449 24.025-29.77zm34.381 159.145c-29.635 0-53.838-22.873-55.702-52.333l19.908-51.53c20.507 10.914 52.225 13.238 72.136.636l19.362 50.876c-1.856 29.468-26.063 52.351-55.704 52.351zm19.493 11.247v25.294c0 11.081-8.991 20.096-20.042 20.096s-20.042-9.015-20.042-20.096v-25.612c6.508 1.993 13.421 3.069 20.591 3.069 6.769 0 13.306-.968 19.493-2.751zm-87.879-53.031c-5.924-2.165-12.125-3.744-18.539-4.656v-10.064c3.854-2.114 7.511-4.602 10.929-7.421 2.601 5.872 5.041 10.447 6.344 12.782.216 3.187.646 6.309 1.266 9.359zm-122.808-131.753c1.44-4.439 4.142-7.409 8.502-9.346 3.192-1.419 4.836-5.021 3.871-8.378-3.766-8.693 3.033-30.449 24.023-29.771h37.754c30.92-.464 63.162 19.342 66.714 51.004-6.198 2.442-12.228 7.271-15.963 12.28l-7.79-20.471c-1.495-4.263-7.207-5.917-10.747-3.106-32.517 24.434-69.63.761-71.181-.251-3.56-2.471-8.919-.754-10.379 3.324l-17.646 45.674c-5.487-12.795-10.749-29.89-7.158-40.959zm15.091 59.279 19.893-51.491c20.507 10.913 52.228 13.238 72.136.636l10.885 28.601c-.043 1.946.05 3.795.244 5.493-5.297 3.625-9.108 8.75-11.154 15.065-3.75 11.578-1.26 25.765 2.724 38.059-10.4 10.211-24.406 16.009-39.041 16.009-29.646 0-53.839-22.896-55.687-52.372zm75.179 63.56v25.354c0 11.081-8.99 20.096-20.041 20.096s-20.042-9.015-20.042-20.096v-25.613c6.508 1.993 13.421 3.069 20.591 3.069 6.663.001 13.216-.977 19.492-2.81zm-83.848 185.276h-35.906v-58.612h35.906zm70.05-26.956v26.956h-56.05v-94.177c0-3.866-3.134-7-7-7s-7 3.134-7 7v21.567h-35.906v-16.207c0-38.804 28.292-71.701 65.671-77.991v6.887c0 18.8 15.271 34.095 34.042 34.095s34.041-15.295 34.041-34.095v-6.925c8.944 1.508 17.375 4.541 25.032 8.818 6.203 11.894 15.708 21.729 27.301 28.329v10.175c-45.426 6.385-80.131 45.817-80.131 92.568zm50.434 89.304h-36.434v-58.982h36.434zm163.911 0h-36.121v-58.982h36.121zm0-72.98h-36.121v-21.711c0-3.866-3.134-6.999-7-6.999s-7 3.134-7 6.999v94.691h-99.79l.011-94.692c0-3.866-3.134-6.999-7-6.999s-7 3.134-7 6.999v21.711h-36.445v-16.324c0-39.087 28.495-72.176 66.131-78.436v7.356c0 18.8 15.271 34.095 34.042 34.095s34.042-15.295 34.042-34.095v-7.332c37.632 6.262 66.13 39.327 66.13 78.412zm-66.13-108.904v-9.552c12.423-6.777 22.543-17.228 28.916-29.954 7.151-3.742 14.94-6.41 23.159-7.795v6.916c0 18.8 15.271 34.095 34.041 34.095s34.042-15.295 34.042-34.095v-6.887c37.379 6.29 65.671 39.188 65.671 77.991v16.207h-35.906v-21.567c0-3.866-3.134-7-7-7s-7 3.134-7 7v94.177h-55.793v-26.956c0-46.752-34.703-86.194-80.13-92.58zm185.829 119.536h-35.906v-58.612h35.906z" />
</vector>

View File

@@ -0,0 +1,15 @@
<?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">
<group android:scaleX="0.1953125"
android:scaleY="0.1953125"
android:translateX="29.25"
android:translateY="29.25">
<path
android:fillColor="#FFFFFF"
android:pathData="M256,128c0,70.7 -57.3,128 -128,128S0,198.7 0,128 57.3,0 128,0s128,57.3 128,128zM128,192c35.3,0 64,-28.7 64,-64s-28.7,-64 -64,-64 -64,28.7 -64,64 28.7,64 64,64zM128,96c17.7,0 32,14.3 32,32s-14.3,32 -32,32 -32,-14.3 -32,-32 14.3,-32 32,-32z"/>
</group>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M6,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1024"
android:viewportHeight="1024"
android:tint="@color/white">
<path
android:fillColor="#000000"
android:pathData="M779.3 196.6c-94.2-94.2-247.6-94.2-341.7 0l-261 260.8c-1.7 1.7-2.6 4-2.6 6.4s.9 4.7 2.6 6.4l36.9 36.9a9 9 0 0 0 12.7 0l261-260.8c32.4-32.4 75.5-50.2 121.3-50.2s88.9 17.8 121.2 50.2c32.4 32.4 50.2 75.5 50.2 121.2 0 45.8-17.8 88.8-50.2 121.2l-266 265.9-43.1 43.1c-40.3 40.3-105.8 40.3-146.1 0-19.5-19.5-30.2-45.4-30.2-73s10.7-53.5 30.2-73l263.9-263.8c6.7-6.6 15.5-10.3 24.9-10.3h.1c9.4 0 18.1 3.7 24.7 10.3 6.7 6.7 10.3 15.5 10.3 24.9 0 9.3-3.7 18.1-10.3 24.7L372.4 653c-1.7 1.7-2.6 4-2.6 6.4s.9 4.7 2.6 6.4l36.9 36.9a9 9 0 0 0 12.7 0l215.6-215.6c19.9-19.9 30.8-46.3 30.8-74.4s-11-54.6-30.8-74.4c-41.1-41.1-107.9-41-149 0L463 364 224.8 602.1A172.22 172.22 0 0 0 174 724.8c0 46.3 18.1 89.8 50.8 122.5 33.9 33.8 78.3 50.7 122.7 50.7 44.4 0 88.8-16.9 122.6-50.7l309.2-309C824.8 492.7 850 432 850 367.5c.1-64.6-25.1-125.3-70.7-170.9z"/>
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#FFFFFF"
android:pathData="M14,24A10,10,0,0,0,24,34V14A10,10,0,0,0,14,24Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M24,2A22,22,0,1,0,46,24,21.9,21.9,0,0,0,24,2ZM6,24A18.1,18.1,0,0,1,24,6v8a10,10,0,0,1,0,20v8A18.1,18.1,0,0,1,6,24Z"/>
</vector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/surface" />
<corners android:radius="24dp" />
<stroke
android:width="1dp"
android:color="@color/text_hint" />
</shape>

View File

@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
tools:context=".ui.auth.AuthActivity">
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="32dp">
<!-- Title -->
<TextView
android:id="@+id/titleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="64dp"
android:text="@string/login_title"
android:textColor="@color/text_primary"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- Description -->
<TextView
android:id="@+id/descriptionTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/login_description"
android:textColor="@color/text_secondary"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleTextView" />
<!-- Email Input -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/emailInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:hint="@string/email_hint"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/descriptionTextView">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/emailEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress"
android:textColor="@color/black" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Password Input -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/password_hint"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/emailInputLayout"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:textColor="@color/black" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Login Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/loginButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:backgroundTint="@color/primary"
android:text="@string/login_button"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/passwordInputLayout" />
<!-- Additional Settings Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/additionalSettingsButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Дополнительные настройки"
android:textColor="@color/primary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/loginButton" />
<!-- Progress Bar -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/additionalSettingsButton" />
<!-- Error Text -->
<TextView
android:id="@+id/errorTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="@color/error"
android:textSize="14sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,226 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
tools:context=".ui.chat.ChatActivity">
<!-- Toolbar -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/primary"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.Material3.Dark"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navigationIcon="@drawable/ic_arrow_back"
app:title="@string/chat_title"
app:titleTextColor="@color/white" />
<!-- Messages RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/messagesRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:padding="8dp"
app:layout_constraintBottom_toTopOf="@id/messageInputLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
tools:listitem="@layout/item_message" />
<!-- Attachment Indicators Container -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/attachmentIndicators"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:elevation="4dp"
app:layout_constraintBottom_toTopOf="@id/messageInputLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<!-- Reply Indicator -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/replyIndicator"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/reply_background"
android:padding="8dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/replySenderText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="John Smith" />
<TextView
android:id="@+id/replyMessageText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:maxLines="2"
android:textColor="@color/text_secondary"
android:textSize="12sp"
app:layout_constraintEnd_toStartOf="@id/replyCancelButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/replySenderText"
tools:text="Original message text..." />
<ImageButton
android:id="@+id/replyCancelButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="Cancel reply"
android:src="@android:drawable/ic_menu_close_clear_cancel"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/text_secondary" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- File Attachment Indicator -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/fileAttachmentIndicator"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/attachment_background"
android:padding="8dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/replyIndicator">
<TextView
android:id="@+id/fileAttachmentText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="@color/primary"
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/fileCancelButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Отправка: document.pdf" />
<ImageButton
android:id="@+id/fileCancelButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="Cancel file attachment"
android:src="@android:drawable/ic_menu_close_clear_cancel"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/primary" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Message Input Layout -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/messageInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/surface"
android:elevation="4dp"
android:minHeight="56dp"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<!-- Message EditText -->
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/messageEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="@drawable/message_input_background"
android:hint="@string/send_message_hint"
android:inputType="textMultiLine"
android:maxLines="4"
android:minHeight="48dp"
android:padding="12dp"
android:textColor="@color/text_primary"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/attachButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<!-- Attach File Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/attachButton"
android:layout_width="40dp"
android:layout_height="48dp"
android:layout_marginEnd="8dp"
android:backgroundTint="@color/secondary"
android:padding="10dp"
app:icon="@drawable/ic_paperclip"
app:iconSize="28sp"
app:iconGravity="start"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/sendButton"
app:layout_constraintTop_toTopOf="parent"/>
<!-- Send Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/sendButton"
android:layout_width="40dp"
android:layout_height="48dp"
android:backgroundTint="@color/primary"
android:paddingStart="32dp"
android:textColor="@color/white"
app:icon="@android:drawable/ic_menu_send"
app:iconSize="40dp"
app:iconGravity="textStart"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Scroll to Bottom FAB -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/scrollToBottomFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:alpha="0.8"
android:rotation="90"
android:src="@android:drawable/ic_menu_send"
android:visibility="gone"
app:backgroundTint="@color/primary"
app:layout_constraintBottom_toTopOf="@id/attachmentIndicators"
app:layout_constraintEnd_toEndOf="parent"
app:tint="@color/white" />
<!-- Loading overlay -->
<ProgressBar
android:id="@+id/loadingProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,296 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
tools:context=".ui.main.CreateChatActivity">
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="24dp">
<!-- Title -->
<TextView
android:id="@+id/titleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="Создать чат"
android:textColor="@color/text_primary"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- Chat Type Selection -->
<TextView
android:id="@+id/chatTypeLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Тип чата"
android:textColor="@color/text_primary"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleTextView" />
<RadioGroup
android:id="@+id/chatTypeRadioGroup"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/chatTypeLabel">
<RadioButton
android:id="@+id/radioPersonal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Личный чат"
android:checked="true" />
<RadioButton
android:id="@+id/radioGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Групповой чат" />
<RadioButton
android:id="@+id/radioExternal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Внешний чат" />
</RadioGroup>
<!-- Provider Info (hidden but used internally) -->
<TextView
android:id="@+id/providerInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Используется CRM Chat Provider (ID: 994)"
android:textColor="@color/text_hint"
android:textSize="12sp"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/chatTypeRadioGroup" />
<!-- Select User Button (Personal Chat) -->
<TextView
android:id="@+id/companionLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Собеседник"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/providerInfo" />
<com.google.android.material.button.MaterialButton
android:id="@+id/selectUserButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Выбрать пользователя"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/companionLabel" />
<TextView
android:id="@+id/selectedUserTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/selectUserButton"
tools:text="Выбран: Иван Иванов (ID: 12031100)" />
<!-- Group Title -->
<TextView
android:id="@+id/groupTitleLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Название группы"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/providerInfo" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/groupTitleInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="Введите название группы"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/groupTitleLabel">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/groupTitleEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Participant IDs (Group Chat) - Hidden for simplified UI -->
<TextView
android:id="@+id/participantsLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="ID участников (через запятую)"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/groupTitleInputLayout" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/participantsInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="1,2,3"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/participantsLabel">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/participantsEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<!-- External Chat Title -->
<TextView
android:id="@+id/externalTitleLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Название чата"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/providerInfo" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/externalTitleInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="Введите название чата"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/externalTitleLabel">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/externalTitleEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Entity ID -->
<TextView
android:id="@+id/entityLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="ID сущности (опционально)"
android:textColor="@color/text_primary"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/externalTitleInputLayout" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/entityInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="Введите ID сущности"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/entityLabel">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/entityEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Create Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/createButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:backgroundTint="@color/primary"
android:text="Создать чат"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/groupTitleInputLayout" />
<!-- Progress Bar -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/createButton" />
<!-- Error Text -->
<TextView
android:id="@+id/errorTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="@color/error"
android:textSize="14sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/primary">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
android:theme="@style/ThemeOverlay.AppCompat.Dark"
app:titleTextColor="@color/white">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageButton
android:id="@+id/backButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Back"
android:padding="12dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_arrow_back"
android:tint="@android:color/white" />
<TextView
android:id="@+id/toolbarTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:textColor="@android:color/white"
android:textSize="18sp"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end"
tools:text="image.jpg" />
<ImageButton
android:id="@+id/downloadButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Download"
android:padding="12dp"
android:scaleType="fitCenter"
android:src="@android:drawable/stat_sys_download"
android:tint="@android:color/white" />
</LinearLayout>
</androidx.appcompat.widget.Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:src="@drawable/ic_launcher_foreground" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
tools:context=".ui.main.MainActivity">
<!-- Toolbar -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/primary"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.Material3.Dark"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="">
<!-- Logo Block -->
<ImageView
android:id="@+id/logoImageView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="start"
android:contentDescription="Medical Control Logo"
android:scaleType="centerInside"
android:src="@mipmap/ic_launcher"/>
<!-- Menu Button -->
<ImageButton
android:id="@+id/menuButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="end"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Menu"
android:padding="12dp"
android:scaleType="centerInside"
android:src="@drawable/ic_menu"
android:visibility="visible" />
</androidx.appcompat.widget.Toolbar>
<!-- SwipeRefreshLayout for chat list -->
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<!-- RecyclerView for chat list -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chatsRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp"
tools:listitem="@layout/item_chat" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<!-- Empty state -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/emptyStateLayout"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<TextView
android:id="@+id/emptyStateTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_chats"
android:textColor="@color/text_secondary"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Loading state -->
<ProgressBar
android:id="@+id/loadingProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<!-- Error state -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/errorStateLayout"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<TextView
android:id="@+id/errorTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/error_loading_chats"
android:textColor="@color/error"
android:textSize="16sp"
app:layout_constraintBottom_toTopOf="@id/retryButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/retryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Floating Action Button for creating new chat -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/createChatFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/create_chat"
android:src="@drawable/ic_add"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:tint="@color/white" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,198 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
tools:context=".ui.profile.ProfileActivity">
<!-- Toolbar -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/primary"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.Material3.Dark"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="Профиль" />
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<!-- Avatar Section -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/avatarSection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:padding="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/avatarImageView"
android:layout_width="120dp"
android:layout_height="120dp"
android:clickable="true"
android:focusable="true"
android:contentDescription="User Avatar - Click to change"
android:scaleType="centerCrop"
android:background="@drawable/message_input_background"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/avatarHintTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Нажмите на аватарку, чтобы изменить"
android:textColor="@color/text_secondary"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/avatarImageView" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- First Name Input -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/firstNameInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginEnd="8dp"
android:hint="Имя"
app:layout_constraintEnd_toStartOf="@id/lastNameInputLayout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/avatarSection">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/firstNameEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Last Name Input -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/lastNameInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginStart="8dp"
android:hint="Фамилия"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/firstNameInputLayout"
app:layout_constraintTop_toBottomOf="@id/avatarSection">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/lastNameEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Email Input -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/emailInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Email"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/firstNameInputLayout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/emailEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Phone Input -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/phoneInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Телефон"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/emailInputLayout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/phoneEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="phone"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Save Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:backgroundTint="@color/primary"
android:text="Сохранить"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/phoneInputLayout" />
<!-- Progress Bar -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/saveButton" />
<!-- Error Text -->
<TextView
android:id="@+id/errorTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="@color/error"
android:textSize="14sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,178 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
tools:context=".ui.settings.SettingsActivity">
<!-- Toolbar -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/primary"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.Material3.Dark"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="Настройки" />
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<!-- Server Settings Section -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/serverSection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/message_input_background"
android:padding="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/serverTitleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Сервер"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- Server URL Input -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/serverUrlInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Адрес сервера"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/serverTitleTextView">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/serverUrlEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<!-- API Key Input -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/apiKeyInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="API-ключ"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/serverUrlInputLayout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/apiKeyEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Theme Settings Section -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/themeSection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:background="@drawable/message_input_background"
android:padding="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/serverSection">
<TextView
android:id="@+id/themeTitleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Тема"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- Theme Toggle -->
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/themeSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Темная тема"
android:textColor="@color/text_primary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/themeTitleTextView" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Save Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:backgroundTint="@color/primary"
android:text="Сохранить"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/themeSection" />
<!-- Progress Bar -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/saveButton" />
<!-- Error Text -->
<TextView
android:id="@+id/errorTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="@color/error"
android:textSize="14sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
tools:context=".ui.main.UserSelectionActivity">
<!-- Toolbar -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/primary"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.Material3.Dark"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:titleTextColor="@color/white" />
<!-- Search View -->
<androidx.appcompat.widget.SearchView
android:id="@+id/searchView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:background="@drawable/message_input_background"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:queryHint="Поиск пользователей..." />
<!-- Users RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/usersRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:padding="8dp"
app:layout_constraintBottom_toTopOf="@id/doneButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/searchView"
tools:listitem="@layout/item_user" />
<!-- Done Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/doneButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="Создать чат"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:cornerRadius="8dp"
tools:visibility="visible" />
<!-- Progress Bar -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- Empty State -->
<TextView
android:id="@+id/emptyTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="32dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Пользователи не найдены" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:padding="16dp">
<!-- Chat Avatar -->
<ImageView
android:id="@+id/chatAvatarImageView"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="16dp"
android:background="@drawable/circle_background"
android:padding="2dp"
android:scaleType="centerInside"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@android:drawable/ic_menu_camera"
tools:srcCompat="@tools:sample/avatars" />
<!-- Chat Name -->
<TextView
android:id="@+id/chatNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/unreadCountBadge"
app:layout_constraintStart_toEndOf="@id/chatAvatarImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="Chat Name" />
<!-- Last Message -->
<TextView
android:id="@+id/lastMessageTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/text_secondary"
android:textSize="14sp"
app:layout_constraintEnd_toStartOf="@id/lastMessageTimeTextView"
app:layout_constraintStart_toEndOf="@id/chatAvatarImageView"
app:layout_constraintTop_toBottomOf="@id/chatNameTextView"
tools:text="Last message text" />
<!-- Last Message Time -->
<TextView
android:id="@+id/lastMessageTimeTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="@color/text_hint"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/lastMessageTextView"
tools:text="12:30" />
<!-- Unread Count Badge -->
<TextView
android:id="@+id/unreadCountBadge"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="4dp"
android:background="@drawable/badge_background"
android:gravity="center"
android:textColor="@android:color/white"
android:textSize="10sp"
android:textStyle="bold"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/chatNameTextView"
app:layout_constraintBottom_toBottomOf="@id/chatNameTextView"
tools:text="5"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,318 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<!-- Reply Indicator (for both sent and received messages) -->
<LinearLayout
android:id="@+id/replyIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:background="@color/reply_background"
android:orientation="vertical"
android:padding="8dp"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible">
<TextView
android:id="@+id/replySenderText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:textStyle="bold"
tools:text="John Smith" />
<TextView
android:id="@+id/replyMessageText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:textColor="@color/text_secondary"
android:textSize="12sp"
tools:text="Original message text..." />
</LinearLayout>
<!-- Sent Message Bubble -->
<androidx.cardview.widget.CardView
android:id="@+id/sentMessageCard"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp"
android:visibility="gone"
app:cardBackgroundColor="@color/message_sent"
app:cardCornerRadius="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/replyIndicator"
tools:visibility="visible">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/sentReplyIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:layout_marginBottom="2dp"
android:background="@color/reply_background"
android:orientation="vertical"
android:padding="4dp"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/sentReplySenderText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:textStyle="bold"
tools:text="John Smith" />
<TextView
android:id="@+id/sentReplyMessageText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:textColor="@color/text_secondary"
android:textSize="12sp"
tools:text="Original message text..." />
</LinearLayout>
<!-- File Attachment for Sent Messages -->
<LinearLayout
android:id="@+id/sentFileLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="4dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:clickable="true"
android:focusable="true"
android:text="📎"
android:textSize="24sp" />
<TextView
android:id="@+id/sentFileNameText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:textColor="@color/message_sent_text"
android:textSize="14sp"
android:textStyle="bold"
tools:text="document.pdf" />
</LinearLayout>
<!-- Image Preview for Sent Messages -->
<ImageView
android:id="@+id/sentImagePreview"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_marginStart="12dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="4dp"
android:adjustViewBounds="true"
android:clickable="true"
android:focusable="true"
android:scaleType="centerCrop"
android:visibility="gone"
tools:src="@drawable/ic_launcher_foreground"
tools:visibility="visible" />
<TextView
android:id="@+id/sentMessageText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:padding="12dp"
android:textAlignment="textEnd"
android:textColor="@color/message_sent_text"
android:textSize="16sp"
tools:text="This is a sent message" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Received Message Bubble -->
<androidx.cardview.widget.CardView
android:id="@+id/receivedMessageCard"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="64dp"
android:layout_marginBottom="4dp"
android:visibility="gone"
app:cardBackgroundColor="@color/message_received"
app:cardCornerRadius="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/replyIndicator"
tools:visibility="visible">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/receivedReplyIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:layout_marginBottom="2dp"
android:background="@color/reply_background"
android:orientation="vertical"
android:padding="4dp"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/receivedReplySenderText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:textStyle="bold"
tools:text="John Smith" />
<TextView
android:id="@+id/receivedReplyMessageText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:textColor="@color/text_secondary"
android:textSize="12sp"
tools:text="Original message text..." />
</LinearLayout>
<!-- File Attachment for Received Messages -->
<LinearLayout
android:id="@+id/receivedFileLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="4dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:clickable="true"
android:focusable="true"
android:text="📎"
android:textSize="24sp" />
<TextView
android:id="@+id/receivedFileNameText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:textColor="@color/message_received_text"
android:textSize="14sp"
android:textStyle="bold"
tools:text="document.pdf" />
</LinearLayout>
<!-- Image Preview for Received Messages -->
<ImageView
android:id="@+id/receivedImagePreview"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_marginStart="12dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="4dp"
android:adjustViewBounds="true"
android:clickable="true"
android:focusable="true"
android:scaleType="centerCrop"
android:visibility="gone"
tools:src="@drawable/ic_launcher_foreground"
tools:visibility="visible" />
<TextView
android:id="@+id/receivedMessageText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:padding="12dp"
android:textColor="@color/message_received_text"
android:textSize="16sp"
tools:text="This is a received message" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Sender Name (for received messages) -->
<TextView
android:id="@+id/senderNameText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:textColor="@color/text_secondary"
android:textSize="10sp"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/receivedMessageCard"
tools:text="John Doe"
tools:visibility="visible" />
<!-- Timestamp -->
<TextView
android:id="@+id/timestampText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="8dp"
android:maxLines="1"
android:textAlignment="textEnd"
android:textColor="@color/text_hint"
android:textSize="10sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/sentMessageCard"
tools:text="Я (17.01.2026 12:30) ✓"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:padding="16dp">
<!-- User Avatar -->
<ImageView
android:id="@+id/userAvatarImageView"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="12dp"
android:background="@drawable/circle_background"
android:padding="2dp"
android:scaleType="centerInside"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@android:drawable/ic_menu_camera"
tools:srcCompat="@tools:sample/avatars" />
<!-- User Name -->
<TextView
android:id="@+id/userNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/userAvatarImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="Иван Иванов" />
<!-- User ID -->
<TextView
android:id="@+id/userIdTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/text_secondary"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/userAvatarImageView"
app:layout_constraintTop_toBottomOf="@id/userNameTextView"
tools:text="ID: 12345" />
<!-- User Email -->
<TextView
android:id="@+id/userEmailTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/text_hint"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/userAvatarImageView"
app:layout_constraintTop_toBottomOf="@id/userIdTextView"
tools:text="user@example.com" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<!-- Back Button -->
<ImageButton
android:id="@+id/backButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="20dp"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_arrow_back"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:contentDescription="Back" />
<!-- Chat Avatar -->
<ImageView
android:id="@+id/chatAvatarImageView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="20dp"
android:background="@drawable/circle_background"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/backButton"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars" />
<!-- Chat Name -->
<TextView
android:id="@+id/chatNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/chatAvatarImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="John Doe" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_profile"
android:title="Профиль"
app:showAsAction="never" />
<item
android:id="@+id/menu_settings"
android:title="Настройки"
app:showAsAction="never" />
<item
android:id="@+id/menu_logout"
android:title="Выход"
app:showAsAction="never" />
</menu>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_done"
android:title="Готово"
android:icon="@android:drawable/ic_menu_send"
app:showAsAction="always" />
</menu>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FFFFFFFF</color>
<color name="white">#FF000000</color>
<!-- Primary colors - Dark theme -->
<color name="primary">#9E9E9E</color>
<color name="primary_dark">#616161</color>
<color name="primary_light">#BDBDBD</color>
<!-- Secondary colors - Dark theme -->
<color name="secondary">#757575</color>
<color name="secondary_dark">#494949</color>
<color name="secondary_light">#A4A4A4</color>
<!-- Background colors - Dark theme -->
<color name="background">#121212</color>
<color name="background_dark">#000000</color>
<!-- Surface colors - Dark theme -->
<color name="surface">#1E1E1E</color>
<!-- Error colors - Dark theme -->
<color name="error">#CF6679</color>
<color name="error_light">#FF8A80</color>
<!-- Text colors - Dark theme -->
<color name="text_primary">#FFFFFF</color>
<color name="text_secondary">#B3B3B3</color>
<color name="text_hint">#808080</color>
<!-- Chat message colors - Dark theme -->
<color name="message_sent">#2D2D2D</color>
<color name="message_received">#333333</color>
<color name="message_sent_text">#FFFFFF</color>
<color name="message_received_text">#FFFFFF</color>
<!-- Reply message colors - Dark theme -->
<color name="reply_background">#2D3748</color>
</resources>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Primary colors - Light theme -->
<color name="primary">#1976D2</color>
<color name="primary_dark">#1565C0</color>
<color name="primary_light">#BBDEFB</color>
<!-- Secondary colors - Light theme -->
<color name="secondary">#FF9800</color>
<color name="secondary_dark">#F57C00</color>
<color name="secondary_light">#FFCC02</color>
<!-- Background colors - Light theme -->
<color name="background">#FAFAFA</color>
<color name="background_dark">#F5F5F5</color>
<!-- Surface colors - Light theme -->
<color name="surface">#FFFFFF</color>
<!-- Error colors - Light theme -->
<color name="error">#B00020</color>
<color name="error_light">#EF5350</color>
<!-- Text colors - Light theme -->
<color name="text_primary">#212121</color>
<color name="text_secondary">#757575</color>
<color name="text_hint">#BDBDBD</color>
<!-- Chat message colors - Light theme -->
<color name="message_sent">#E3F2FD</color>
<color name="message_received">#F5F5F5</color>
<color name="message_sent_text">#1976D2</color>
<color name="message_received_text">#212121</color>
<!-- Reply message colors - Light theme -->
<color name="reply_background">#E8F5E8</color>
<!-- File attachment colors - Light theme -->
<color name="attachment_background">#E3F2FD</color>
<!-- Selection colors -->
<color name="selected_item_background">#E8F5E8</color>
</resources>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Medical Control</string>
<!-- Auth strings -->
<string name="email_hint">Электронная почта</string>
<string name="password_hint">Пароль</string>
<string name="login_button">Войти</string>
<string name="login_title">Авторизация</string>
<string name="login_description">Введите email и пароль для доступа к чату</string>
<string name="email_required">Электронная почта обязательна</string>
<string name="password_required">Пароль обязателен</string>
<string name="login_failed">Ошибка входа. Проверьте данные.</string>
<!-- Enhanced error messages -->
<string name="error_empty_response">Сервер вернул пустой ответ. Проверьте подключение к серверу и правильность URL.</string>
<string name="error_empty_token">Сервер вернул пустой токен. Проверьте правильность email и пароля.</string>
<string name="error_network">Ошибка сети. Проверьте подключение к интернету.</string>
<string name="error_timeout">Таймаут соединения. Проверьте подключение к серверу и попробуйте снова.</string>
<string name="error_server_not_found">Сервер не найден. Проверьте URL сервера и подключение к сети.</string>
<string name="error_invalid_credentials">Неверный email или пароль. Проверьте правильность введенных данных.</string>
<string name="error_forbidden">Доступ запрещен. Проверьте API ключ и права доступа.</string>
<string name="error_server_error">Ошибка сервера. Сервер временно недоступен, попробуйте позже.</string>
<string name="error_invalid_url">URL сервера должен начинаться с http:// или https://</string>
<string name="error_unexpected">Неожиданная ошибка. Попробуйте перезапустить приложение.</string>
<!-- Chat strings -->
<string name="chats_title">Medical Control</string>
<string name="chat_title">Чат</string>
<string name="send_message_hint">Введите сообщение...</string>
<string name="send_button">Отправить</string>
<string name="no_chats">Нет доступных чатов</string>
<string name="loading">Загрузка...</string>
<string name="error_loading_chats">Ошибка загрузки чатов</string>
<string name="error_sending_message">Ошибка отправки сообщения</string>
<!-- Common strings -->
<string name="retry">Повторить</string>
<string name="ok">ОК</string>
<string name="cancel">Отмена</string>
<string name="error">Ошибка</string>
<string name="create_chat">Создать чат</string>
<string name="theme_toggle">Переключить тему</string>
</resources>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.CRMChat" parent="Theme.Material3.DayNight">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryVariant">@color/primary_dark</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/secondary</item>
<item name="colorSecondaryVariant">@color/secondary_dark</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
<style name="Theme.CRMChat.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="Theme.CRMChat.AppBarOverlay" parent="ThemeOverlay.Material3.Dark" />
<style name="Theme.CRMChat.PopupOverlay" parent="ThemeOverlay.Material3.Light" />
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- TODO: Use <include> for each file type or <exclude> for each file type -->
<!-- <include domain="sharedpref" path="."/> -->
<!-- <exclude domain="sharedpref" path="device.xml"/> -->
</full-backup-content>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> for each file type or <exclude> for each file type -->
<!-- <include .../> -->
<!-- <exclude .../> -->
</cloud-backup>
<device-transfer>
<!-- TODO: Use <include> for each file type or <exclude> for each file type -->
<!-- <include .../> -->
<!-- <exclude .../> -->
</device-transfer>
</data-extraction-rules>

View File

@@ -0,0 +1,136 @@
package com.crm.chat.data.repository
import com.crm.chat.data.api.ApiClient
import com.crm.chat.data.api.AuthApiService
import com.crm.chat.data.model.AuthResponse
import com.crm.chat.data.model.LoginRequest
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import retrofit2.Response
class AuthRepositoryTest {
private lateinit var authApiService: AuthApiService
private lateinit var apiClient: ApiClient
private lateinit var authRepository: AuthRepository
@Before
fun setup() {
authApiService = mockk()
apiClient = mockk()
coEvery { apiClient.authApiService } returns authApiService
authRepository = AuthRepository(apiClient)
}
@Test
fun `login returns success when server returns valid token`() = runBlocking {
// Given
val validToken = "valid-jwt-token"
val authResponse = AuthResponse(token = validToken)
val response = Response.success(authResponse)
coEvery {
authApiService.loginSite(any())
} returns response
// When
val result = authRepository.login("test@example.com", "password")
// Then
assertTrue(result.isSuccess)
assertEquals(validToken, result.getOrNull()?.token)
}
@Test
fun `login returns error when server returns null response`() = runBlocking {
// Given
val response = Response.success<AuthResponse>(null)
coEvery {
authApiService.loginSite(any())
} returns response
// When
val result = authRepository.login("test@example.com", "password")
// Then
assertTrue(result.isFailure)
assertEquals("Сервер вернул пустой ответ. Проверьте подключение к серверу и правильность URL.",
result.exceptionOrNull()?.message)
}
@Test
fun `login returns error when server returns empty token`() = runBlocking {
// Given
val authResponse = AuthResponse(token = null)
val response = Response.success(authResponse)
coEvery {
authApiService.loginSite(any())
} returns response
// When
val result = authRepository.login("test@example.com", "password")
// Then
assertTrue(result.isFailure)
assertEquals("Сервер вернул пустой токен. Проверьте правильность email и пароля.",
result.exceptionOrNull()?.message)
}
@Test
fun `login returns error when server returns 401`() = runBlocking {
// Given
val response = Response.error<AuthResponse>(401, mockk())
coEvery {
authApiService.loginSite(any())
} returns response
// When
val result = authRepository.login("test@example.com", "password")
// Then
assertTrue(result.isFailure)
assertEquals("Неверный email или пароль (401). Проверьте правильность введенных данных.",
result.exceptionOrNull()?.message)
}
@Test
fun `login returns error when server returns 404`() = runBlocking {
// Given
val response = Response.error<AuthResponse>(404, mockk())
coEvery {
authApiService.loginSite(any())
} returns response
// When
val result = authRepository.login("test@example.com", "password")
// Then
assertTrue(result.isFailure)
assertEquals("Сервер не найден (404). Проверьте URL сервера и подключение к сети.",
result.exceptionOrNull()?.message)
}
@Test
fun `login returns error when network exception occurs`() = runBlocking {
// Given
coEvery {
authApiService.loginSite(any())
} throws java.net.UnknownHostException("Host not found")
// When
val result = authRepository.login("test@example.com", "password")
// Then
assertTrue(result.isFailure)
assertEquals("Нет подключения к серверу. Проверьте интернет-соединение и правильность URL.",
result.exceptionOrNull()?.message)
}
}

View File

@@ -0,0 +1,230 @@
package com.crm.chat.data.repository
import com.crm.chat.data.api.ApiClient
import com.crm.chat.data.api.ChatApiService
import com.crm.chat.data.model.*
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import retrofit2.Response
class ChatRepositoryTest {
private lateinit var chatApiService: ChatApiService
private lateinit var apiClient: ApiClient
private lateinit var chatRepository: ChatRepository
@Before
fun setup() {
chatApiService = mockk()
apiClient = mockk()
chatRepository = ChatRepository(apiClient)
}
@Test
fun `getChats returns success when server returns valid response`() = runBlocking {
// Given
val chat = Chat(
id = 1L,
name = "Test Chat",
type = ChatType.PERSONAL,
providerId = 1L
)
val chatsResponse = ChatsResponse(chats = listOf(chat), totalCount = 1)
val response = Response.success(chatsResponse)
coEvery {
chatApiService.getChats(any(), any())
} returns response
// When
val result = chatRepository.getChats()
// Then
assertTrue(result.isSuccess)
assertEquals(1, result.getOrNull()?.chats?.size)
assertEquals("Test Chat", result.getOrNull()?.chats?.first()?.name)
}
@Test
fun `getChats returns error when server returns error`() = runBlocking {
// Given
val response = Response.error<ChatsResponse>(500, mockk())
coEvery {
chatApiService.getChats(any(), any())
} returns response
// When
val result = chatRepository.getChats()
// Then
assertTrue(result.isFailure)
}
@Test
fun `createPersonalChat returns success when server returns valid response`() = runBlocking {
// Given
val chat = Chat(
id = 1L,
name = "Personal Chat",
type = ChatType.PERSONAL,
providerId = 1L
)
val response = Response.success(chat)
val request = CreatePersonalChatRequest(providerId = 1L, companionId = 2L)
coEvery {
chatApiService.createPersonalChat(any())
} returns response
// When
val result = chatRepository.createPersonalChat(request)
// Then
assertTrue(result.isSuccess)
assertEquals("Personal Chat", result.getOrNull()?.name)
}
@Test
fun `createGroupChat returns success when server returns valid response`() = runBlocking {
// Given
val chat = Chat(
id = 1L,
name = "Group Chat",
type = ChatType.GROUP,
providerId = 1L
)
val response = Response.success(chat)
val request = CreateGroupChatRequest(
providerId = 1L,
participantIds = listOf(1L, 2L, 3L),
title = "Test Group"
)
coEvery {
chatApiService.createGroupChat(any())
} returns response
// When
val result = chatRepository.createGroupChat(request)
// Then
assertTrue(result.isSuccess)
assertEquals("Group Chat", result.getOrNull()?.name)
}
@Test
fun `createExternalChat returns success when server returns valid response`() = runBlocking {
// Given
val chat = Chat(
id = 1L,
name = "External Chat",
type = ChatType.EXTERNAL,
providerId = 1L
)
val response = Response.success(chat)
val request = CreateExternalChatRequest(title = "External Chat")
coEvery {
chatApiService.createExternalChat(any())
} returns response
// When
val result = chatRepository.createExternalChat(request)
// Then
assertTrue(result.isSuccess)
assertEquals("External Chat", result.getOrNull()?.name)
}
@Test
fun `sendMessage returns success when server returns valid response`() = runBlocking {
// Given
val message = ChatMessage(
id = 1L,
chatId = 1L,
content = "Hello",
senderId = 1L,
senderName = "User",
timestamp = "2023-01-01T00:00:00Z",
status = ChatMessageStatus.SENT
)
val response = Response.success(message)
val request = SendMessageRequest(content = "Hello")
coEvery {
chatApiService.sendMessage(any(), any())
} returns response
// When
val result = chatRepository.sendMessage(1L, request)
// Then
assertTrue(result.isSuccess)
assertEquals("Hello", result.getOrNull()?.content)
}
@Test
fun `deleteChat returns success when server returns success`() = runBlocking {
// Given
val response = Response.success(Unit)
coEvery {
chatApiService.deleteChat(any())
} returns response
// When
val result = chatRepository.deleteChat(1L)
// Then
assertTrue(result.isSuccess)
}
@Test
fun `reactToMessage returns success when server returns valid response`() = runBlocking {
// Given
val message = ChatMessage(
id = 1L,
chatId = 1L,
content = "Hello",
senderId = 1L,
senderName = "User",
timestamp = "2023-01-01T00:00:00Z",
status = ChatMessageStatus.SENT
)
val response = Response.success(message)
coEvery {
chatApiService.reactToMessage(any(), any(), any())
} returns response
// When
val result = chatRepository.reactToMessage(1L, 1L, "👍")
// Then
assertTrue(result.isSuccess)
assertEquals("Hello", result.getOrNull()?.content)
}
@Test
fun `getUnseenCount returns success when server returns valid response`() = runBlocking {
// Given
val count = 5
val response = Response.success(count)
coEvery {
chatApiService.getUnseenCount()
} returns response
// When
val result = chatRepository.getUnseenCount()
// Then
assertTrue(result.isSuccess)
assertEquals(5, result.getOrNull())
}
}

View File

@@ -0,0 +1,134 @@
package com.crm.chat.ui.auth
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.crm.chat.data.AppConstants
import com.crm.chat.data.api.ApiClient
import com.crm.chat.data.api.AuthApiService
import com.crm.chat.data.model.AuthResponse
import com.crm.chat.data.repository.AuthRepository
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AuthViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private lateinit var authApiService: AuthApiService
private lateinit var apiClient: ApiClient
private lateinit var authRepository: AuthRepository
private lateinit var authViewModel: AuthViewModel
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
authApiService = mockk()
apiClient = mockk()
coEvery { apiClient.authApiService } returns authApiService
authRepository = AuthRepository(apiClient)
authViewModel = AuthViewModel()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `login returns success when valid credentials provided`() = runTest {
// Given
val validToken = "valid-jwt-token"
val authResponse = AuthResponse(token = validToken)
val response = retrofit2.Response.success(authResponse)
coEvery {
authApiService.loginSite(any())
} returns response
// When
authViewModel.login("test@example.com", "password")
// Then
val state = authViewModel.loginState.value
assertTrue(state is AuthViewModel.LoginState.Success)
val successState = state as AuthViewModel.LoginState.Success
assertEquals(validToken, successState.authResponse.token)
assertEquals(AppConstants.SERVER_URL, successState.serverUrl)
}
@Test
fun `login returns error when email is empty`() = runTest {
// When
authViewModel.login("", "password")
// Then
val state = authViewModel.loginState.value
assertTrue(state is AuthViewModel.LoginState.Error)
val errorState = state as AuthViewModel.LoginState.Error
assertEquals("Электронная почта обязательна для авторизации", errorState.message)
}
@Test
fun `login returns error when password is empty`() = runTest {
// When
authViewModel.login("test@example.com", "")
// Then
val state = authViewModel.loginState.value
assertTrue(state is AuthViewModel.LoginState.Error)
val errorState = state as AuthViewModel.LoginState.Error
assertEquals("Пароль обязателен для авторизации", errorState.message)
}
@Test
fun `login returns error when server returns empty token`() = runTest {
// Given
val authResponse = AuthResponse(token = null)
val response = retrofit2.Response.success(authResponse)
coEvery {
authApiService.loginSite(any())
} returns response
// When
authViewModel.login("test@example.com", "password")
// Then
val state = authViewModel.loginState.value
assertTrue(state is AuthViewModel.LoginState.Error)
val errorState = state as AuthViewModel.LoginState.Error
assertEquals("Сервер вернул пустой токен. Проверьте правильность email и пароля.", errorState.message)
}
@Test
fun `login returns error when network exception occurs`() = runTest {
// Given
coEvery {
authApiService.loginSite(any())
} throws java.net.UnknownHostException("Host not found")
// When
authViewModel.login("test@example.com", "password")
// Then
val state = authViewModel.loginState.value
assertTrue(state is AuthViewModel.LoginState.Error)
val errorState = state as AuthViewModel.LoginState.Error
assertEquals("Неожиданная ошибка: Host not found. Попробуйте перезапустить приложение.", errorState.message)
}
}