Init
81
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
6
app/src/main/java/com/crm/chat/data/Constants.kt
Normal 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"
|
||||
}
|
||||
83
app/src/main/java/com/crm/chat/data/api/ApiClient.kt
Normal 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
|
||||
}
|
||||
19
app/src/main/java/com/crm/chat/data/api/AuthApiService.kt
Normal 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>
|
||||
}
|
||||
171
app/src/main/java/com/crm/chat/data/api/ChatApiService.kt
Normal 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
|
||||
)
|
||||
27
app/src/main/java/com/crm/chat/data/api/UserApiService.kt
Normal 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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
21
app/src/main/java/com/crm/chat/data/model/AuthRequest.kt
Normal 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
|
||||
)
|
||||
144
app/src/main/java/com/crm/chat/data/model/ChatModels.kt
Normal 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
|
||||
)
|
||||
59
app/src/main/java/com/crm/chat/data/model/ChatRequests.kt
Normal 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
|
||||
)
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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?
|
||||
)
|
||||
19
app/src/main/java/com/crm/chat/data/model/User.kt
Normal 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()
|
||||
}
|
||||
11
app/src/main/java/com/crm/chat/data/model/UserProfile.kt
Normal 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?
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
529
app/src/main/java/com/crm/chat/data/repository/ChatRepository.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
163
app/src/main/java/com/crm/chat/data/repository/UserRepository.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
223
app/src/main/java/com/crm/chat/ui/auth/AuthActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
116
app/src/main/java/com/crm/chat/ui/auth/AuthViewModel.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
815
app/src/main/java/com/crm/chat/ui/chat/ChatActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
34
app/src/main/java/com/crm/chat/ui/chat/ChatStates.kt
Normal 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()
|
||||
}
|
||||
383
app/src/main/java/com/crm/chat/ui/chat/ChatViewModel.kt
Normal 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")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
275
app/src/main/java/com/crm/chat/ui/chat/ImageViewerActivity.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
743
app/src/main/java/com/crm/chat/ui/chat/MessageAdapter.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
286
app/src/main/java/com/crm/chat/ui/main/ChatAdapter.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
315
app/src/main/java/com/crm/chat/ui/main/CreateChatActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
531
app/src/main/java/com/crm/chat/ui/main/MainActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
739
app/src/main/java/com/crm/chat/ui/main/MainViewModel.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
377
app/src/main/java/com/crm/chat/ui/main/UserSelectionActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
231
app/src/main/java/com/crm/chat/ui/profile/ProfileActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
348
app/src/main/java/com/crm/chat/utils/NotificationHelper.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
139
app/src/main/java/com/crm/chat/worker/BackgroundSyncWorker.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
5
app/src/main/res/drawable/badge_background.xml
Normal 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>
|
||||
8
app/src/main/res/drawable/circle_background.xml
Normal 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>
|
||||
11
app/src/main/res/drawable/ic_add.xml
Normal 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>
|
||||
14
app/src/main/res/drawable/ic_arrow_back.xml
Normal 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>
|
||||
11
app/src/main/res/drawable/ic_chat.xml
Normal 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>
|
||||
84
app/src/main/res/drawable/ic_group_chat.xml
Normal 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>
|
||||
15
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_menu.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_paperclip.xml
Normal 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>
|
||||
13
app/src/main/res/drawable/ic_theme_toggle.xml
Normal 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>
|
||||
8
app/src/main/res/drawable/message_input_background.xml
Normal 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>
|
||||
145
app/src/main/res/layout/activity_auth.xml
Normal 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>
|
||||
226
app/src/main/res/layout/activity_chat.xml
Normal 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>
|
||||
296
app/src/main/res/layout/activity_create_chat.xml
Normal 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>
|
||||
80
app/src/main/res/layout/activity_image_viewer.xml
Normal 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>
|
||||
152
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
198
app/src/main/res/layout/activity_profile.xml
Normal 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>
|
||||
178
app/src/main/res/layout/activity_settings.xml
Normal 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>
|
||||
89
app/src/main/res/layout/activity_user_selection.xml
Normal 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>
|
||||
99
app/src/main/res/layout/item_chat.xml
Normal 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>
|
||||
318
app/src/main/res/layout/item_message.xml
Normal 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>
|
||||
82
app/src/main/res/layout/item_user.xml
Normal 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>
|
||||
53
app/src/main/res/layout/toolbar_chat.xml
Normal 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>
|
||||
20
app/src/main/res/menu/main_menu.xml
Normal 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>
|
||||
9
app/src/main/res/menu/menu_user_selection.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
40
app/src/main/res/values-night/colors.xml
Normal 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>
|
||||
46
app/src/main/res/values/colors.xml
Normal 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>
|
||||
44
app/src/main/res/values/strings.xml
Normal 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>
|
||||
26
app/src/main/res/values/themes.xml
Normal 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>
|
||||
6
app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
13
app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
134
app/src/test/java/com/crm/chat/ui/auth/AuthViewModelTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||