Merge pull request #8 from melod1n/code_saving

Code saving
This commit is contained in:
2021-10-11 12:10:37 +03:00
committed by GitHub
127 changed files with 2585 additions and 1565 deletions
+19 -10
View File
@@ -7,9 +7,9 @@ plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
id("kotlin-kapt") id("kotlin-kapt")
id("kotlin-parcelize")
id("androidx.navigation.safeargs.kotlin") id("androidx.navigation.safeargs.kotlin")
id("dagger.hilt.android.plugin") id("dagger.hilt.android.plugin")
id("kotlin-parcelize")
} }
android { android {
@@ -38,6 +38,9 @@ android {
getByName("release") { getByName("release") {
isMinifyEnabled = false isMinifyEnabled = false
buildConfigField("String", "vkLogin", login)
buildConfigField("String", "vkPassword", password)
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@@ -68,7 +71,7 @@ android {
kapt { kapt {
correctErrorTypes = true correctErrorTypes = true
//use this shit if you don't want to have hilt errors //use this shit if you don't want have hilt errors
javacOptions { javacOptions {
option("-Adagger.hilt.android.internal.disableAndroidSuperclassValidation=true") option("-Adagger.hilt.android.internal.disableAndroidSuperclassValidation=true")
} }
@@ -79,13 +82,19 @@ dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
implementation("com.github.massoudss:waveformSeekBar:3.1.0")
implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
implementation("androidx.work:work-runtime-ktx:2.6.0") implementation("androidx.work:work-runtime-ktx:2.6.0")
implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.appcompat:appcompat:1.4.0-alpha03") implementation("androidx.paging:paging-runtime-ktx:3.0.1")
implementation("com.google.android.material:material:1.5.0-alpha03")
implementation("androidx.core:core-ktx:1.7.0-beta01") implementation("androidx.appcompat:appcompat:1.4.0-beta01")
implementation("com.google.android.material:material:1.5.0-alpha04")
implementation("androidx.core:core-ktx:1.7.0-beta02")
implementation("androidx.preference:preference-ktx:1.1.1") implementation("androidx.preference:preference-ktx:1.1.1")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
implementation("androidx.recyclerview:recyclerview:1.2.1") implementation("androidx.recyclerview:recyclerview:1.2.1")
@@ -93,7 +102,7 @@ dependencies {
implementation("androidx.fragment:fragment-ktx:1.3.6") implementation("androidx.fragment:fragment-ktx:1.3.6")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2-native-mt") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
implementation("androidx.room:room-ktx:2.3.0") implementation("androidx.room:room-ktx:2.3.0")
implementation("androidx.room:room-runtime:2.3.0") implementation("androidx.room:room-runtime:2.3.0")
@@ -113,16 +122,16 @@ dependencies {
implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.google.dagger:hilt-android:2.38.1") implementation("com.google.dagger:hilt-android:2.39.1")
kapt("com.google.dagger:hilt-android-compiler:2.38.1") kapt("com.google.dagger:hilt-android-compiler:2.39.1")
implementation("androidx.hilt:hilt-navigation-fragment:1.0.0") implementation("androidx.hilt:hilt-navigation-fragment:1.0.0")
implementation("com.github.yogacp:android-viewbinding:1.0.3") implementation("com.github.yogacp:android-viewbinding:1.0.3")
implementation("io.coil-kt:coil:1.3.2") implementation("io.coil-kt:coil:1.4.0")
implementation("com.google.code.gson:gson:2.8.8") implementation("com.google.code.gson:gson:2.8.8")
implementation("org.jsoup:jsoup:1.14.2") implementation("org.jsoup:jsoup:1.14.3")
implementation("ch.acra:acra:4.11.1") implementation("ch.acra:acra:4.11.1")
} }
+1
View File
@@ -9,6 +9,7 @@
<application <application
android:name=".common.AppGlobal" android:name=".common.AppGlobal"
android:allowBackup="false" android:allowBackup="false"
android:extractNativeLibs="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@@ -1,8 +1,17 @@
package com.meloda.fast.activity package com.meloda.fast.activity
import android.os.Bundle
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.meloda.fast.R import com.meloda.fast.R
import com.meloda.fast.base.BaseActivity import com.meloda.fast.base.BaseActivity
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : BaseActivity(R.layout.activity_main) class MainActivity : BaseActivity(R.layout.activity_main) {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
}
}
@@ -0,0 +1,7 @@
package com.meloda.fast.api
object ApiExtensions {
val Boolean.intString get() = (if (this) 1 else 0).toString()
}
@@ -6,6 +6,7 @@ import com.meloda.fast.common.AppGlobal
object UserConfig { object UserConfig {
private const val FAST_TOKEN = "fast_token"
private const val TOKEN = "token" private const val TOKEN = "token"
private const val USER_ID = "user_id" private const val USER_ID = "user_id"
@@ -25,8 +26,16 @@ object UserConfig {
AppGlobal.preferences.edit().putString(TOKEN, value).apply() AppGlobal.preferences.edit().putString(TOKEN, value).apply()
} }
var fastToken: String = ""
get() = AppGlobal.preferences.getString(FAST_TOKEN, "") ?: ""
set(value) {
field = value
AppGlobal.preferences.edit().putString(FAST_TOKEN, value).apply()
}
fun clear() { fun clear() {
accessToken = "" accessToken = ""
fastToken = ""
userId = -1 userId = -1
} }
@@ -1,5 +1,7 @@
package com.meloda.fast.api package com.meloda.fast.api
import com.meloda.fast.api.model.attachments.*
object VKConstants { object VKConstants {
const val GROUP_FIELDS = "description,members_count,counters,status,verified" const val GROUP_FIELDS = "description,members_count,counters,status,verified"
@@ -7,6 +9,8 @@ object VKConstants {
const val USER_FIELDS = const val USER_FIELDS =
"photo_50,photo_100,photo_200,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info" "photo_50,photo_100,photo_200,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info"
const val ALL_FIELDS = "$USER_FIELDS,$GROUP_FIELDS"
const val API_VERSION = "5.132" const val API_VERSION = "5.132"
const val VK_APP_ID = "2274003" const val VK_APP_ID = "2274003"
const val VK_SECRET = "hHbZxrka2uZ6jB1inYsH" const val VK_SECRET = "hHbZxrka2uZ6jB1inYsH"
@@ -33,4 +37,15 @@ object VKConstants {
const val PASSWORD = "password" const val PASSWORD = "password"
} }
} }
val restrictedToEditAttachments = listOf(
VkCall::class.java,
VkCurator::class.java,
VkEvent::class.java,
VkGift::class.java,
VkGraffiti::class.java,
VkGroupCall::class.java,
VkStory::class.java,
VkVoiceMessage::class.java
)
} }
@@ -8,11 +8,9 @@ open class VKException(
var code: Int = -1, var code: Int = -1,
var description: String = "", var description: String = "",
var error: String var error: String
) : ) : IOException(description) {
IOException(description) {
var captcha: Pair<String, String>? = null // TODO: 10-Oct-21 remove this
var validationSid: String? = null
var json: JSONObject? = null var json: JSONObject? = null
override fun toString(): String { override fun toString(): String {
@@ -7,6 +7,7 @@ import android.text.SpannableString
import android.text.style.StyleSpan import android.text.style.StyleSpan
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.meloda.fast.R import com.meloda.fast.R
import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.VkUser
@@ -16,6 +17,88 @@ import com.meloda.fast.api.model.base.attachments.BaseVkAttachmentItem
object VkUtils { object VkUtils {
fun <T> attachmentToString(
attachmentClass: Class<T>,
id: Int,
ownerId: Int,
withAccessKey: Boolean,
accessKey: String?
): String {
val type = when (attachmentClass) {
VkAudio::class.java -> "audio"
VkFile::class.java -> "doc"
VkVideo::class.java -> "video"
VkPhoto::class.java -> "photo"
else -> throw IllegalArgumentException("unknown attachment class: $attachmentClass")
}
val result = StringBuilder(type).append(ownerId).append('_').append(id)
if (withAccessKey && !accessKey.isNullOrBlank()) {
result.append('_')
result.append(accessKey)
}
return result.toString()
}
fun getMessageUser(message: VkMessage, profiles: Map<Int, VkUser>): VkUser? {
return (if (!message.isUser()) null
else profiles[message.fromId]).also { message.user.value = it }
}
fun getMessageGroup(message: VkMessage, groups: Map<Int, VkGroup>): VkGroup? {
return (if (!message.isGroup()) null
else groups[message.fromId]).also { message.group.value = it }
}
fun getMessageAvatar(
message: VkMessage,
messageUser: VkUser?,
messageGroup: VkGroup?
): String? {
return when {
message.isUser() -> messageUser?.photo200
message.isGroup() -> messageGroup?.photo200
else -> null
}
}
fun getMessageTitle(
message: VkMessage,
messageUser: VkUser?,
messageGroup: VkGroup?
): String? {
return when {
message.isUser() -> messageUser?.fullName
message.isGroup() -> messageGroup?.name
else -> null
}
}
fun getConversationUser(conversation: VkConversation, profiles: Map<Int, VkUser>): VkUser? {
return (if (!conversation.isUser()) null
else profiles[conversation.id]).also { conversation.user.value = it }
}
fun getConversationGroup(conversation: VkConversation, groups: Map<Int, VkGroup>): VkGroup? {
return (if (!conversation.isGroup()) null
else groups[conversation.id]).also { conversation.group.value = it }
}
fun getConversationAvatar(
conversation: VkConversation,
conversationUser: VkUser?,
conversationGroup: VkGroup?
): String? {
return when {
conversation.ownerId == VKConstants.FAST_GROUP_ID -> null
conversation.isUser() -> conversationUser?.photo200
conversation.isGroup() -> conversationGroup?.photo200
conversation.isChat() -> conversation.photo200
else -> null
}
}
fun prepareMessageText(text: String, forConversations: Boolean? = null): String { fun prepareMessageText(text: String, forConversations: Boolean? = null): String {
return text.apply { return text.apply {
if (forConversations == true) replace("\n", "") if (forConversations == true) replace("\n", "")
@@ -42,6 +125,12 @@ object VkUtils {
return forwards return forwards
} }
fun parseReplyMessage(baseReplyMessage: BaseVkMessage?): VkMessage? {
if (baseReplyMessage == null) return null
return baseReplyMessage.asVkMessage()
}
fun parseAttachments(baseAttachments: List<BaseVkAttachmentItem>?): List<VkAttachment>? { fun parseAttachments(baseAttachments: List<BaseVkAttachmentItem>?): List<VkAttachment>? {
if (baseAttachments.isNullOrEmpty()) return null if (baseAttachments.isNullOrEmpty()) return null
@@ -77,9 +166,7 @@ object VkUtils {
} }
BaseVkAttachmentItem.AttachmentType.VOICE -> { BaseVkAttachmentItem.AttachmentType.VOICE -> {
val voiceMessage = baseAttachment.voiceMessage ?: continue val voiceMessage = baseAttachment.voiceMessage ?: continue
attachments += VkVoiceMessage( attachments += voiceMessage.asVkVoiceMessage()
link = voiceMessage.link_mp3
)
} }
BaseVkAttachmentItem.AttachmentType.STICKER -> { BaseVkAttachmentItem.AttachmentType.STICKER -> {
val sticker = baseAttachment.sticker ?: continue val sticker = baseAttachment.sticker ?: continue
@@ -87,9 +174,7 @@ object VkUtils {
} }
BaseVkAttachmentItem.AttachmentType.GIFT -> { BaseVkAttachmentItem.AttachmentType.GIFT -> {
val gift = baseAttachment.gift ?: continue val gift = baseAttachment.gift ?: continue
attachments += VkGift( attachments += gift.asVkGift()
link = gift.thumb_48
)
} }
BaseVkAttachmentItem.AttachmentType.WALL -> { BaseVkAttachmentItem.AttachmentType.WALL -> {
val wall = baseAttachment.wall ?: continue val wall = baseAttachment.wall ?: continue
@@ -97,9 +182,7 @@ object VkUtils {
} }
BaseVkAttachmentItem.AttachmentType.GRAFFITI -> { BaseVkAttachmentItem.AttachmentType.GRAFFITI -> {
val graffiti = baseAttachment.graffiti ?: continue val graffiti = baseAttachment.graffiti ?: continue
attachments += VkGraffiti( attachments += graffiti.asVkGraffiti()
link = graffiti.url
)
} }
BaseVkAttachmentItem.AttachmentType.POLL -> { BaseVkAttachmentItem.AttachmentType.POLL -> {
val poll = baseAttachment.poll ?: continue val poll = baseAttachment.poll ?: continue
@@ -115,9 +198,7 @@ object VkUtils {
} }
BaseVkAttachmentItem.AttachmentType.CALL -> { BaseVkAttachmentItem.AttachmentType.CALL -> {
val call = baseAttachment.call ?: continue val call = baseAttachment.call ?: continue
attachments += VkCall( attachments += call.asVkCall()
initiatorId = call.initiator_id
)
} }
BaseVkAttachmentItem.AttachmentType.GROUP_CALL_IN_PROGRESS -> { BaseVkAttachmentItem.AttachmentType.GROUP_CALL_IN_PROGRESS -> {
val groupCall = baseAttachment.groupCall ?: continue val groupCall = baseAttachment.groupCall ?: continue
@@ -1,38 +1,50 @@
package com.meloda.fast.api.model package com.meloda.fast.api.model
import android.os.Parcelable import android.os.Parcelable
import androidx.lifecycle.MutableLiveData
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Entity(tableName = "conversations") @Entity(tableName = "conversations")
@Parcelize @Parcelize
data class VkConversation( data class VkConversation(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
val id: Int, var id: Int,
val ownerId: Int?, var ownerId: Int?,
val title: String?, var title: String?,
val photo200: String?, var photo200: String?,
val type: String, var type: String,
val callInProgress: Boolean, var callInProgress: Boolean,
val isPhantom: Boolean, var isPhantom: Boolean,
val lastConversationMessageId: Int, var lastConversationMessageId: Int,
val inRead: Int, var inRead: Int,
val outRead: Int, var outRead: Int,
val isMarkedUnread: Boolean, var isMarkedUnread: Boolean,
val lastMessageId: Int, var lastMessageId: Int,
val unreadCount: Int?, var unreadCount: Int?,
val membersCount: Int?, var membersCount: Int?,
val isPinned: Boolean, var isPinned: Boolean,
var canChangePin: Boolean,
@Embedded(prefix = "pinnedMessage_") @Embedded(prefix = "pinnedMessage_")
var pinnedMessage: VkMessage? = null, var pinnedMessage: VkMessage? = null,
@Embedded(prefix = "lastMessage_") @Embedded(prefix = "lastMessage_")
var lastMessage: VkMessage? = null var lastMessage: VkMessage? = null,
) : Parcelable { ) : Parcelable {
@Ignore
@IgnoredOnParcel
val user = MutableLiveData<VkUser?>()
@Ignore
@IgnoredOnParcel
val group = MutableLiveData<VkGroup?>()
fun isChat() = type == "chat" fun isChat() = type == "chat"
fun isUser() = type == "user" fun isUser() = type == "user"
fun isGroup() = type == "group" fun isGroup() = type == "group"
@@ -1,17 +1,23 @@
package com.meloda.fast.api.model package com.meloda.fast.api.model
import android.os.Parcelable import androidx.lifecycle.MutableLiveData
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VKConstants
import com.meloda.fast.api.model.attachments.VkAttachment import com.meloda.fast.api.model.attachments.VkAttachment
import com.meloda.fast.base.adapter.SelectableItem
import com.meloda.fast.util.TimeUtils
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Entity(tableName = "messages") @Entity(tableName = "messages")
@Parcelize @Parcelize
data class VkMessage( data class VkMessage(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
val id: Int, var id: Int,
val text: String? = null, var text: String? = null,
val isOut: Boolean, val isOut: Boolean,
val peerId: Int, val peerId: Int,
val fromId: Int, val fromId: Int,
@@ -23,10 +29,22 @@ data class VkMessage(
val actionConversationMessageId: Int? = null, val actionConversationMessageId: Int? = null,
val actionMessage: String? = null, val actionMessage: String? = null,
val geoType: String? = null, val geoType: String? = null,
val important: Boolean = false, var important: Boolean = false,
var forwards: List<VkMessage>? = null, var forwards: List<VkMessage>? = null,
var attachments: List<VkAttachment>? = null var attachments: List<VkAttachment>? = null,
) : Parcelable {
// @Embedded(prefix = "replyMessage_")
var replyMessage: VkMessage? = null
) : SelectableItem() {
@Ignore
@IgnoredOnParcel
val user = MutableLiveData<VkUser?>()
@Ignore
@IgnoredOnParcel
val group = MutableLiveData<VkGroup?>()
fun isPeerChat() = peerId > 2_000_000_000 fun isPeerChat() = peerId > 2_000_000_000
@@ -43,40 +61,12 @@ data class VkMessage(
return Action.parse(action) return Action.parse(action)
} }
fun copyMessage( fun canEdit() =
id: Int = this.id, fromId == UserConfig.userId &&
text: String? = this.text, (attachments == null || !VKConstants.restrictedToEditAttachments.contains(
isOut: Boolean = this.isOut, attachments!![0].javaClass
peerId: Int = this.peerId, )) &&
fromId: Int = this.fromId, (System.currentTimeMillis() / 1000 - date.toLong() < TimeUtils.ONE_DAY_IN_SECONDS)
date: Int = this.date,
randomId: Int = this.randomId,
action: String? = this.action,
actionMemberId: Int? = this.actionMemberId,
actionText: String? = this.actionText,
actionConversationMessageId: Int? = this.actionConversationMessageId,
actionMessage: String? = this.actionMessage,
geoType: String? = this.geoType,
important: Boolean = this.important
) = VkMessage(
id = id,
text = text,
isOut = isOut,
peerId = peerId,
fromId = fromId,
date = date,
randomId = randomId,
action = action,
actionMemberId = actionMemberId,
actionText = actionText,
actionConversationMessageId = actionConversationMessageId,
actionMessage = actionMessage,
geoType = geoType,
important = important
).also {
it.attachments = attachments
it.forwards = forwards
}
enum class Action(val value: String) { enum class Action(val value: String) {
CHAT_CREATE("chat_create"), CHAT_CREATE("chat_create"),
@@ -4,4 +4,8 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
open class VkAttachment : Parcelable open class VkAttachment : Parcelable {
open fun asString(withAccessKey: Boolean = true) = ""
}
@@ -1,17 +1,28 @@
package com.meloda.fast.api.model.attachments package com.meloda.fast.api.model.attachments
import com.meloda.fast.api.VkUtils
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class VkAudio( data class VkAudio(
val id: Int, val id: Int,
val ownerId: Int,
val title: String, val title: String,
val artist: String, val artist: String,
val url: String, val url: String,
val duration: Int val duration: Int,
val accessKey: String?
) : VkAttachment() { ) : VkAttachment() {
@IgnoredOnParcel @IgnoredOnParcel
val className: String = this::class.java.name val className: String = this::class.java.name
override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString(
attachmentClass = this::class.java,
id = id,
ownerId = ownerId,
withAccessKey = withAccessKey,
accessKey = accessKey
)
} }
@@ -5,7 +5,12 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class VkCall( data class VkCall(
val initiatorId: Int val initiatorId: Int,
val receiverId: Int,
val state: String,
val time: Int,
val duration: Int,
val isVideo: Boolean
) : VkAttachment() { ) : VkAttachment() {
@IgnoredOnParcel @IgnoredOnParcel
@@ -1,17 +1,28 @@
package com.meloda.fast.api.model.attachments package com.meloda.fast.api.model.attachments
import com.meloda.fast.api.VkUtils
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class VkFile( data class VkFile(
val id: Int, val id: Int,
val ownerId: Int,
val title: String, val title: String,
val ext: String, val ext: String,
val size: Int, val size: Int,
val url: String val url: String,
val accessKey: String?
) : VkAttachment() { ) : VkAttachment() {
@IgnoredOnParcel @IgnoredOnParcel
val className: String = this::class.java.name val className: String = this::class.java.name
override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString(
attachmentClass = this::class.java,
id = id,
ownerId = ownerId,
withAccessKey = withAccessKey,
accessKey = accessKey
)
} }
@@ -5,7 +5,10 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class VkGift( data class VkGift(
val link: String val id: Int,
val thumb256: String?,
val thumb96: String?,
val thumb48: String
) : VkAttachment() { ) : VkAttachment() {
@IgnoredOnParcel @IgnoredOnParcel
@@ -5,7 +5,12 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class VkGraffiti( data class VkGraffiti(
val link: String val id: Int,
val ownerId: Int,
val url: String,
val width: Int,
val height: Int,
val accessKey: String
) : VkAttachment() { ) : VkAttachment() {
@IgnoredOnParcel @IgnoredOnParcel
@@ -9,7 +9,7 @@ data class VkLink(
val title: String?, val title: String?,
val caption: String?, val caption: String?,
val photo: VkPhoto?, val photo: VkPhoto?,
val target: String, val target: String?,
val isFavorite: Boolean val isFavorite: Boolean
) : VkAttachment() { ) : VkAttachment() {
@@ -1,8 +1,11 @@
package com.meloda.fast.api.model.attachments package com.meloda.fast.api.model.attachments
import androidx.room.Ignore
import com.meloda.fast.api.VkUtils
import com.meloda.fast.api.model.base.attachments.BaseVkPhoto import com.meloda.fast.api.model.base.attachments.BaseVkPhoto
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.util.*
@Parcelize @Parcelize
data class VkPhoto( data class VkPhoto(
@@ -13,17 +16,70 @@ data class VkPhoto(
val hasTags: Boolean, val hasTags: Boolean,
val accessKey: String?, val accessKey: String?,
val sizes: List<BaseVkPhoto.Size>, val sizes: List<BaseVkPhoto.Size>,
val text: String, val text: String?,
val userId: Int? val userId: Int?
) : VkAttachment() { ) : VkAttachment() {
@Ignore
@IgnoredOnParcel
private val sizesChars = Stack<Char>()
init {
sizesChars.push('s')
sizesChars.push('m')
sizesChars.push('x')
sizesChars.push('o')
sizesChars.push('p')
sizesChars.push('q')
sizesChars.push('r')
sizesChars.push('y')
sizesChars.push('z')
sizesChars.push('w')
}
@IgnoredOnParcel @IgnoredOnParcel
val className: String = this::class.java.name val className: String = this::class.java.name
fun sizeOfType(type: Char): BaseVkPhoto.Size? { override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString(
attachmentClass = this::class.java,
id = id,
ownerId = ownerId,
withAccessKey = withAccessKey,
accessKey = accessKey
)
fun getMaxSize(): BaseVkPhoto.Size? {
return getSizeOrSmaller(sizesChars.peek())
}
fun getSizeOrNull(type: Char): BaseVkPhoto.Size? {
for (size in sizes) { for (size in sizes) {
if (size.type == type.toString()) if (size.type == type.toString()) return size
}
return null
}
fun getSizeOrSmaller(type: Char): BaseVkPhoto.Size? {
val photoStack = sizesChars.clone() as Stack<Char>
val sizeIndex = photoStack.search(type)
if (sizeIndex == -1) return null
for (i in 0 until sizeIndex) {
photoStack.pop()
}
for (i in 0 until photoStack.size) {
val size = getSizeOrNull(photoStack.peek())
if (size == null) {
photoStack.pop()
continue
} else {
return size return size
}
} }
return null return null
@@ -1,5 +1,6 @@
package com.meloda.fast.api.model.attachments package com.meloda.fast.api.model.attachments
import com.meloda.fast.api.VkUtils
import com.meloda.fast.api.model.base.attachments.BaseVkVideo import com.meloda.fast.api.model.base.attachments.BaseVkVideo
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -7,8 +8,10 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class VkVideo( data class VkVideo(
val id: Int, val id: Int,
val ownerId: Int,
val images: List<BaseVkVideo.Image>, val images: List<BaseVkVideo.Image>,
val firstFrames: List<BaseVkVideo.FirstFrame>? val firstFrames: List<BaseVkVideo.FirstFrame>?,
val accessKey: String?
) : VkAttachment() { ) : VkAttachment() {
@IgnoredOnParcel @IgnoredOnParcel
@@ -18,4 +21,12 @@ data class VkVideo(
return images.find { it.width == width } return images.find { it.width == width }
} }
override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString(
attachmentClass = this::class.java,
id = id,
ownerId = ownerId,
withAccessKey = withAccessKey,
accessKey = accessKey
)
} }
@@ -5,9 +5,18 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class VkVoiceMessage( data class VkVoiceMessage(
val link: String val id: Int,
val ownerId: Int,
val duration: Int,
val waveform: List<Int>,
val linkOgg: String,
val linkMp3: String,
val accessKey: String,
val transcriptState: String,
val transcript: String
) : VkAttachment() { ) : VkAttachment() {
@IgnoredOnParcel @IgnoredOnParcel
val className: String = this::class.java.name val className: String = this::class.java.name
} }
@@ -40,7 +40,8 @@ data class BaseVkConversation(
unreadCount = unread_count, unreadCount = unread_count,
membersCount = chat_settings?.members_count, membersCount = chat_settings?.members_count,
ownerId = chat_settings?.owner_id, ownerId = chat_settings?.owner_id,
isPinned = sort_id.major_id > 0 isPinned = sort_id.major_id > 0,
canChangePin = chat_settings?.acl?.can_change_pin == true
).apply { ).apply {
this.lastMessage = lastMessage this.lastMessage = lastMessage
this.pinnedMessage = chat_settings?.pinned_message?.asVkMessage() this.pinnedMessage = chat_settings?.pinned_message?.asVkMessage()
@@ -23,7 +23,8 @@ data class BaseVkMessage(
val payload: String, val payload: String,
val geo: Geo?, val geo: Geo?,
val action: Action?, val action: Action?,
val ttl: Int val ttl: Int,
val reply_message: BaseVkMessage?
) : Parcelable { ) : Parcelable {
fun asVkMessage() = VkMessage( fun asVkMessage() = VkMessage(
@@ -44,6 +45,7 @@ data class BaseVkMessage(
).also { ).also {
it.attachments = VkUtils.parseAttachments(attachments) it.attachments = VkUtils.parseAttachments(attachments)
it.forwards = VkUtils.parseForwards(fwd_messages) it.forwards = VkUtils.parseForwards(fwd_messages)
it.replyMessage = VkUtils.parseReplyMessage(reply_message)
} }
@Parcelize @Parcelize
@@ -13,7 +13,7 @@ data class BaseVkAudio(
val url: String, val url: String,
val date: Int, val date: Int,
val owner_id: Int, val owner_id: Int,
val access_key: String, val access_key: String?,
val is_explicit: Boolean, val is_explicit: Boolean,
val is_focus_track: Boolean, val is_focus_track: Boolean,
val is_licensed: Boolean, val is_licensed: Boolean,
@@ -27,10 +27,12 @@ data class BaseVkAudio(
fun asVkAudio() = VkAudio( fun asVkAudio() = VkAudio(
id = id, id = id,
ownerId = owner_id,
title = title, title = title,
artist = artist, artist = artist,
url = url, url = url,
duration = duration duration = duration,
accessKey = access_key
) )
@Parcelize @Parcelize
@@ -1,6 +1,7 @@
package com.meloda.fast.api.model.base.attachments package com.meloda.fast.api.model.base.attachments
import android.os.Parcelable import android.os.Parcelable
import com.meloda.fast.api.model.attachments.VkCall
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@@ -11,4 +12,15 @@ data class BaseVkCall(
val time: Int, val time: Int,
val duration: Int, val duration: Int,
val video: Boolean val video: Boolean
) : Parcelable ) : Parcelable {
fun asVkCall() = VkCall(
initiatorId = initiator_id,
receiverId = receiver_id,
state = state,
time = time,
duration = duration,
isVideo = video
)
}
@@ -16,16 +16,18 @@ data class BaseVkFile(
val url: String, val url: String,
val preview: Preview?, val preview: Preview?,
val ic_licensed: Int, val ic_licensed: Int,
val access_key: String, val access_key: String?,
val web_preview_url: String? val web_preview_url: String?
) : BaseVkAttachment() { ) : BaseVkAttachment() {
fun asVkFile() = VkFile( fun asVkFile() = VkFile(
id = id, id = id,
ownerId = owner_id,
title = title, title = title,
ext = ext, ext = ext,
url = url, url = url,
size = size size = size,
accessKey = access_key
) )
@Parcelize @Parcelize
@@ -1,6 +1,7 @@
package com.meloda.fast.api.model.base.attachments package com.meloda.fast.api.model.base.attachments
import android.os.Parcelable import android.os.Parcelable
import com.meloda.fast.api.model.attachments.VkGift
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@@ -9,4 +10,13 @@ data class BaseVkGift(
val thumb_256: String?, val thumb_256: String?,
val thumb_96: String?, val thumb_96: String?,
val thumb_48: String val thumb_48: String
) : Parcelable ) : Parcelable {
fun asVkGift() = VkGift(
id = id,
thumb256 = thumb_256,
thumb96 = thumb_96,
thumb48 = thumb_48
)
}
@@ -1,6 +1,7 @@
package com.meloda.fast.api.model.base.attachments package com.meloda.fast.api.model.base.attachments
import android.os.Parcelable import android.os.Parcelable
import com.meloda.fast.api.model.attachments.VkGraffiti
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@@ -11,4 +12,15 @@ data class BaseVkGraffiti(
val width: Int, val width: Int,
val height: Int, val height: Int,
val access_key: String val access_key: String
) : Parcelable ) : Parcelable {
fun asVkGraffiti() = VkGraffiti(
id = id,
ownerId = owner_id,
url = url,
width = width,
height = height,
accessKey = access_key
)
}
@@ -9,7 +9,7 @@ data class BaseVkLink(
val title: String?, val title: String?,
val caption: String?, val caption: String?,
val photo: BaseVkPhoto?, val photo: BaseVkPhoto?,
val target: String, val target: String?,
val is_favorite: Boolean val is_favorite: Boolean
) : BaseVkAttachment() { ) : BaseVkAttachment() {
@@ -13,7 +13,7 @@ data class BaseVkPhoto(
val has_tags: Boolean, val has_tags: Boolean,
val access_key: String?, val access_key: String?,
val sizes: List<Size>, val sizes: List<Size>,
val text: String, val text: String?,
val user_id: Int?, val user_id: Int?,
val lat: Double?, val lat: Double?,
val long: Double?, val long: Double?,
@@ -26,7 +26,7 @@ data class BaseVkVideo(
val can_add_to_faves: Int, val can_add_to_faves: Int,
val can_add: Int, val can_add: Int,
val can_attach_link: Int, val can_attach_link: Int,
val access_key: String, val access_key: String?,
val owner_id: Int, val owner_id: Int,
val ov_id: String, val ov_id: String,
val is_favorite: Boolean, val is_favorite: Boolean,
@@ -40,8 +40,10 @@ data class BaseVkVideo(
fun asVkVideo() = VkVideo( fun asVkVideo() = VkVideo(
id = id, id = id,
ownerId = owner_id,
images = image, images = image,
firstFrames = first_frame firstFrames = first_frame,
accessKey = access_key
) )
@Parcelize @Parcelize
@@ -1,6 +1,7 @@
package com.meloda.fast.api.model.base.attachments package com.meloda.fast.api.model.base.attachments
import android.os.Parcelable import android.os.Parcelable
import com.meloda.fast.api.model.attachments.VkVoiceMessage
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@@ -14,4 +15,18 @@ data class BaseVkVoiceMessage(
val access_key: String, val access_key: String,
val transcript_state: String, val transcript_state: String,
val transcript: String val transcript: String
) : Parcelable ) : Parcelable {
fun asVkVoiceMessage() = VkVoiceMessage(
id = id,
ownerId = owner_id,
duration = duration,
waveform = waveform,
linkOgg = link_ogg,
linkMp3 = link_mp3,
accessKey = access_key,
transcriptState = transcript_state,
transcript = transcript
)
}
@@ -1,84 +0,0 @@
package com.meloda.fast.api.model.request
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class MessagesGetHistoryRequest(
val count: Int? = null,
val offset: Int? = null,
val peerId: Int,
val extended: Boolean? = null,
val startMessageId: Int? = null,
val rev: Boolean? = null,
val fields: String? = null,
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString()
).apply {
count?.let { this["count"] = it.toString() }
offset?.let { this["offset"] = it.toString() }
extended?.let { this["extended"] = (if (it) 1 else 0).toString() }
startMessageId?.let { this["start_message_id"] = it.toString() }
rev?.let { this["rev"] = (if (it) 1 else 0).toString() }
fields?.let { this["fields"] = it }
}
}
@Parcelize
data class MessagesSendRequest(
val peerId: Int,
val randomId: Int = 0,
val message: String? = null,
val lat: Int? = null,
val lon: Int? = null,
val replyTo: Int? = null,
val stickerId: Int? = null,
val disableMentions: Boolean? = null,
val dontParseLinks: Boolean? = null
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString(),
"random_id" to randomId.toString()
).apply {
message?.let { this["message"] = it }
lat?.let { this["lat"] = it.toString() }
lon?.let { this["lon"] = it.toString() }
replyTo?.let { this["reply_to"] = it.toString() }
stickerId?.let { this["sticker_id"] = it.toString() }
disableMentions?.let { this["disable_mentions"] = (if (it) 1 else 0).toString() }
dontParseLinks?.let { this["dont_parse_links"] = (if (it) 1 else 0).toString() }
}
}
@Parcelize
data class MessagesMarkAsImportantRequest(
val messagesIds: List<Int>,
val important: Boolean
) : Parcelable {
val map
get() = mutableMapOf(
"message_ids" to messagesIds.joinToString { it.toString() },
"important" to (if (important) 1 else 0).toString()
)
}
@Parcelize
data class MessagesGetLongPollServerRequest(
val needPts: Boolean,
val version: Int
) : Parcelable {
val map
get() = mutableMapOf(
"need_pts" to (if (needPts) 1 else 0).toString(),
"version" to version.toString()
)
}
@@ -1,2 +0,0 @@
package com.meloda.fast.api.model.response
@@ -17,6 +17,7 @@ class AuthInterceptor : Interceptor {
builder.addQueryParameter("access_token", URLEncoder.encode(it, "utf-8")) builder.addQueryParameter("access_token", URLEncoder.encode(it, "utf-8"))
} }
// TODO: 9/29/2021 crash on timeout
return chain.proceed(chain.request().newBuilder().apply { url(builder.build()) }.build()) return chain.proceed(chain.request().newBuilder().apply { url(builder.build()) }.build())
} }
@@ -1,6 +1,7 @@
package com.meloda.fast.api.network package com.meloda.fast.api.network
import com.meloda.fast.api.VKException import com.meloda.fast.api.VKException
import com.meloda.fast.api.base.ApiError
import com.meloda.fast.api.base.ApiResponse import com.meloda.fast.api.base.ApiResponse
import okhttp3.Request import okhttp3.Request
import okio.IOException import okio.IOException
@@ -93,7 +94,6 @@ internal class ResultCall<T>(proxy: Call<T>) : CallDelegate<T, Answer<T>>(proxy)
if (result is Answer.Error && isVkException) if (checkErrors(call, result)) return if (result is Answer.Error && isVkException) if (checkErrors(call, result)) return
callback.onResponse(proxy, Response.success(result)) callback.onResponse(proxy, Response.success(result))
} }
@@ -105,6 +105,11 @@ internal class ResultCall<T>(proxy: Call<T>) : CallDelegate<T, Answer<T>>(proxy)
} }
private fun checkErrors(call: Call<T>, result: Answer.Error): Boolean { private fun checkErrors(call: Call<T>, result: Answer.Error): Boolean {
if (result.throwable is ApiError) {
onFailure(call, result.throwable)
return true
}
val json = JSONObject(result.throwable.message ?: "{}") val json = JSONObject(result.throwable.message ?: "{}")
return if (json.has("error")) { return if (json.has("error")) {
@@ -1,6 +1,6 @@
package com.meloda.fast.api.network package com.meloda.fast.api.network
object VKUrls { object VkUrls {
const val OAUTH = "https://oauth.vk.com" const val OAUTH = "https://oauth.vk.com"
const val API = "https://api.vk.com/method" const val API = "https://api.vk.com/method"
@@ -12,6 +12,10 @@ object VKUrls {
object Conversations { object Conversations {
const val Get = "$API/messages.getConversations" const val Get = "$API/messages.getConversations"
const val Delete = "$API/messages.deleteConversation"
const val Pin = "$API/messages.pinConversation"
const val Unpin = "$API/messages.unpinConversation"
const val ReorderPinned = "$API/messages.reorderPinnedConversations"
} }
object Users { object Users {
@@ -24,6 +28,10 @@ object VKUrls {
const val MarkAsImportant = "$API/messages.markAsImportant" const val MarkAsImportant = "$API/messages.markAsImportant"
const val GetLongPollServer = "$API/messages.getLongPollServer" const val GetLongPollServer = "$API/messages.getLongPollServer"
const val GetLongPollHistory = "$API/messages.getLongPollHistory" const val GetLongPollHistory = "$API/messages.getLongPollHistory"
const val Pin = "$API/messages.pin"
const val Unpin = "$API/messages.unpin"
const val Delete = "$API/messages.delete"
const val Edit = "$API/messages.edit"
} }
@@ -1,7 +1,5 @@
package com.meloda.fast.api.network.datasource package com.meloda.fast.api.network.auth
import com.meloda.fast.api.network.repo.AuthRepo
import com.meloda.fast.api.model.request.RequestAuthDirect
import javax.inject.Inject import javax.inject.Inject
class AuthDataSource @Inject constructor( class AuthDataSource @Inject constructor(
@@ -0,0 +1,17 @@
package com.meloda.fast.api.network.auth
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VkUrls
import retrofit2.http.GET
import retrofit2.http.Query
import retrofit2.http.QueryMap
interface AuthRepo {
@GET(VkUrls.Auth.DirectAuth)
suspend fun auth(@QueryMap param: Map<String, String?>): Answer<ResponseAuthDirect>
@GET(VkUrls.Auth.SendSms)
suspend fun sendSms(@Query("sid") validationSid: String): Answer<ResponseSendSms>
}
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.request package com.meloda.fast.api.network.auth
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.response package com.meloda.fast.api.network.auth
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
@@ -0,0 +1,22 @@
package com.meloda.fast.api.network.conversations
import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.database.dao.ConversationsDao
import javax.inject.Inject
class ConversationsDataSource @Inject constructor(
private val repo: ConversationsRepo,
private val dao: ConversationsDao
) {
suspend fun get(params: ConversationsGetRequest) = repo.get(params.map)
suspend fun delete(params: ConversationsDeleteRequest) = repo.delete(params.map)
suspend fun pin(params: ConversationsPinRequest) = repo.pin(params.map)
suspend fun unpin(params: ConversationsUnpinRequest) = repo.unpin(params.map)
suspend fun store(conversations: List<VkConversation>) = dao.insert(conversations)
}
@@ -0,0 +1,32 @@
package com.meloda.fast.api.network.conversations
import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VkUrls
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface ConversationsRepo {
@FormUrlEncoded
@POST(VkUrls.Conversations.Get)
suspend fun get(@FieldMap params: Map<String, String>): Answer<ApiResponse<ConversationsGetResponse>>
@FormUrlEncoded
@POST(VkUrls.Conversations.Delete)
suspend fun delete(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
@FormUrlEncoded
@POST(VkUrls.Conversations.Pin)
suspend fun pin(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
@FormUrlEncoded
@POST(VkUrls.Conversations.Unpin)
suspend fun unpin(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
@FormUrlEncoded
@POST(VkUrls.Conversations.ReorderPinned)
suspend fun reorderPinned(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
}
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.request package com.meloda.fast.api.network.conversations
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -24,3 +24,18 @@ data class ConversationsGetRequest(
startMessageId?.let { this["start_message_id"] = it.toString() } startMessageId?.let { this["start_message_id"] = it.toString() }
} }
} }
@Parcelize
data class ConversationsDeleteRequest(val peerId: Int) : Parcelable {
val map get() = mapOf("peer_id" to peerId.toString())
}
@Parcelize
data class ConversationsPinRequest(val peerId: Int) : Parcelable {
val map get() = mapOf("peer_id" to peerId.toString())
}
@Parcelize
data class ConversationsUnpinRequest(val peerId: Int) : Parcelable {
val map get() = mapOf("peer_id" to peerId.toString())
}
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.response package com.meloda.fast.api.network.conversations
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
@@ -1,18 +0,0 @@
package com.meloda.fast.api.network.datasource
import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.network.repo.ConversationsRepo
import com.meloda.fast.api.model.request.ConversationsGetRequest
import com.meloda.fast.database.dao.ConversationsDao
import javax.inject.Inject
class ConversationsDataSource @Inject constructor(
private val repo: ConversationsRepo,
private val dao: ConversationsDao
) {
suspend fun getAllChats(params: ConversationsGetRequest) = repo.getAllChats(params.map)
suspend fun storeConversations(conversations: List<VkConversation>) = dao.insert(conversations)
}
@@ -1,4 +1,4 @@
package com.meloda.fast.api.network.repo package com.meloda.fast.api.network.longpoll
import com.meloda.fast.api.base.ApiResponse import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.network.Answer import com.meloda.fast.api.network.Answer
@@ -1,11 +1,6 @@
package com.meloda.fast.api.network.datasource package com.meloda.fast.api.network.messages
import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.request.MessagesGetHistoryRequest
import com.meloda.fast.api.model.request.MessagesGetLongPollServerRequest
import com.meloda.fast.api.model.request.MessagesMarkAsImportantRequest
import com.meloda.fast.api.model.request.MessagesSendRequest
import com.meloda.fast.api.network.repo.MessagesRepo
import com.meloda.fast.database.dao.MessagesDao import com.meloda.fast.database.dao.MessagesDao
import javax.inject.Inject import javax.inject.Inject
@@ -26,8 +21,20 @@ class MessagesDataSource @Inject constructor(
suspend fun getLongPollServer(params: MessagesGetLongPollServerRequest) = suspend fun getLongPollServer(params: MessagesGetLongPollServerRequest) =
repo.getLongPollServer(params.map) repo.getLongPollServer(params.map)
suspend fun storeMessages(messages: List<VkMessage>) = dao.insert(messages) suspend fun pin(params: MessagesPinMessageRequest) =
repo.pin(params.map)
suspend fun getCachedMessages(peerId: Int) = dao.getByPeerId(peerId) suspend fun unpin(params: MessagesUnPinMessageRequest) =
repo.unpin(params.map)
suspend fun delete(params: MessagesDeleteRequest) =
repo.delete(params.map)
suspend fun edit(params: MessagesEditRequest) =
repo.edit(params.map)
suspend fun store(messages: List<VkMessage>) = dao.insert(messages)
suspend fun getCached(peerId: Int) = dao.getByPeerId(peerId)
} }
@@ -0,0 +1,46 @@
package com.meloda.fast.api.network.messages
import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.model.base.BaseVkLongPoll
import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VkUrls
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface MessagesRepo {
@FormUrlEncoded
@POST(VkUrls.Messages.GetHistory)
suspend fun getHistory(@FieldMap params: Map<String, String>): Answer<ApiResponse<MessagesGetHistoryResponse>>
@FormUrlEncoded
@POST(VkUrls.Messages.Send)
suspend fun send(@FieldMap params: Map<String, String>): Answer<ApiResponse<Int>>
@FormUrlEncoded
@POST(VkUrls.Messages.MarkAsImportant)
suspend fun markAsImportant(@FieldMap params: Map<String, String>): Answer<ApiResponse<List<Int>>>
@FormUrlEncoded
@POST(VkUrls.Messages.GetLongPollServer)
suspend fun getLongPollServer(@FieldMap params: Map<String, String>): Answer<ApiResponse<BaseVkLongPoll>>
@FormUrlEncoded
@POST(VkUrls.Messages.Pin)
suspend fun pin(@FieldMap params: Map<String, String>): Answer<ApiResponse<BaseVkMessage>>
@FormUrlEncoded
@POST(VkUrls.Messages.Unpin)
suspend fun unpin(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
@FormUrlEncoded
@POST(VkUrls.Messages.Delete)
suspend fun delete(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
@FormUrlEncoded
@POST(VkUrls.Messages.Edit)
suspend fun edit(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
}
@@ -0,0 +1,168 @@
package com.meloda.fast.api.network.messages
import android.os.Parcelable
import com.meloda.fast.api.ApiExtensions.intString
import com.meloda.fast.api.model.attachments.VkAttachment
import kotlinx.parcelize.Parcelize
@Parcelize
data class MessagesGetHistoryRequest(
val count: Int? = null,
val offset: Int? = null,
val peerId: Int,
val extended: Boolean? = null,
val startMessageId: Int? = null,
val rev: Boolean? = null,
val fields: String? = null,
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString()
).apply {
count?.let { this["count"] = it.toString() }
offset?.let { this["offset"] = it.toString() }
extended?.let { this["extended"] = it.intString }
startMessageId?.let { this["start_message_id"] = it.toString() }
rev?.let { this["rev"] = it.intString }
fields?.let { this["fields"] = it }
}
}
@Parcelize
data class MessagesSendRequest(
val peerId: Int,
val randomId: Int = 0,
val message: String? = null,
val lat: Int? = null,
val lon: Int? = null,
val replyTo: Int? = null,
val stickerId: Int? = null,
val disableMentions: Boolean? = null,
val dontParseLinks: Boolean? = null
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString(),
"random_id" to randomId.toString()
).apply {
message?.let { this["message"] = it }
lat?.let { this["lat"] = it.toString() }
lon?.let { this["lon"] = it.toString() }
replyTo?.let { this["reply_to"] = it.toString() }
stickerId?.let { this["sticker_id"] = it.toString() }
disableMentions?.let { this["disable_mentions"] = it.intString }
dontParseLinks?.let { this["dont_parse_links"] = it.intString }
}
}
@Parcelize
data class MessagesMarkAsImportantRequest(
val messagesIds: List<Int>,
val important: Boolean
) : Parcelable {
val map
get() = mutableMapOf(
"message_ids" to messagesIds.joinToString { it.toString() },
"important" to important.intString
)
}
@Parcelize
data class MessagesGetLongPollServerRequest(
val needPts: Boolean,
val version: Int
) : Parcelable {
val map
get() = mutableMapOf(
"need_pts" to needPts.intString,
"version" to version.toString()
)
}
@Parcelize
data class MessagesPinMessageRequest(
val peerId: Int,
val messageId: Int? = null,
val conversationMessageId: Int? = null
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString()
).apply {
messageId?.let { this["message_id"] = it.toString() }
conversationMessageId?.let { this["conversation_message_id"] = it.toString() }
}
}
@Parcelize
data class MessagesUnPinMessageRequest(val peerId: Int) : Parcelable {
val map get() = mutableMapOf("peer_id" to peerId.toString())
}
@Parcelize
data class MessagesDeleteRequest(
val peerId: Int,
val messagesIds: List<Int>? = null,
val conversationsMessagesIds: List<Int>? = null,
val isSpam: Boolean? = null,
val deleteForAll: Boolean? = null
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString()
).apply {
isSpam?.let { this["spam"] = it.intString }
deleteForAll?.let { this["delete_for_all"] = it.intString }
messagesIds?.let {
this["message_ids"] = it.joinToString { id -> id.toString() }
}
conversationsMessagesIds?.let {
this["conversation_message_ids"] = it.joinToString { id -> id.toString() }
}
}
}
@Parcelize
data class MessagesEditRequest(
val peerId: Int,
val messageId: Int,
val message: String? = null,
val lat: Float? = null,
val lon: Float? = null,
val attachments: List<VkAttachment>? = null,
val notParseLinks: Boolean = false,
val keepSnippets: Boolean = true,
val keepForwardedMessages: Boolean = true
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString(),
"message_id" to messageId.toString(),
"dont_parse_links" to notParseLinks.intString,
"keep_snippets" to keepSnippets.intString,
"keep_forward_messages" to keepForwardedMessages.intString
).apply {
message?.let { this["message"] = it }
lat?.let { this["lat"] = it.toString() }
lon?.let { this["lon"] = it.toString() }
attachments?.let {
val attachments =
if (it.isEmpty()) ""
else it.joinToString(separator = ",") { attachment -> attachment.asString() }
this["attachment"] = attachments
}
}
}
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.response package com.meloda.fast.api.network.messages
import android.os.Parcelable import android.os.Parcelable
import com.meloda.fast.api.model.base.BaseVkConversation import com.meloda.fast.api.model.base.BaseVkConversation
@@ -1,17 +0,0 @@
package com.meloda.fast.api.network.repo
import com.meloda.fast.api.network.VKUrls
import com.meloda.fast.api.model.response.ResponseAuthDirect
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.model.response.ResponseSendSms
import retrofit2.http.*
interface AuthRepo {
@GET(VKUrls.Auth.DirectAuth)
suspend fun auth(@QueryMap param: Map<String, String?>): Answer<ResponseAuthDirect>
@GET(VKUrls.Auth.SendSms)
suspend fun sendSms(@Query("sid") validationSid: String): Answer<ResponseSendSms>
}
@@ -1,17 +0,0 @@
package com.meloda.fast.api.network.repo
import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VKUrls
import com.meloda.fast.api.model.response.ConversationsGetResponse
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface ConversationsRepo {
@FormUrlEncoded
@POST(VKUrls.Conversations.Get)
suspend fun getAllChats(@FieldMap params: Map<String, String>): Answer<ApiResponse<ConversationsGetResponse>>
}
@@ -1,30 +0,0 @@
package com.meloda.fast.api.network.repo
import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.model.base.BaseVkLongPoll
import com.meloda.fast.api.model.response.MessagesGetHistoryResponse
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VKUrls
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface MessagesRepo {
@FormUrlEncoded
@POST(VKUrls.Messages.GetHistory)
suspend fun getHistory(@FieldMap params: Map<String, String>): Answer<ApiResponse<MessagesGetHistoryResponse>>
@FormUrlEncoded
@POST(VKUrls.Messages.Send)
suspend fun send(@FieldMap params: Map<String, String>): Answer<ApiResponse<Int>>
@FormUrlEncoded
@POST(VKUrls.Messages.MarkAsImportant)
suspend fun markAsImportant(@FieldMap params: Map<String, String>): Answer<ApiResponse<List<Int>>>
@FormUrlEncoded
@POST(VKUrls.Messages.GetLongPollServer)
suspend fun getLongPollServer(@FieldMap params: Map<String, String>): Answer<ApiResponse<BaseVkLongPoll>>
}
@@ -1,8 +1,6 @@
package com.meloda.fast.api.network.datasource package com.meloda.fast.api.network.users
import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.VkUser
import com.meloda.fast.api.network.repo.UsersRepo
import com.meloda.fast.api.model.request.UsersGetRequest
import com.meloda.fast.database.dao.UsersDao import com.meloda.fast.database.dao.UsersDao
import javax.inject.Inject import javax.inject.Inject
@@ -1,9 +1,9 @@
package com.meloda.fast.api.network.repo package com.meloda.fast.api.network.users
import com.meloda.fast.api.base.ApiResponse import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.model.base.BaseVkUser import com.meloda.fast.api.model.base.BaseVkUser
import com.meloda.fast.api.network.Answer import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VKUrls import com.meloda.fast.api.network.VkUrls
import retrofit2.http.FieldMap import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST import retrofit2.http.POST
@@ -11,7 +11,7 @@ import retrofit2.http.POST
interface UsersRepo { interface UsersRepo {
@FormUrlEncoded @FormUrlEncoded
@POST(VKUrls.Users.GetById) @POST(VkUrls.Users.GetById)
suspend fun getById( suspend fun getById(
@FieldMap params: Map<String, String>? @FieldMap params: Map<String, String>?
): Answer<ApiResponse<List<BaseVkUser>>> ): Answer<ApiResponse<List<BaseVkUser>>>
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.request package com.meloda.fast.api.network.users
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -0,0 +1,2 @@
package com.meloda.fast.api.network.users
@@ -1,13 +1,11 @@
package com.meloda.fast.base package com.meloda.fast.base
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.LifecycleRegistry
import com.google.android.material.snackbar.Snackbar
abstract class BaseActivity : AppCompatActivity, LifecycleOwner { abstract class BaseActivity : AppCompatActivity, LifecycleOwner {
@@ -39,10 +37,4 @@ abstract class BaseActivity : AppCompatActivity, LifecycleOwner {
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
} }
val rootView: View? get() = findViewById(android.R.id.content)
fun requireRootView() = rootView!!
var errorSnackbar: Snackbar? = null
} }
@@ -11,7 +11,7 @@ import com.meloda.fast.activity.MainActivity
import com.meloda.fast.api.UserConfig import com.meloda.fast.api.UserConfig
import com.meloda.fast.base.viewmodel.BaseViewModel import com.meloda.fast.base.viewmodel.BaseViewModel
import com.meloda.fast.base.viewmodel.IllegalTokenEvent import com.meloda.fast.base.viewmodel.IllegalTokenEvent
import com.meloda.fast.base.viewmodel.VKEvent import com.meloda.fast.base.viewmodel.VkEvent
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -30,7 +30,7 @@ abstract class BaseViewModelFragment<VM : BaseViewModel> : BaseFragment {
} }
} }
protected open fun onEvent(event: VKEvent) { protected open fun onEvent(event: VkEvent) {
if (event is IllegalTokenEvent) { if (event is IllegalTokenEvent) {
Toast.makeText( Toast.makeText(
requireContext(), R.string.authorization_failed, Toast.LENGTH_LONG requireContext(), R.string.authorization_failed, Toast.LENGTH_LONG
@@ -114,7 +114,7 @@ abstract class BaseAdapter<Item, VH : BaseHolder>(
holder.bind(position) holder.bind(position)
} }
protected fun initListeners(itemView: View, position: Int) { protected open fun initListeners(itemView: View, position: Int) {
if (itemView is AdapterView<*>) return if (itemView is AdapterView<*>) return
itemView.setOnClickListener { itemClickListener.invoke(position) } itemView.setOnClickListener { itemClickListener.invoke(position) }
@@ -0,0 +1,15 @@
package com.meloda.fast.base.adapter
import android.os.Parcelable
import androidx.room.Ignore
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
open class SelectableItem : Parcelable {
@Ignore
@IgnoredOnParcel
var isSelected: Boolean = false
}
@@ -15,7 +15,7 @@ abstract class BaseViewModel : ViewModel() {
var unknownErrorDefaultText: String = "" var unknownErrorDefaultText: String = ""
protected val tasksEventChannel = Channel<VKEvent>() protected val tasksEventChannel = Channel<VkEvent>()
val tasksEvent = tasksEventChannel.receiveAsFlow() val tasksEvent = tasksEventChannel.receiveAsFlow()
protected fun <T> makeJob( protected fun <T> makeJob(
@@ -25,23 +25,35 @@ abstract class BaseViewModel : ViewModel() {
onEnd: (suspend () -> Unit)? = null, onEnd: (suspend () -> Unit)? = null,
onError: (suspend (Throwable) -> Unit)? = null onError: (suspend (Throwable) -> Unit)? = null
) = viewModelScope.launch { ) = viewModelScope.launch {
onStart?.invoke() onStart?.invoke() ?: onStart()
when (val response = job()) { when (val response = job()) {
is Answer.Success -> onAnswer(response.data) is Answer.Success -> onAnswer(response.data)
is Answer.Error -> { is Answer.Error -> {
checkErrors(response.throwable) checkErrors(response.throwable)
onError?.invoke(response.throwable) onError?.invoke(response.throwable) ?: onError(response.throwable)
?: sendEvent(
ErrorEvent(
response.throwable.message
?: unknownErrorDefaultText
)
)
} }
} }
}.also { it.invokeOnCompletion { viewModelScope.launch { onEnd?.invoke() } } } }.also {
it.invokeOnCompletion {
viewModelScope.launch {
onEnd?.invoke() ?: onStop()
}
}
}
protected suspend fun <T : VKEvent> sendEvent(event: T) = tasksEventChannel.send(event) protected suspend fun onStart() {
sendEvent(StartProgressEvent)
}
protected suspend fun onStop() {
sendEvent(StopProgressEvent)
}
protected suspend fun onError(throwable: Throwable) {
sendEvent(ErrorEvent(throwable.message ?: unknownErrorDefaultText))
}
protected suspend fun <T : VkEvent> sendEvent(event: T) = tasksEventChannel.send(event)
private suspend fun checkErrors(throwable: Throwable) { private suspend fun checkErrors(throwable: Throwable) {
when (throwable) { when (throwable) {
@@ -5,15 +5,15 @@ data class ShowDialogInfoEvent(
val message: String, val message: String,
val positiveBtn: String? = null, val positiveBtn: String? = null,
val negativeBtn: String? = null val negativeBtn: String? = null
) : VKEvent() ) : VkEvent()
data class ErrorEvent(val errorText: String) : VKEvent() data class ErrorEvent(val errorText: String) : VkEvent()
object IllegalTokenEvent : VKEvent() object IllegalTokenEvent : VkEvent()
data class CaptchaEvent(val sid: String, val image: String) : VKEvent() data class CaptchaEvent(val sid: String, val image: String) : VkEvent()
data class ValidationEvent(val sid: String) : VKEvent() data class ValidationEvent(val sid: String) : VkEvent()
object StartProgressEvent : VKEvent() object StartProgressEvent : VkEvent()
object StopProgressEvent : VKEvent() object StopProgressEvent : VkEvent()
abstract class VKEvent abstract class VkEvent
@@ -13,6 +13,7 @@ import kotlinx.coroutines.Job
object AppSettings { object AppSettings {
val keyIsMultilineEnabled = booleanPreferencesKey("isMultilineEnabled") val keyIsMultilineEnabled = booleanPreferencesKey("isMultilineEnabled")
} }
val Context.dataStore: DataStore<Preferences> by preferencesDataStore( val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
@@ -19,7 +19,7 @@ import com.meloda.fast.database.dao.UsersDao
VkUser::class, VkUser::class,
VkGroup::class VkGroup::class
], ],
version = 24, version = 26,
exportSchema = false, exportSchema = false,
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@@ -4,11 +4,15 @@ import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.meloda.fast.api.network.AuthInterceptor import com.meloda.fast.api.network.AuthInterceptor
import com.meloda.fast.api.network.ResultCallFactory import com.meloda.fast.api.network.ResultCallFactory
import com.meloda.fast.api.network.datasource.AuthDataSource import com.meloda.fast.api.network.auth.AuthRepo
import com.meloda.fast.api.network.datasource.ConversationsDataSource import com.meloda.fast.api.network.auth.AuthDataSource
import com.meloda.fast.api.network.datasource.MessagesDataSource import com.meloda.fast.api.network.conversations.ConversationsDataSource
import com.meloda.fast.api.network.datasource.UsersDataSource import com.meloda.fast.api.network.conversations.ConversationsRepo
import com.meloda.fast.api.network.repo.* import com.meloda.fast.api.network.longpoll.LongPollRepo
import com.meloda.fast.api.network.messages.MessagesDataSource
import com.meloda.fast.api.network.users.UsersDataSource
import com.meloda.fast.api.network.messages.MessagesRepo
import com.meloda.fast.api.network.users.UsersRepo
import com.meloda.fast.database.dao.ConversationsDao import com.meloda.fast.database.dao.ConversationsDao
import com.meloda.fast.database.dao.MessagesDao import com.meloda.fast.database.dao.MessagesDao
import com.meloda.fast.database.dao.UsersDao import com.meloda.fast.database.dao.UsersDao
@@ -1,37 +0,0 @@
package com.meloda.fast.extensions
import android.content.Context
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.*
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
object ContextExtensions {
fun Context.drawable(@DrawableRes resId: Int): Drawable? {
return ContextCompat.getDrawable(this, resId)
}
@ColorInt
fun Context.color(@ColorRes resId: Int): Int {
return ContextCompat.getColor(this, resId)
}
fun Context.font(@FontRes resId: Int): Typeface? {
return ResourcesCompat.getFont(this, resId)
}
fun Context.string(@StringRes resId: Int): String {
return getString(resId)
}
fun Context.view(resId: Int, root: ViewGroup? = null, attachToRoot: Boolean = false): View {
return LayoutInflater.from(this).inflate(resId, root, attachToRoot)
}
}
@@ -1,13 +0,0 @@
package com.meloda.fast.extensions
import android.graphics.drawable.Drawable
import androidx.annotation.ColorInt
object DrawableExtensions {
fun Drawable?.tint(@ColorInt color: Int): Drawable? {
this?.setTint(color)
return this
}
}
@@ -1,73 +0,0 @@
package com.meloda.fast.extensions
import android.graphics.*
import kotlin.math.min
fun Bitmap.borderedCircularBitmap(
borderColor: Int = 0,
borderWidth: Int = 0
): Bitmap? {
val bitmap = Bitmap.createBitmap(
width, // width in pixels
height, // height in pixels
Bitmap.Config.ARGB_8888
)
// canvas to draw circular bitmap
val canvas = Canvas(bitmap)
// get the maximum radius
val radius = min(width / 2f, height / 2f)
// create a path to draw circular bitmap border
val borderPath = Path().apply {
addCircle(
width / 2f,
height / 2f,
radius,
Path.Direction.CCW
)
}
// draw border on circular bitmap
canvas.clipPath(borderPath)
canvas.drawColor(borderColor)
// create a path for circular bitmap
val bitmapPath = Path().apply {
addCircle(
width / 2f,
height / 2f,
radius - borderWidth,
Path.Direction.CCW
)
}
canvas.clipPath(bitmapPath)
val paint = Paint().apply {
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
isAntiAlias = true
}
// clear the circular bitmap drawing area
// it will keep bitmap transparency
canvas.drawBitmap(this, 0f, 0f, paint)
// now draw the circular bitmap
canvas.drawBitmap(this, 0f, 0f, null)
val diameter = (radius * 2).toInt()
val x = (width - diameter) / 2
val y = (height - diameter) / 2
// return cropped circular bitmap with border
return Bitmap.createBitmap(
bitmap, // source bitmap
x, // x coordinate of the first pixel in source
y, // y coordinate of the first pixel in source
diameter, // width
diameter // height
)
}
@@ -1,11 +0,0 @@
package com.meloda.fast.extensions
import kotlin.math.roundToInt
object FloatExtensions {
fun Float.int(): Int {
return roundToInt()
}
}
@@ -1,116 +0,0 @@
package com.meloda.fast.extensions
import androidx.annotation.UiThread
import androidx.lifecycle.MutableLiveData
object LiveDataExtensions {
operator fun <T> MutableLiveData<MutableList<T>>.set(position: Int, v: T) {
val value = (this.value ?: arrayListOf()).apply { this[position] = v }
this.value = value
}
operator fun <T> MutableLiveData<MutableList<T>>.get(position: Int): T {
return (value as MutableList<T>)[position]
}
@JvmOverloads
fun <T> MutableLiveData<MutableList<T>>.add(v: T, position: Int = -1) {
val value = (this.value ?: arrayListOf()).apply {
if (position == -1) this.add(v) else this.add(position, v)
}
this.value = value
}
@JvmOverloads
fun <T> MutableLiveData<MutableList<T>>.addAll(values: List<T>, position: Int = -1) {
val value = (this.value ?: arrayListOf()).apply {
if (position == -1) this.addAll(values)
else this.addAll(position, values)
}
this.value = value
}
@Suppress("TYPE_INFERENCE_ONLY_INPUT_TYPES_WARNING")
fun <T> MutableLiveData<MutableList<T>>.removeAll(values: List<T>) {
val value = (this.value ?: arrayListOf()).apply {
this.removeAll(values)
}
this.value = value
}
fun <T> MutableLiveData<MutableList<T>>.removeAt(index: Int) {
val value = (this.value ?: arrayListOf()).apply {
this.removeAt(index)
}
this.value = value
}
fun <T> MutableLiveData<MutableList<T>>.remove(item: T) {
val value = (this.value ?: arrayListOf()).apply {
this.remove(item)
}
this.value = value
}
operator fun <T> MutableLiveData<MutableList<T>>.iterator(): Iterator<T> {
return (value as MutableList<T>).iterator()
}
fun <T> MutableLiveData<MutableList<T>>.clear() {
value = arrayListOf()
}
val <T> MutableLiveData<MutableList<T>>.indices get() = (value as MutableList<T>).indices
val <T> MutableLiveData<MutableList<T>>.size get() = (value as MutableList<T>).size
fun <T> MutableLiveData<MutableList<T>>.isEmpty(): Boolean {
return (value as MutableList<T>).isEmpty()
}
fun <T> MutableLiveData<MutableList<T>>.isNotEmpty(): Boolean {
return !isEmpty()
}
fun <T> MutableLiveData<MutableList<T>>.requireValue() = value!!
@UiThread
operator fun <T> MutableLiveData<MutableList<T>>.plusAssign(values: List<T>) {
val value = (this.value ?: arrayListOf()).apply {
this.addAll(values)
}
this.value = value
}
operator fun <T> MutableLiveData<MutableList<T>>.plusAssign(v: T) {
val value = (this.value ?: arrayListOf()).apply {
this.add(v)
}
this.value = value
}
operator fun <T> MutableLiveData<MutableList<T>>.minusAssign(values: List<T>) {
val value = (this.value ?: arrayListOf()).apply {
this.removeAll(values)
}
this.value = value
}
operator fun <T> MutableLiveData<MutableList<T>>.minusAssign(v: T) {
val value = (this.value ?: arrayListOf()).apply {
this.remove(v)
}
this.value = value
}
}
@@ -1,11 +0,0 @@
package com.meloda.fast.extensions
import java.util.*
object StringExtensions {
fun String.lowerCase(): String {
return toLowerCase(Locale.getDefault())
}
}
@@ -1,17 +1,11 @@
package com.meloda.fast.extensions package com.meloda.fast.extensions
import android.widget.TextView import android.widget.TextView
import com.google.android.material.textfield.TextInputLayout
object TextViewExtensions { object TextViewExtensions {
fun TextView.clear() { fun TextView.clear() {
text = "" text = null
} }
fun TextInputLayout.clear() {
editText?.setText("")
}
} }
@@ -1,10 +0,0 @@
package com.meloda.fast.io
import java.io.ByteArrayOutputStream
class BytesOutputStream : ByteArrayOutputStream {
constructor() : super(8192)
constructor(size: Int) : super(size)
val byteArray: ByteArray = buf
}
@@ -1,12 +0,0 @@
package com.meloda.fast.io
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
object Charsets {
val ASCII: Charset = StandardCharsets.US_ASCII
val UTF_8: Charset = StandardCharsets.UTF_8
}
@@ -1,174 +0,0 @@
package com.meloda.fast.io
import org.jetbrains.annotations.Contract
import java.io.*
import java.nio.charset.Charset
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import kotlin.math.max
object EasyStreams {
const val BUFFER_SIZE = 8192
const val CHAR_BUFFER_SIZE = 4096
@JvmOverloads
@Throws(IOException::class)
fun read(from: InputStream, encoding: Charset? = Charsets.UTF_8): String {
return read(InputStreamReader(from, encoding))
}
@JvmStatic
@Throws(IOException::class)
fun read(from: Reader): String {
val builder = StringWriter(CHAR_BUFFER_SIZE)
return try {
copy(from, builder)
builder.toString()
} finally {
close(from)
}
}
@JvmStatic
@Throws(IOException::class)
fun readBytes(from: InputStream): ByteArray {
val output = ByteArrayOutputStream(max(from.available(), BUFFER_SIZE))
try {
copy(from, output)
} finally {
close(from)
}
return output.toByteArray()
}
@Throws(IOException::class)
fun write(from: ByteArray?, to: OutputStream) {
try {
to.write(from)
to.flush()
} finally {
close(to)
}
}
@Throws(IOException::class)
fun write(from: String?, to: OutputStream?) {
write(from, OutputStreamWriter(to, Charsets.UTF_8))
}
@Throws(IOException::class)
fun write(from: CharArray?, to: Writer) {
try {
to.write(from)
to.flush()
} finally {
close(to)
}
}
@JvmStatic
@Throws(IOException::class)
fun write(from: String?, to: Writer) {
try {
to.write(from)
to.flush()
} finally {
close(to)
}
}
@Throws(IOException::class)
fun copy(from: Reader, to: Writer): Long {
val buffer = CharArray(CHAR_BUFFER_SIZE)
var read: Int
var total: Long = 0
while (from.read(buffer).also { read = it } != -1) {
to.write(buffer, 0, read)
total += read.toLong()
}
return total
}
@Throws(IOException::class)
fun copy(from: InputStream, to: OutputStream): Long {
val buffer = ByteArray(BUFFER_SIZE)
var read: Int
var total: Long = 0
while (from.read(buffer).also { read = it } != -1) {
to.write(buffer, 0, read)
total += read.toLong()
}
return total
}
fun buffer(input: InputStream?): BufferedInputStream {
return buffer(input, BUFFER_SIZE)
}
@Contract("null, _ -> new")
fun buffer(input: InputStream?, size: Int): BufferedInputStream {
return if (input is BufferedInputStream) input else BufferedInputStream(input, size)
}
fun buffer(output: OutputStream?): BufferedOutputStream {
return buffer(output, BUFFER_SIZE)
}
@Contract("null, _ -> new")
fun buffer(output: OutputStream?, size: Int): BufferedOutputStream {
return if (output is BufferedOutputStream) output else BufferedOutputStream(output, size)
}
fun buffer(input: Reader?): BufferedReader {
return buffer(input, CHAR_BUFFER_SIZE)
}
@Contract("null, _ -> new")
fun buffer(input: Reader?, size: Int): BufferedReader {
return if (input is BufferedReader) input else BufferedReader(input, size)
}
fun buffer(output: Writer?): BufferedWriter {
return buffer(output, CHAR_BUFFER_SIZE)
}
@Contract("null, _ -> new")
fun buffer(output: Writer?, size: Int): BufferedWriter {
return if (output is BufferedWriter) output else BufferedWriter(output, size)
}
@Throws(IOException::class)
fun gzip(input: InputStream?): GZIPInputStream {
return gzip(input, BUFFER_SIZE)
}
@Contract("null, _ -> new")
@Throws(IOException::class)
fun gzip(input: InputStream?, size: Int): GZIPInputStream {
return if (input is GZIPInputStream) input else GZIPInputStream(input, size)
}
@Throws(IOException::class)
fun gzip(input: OutputStream?): GZIPOutputStream {
return gzip(input, BUFFER_SIZE)
}
@Contract("null, _ -> new")
@Throws(IOException::class)
fun gzip(input: OutputStream?, size: Int): GZIPOutputStream {
return if (input is GZIPOutputStream) input else GZIPOutputStream(input, size)
}
fun close(c: Closeable?): Boolean {
if (c != null) {
try {
c.close()
return true
} catch (e: IOException) {
e.printStackTrace()
}
}
return false
}
}
@@ -1,96 +0,0 @@
package com.meloda.fast.io
import org.jetbrains.annotations.Contract
import java.io.*
import java.math.BigInteger
object FileStreams {
val lineSeparatorChar = lineSeparator()[0]
const val ONE_KB = 1024
const val ONE_MB = ONE_KB * 1024
const val ONE_GB = ONE_MB * 1024
const val ONE_TB = ONE_GB * 1024L
const val ONE_PB = ONE_TB * 1024L
const val ONE_EB = ONE_PB * 1024L
val ONE_ZB: BigInteger = BigInteger.valueOf(ONE_EB).multiply(BigInteger.valueOf(1024L))
val ONE_YB: BigInteger = ONE_ZB.multiply(BigInteger.valueOf(1024L))
@Throws(IOException::class)
fun read(from: File?): String {
return EasyStreams.read(reader(from))
}
@Throws(IOException::class)
fun write(from: String?, to: File?) {
EasyStreams.write(from, writer(to))
}
@Throws(IOException::class)
fun write(from: ByteArray?, to: File?) {
EasyStreams.write(from, FileOutputStream(to))
}
@Throws(IOException::class)
fun append(from: ByteArray?, to: File?) {
EasyStreams.write(from, FileOutputStream(to, true))
}
@Throws(IOException::class)
fun append(from: CharArray?, to: File?) {
EasyStreams.write(from, FileWriter(to, true))
}
@Throws(IOException::class)
fun append(from: CharSequence, to: File?) {
EasyStreams.write(if (from is String) from else from.toString(), FileWriter(to, true))
}
fun delete(dir: File) {
if (dir.isDirectory) {
val files = dir.listFiles() ?: return
for (file in files) {
delete(file)
}
} else {
dir.delete()
}
}
fun lineSeparator(): String {
return System.lineSeparator()
}
fun search(dir: File, name: String?): File? {
require(dir.isDirectory) { "dir can't be file." }
val files = dir.listFiles() ?: return null
if (files.isEmpty()) {
return null
}
for (file in files) {
if (file.isDirectory) {
search(file, name)
} else if (file.name.contains(name!!)) {
return file
}
}
return null
}
@Contract("_ -> new")
@Throws(FileNotFoundException::class)
fun reader(from: File?): Reader {
return InputStreamReader(FileInputStream(from), Charsets.UTF_8)
}
@Contract("_ -> new")
@Throws(FileNotFoundException::class)
fun writer(to: File?): Writer {
return OutputStreamWriter(FileOutputStream(to), Charsets.UTF_8)
}
}
@@ -75,29 +75,17 @@ class ConversationsAdapter constructor(
return return
} }
val chatUser: VkUser? = if (conversation.isUser()) { val conversationUser = VkUtils.getConversationUser(conversation, profiles)
profiles[conversation.id] val conversationGroup = VkUtils.getConversationGroup(conversation, groups)
} else null
val messageUser: VkUser? = if (message.isUser()) { val messageUser = VkUtils.getMessageUser(message, profiles)
profiles[message.fromId] val messageGroup = VkUtils.getMessageGroup(message, groups)
} else null
val chatGroup: VkGroup? = if (conversation.isGroup()) { val avatar = VkUtils.getConversationAvatar(
groups[conversation.id] conversation = conversation,
} else null conversationUser = conversationUser,
conversationGroup = conversationGroup
val messageGroup: VkGroup? = if (message.isGroup()) { )
groups[message.fromId]
} else null
val avatar = when {
conversation.ownerId == VKConstants.FAST_GROUP_ID -> null
conversation.isUser() && chatUser != null && !chatUser.photo200.isNullOrBlank() -> chatUser.photo200
conversation.isGroup() && chatGroup != null && !chatGroup.photo200.isNullOrBlank() -> chatGroup.photo200
conversation.isChat() && !conversation.photo200.isNullOrBlank() -> conversation.photo200
else -> null
}
binding.avatar.isVisible = avatar != null binding.avatar.isVisible = avatar != null
@@ -136,7 +124,7 @@ class ConversationsAdapter constructor(
} }
} }
binding.online.isVisible = chatUser?.online == true binding.online.isVisible = conversationUser?.online == true
binding.pin.isVisible = conversation.isPinned binding.pin.isVisible = conversation.isPinned
@@ -210,7 +198,8 @@ class ConversationsAdapter constructor(
binding.message.text = spanMessage binding.message.text = spanMessage
binding.title.text = binding.title.text =
getItem(position).title ?: chatUser?.toString() ?: chatGroup?.name ?: "..." getItem(position).title ?: conversationUser?.toString() ?: conversationGroup?.name
?: "..."
binding.date.text = TimeUtils.getLocalizedTime(context, message.date * 1000L) binding.date.text = TimeUtils.getLocalizedTime(context, message.date * 1000L)
@@ -232,6 +221,18 @@ class ConversationsAdapter constructor(
} }
} }
fun removeConversation(conversationId: Int): Int? {
for (i in values.indices) {
val conversation = values[i]
if (conversation.id == conversationId) {
values.removeAt(i)
return i
}
}
return null
}
companion object { companion object {
private val COMPARATOR = object : DiffUtil.ItemCallback<VkConversation>() { private val COMPARATOR = object : DiffUtil.ItemCallback<VkConversation>() {
override fun areItemsTheSame( override fun areItemsTheSame(
@@ -1,43 +1,46 @@
package com.meloda.fast.screens.conversations package com.meloda.fast.screens.conversations
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Gravity
import android.view.View import android.view.View
import android.viewbinding.library.fragment.viewBinding import android.viewbinding.library.fragment.viewBinding
import androidx.core.content.ContextCompat import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import coil.load import coil.load
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.snackbar.Snackbar import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.meloda.fast.R import com.meloda.fast.R
import com.meloda.fast.activity.MainActivity
import com.meloda.fast.api.UserConfig import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.base.BaseViewModelFragment import com.meloda.fast.base.BaseViewModelFragment
import com.meloda.fast.base.viewmodel.StartProgressEvent import com.meloda.fast.base.viewmodel.StartProgressEvent
import com.meloda.fast.base.viewmodel.StopProgressEvent import com.meloda.fast.base.viewmodel.StopProgressEvent
import com.meloda.fast.base.viewmodel.VKEvent import com.meloda.fast.base.viewmodel.VkEvent
import com.meloda.fast.common.AppGlobal
import com.meloda.fast.common.AppSettings import com.meloda.fast.common.AppSettings
import com.meloda.fast.common.dataStore import com.meloda.fast.common.dataStore
import com.meloda.fast.databinding.FragmentConversationsBinding import com.meloda.fast.databinding.FragmentConversationsBinding
import com.meloda.fast.util.AndroidUtils import com.meloda.fast.util.AndroidUtils
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class ConversationsFragment : class ConversationsFragment :
BaseViewModelFragment<ConversationsViewModel>(R.layout.fragment_conversations) { BaseViewModelFragment<ConversationsViewModel>(R.layout.fragment_conversations) {
companion object {
const val TAG = "ConversationsFragment"
}
override val viewModel: ConversationsViewModel by viewModels() override val viewModel: ConversationsViewModel by viewModels()
private val binding: FragmentConversationsBinding by viewBinding() private val binding: FragmentConversationsBinding by viewBinding()
@@ -53,8 +56,25 @@ class ConversationsFragment :
} }
} }
private val avatarPopupMenu: PopupMenu
get() =
PopupMenu(
requireContext(),
binding.avatar,
Gravity.BOTTOM
).apply {
menu.add(getString(R.string.log_out))
setOnMenuItemClickListener { item ->
if (item.title == getString(R.string.log_out)) {
showLogOutDialog()
return@setOnMenuItemClickListener true
}
false
}
}
private var isPaused = false private var isPaused = false
private var isExpanded = true
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
@@ -71,14 +91,10 @@ class ConversationsFragment :
requireContext().dataStore.data.map { requireContext().dataStore.data.map {
adapter.isMultilineEnabled = it[AppSettings.keyIsMultilineEnabled] ?: true adapter.isMultilineEnabled = it[AppSettings.keyIsMultilineEnabled] ?: true
adapter.notifyItemRangeChanged(0, adapter.itemCount) adapter.notifyItemRangeChanged(0, adapter.itemCount)
}.collect { } }.collect()
} }
binding.createChat.setOnClickListener { binding.createChat.setOnClickListener {}
Snackbar.make(it, "Test snackbar", Snackbar.LENGTH_SHORT)
.setAction("Action") {}
.show()
}
UserConfig.vkUser.observe(viewLifecycleOwner) { UserConfig.vkUser.observe(viewLifecycleOwner) {
it?.let { user -> binding.avatar.load(user.photo200) { crossfade(100) } } it?.let { user -> binding.avatar.load(user.photo200) { crossfade(100) } }
@@ -87,28 +103,32 @@ class ConversationsFragment :
binding.appBar.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener { _, verticalOffset -> binding.appBar.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener { _, verticalOffset ->
if (isPaused) return@OnOffsetChangedListener if (isPaused) return@OnOffsetChangedListener
if (verticalOffset <= -100) { binding.appBar.animate().translationZ(
binding.avatarContainer.alpha = 0f if (verticalOffset < 0) AndroidUtils.px(3).roundToInt().toFloat()
return@OnOffsetChangedListener else 0f
} ).setDuration(50).start()
val alpha = 1 - abs(verticalOffset * 0.01).toFloat() val padding = AndroidUtils.px(if (verticalOffset <= -100) 10 else 30).roundToInt()
binding.avatarContainer.updatePadding(
bottom = padding,
right = padding
)
val minusAlpha = (1 - (abs(verticalOffset) * 0.01)).toFloat()
val plusAlpha = (abs(1 + verticalOffset * 0.01) * 1.01).toFloat()
println("Fast::ConversationsFragment::onOffset offset: $verticalOffset; minusAlpha: $minusAlpha; plusAlpha: $plusAlpha")
val alpha: Float = if (verticalOffset <= -100) plusAlpha else minusAlpha
binding.avatarContainer.alpha = alpha binding.avatarContainer.alpha = alpha
}) })
if (isPaused) { binding.avatar.setOnClickListener { avatarPopupMenu.show() }
isPaused = false
return
}
binding.toolbar.overflowIcon = ContextCompat.getDrawable(requireContext(), R.drawable.test) binding.avatar.setOnLongClickListener {
lifecycleScope.launch {
viewModel.loadProfileUser()
viewModel.loadConversations()
binding.avatar.setOnClickListener {
lifecycleScope.launchWhenResumed {
requireContext().dataStore.edit { settings -> requireContext().dataStore.edit { settings ->
val isMultilineEnabled = settings[AppSettings.keyIsMultilineEnabled] ?: true val isMultilineEnabled = settings[AppSettings.keyIsMultilineEnabled] ?: true
settings[AppSettings.keyIsMultilineEnabled] = !isMultilineEnabled settings[AppSettings.keyIsMultilineEnabled] = !isMultilineEnabled
@@ -117,15 +137,59 @@ class ConversationsFragment :
adapter.notifyItemRangeChanged(0, adapter.itemCount) adapter.notifyItemRangeChanged(0, adapter.itemCount)
} }
} }
true
} }
if (isPaused) {
isPaused = false
return
}
viewModel.loadProfileUser()
viewModel.loadConversations()
} }
override fun onEvent(event: VKEvent) { private fun showLogOutDialog() {
val isEasterEgg = UserConfig.userId == UserConfig.userId
MaterialAlertDialogBuilder(requireContext())
.setTitle(
if (isEasterEgg) "Выйти внаружу?"
else getString(R.string.sign_out_confirm_title)
)
.setMessage(R.string.sign_out_confirm)
.setPositiveButton(
if (isEasterEgg) "Выйти внаружу"
else getString(R.string.action_sign_out)
) { _, _ ->
lifecycleScope.launch(Dispatchers.Default) {
UserConfig.clear()
AppGlobal.appDatabase.clearAllTables()
requireActivity().finishAffinity()
requireActivity().startActivity(
Intent(
requireContext(),
MainActivity::class.java
)
)
}
}
.setNegativeButton(R.string.no, null)
.show()
}
override fun onEvent(event: VkEvent) {
super.onEvent(event) super.onEvent(event)
when (event) { when (event) {
is ConversationsLoaded -> refreshConversations(event)
is StartProgressEvent -> onProgressStarted() is StartProgressEvent -> onProgressStarted()
is StopProgressEvent -> onProgressStopped() is StopProgressEvent -> onProgressStopped()
is ConversationsLoaded -> refreshConversations(event)
is ConversationsDelete -> deleteConversation(event.peerId)
// TODO: 10-Oct-21 remove this and sort conversations list
is ConversationsPin, is ConversationsUnpin -> viewModel.loadConversations()
} }
} }
@@ -179,13 +243,19 @@ class ConversationsFragment :
private fun fillRecyclerView(values: List<VkConversation>) { private fun fillRecyclerView(values: List<VkConversation>) {
adapter.values.clear() adapter.values.clear()
adapter.values += values adapter.values += values
adapter.notifyItemRangeChanged(0, adapter.itemCount) adapter.submitList(values)
} }
private fun onItemClick(position: Int) { private fun onItemClick(position: Int) {
val conversation = adapter[position] val conversation = adapter[position]
val user = if (conversation.isUser()) adapter.profiles[conversation.id] else null
val group = if (conversation.isGroup()) adapter.groups[conversation.id] else null val user =
if (conversation.isUser()) adapter.profiles[conversation.id]
else null
val group =
if (conversation.isGroup()) adapter.groups[conversation.id]
else null
findNavController().navigate( findNavController().navigate(
R.id.toMessagesHistory, R.id.toMessagesHistory,
@@ -198,8 +268,81 @@ class ConversationsFragment :
} }
private fun onItemLongClick(position: Int): Boolean { private fun onItemLongClick(position: Int): Boolean {
binding.createChat.performClick() showOptionsDialog(position)
return true return true
} }
private fun showOptionsDialog(position: Int) {
val conversation = adapter[position]
var canPinOneMoreDialog = true
if (adapter.itemCount > 4) {
val firstFiveDialogs = adapter.values.subList(0, 5)
var pinnedCount = 0
firstFiveDialogs.forEach { if (it.isPinned) pinnedCount++ }
if (pinnedCount == 5 && position > 4) {
canPinOneMoreDialog = false
}
}
val pin = getString(
if (conversation.isPinned) R.string.conversation_context_action_unpin
else R.string.conversation_context_action_pin
)
val delete = getString(R.string.conversation_context_action_delete)
val params = mutableListOf<String>()
if (canPinOneMoreDialog) params += pin
params += delete
val arrayParams = params.toTypedArray()
MaterialAlertDialogBuilder(requireContext())
.setItems(arrayParams) { _, which ->
when (params[which]) {
pin -> showPinConversationDialog(conversation)
delete -> showDeleteConversationDialog(conversation.id)
}
}.show()
}
private fun showDeleteConversationDialog(conversationId: Int) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.confirm_delete_conversation)
.setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteConversation(conversationId)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun deleteConversation(conversationId: Int) {
val index = adapter.removeConversation(conversationId) ?: return
adapter.notifyItemRemoved(index)
}
private fun showPinConversationDialog(conversation: VkConversation) {
val isPinned = conversation.isPinned
MaterialAlertDialogBuilder(requireContext())
.setTitle(
if (isPinned) R.string.confirm_unpin_conversation
else R.string.confirm_pin_conversation
)
.setPositiveButton(
if (isPinned) R.string.action_unpin
else R.string.action_pin
) { _, _ ->
viewModel.pinConversation(
peerId = conversation.id,
pin = !isPinned
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
} }
@@ -3,17 +3,14 @@ package com.meloda.fast.screens.conversations
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.meloda.fast.api.UserConfig import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VKConstants import com.meloda.fast.api.VKConstants
import com.meloda.fast.api.network.datasource.ConversationsDataSource
import com.meloda.fast.api.network.datasource.UsersDataSource
import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.VkUser
import com.meloda.fast.api.model.request.ConversationsGetRequest import com.meloda.fast.api.network.conversations.*
import com.meloda.fast.api.model.request.UsersGetRequest import com.meloda.fast.api.network.users.UsersDataSource
import com.meloda.fast.api.network.users.UsersGetRequest
import com.meloda.fast.base.viewmodel.BaseViewModel import com.meloda.fast.base.viewmodel.BaseViewModel
import com.meloda.fast.base.viewmodel.StartProgressEvent import com.meloda.fast.base.viewmodel.VkEvent
import com.meloda.fast.base.viewmodel.StopProgressEvent
import com.meloda.fast.base.viewmodel.VKEvent
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -22,18 +19,20 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ConversationsViewModel @Inject constructor( class ConversationsViewModel @Inject constructor(
private val dataSource: ConversationsDataSource, private val conversations: ConversationsDataSource,
private val usersDataSource: UsersDataSource private val users: UsersDataSource
) : BaseViewModel() { ) : BaseViewModel() {
fun loadConversations() = viewModelScope.launch(Dispatchers.Default) { fun loadConversations(
offset: Int? = null
) = viewModelScope.launch(Dispatchers.Default) {
makeJob({ makeJob({
dataSource.getAllChats( conversations.get(
ConversationsGetRequest( ConversationsGetRequest(
count = 30, count = 30,
// offset = 177,
extended = true, extended = true,
fields = "${VKConstants.USER_FIELDS},${VKConstants.GROUP_FIELDS}" offset = offset,
fields = VKConstants.ALL_FIELDS
) )
) )
}, },
@@ -52,6 +51,7 @@ class ConversationsViewModel @Inject constructor(
sendEvent( sendEvent(
ConversationsLoaded( ConversationsLoaded(
count = response.count, count = response.count,
offset = offset,
unreadCount = response.unreadCount ?: 0, unreadCount = response.unreadCount ?: 0,
conversations = response.items.map { items -> conversations = response.items.map { items ->
items.conversation.asVkConversation( items.conversation.asVkConversation(
@@ -63,34 +63,59 @@ class ConversationsViewModel @Inject constructor(
) )
) )
} }
}, }
onError = { )
val er = it
throw it
},
onStart = { sendEvent(StartProgressEvent) },
onEnd = { sendEvent(StopProgressEvent) })
} }
fun loadProfileUser() = viewModelScope.launch { fun loadProfileUser() = viewModelScope.launch {
makeJob({ makeJob({ users.getById(UsersGetRequest(fields = VKConstants.USER_FIELDS)) },
usersDataSource.getById(UsersGetRequest(fields = "online,photo_200"))
},
onAnswer = { onAnswer = {
it.response?.let { r -> it.response?.let { r ->
val users = r.map { u -> u.asVkUser() } val users = r.map { u -> u.asVkUser() }
usersDataSource.storeUsers(users) this@ConversationsViewModel.users.storeUsers(users)
UserConfig.vkUser.value = users[0] UserConfig.vkUser.value = users[0]
} }
}) })
} }
fun deleteConversation(peerId: Int) = viewModelScope.launch {
makeJob({
conversations.delete(
ConversationsDeleteRequest(peerId)
)
}, onAnswer = { sendEvent(ConversationsDelete(peerId)) })
}
fun pinConversation(
peerId: Int,
pin: Boolean
) = viewModelScope.launch {
if (pin) {
makeJob(
{ conversations.pin(ConversationsPinRequest(peerId)) },
onAnswer = { sendEvent(ConversationsPin(peerId)) }
)
} else {
makeJob(
{ conversations.unpin(ConversationsUnpinRequest(peerId)) },
onAnswer = { sendEvent(ConversationsUnpin(peerId)) }
)
}
}
} }
data class ConversationsLoaded( data class ConversationsLoaded(
val count: Int, val count: Int,
val offset: Int?,
val unreadCount: Int?, val unreadCount: Int?,
val conversations: List<VkConversation>, val conversations: List<VkConversation>,
val profiles: HashMap<Int, VkUser>, val profiles: HashMap<Int, VkUser>,
val groups: HashMap<Int, VkGroup> val groups: HashMap<Int, VkGroup>
) : VKEvent() ) : VkEvent()
data class ConversationsDelete(val peerId: Int) : VkEvent()
data class ConversationsPin(val peerId: Int) : VkEvent()
data class ConversationsUnpin(val peerId: Int) : VkEvent()
@@ -1,11 +1,17 @@
package com.meloda.fast.screens.login package com.meloda.fast.screens.login
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.viewbinding.library.fragment.viewBinding import android.viewbinding.library.fragment.viewBinding
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
@@ -19,6 +25,8 @@ import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import com.meloda.fast.BuildConfig import com.meloda.fast.BuildConfig
import com.meloda.fast.R import com.meloda.fast.R
import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VKConstants
import com.meloda.fast.base.BaseViewModelFragment import com.meloda.fast.base.BaseViewModelFragment
import com.meloda.fast.base.viewmodel.* import com.meloda.fast.base.viewmodel.*
import com.meloda.fast.databinding.DialogCaptchaBinding import com.meloda.fast.databinding.DialogCaptchaBinding
@@ -29,7 +37,10 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jsoup.Jsoup
import java.net.URLEncoder
import java.util.* import java.util.*
import java.util.regex.Pattern
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
@AndroidEntryPoint @AndroidEntryPoint
@@ -59,14 +70,14 @@ class LoginFragment : BaseViewModelFragment<LoginViewModel>(R.layout.fragment_lo
binding.loginInput.clearFocus() binding.loginInput.clearFocus()
} }
override fun onEvent(event: VKEvent) { override fun onEvent(event: VkEvent) {
super.onEvent(event) super.onEvent(event)
when (event) { when (event) {
is ShowError -> showErrorSnackbar(event.errorDescription) is ErrorEvent -> showErrorSnackbar(event.errorText)
is CaptchaEvent -> showCaptchaDialog(event.sid, event.image) is CaptchaEvent -> showCaptchaDialog(event.sid, event.image)
is ValidationEvent -> showValidationRequired(event.sid) is ValidationEvent -> showValidationRequired(event.sid)
is SuccessAuth -> goToMain(event.haveAuthorized) is SuccessAuth -> goToMain(event)
is CodeSent -> showValidationDialog() is CodeSent -> showValidationDialog()
is StartProgressEvent -> onProgressStarted() is StartProgressEvent -> onProgressStarted()
@@ -89,11 +100,91 @@ class LoginFragment : BaseViewModelFragment<LoginViewModel>(R.layout.fragment_lo
} }
private fun prepareViews() { private fun prepareViews() {
prepareWebView()
prepareEmailEditText() prepareEmailEditText()
preparePasswordEditText() preparePasswordEditText()
prepareAuthButton() prepareAuthButton()
} }
@SuppressLint("SetJavaScriptEnabled")
private fun prepareWebView() {
with(binding.webView) {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
clearCache(true)
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
parseAuthUrl(url)
}
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
val a = Jsoup.parse(url)
val b = 0
}
}
}
CookieManager.getInstance().apply {
removeAllCookies(null)
flush()
setAcceptCookie(true)
}
}
private fun launchWebView() {
binding.webView.loadUrl(
"https://oauth.vk.com/authorize?client_id=${UserConfig.FAST_APP_ID}&" +
"display=mobile&scope=136297695&" +
"redirect_uri=${
URLEncoder.encode(
"https://oauth.vk.com/blank.html",
Charsets.UTF_8.toString()
)
}&response_type=token&v=${VKConstants.API_VERSION}"
)
}
private fun parseAuthUrl(url: String) {
if (url.isBlank()) return
if (url.startsWith("https://oauth.vk.com/blank.html")) {
if (url.contains("error")) {
Log.e("Fast::Login", "errorUrl: $url")
return
}
val authData = parseRedirectUrl(url)
if (authData == null) {
Log.e("Fast::Login", "errorUrl: $url")
return
}
val token = authData.first
UserConfig.fastToken = token
}
}
private fun parseRedirectUrl(url: String): Pair<String, Int>? {
val accessToken = extractPattern(url, "access_token=(.*?)&") ?: return null
val userId = extractPattern(url, "id=(\\d*)")?.toIntOrNull() ?: return null
return accessToken to userId
}
private fun extractPattern(string: String, pattern: String): String? {
val p = Pattern.compile(pattern)
val m = p.matcher(string)
return if (m.find()) {
m.group(1)
} else null
}
private fun prepareEmailEditText() { private fun prepareEmailEditText() {
binding.loginInput.addTextChangedListener { binding.loginInput.addTextChangedListener {
if (!binding.loginLayout.error.isNullOrBlank()) binding.loginLayout.error = "" if (!binding.loginLayout.error.isNullOrBlank()) binding.loginLayout.error = ""
@@ -293,8 +384,13 @@ class LoginFragment : BaseViewModelFragment<LoginViewModel>(R.layout.fragment_lo
snackbar.show() snackbar.show()
} }
private fun goToMain(haveAuthorized: Boolean) = lifecycleScope.launch { private fun goToMain(event: SuccessAuth) = lifecycleScope.launch {
if (haveAuthorized) delay(500) UserConfig.userId = event.userId
UserConfig.accessToken = event.vkToken
if (event.haveAuthorized) delay(500)
launchWebView()
findNavController().navigate(R.id.toMain) findNavController().navigate(R.id.toMain)
} }
@@ -1,11 +1,10 @@
package com.meloda.fast.screens.login package com.meloda.fast.screens.login
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VKConstants import com.meloda.fast.api.VKConstants
import com.meloda.fast.api.VKException import com.meloda.fast.api.VKException
import com.meloda.fast.api.model.request.RequestAuthDirect import com.meloda.fast.api.network.auth.AuthDataSource
import com.meloda.fast.api.network.datasource.AuthDataSource import com.meloda.fast.api.network.auth.RequestAuthDirect
import com.meloda.fast.base.viewmodel.* import com.meloda.fast.base.viewmodel.*
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -45,33 +44,37 @@ class LoginViewModel @Inject constructor(
return@makeJob return@makeJob
} }
UserConfig.userId = it.userId sendEvent(
UserConfig.accessToken = it.accessToken SuccessAuth(
userId = it.userId,
sendEvent(SuccessAuth()) vkToken = it.accessToken
)
)
}, },
onError = { onError = {
if (it !is VKException) return@makeJob if (it !is VKException) {
onError(it)
return@makeJob
}
// TODO: 9/27/2021 use `delay` parameter
twoFaCode?.let { sendEvent(CodeSent) } twoFaCode?.let { sendEvent(CodeSent) }
}, }
onStart = { sendEvent(StartProgressEvent) },
onEnd = { sendEvent(StopProgressEvent) }
) )
} }
fun sendSms(validationSid: String) = viewModelScope.launch { fun sendSms(validationSid: String) = viewModelScope.launch {
makeJob({ dataSource.sendSms(validationSid) }, makeJob({ dataSource.sendSms(validationSid) },
onAnswer = { sendEvent(CodeSent) }, onAnswer = { sendEvent(CodeSent) }
onError = {}, )
onStart = {},
onEnd = {})
} }
} }
data class ShowError(val errorDescription: String) : VKEvent() object CodeSent : VkEvent()
object CodeSent : VKEvent() data class SuccessAuth(
val haveAuthorized: Boolean = true,
data class SuccessAuth(val haveAuthorized: Boolean = true) : VKEvent() val userId: Int,
val vkToken: String
) : VkEvent()
@@ -42,5 +42,4 @@ class MainFragment : BaseViewModelFragment<MainViewModel>(R.layout.fragment_main
} }
} }
} }
@@ -15,9 +15,11 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isNotEmpty import androidx.core.view.isNotEmpty
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.setPadding import androidx.core.view.setPadding
import androidx.core.view.updatePadding
import coil.load import coil.load
import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.imageview.ShapeableImageView
import com.meloda.fast.R import com.meloda.fast.R
import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VkUtils import com.meloda.fast.api.VkUtils
import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkMessage
@@ -30,9 +32,11 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.roundToInt import kotlin.math.roundToInt
// TODO: 9/29/2021 use recyclerview for viewing attachments
class AttachmentInflater constructor( class AttachmentInflater constructor(
private val context: Context, private val context: Context,
private val container: LinearLayoutCompat, private val container: LinearLayoutCompat,
private val textContainer: LinearLayoutCompat,
private val message: VkMessage, private val message: VkMessage,
private val profiles: Map<Int, VkUser>, private val profiles: Map<Int, VkUser>,
private val groups: Map<Int, VkGroup> private val groups: Map<Int, VkGroup>
@@ -44,16 +48,28 @@ class AttachmentInflater constructor(
private val playColor = ContextCompat.getColor(context, R.color.a3_700) private val playColor = ContextCompat.getColor(context, R.color.a3_700)
private val playBackgroundColor = ContextCompat.getColor(context, R.color.a3_200) private val playBackgroundColor = ContextCompat.getColor(context, R.color.a3_200)
var photoClickListener: ((url: String) -> Unit)? = null
fun setPhotoClickListener(unit: ((url: String) -> Unit)?): AttachmentInflater {
this.photoClickListener = unit
return this
}
fun inflate() { fun inflate() {
if (message.attachments.isNullOrEmpty()) return if (message.attachments.isNullOrEmpty()) return
attachments = message.attachments!! attachments = message.attachments!!
container.removeAllViews() container.removeAllViews()
textContainer.removeAllViews()
if (attachments.size == 1) { if (attachments.size == 1) {
when (val attachment = attachments[0]) { when (val attachment = attachments[0]) {
is VkSticker -> return sticker(attachment) is VkSticker -> return sticker(attachment)
is VkWall -> return wall(attachment) is VkWall -> return wall(attachment)
is VkVoiceMessage -> return voice(attachment)
is VkCall -> return call(attachment)
is VkGraffiti -> return graffiti(attachment)
is VkGift -> return gift(attachment)
} }
} }
@@ -82,7 +98,6 @@ class AttachmentInflater constructor(
is VkAudio -> audio(attachment) is VkAudio -> audio(attachment)
is VkFile -> file(attachment) is VkFile -> file(attachment)
is VkLink -> link(attachment) is VkLink -> link(attachment)
is VkStory -> story(attachment)
else -> Log.e( else -> Log.e(
"Attachment inflater", "Attachment inflater",
@@ -94,12 +109,15 @@ class AttachmentInflater constructor(
} }
private fun photo(photo: VkPhoto) { private fun photo(photo: VkPhoto) {
val size = photo.sizeOfType('m') ?: return val size = photo.getSizeOrSmaller('y') ?: return
val newPhoto = ShapeableImageView(context).apply { val newPhoto = ShapeableImageView(context).apply {
layoutParams = LinearLayoutCompat.LayoutParams( layoutParams = LinearLayoutCompat.LayoutParams(
AndroidUtils.px(size.width).roundToInt(), // ViewGroup.LayoutParams.MATCH_PARENT,
AndroidUtils.px(size.height).roundToInt() size.width,
size.height
// AndroidUtils.px(size.width).roundToInt(),
// AndroidUtils.px(size.height).roundToInt()
) )
shapeAppearanceModel = shapeAppearanceModel =
@@ -110,6 +128,12 @@ class AttachmentInflater constructor(
scaleType = ImageView.ScaleType.CENTER_CROP scaleType = ImageView.ScaleType.CENTER_CROP
} }
if (photoClickListener != null) {
newPhoto.setOnClickListener { photoClickListener?.invoke(size.url) }
} else {
newPhoto.setOnClickListener(null)
}
val spacer = Space(context).also { val spacer = Space(context).also {
it.layoutParams = LinearLayoutCompat.LayoutParams( it.layoutParams = LinearLayoutCompat.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
@@ -222,14 +246,7 @@ class AttachmentInflater constructor(
binding.caption.text = link.caption binding.caption.text = link.caption
binding.caption.isVisible = !link.caption.isNullOrBlank() binding.caption.isVisible = !link.caption.isNullOrBlank()
binding.preview.shapeAppearanceModel.toBuilder() link.photo?.getMaxSize()?.let {
.setAllCornerSizes(40f)
.build()
.let {
binding.preview.shapeAppearanceModel = it
}
link.photo?.sizeOfType('m')?.let {
binding.preview.load(it.url) { crossfade(150) } binding.preview.load(it.url) { crossfade(150) }
binding.preview.isVisible = true binding.preview.isVisible = true
return return
@@ -245,8 +262,8 @@ class AttachmentInflater constructor(
with(binding.image) { with(binding.image) {
layoutParams = LinearLayoutCompat.LayoutParams( layoutParams = LinearLayoutCompat.LayoutParams(
AndroidUtils.px(180).roundToInt(), AndroidUtils.px(140).roundToInt(),
AndroidUtils.px(180).roundToInt() AndroidUtils.px(140).roundToInt()
) )
load(url) { crossfade(150) } load(url) { crossfade(150) }
@@ -282,7 +299,7 @@ class AttachmentInflater constructor(
binding.avatar.isVisible = group != null || user != null binding.avatar.isVisible = group != null || user != null
binding.avatar.shapeAppearanceModel.toBuilder() binding.avatar.shapeAppearanceModel.toBuilder()
.setAllCornerSizes(40f) .setAllCornerSizes(AndroidUtils.px(20))
.build() .build()
.let { .let {
binding.avatar.shapeAppearanceModel = it binding.avatar.shapeAppearanceModel = it
@@ -302,8 +319,91 @@ class AttachmentInflater constructor(
).format(wall.date * 1000L) ).format(wall.date * 1000L)
} }
private fun story(story: VkStory) { private fun voice(voiceMessage: VkVoiceMessage) {
val binding = ItemMessageAttachmentVoiceBinding.inflate(inflater, textContainer, true)
if (message.isOut)
binding.root.updatePadding(
bottom = AndroidUtils.px(6).roundToInt(),
left = AndroidUtils.px(6).roundToInt()
)
val waveform = IntArray(voiceMessage.waveform.size)
voiceMessage.waveform.forEachIndexed { index, i -> waveform[index] = i }
binding.waveform.sample = waveform
binding.waveform.maxProgress = 100f
binding.waveform.progress = 100f
binding.duration.text = SimpleDateFormat(
"mm:ss",
Locale.getDefault()
).format(voiceMessage.duration * 1000L)
}
private fun call(call: VkCall) {
val binding = ItemMessageAttachmentCallBinding.inflate(inflater, textContainer, true)
if (message.isOut)
binding.root.updatePadding(
bottom = AndroidUtils.px(5).roundToInt(),
left = AndroidUtils.px(6).roundToInt()
)
val callType =
context.getString(
if (call.initiatorId == UserConfig.userId) R.string.message_call_type_outgoing
else R.string.message_call_type_incoming
)
binding.type.text = callType
var callState =
context.getString(
if (call.state == "reached") R.string.message_call_state_ended
else if (call.state == "canceled_by_initiator") {
if (call.initiatorId == UserConfig.userId) R.string.message_call_state_cancelled
else R.string.message_call_state_missed
} else R.string.message_call_unknown
)
if (callState == context.getString(R.string.message_call_unknown)) callState = call.state
binding.state.text = callState
}
private fun graffiti(graffiti: VkGraffiti) {
val binding = ItemMessageAttachmentGraffitiBinding.inflate(inflater, container, true)
val url = graffiti.url
val heightCoefficient = graffiti.height / AndroidUtils.px(140)
with(binding.image) {
layoutParams = LinearLayoutCompat.LayoutParams(
AndroidUtils.px(140).roundToInt(),
(graffiti.height / heightCoefficient).roundToInt()
)
load(url) { crossfade(150) }
}
}
private fun gift(gift: VkGift) {
val binding = ItemMessageAttachmentGiftBinding.inflate(inflater, container, true)
val url = gift.thumb256 ?: gift.thumb96 ?: gift.thumb48
with(binding.image) {
shapeAppearanceModel = shapeAppearanceModel.withCornerSize { AndroidUtils.px(12) }
layoutParams = LinearLayoutCompat.LayoutParams(
AndroidUtils.px(140).roundToInt(),
AndroidUtils.px(140).roundToInt()
)
load(url) { crossfade(150) }
}
} }
} }
@@ -5,6 +5,7 @@ import android.graphics.Color
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.LinearLayoutCompat import androidx.appcompat.widget.LinearLayoutCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
@@ -29,7 +30,9 @@ class MessagesHistoryAdapter constructor(
val conversation: VkConversation, val conversation: VkConversation,
val profiles: HashMap<Int, VkUser> = hashMapOf(), val profiles: HashMap<Int, VkUser> = hashMapOf(),
val groups: HashMap<Int, VkGroup> = hashMapOf() val groups: HashMap<Int, VkGroup> = hashMapOf()
) : BaseAdapter<VkMessage, MessagesHistoryAdapter.Holder>(context, values, COMPARATOR) { ) : BaseAdapter<VkMessage, MessagesHistoryAdapter.BasicHolder>(context, values, COMPARATOR) {
var avatarLongClickListener: ((position: Int) -> Unit)? = null
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {
when { when {
@@ -49,7 +52,7 @@ class MessagesHistoryAdapter constructor(
private fun isPositionHeader(position: Int) = position == 0 private fun isPositionHeader(position: Int) = position == 0
private fun isPositionFooter(position: Int) = position >= actualSize private fun isPositionFooter(position: Int) = position >= actualSize
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BasicHolder {
return when (viewType) { return when (viewType) {
// magick numbers is great! // magick numbers is great!
HEADER -> Header(createEmptyView(60)) HEADER -> Header(createEmptyView(60))
@@ -67,6 +70,21 @@ class MessagesHistoryAdapter constructor(
} }
} }
// override fun initListeners(itemView: View, position: Int) {
// if (itemView is AdapterView<*>) return
//
// itemView.setOnClickListener { onItemClickListener?.invoke(position, itemView) }
// itemView.setOnLongClickListener { itemLongClickListener.invoke(position) }
// }
val actualSize get() = values.size
override fun getItemCount(): Int {
if (actualSize == 0) return 2
return super.getItemCount() + 2
}
private fun createEmptyView(size: Int) = View(context).apply { private fun createEmptyView(size: Int) = View(context).apply {
layoutParams = ViewGroup.LayoutParams( layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
@@ -78,22 +96,22 @@ class MessagesHistoryAdapter constructor(
isFocusable = false isFocusable = false
} }
override fun onBindViewHolder(holder: Holder, position: Int) { override fun onBindViewHolder(holder: BasicHolder, position: Int) {
if (holder is Header || holder is Footer) return if (holder is Header || holder is Footer) return
initListeners(holder.itemView, position) initListeners(holder.itemView, position)
holder.bind(position) holder.bind(position)
} }
open inner class Holder(v: View = View(context)) : BaseHolder(v) open inner class BasicHolder(v: View = View(context)) : BaseHolder(v)
inner class Header(v: View) : Holder(v) inner class Header(v: View) : BasicHolder(v)
inner class Footer(v: View) : Holder(v) inner class Footer(v: View) : BasicHolder(v)
inner class IncomingMessage( inner class IncomingMessage(
private val binding: ItemMessageInBinding private val binding: ItemMessageInBinding
) : Holder(binding.root) { ) : BasicHolder(binding.root) {
override fun bind(position: Int) { override fun bind(position: Int) {
val message = getItem(position) val message = getItem(position)
@@ -103,33 +121,42 @@ class MessagesHistoryAdapter constructor(
MessagesPreparator( MessagesPreparator(
context = context, context = context,
root = binding.root,
conversation = conversation, conversation = conversation,
message = message, message = message,
prevMessage = prevMessage, prevMessage = prevMessage,
nextMessage = nextMessage, nextMessage = nextMessage,
title = binding.title,
avatar = binding.avatar, avatar = binding.avatar,
bubble = binding.bubble, bubble = binding.bubble,
text = binding.text, text = binding.text,
spacer = binding.spacer, spacer = binding.spacer,
time = binding.time,
unread = binding.unread, unread = binding.unread,
textContainer = binding.textContainer,
attachmentContainer = binding.attachmentContainer, attachmentContainer = binding.attachmentContainer,
attachmentSpacer = binding.attachmentSpacer, attachmentSpacer = binding.attachmentSpacer,
profiles = profiles, profiles = profiles,
groups = groups groups = groups
).prepare() ).setPhotoClickListener {
Toast.makeText(context, "Photo url: $it", Toast.LENGTH_LONG).show()
}.prepare()
binding.avatar.setOnLongClickListener() {
avatarLongClickListener?.invoke(position)
true
}
} }
} }
inner class OutgoingMessage( inner class OutgoingMessage(
private val binding: ItemMessageOutBinding private val binding: ItemMessageOutBinding
) : Holder(binding.root) { ) : BasicHolder(binding.root) {
init {
binding.bubbleStroke.setOnClickListener { binding.bubble.performClick() }
}
override fun bind(position: Int) { override fun bind(position: Int) {
val message = getItem(position) val message = getItem(position)
@@ -138,16 +165,17 @@ class MessagesHistoryAdapter constructor(
MessagesPreparator( MessagesPreparator(
context = context, context = context,
root = binding.root,
conversation = conversation, conversation = conversation,
message = message, message = message,
prevMessage = prevMessage, prevMessage = prevMessage,
bubble = binding.bubble, bubble = binding.bubble,
bubbleStroke = binding.bubbleStroke,
text = binding.text, text = binding.text,
spacer = binding.spacer, spacer = binding.spacer,
time = binding.time,
unread = binding.unread, unread = binding.unread,
textContainer = binding.textContainer,
attachmentContainer = binding.attachmentContainer, attachmentContainer = binding.attachmentContainer,
attachmentSpacer = binding.attachmentSpacer, attachmentSpacer = binding.attachmentSpacer,
@@ -159,7 +187,7 @@ class MessagesHistoryAdapter constructor(
inner class ServiceMessage( inner class ServiceMessage(
private val binding: ItemMessageServiceBinding private val binding: ItemMessageServiceBinding
) : Holder(binding.root) { ) : BasicHolder(binding.root) {
private val youPrefix = context.getString(R.string.you_message_prefix) private val youPrefix = context.getString(R.string.you_message_prefix)
@@ -198,7 +226,7 @@ class MessagesHistoryAdapter constructor(
binding.photo.isVisible = true binding.photo.isVisible = true
val size = attachment.sizeOfType('m') ?: return@let val size = attachment.getSizeOrSmaller('y') ?: return@let
binding.photo.layoutParams = LinearLayoutCompat.LayoutParams( binding.photo.layoutParams = LinearLayoutCompat.LayoutParams(
size.width, size.width,
@@ -213,11 +241,39 @@ class MessagesHistoryAdapter constructor(
} }
} }
private val actualSize get() = values.size fun removeMessageById(id: Int): Int? {
for (i in values.indices) {
val message = values[i]
if (message.id == id) {
values.removeAt(i)
return i
}
}
override fun getItemCount(): Int { return null
if (actualSize == 0) return 2 }
return super.getItemCount() + 2
fun removeMessagesByIds(ids: List<Int>): List<Int> {
val positions = mutableListOf<Int>()
for (i in values.indices) {
val message = values[i]
if (ids.contains(message.id)) {
values.removeAt(i)
positions += i
}
}
return positions
}
fun searchMessageIndex(messageId: Int): Int? {
for (i in values.indices) {
val message = values[i]
if (message.id == messageId) return i
}
return null
} }
companion object { companion object {
@@ -1,12 +1,16 @@
package com.meloda.fast.screens.messages package com.meloda.fast.screens.messages
import android.graphics.Color import android.content.res.ColorStateList
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.view.View import android.view.View
import android.viewbinding.library.fragment.viewBinding import android.viewbinding.library.fragment.viewBinding
import android.widget.Toast
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.setPadding
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@@ -16,6 +20,8 @@ import coil.load
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.meloda.fast.R import com.meloda.fast.R
import com.meloda.fast.api.UserConfig import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VKConstants
import com.meloda.fast.api.VkUtils
import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkMessage
@@ -23,7 +29,8 @@ import com.meloda.fast.api.model.VkUser
import com.meloda.fast.base.BaseViewModelFragment import com.meloda.fast.base.BaseViewModelFragment
import com.meloda.fast.base.viewmodel.StartProgressEvent import com.meloda.fast.base.viewmodel.StartProgressEvent
import com.meloda.fast.base.viewmodel.StopProgressEvent import com.meloda.fast.base.viewmodel.StopProgressEvent
import com.meloda.fast.base.viewmodel.VKEvent import com.meloda.fast.base.viewmodel.VkEvent
import com.meloda.fast.databinding.DialogMessageDeleteBinding
import com.meloda.fast.databinding.FragmentMessagesHistoryBinding import com.meloda.fast.databinding.FragmentMessagesHistoryBinding
import com.meloda.fast.extensions.TextViewExtensions.clear import com.meloda.fast.extensions.TextViewExtensions.clear
import com.meloda.fast.util.AndroidUtils import com.meloda.fast.util.AndroidUtils
@@ -32,6 +39,7 @@ import dagger.hilt.android.AndroidEntryPoint
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class MessagesHistoryFragment : class MessagesHistoryFragment :
@@ -43,7 +51,7 @@ class MessagesHistoryFragment :
private val action = MutableLiveData<Action>() private val action = MutableLiveData<Action>()
private enum class Action { private enum class Action {
RECORD, SEND RECORD, SEND, EDIT, DELETE
} }
private val user: VkUser? by lazy { private val user: VkUser? by lazy {
@@ -62,14 +70,19 @@ class MessagesHistoryFragment :
MessagesHistoryAdapter(requireContext(), mutableListOf(), conversation).also { MessagesHistoryAdapter(requireContext(), mutableListOf(), conversation).also {
it.itemClickListener = this::onItemClick it.itemClickListener = this::onItemClick
it.itemLongClickListener = this::onItemLongClick it.itemLongClickListener = this::onItemLongClick
it.avatarLongClickListener = this::onAvatarLongClickListener
} }
} }
private var timestampTimer: Timer? = null private var timestampTimer: Timer? = null
private lateinit var attachmentController: AttachmentPanelController
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
attachmentController = AttachmentPanelController().init()
val title = when { val title = when {
conversation.isChat() -> conversation.title conversation.isChat() -> conversation.title
conversation.isUser() -> user?.toString() conversation.isUser() -> user?.toString()
@@ -98,19 +111,7 @@ class MessagesHistoryFragment :
binding.status.text = status ?: "..." binding.status.text = status ?: "..."
val avatar = when { prepareAvatar()
conversation.isChat() -> conversation.photo200
conversation.isUser() -> user?.photo200
conversation.isGroup() -> group?.photo200
else -> null
}
binding.avatar.load(avatar) {
crossfade(false)
error(ColorDrawable(Color.RED))
}
binding.online.isVisible = user?.online == true
prepareViews() prepareViews()
@@ -166,8 +167,14 @@ class MessagesHistoryFragment :
}) })
binding.message.doAfterTextChanged { binding.message.doAfterTextChanged {
val newValue = if (it.toString().isNotBlank()) Action.SEND val canSend = it.toString().isNotBlank()
else Action.RECORD
val newValue: Action =
when {
attachmentController.isEditing -> if (it.isNullOrBlank()) Action.DELETE else Action.EDIT
canSend -> Action.SEND
else -> Action.RECORD
}
if (action.value != newValue) action.value = newValue if (action.value != newValue) action.value = newValue
} }
@@ -192,52 +199,159 @@ class MessagesHistoryFragment :
Action.SEND -> { Action.SEND -> {
binding.action.setImageResource(R.drawable.ic_round_send_24) binding.action.setImageResource(R.drawable.ic_round_send_24)
} }
Action.EDIT -> {
binding.action.setImageResource(R.drawable.ic_round_done_24)
}
Action.DELETE -> {
binding.action.setImageResource(R.drawable.ic_trash_can_outline_24)
}
else -> return@observe else -> return@observe
} }
} }
attachmentController.isPanelVisible.observe(viewLifecycleOwner) {
if (it) binding.message.setSelection(binding.message.text.toString().length)
val layoutParams = binding.refreshLayout.layoutParams as CoordinatorLayout.LayoutParams
layoutParams.bottomMargin =
if (it) (binding.attachmentPanel.height / 1.5).roundToInt() else 0
}
binding.attachmentPanel.setOnClickListener c@{
val message = attachmentController.message.value ?: return@c
val index = adapter.values.indexOf(message)
if (index == -1) return@c
binding.recyclerView.smoothScrollToPosition(index)
}
binding.dismissReply.setOnClickListener {
if (attachmentController.message.value != null)
attachmentController.message.value = null
}
}
private fun prepareAvatar() {
val avatar = when {
conversation.ownerId == VKConstants.FAST_GROUP_ID -> null
conversation.isUser() -> user?.photo200
conversation.isGroup() -> group?.photo200
conversation.isChat() -> conversation.photo200
else -> null
}
binding.avatar.isVisible = avatar != null
if (avatar == null) {
binding.avatarPlaceholder.isVisible = true
if (conversation.ownerId == VKConstants.FAST_GROUP_ID) {
binding.placeholderBack.setImageDrawable(
ColorDrawable(
ContextCompat.getColor(requireContext(), R.color.a1_400)
)
)
binding.placeholder.imageTintList =
ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.a1_0))
binding.placeholder.setImageResource(R.drawable.ic_fast_logo)
binding.placeholder.setPadding(18)
} else {
binding.placeholderBack.setImageDrawable(
ColorDrawable(
ContextCompat.getColor(requireContext(), R.color.n1_50)
)
)
binding.placeholder.imageTintList =
ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.n2_500))
binding.placeholder.setImageResource(R.drawable.ic_account_circle_cut)
binding.placeholder.setPadding(0)
binding.avatar.setImageDrawable(null)
}
} else {
binding.avatar.load(avatar) {
crossfade(200)
target {
binding.avatarPlaceholder.isVisible = false
binding.avatar.setImageDrawable(it)
}
}
}
binding.phantomIcon.isVisible = conversation.isPhantom
binding.online.isVisible = user?.online == true
binding.pin.isVisible = conversation.isPinned
}
private fun performAction() {
when (action.value) {
Action.RECORD -> {
}
Action.SEND -> {
val messageText = binding.message.text.toString().trim()
if (messageText.isBlank()) return
val date = System.currentTimeMillis()
val message = VkMessage(
id = -1,
text = messageText,
isOut = true,
peerId = conversation.id,
fromId = UserConfig.userId,
date = (date / 1000).toInt(),
randomId = 0,
replyMessage = attachmentController.message.value
)
adapter.add(message)
adapter.notifyItemInserted(adapter.actualSize - 1)
binding.recyclerView.smoothScrollToPosition(adapter.lastPosition)
binding.message.clear()
val replyMessage = attachmentController.message.value
attachmentController.message.value = null
viewModel.sendMessage(
peerId = conversation.id,
message = messageText,
randomId = 0,
replyTo = replyMessage?.id
) { message.id = it }
}
Action.EDIT -> {
val message = attachmentController.message.value ?: return
val messageText = binding.message.text.toString().trim()
attachmentController.message.value = null
viewModel.editMessage(
originalMessage = message,
peerId = conversation.id,
messageId = message.id,
message = messageText,
attachments = message.attachments
)
}
Action.DELETE -> attachmentController.message.value?.let {
showDeleteMessageDialog(it)
}
}
} }
override fun onEvent(event: VkEvent) {
private fun performAction() {
if (action.value == Action.RECORD) {
} else if (action.value == Action.SEND) {
val messageText = binding.message.text.toString().trim()
if (messageText.isBlank()) return
val date = System.currentTimeMillis()
var message = VkMessage(
id = -1,
text = messageText,
isOut = true,
peerId = conversation.id,
fromId = UserConfig.userId,
date = (date / 1000).toInt(),
randomId = 0
)
adapter.add(message)
adapter.notifyDataSetChanged()
binding.recyclerView.smoothScrollToPosition(adapter.lastPosition)
binding.message.clear()
viewModel.sendMessage(
peerId = conversation.id,
message = messageText,
randomId = 0
) { message = message.copyMessage(id = it) }
}
}
override fun onEvent(event: VKEvent) {
super.onEvent(event) super.onEvent(event)
when (event) { when (event) {
is MessagesMarkAsImportant -> markMessagesAsImportant(event)
is MessagesLoaded -> refreshMessages(event)
is StartProgressEvent -> onProgressStarted() is StartProgressEvent -> onProgressStarted()
is StopProgressEvent -> onProgressStopped() is StopProgressEvent -> onProgressStopped()
is MessagesMarkAsImportant -> markMessagesAsImportant(event)
is MessagesLoaded -> refreshMessages(event)
is MessagesPin -> conversation.pinnedMessage = event.message
is MessagesUnpin -> conversation.pinnedMessage = null
is MessagesDelete -> deleteMessages(event)
is MessagesEdit -> editMessage(event)
} }
} }
@@ -283,17 +397,21 @@ class MessagesHistoryFragment :
private fun markMessagesAsImportant(event: MessagesMarkAsImportant) { private fun markMessagesAsImportant(event: MessagesMarkAsImportant) {
var changed = false var changed = false
val positions = mutableListOf<Int>()
for (i in adapter.values.indices) { for (i in adapter.values.indices) {
val message = adapter.values[i] val message = adapter.values[i]
message.important = event.important
if (event.messagesIds.contains(message.id)) { if (event.messagesIds.contains(message.id)) {
if (!changed) changed = true if (!changed) changed = true
adapter.values[i] = message.copyMessage(
important = event.important positions.add(i)
)
adapter.values[i] = message
} }
} }
if (changed) adapter.notifyDataSetChanged() if (changed) positions.forEach { adapter.notifyItemChanged(it) }
} }
private fun refreshMessages(event: MessagesLoaded) { private fun refreshMessages(event: MessagesLoaded) {
@@ -315,30 +433,235 @@ class MessagesHistoryFragment :
} }
private fun onItemClick(position: Int) { private fun onItemClick(position: Int) {
showOptionsDialog(position)
}
private fun onItemLongClick(position: Int) = true
private fun onAvatarLongClickListener(position: Int) {
val message = adapter.values[position]
val messageUser = VkUtils.getMessageUser(message, adapter.profiles)
val messageGroup = VkUtils.getMessageGroup(message, adapter.groups)
val title = VkUtils.getMessageTitle(message, messageUser, messageGroup)
Toast.makeText(requireContext(), title, Toast.LENGTH_SHORT).show()
}
private fun showOptionsDialog(position: Int) {
val message = adapter.values[position] val message = adapter.values[position]
if (message.action != null) return if (message.action != null) return
val important = if (message.important) "Unmark as important" else "Mark as important" val time = getString(
R.string.time_format,
SimpleDateFormat(
"dd.MM.yyyy, HH:mm:ss",
Locale.getDefault()
).format(message.date * 1000L)
)
val params = arrayOf(important) val important = getString(
if (message.important) R.string.message_context_action_unmark_as_important
else R.string.message_context_action_mark_as_important
)
val dialog = MaterialAlertDialogBuilder(requireContext()) val reply = getString(R.string.message_context_action_reply)
.setItems(params) { _, which ->
if (which == 0) { val isMessageAlreadyPinned = message.id == conversation.pinnedMessage?.id
viewModel.markAsImportant(
val pin = getString(
if (isMessageAlreadyPinned) R.string.message_context_action_unpin
else R.string.message_context_action_pin
)
val edit = getString(R.string.message_context_action_edit)
val delete = getString(R.string.message_context_action_delete)
val params = mutableListOf(
important, reply
)
if (conversation.canChangePin) {
params += pin
}
if (message.canEdit()) {
params += edit
}
params += delete
val arrayParams = params.toTypedArray()
MaterialAlertDialogBuilder(requireContext())
.setTitle(time)
.setItems(arrayParams) { _, which ->
when (params[which]) {
important -> viewModel.markAsImportant(
messagesIds = listOf(message.id), messagesIds = listOf(message.id),
important = !message.important important = !message.important
) )
reply -> {
if (attachmentController.message.value != message)
attachmentController.message.value = message
}
pin ->
showPinMessageDialog(
peerId = conversation.id,
messageId = message.id,
pin = !isMessageAlreadyPinned
)
edit -> {
attachmentController.isEditing = true
if (attachmentController.message.value != message)
attachmentController.message.value = message
}
delete -> showDeleteMessageDialog(message)
}
}.show()
}
private fun showPinMessageDialog(
peerId: Int,
messageId: Int?,
pin: Boolean
) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(
if (pin) R.string.confirm_pin_message
else R.string.confirm_unpin_message
)
.setPositiveButton(
if (pin) R.string.action_pin
else R.string.action_unpin
) { _, _ ->
viewModel.pinMessage(
peerId = peerId,
messageId = messageId,
pin = pin
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showDeleteMessageDialog(message: VkMessage) {
val binding = DialogMessageDeleteBinding.inflate(layoutInflater, null, false)
binding.check.setText(
if (message.isOut) R.string.message_delete_for_all
else R.string.message_mark_as_spam
)
binding.check.isEnabled =
(conversation.id != UserConfig.userId) && (!message.isOut || message.canEdit())
if (conversation.id == UserConfig.userId) binding.check.isChecked = true
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.confirm_delete_message)
.setView(binding.root)
.setPositiveButton(R.string.action_delete) { _, _ ->
attachmentController.message.value = null
viewModel.deleteMessage(
peerId = conversation.id,
messagesIds = listOf(message.id),
isSpam = if (message.isOut) null else binding.check.isChecked,
deleteForAll = if (!binding.check.isEnabled) null else binding.check.isChecked
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun deleteMessages(event: MessagesDelete) {
adapter.removeMessagesByIds(event.messagesIds).let {
it.forEach { index -> adapter.notifyItemRemoved(index) }
}
}
private fun editMessage(event: MessagesEdit) {
adapter.searchMessageIndex(event.message.id)?.let { index ->
adapter.values[index] = event.message
adapter.notifyItemChanged(index)
}
}
private inner class AttachmentPanelController {
val isPanelVisible = MutableLiveData(false)
val message = MutableLiveData<VkMessage?>()
var isEditing = false
fun init(): AttachmentPanelController {
message.observe(viewLifecycleOwner) { value ->
if (value != null) {
applyMessage(value)
} else {
clearMessage()
} }
} }
dialog.show() message.value = null
return this
}
} private fun applyMessage(message: VkMessage) {
showPanel()
private fun onItemLongClick(position: Int): Boolean { val title = when {
message.isGroup() && message.group.value != null -> message.group.value?.name
message.isUser() && message.user.value != null -> message.user.value?.fullName
else -> null
}
binding.replyMessageTitle.text = title
binding.replyMessageText.text = message.text ?: "[no_message]"
if (isEditing) {
binding.message.setText(message.text ?: "[no_message]")
}
}
private fun clearMessage() {
hidePanel()
binding.replyMessageTitle.clear()
binding.replyMessageText.clear()
if (isEditing) {
isEditing = false
binding.message.clear()
}
}
private fun showPanel(duration: Long = 250) {
if (attachmentController.isPanelVisible.value == false)
attachmentController.isPanelVisible.value = true
binding.attachmentPanel.animate()
.translationY(0f)
.alpha(1f)
.setDuration(duration)
.withStartAction { binding.attachmentPanel.isVisible = true }
.start()
}
private fun hidePanel(duration: Long = 250) {
if (attachmentController.isPanelVisible.value == true)
attachmentController.isPanelVisible.value = false
binding.attachmentPanel.animate()
.alpha(0f)
.translationY(50f)
.setDuration(duration)
.withEndAction { binding.attachmentPanel.isVisible = false }
.start()
}
return true
} }
} }
@@ -6,33 +6,29 @@ import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.VkUser
import com.meloda.fast.api.model.request.MessagesGetHistoryRequest import com.meloda.fast.api.model.attachments.VkAttachment
import com.meloda.fast.api.model.request.MessagesMarkAsImportantRequest import com.meloda.fast.api.network.messages.*
import com.meloda.fast.api.model.request.MessagesSendRequest
import com.meloda.fast.api.network.datasource.MessagesDataSource
import com.meloda.fast.base.viewmodel.BaseViewModel import com.meloda.fast.base.viewmodel.BaseViewModel
import com.meloda.fast.base.viewmodel.StartProgressEvent import com.meloda.fast.base.viewmodel.VkEvent
import com.meloda.fast.base.viewmodel.StopProgressEvent
import com.meloda.fast.base.viewmodel.VKEvent
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MessagesHistoryViewModel @Inject constructor( class MessagesHistoryViewModel @Inject constructor(
private val dataSource: MessagesDataSource private val messages: MessagesDataSource
) : BaseViewModel() { ) : BaseViewModel() {
fun loadHistory( fun loadHistory(
peerId: Int peerId: Int
) = viewModelScope.launch { ) = viewModelScope.launch {
makeJob({ makeJob({
dataSource.getHistory( messages.getHistory(
MessagesGetHistoryRequest( MessagesGetHistoryRequest(
count = 30, count = 30,
peerId = peerId, peerId = peerId,
extended = true, extended = true,
fields = "${VKConstants.USER_FIELDS},${VKConstants.GROUP_FIELDS}" fields = VKConstants.ALL_FIELDS
) )
) )
}, },
@@ -53,18 +49,18 @@ class MessagesHistoryViewModel @Inject constructor(
} }
} }
val messages = hashMapOf<Int, VkMessage>() val hashMessages = hashMapOf<Int, VkMessage>()
response.items.forEach { baseMessage -> response.items.forEach { baseMessage ->
baseMessage.asVkMessage().let { message -> messages[message.id] = message } baseMessage.asVkMessage().let { message -> hashMessages[message.id] = message }
} }
dataSource.storeMessages(messages.values.toList()) messages.store(hashMessages.values.toList())
val conversations = hashMapOf<Int, VkConversation>() val conversations = hashMapOf<Int, VkConversation>()
response.conversations?.let { baseConversations -> response.conversations?.let { baseConversations ->
baseConversations.forEach { baseConversation -> baseConversations.forEach { baseConversation ->
baseConversation.asVkConversation( baseConversation.asVkConversation(
messages[baseConversation.last_message_id] hashMessages[baseConversation.last_message_id]
).let { conversation -> conversations[conversation.id] = conversation } ).let { conversation -> conversations[conversation.id] = conversation }
} }
} }
@@ -75,41 +71,33 @@ class MessagesHistoryViewModel @Inject constructor(
profiles = profiles, profiles = profiles,
groups = groups, groups = groups,
conversations = conversations, conversations = conversations,
messages = messages.values.toList() messages = hashMessages.values.toList()
) )
) )
}, })
onError = {
val throwable = it
throw it
},
onStart = { sendEvent(StartProgressEvent) },
onEnd = { sendEvent(StopProgressEvent) })
} }
fun sendMessage( fun sendMessage(
peerId: Int, peerId: Int,
message: String? = null, message: String? = null,
randomId: Int = 0, randomId: Int = 0,
replyTo: Int? = null,
setId: ((messageId: Int) -> Unit)? = null setId: ((messageId: Int) -> Unit)? = null
) = viewModelScope.launch { ) = viewModelScope.launch {
makeJob( makeJob(
{ {
dataSource.send( messages.send(
MessagesSendRequest( MessagesSendRequest(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message message = message,
replyTo = replyTo
) )
) )
}, },
onAnswer = { onAnswer = {
val response = it.response ?: return@makeJob val response = it.response ?: return@makeJob
setId?.invoke(response) setId?.invoke(response)
},
onError = {
val throwable = it
val i = 0
}) })
} }
@@ -118,7 +106,7 @@ class MessagesHistoryViewModel @Inject constructor(
important: Boolean important: Boolean
) = viewModelScope.launch { ) = viewModelScope.launch {
makeJob({ makeJob({
dataSource.markAsImportant( messages.markAsImportant(
MessagesMarkAsImportantRequest( MessagesMarkAsImportantRequest(
messagesIds = messagesIds, messagesIds = messagesIds,
important = important important = important
@@ -133,13 +121,84 @@ class MessagesHistoryViewModel @Inject constructor(
important = important important = important
) )
) )
},
onError = {
val throwable = it
val i = 0
}) })
} }
fun pinMessage(
peerId: Int,
messageId: Int? = null,
conversationMessageId: Int? = null,
pin: Boolean
) = viewModelScope.launch {
if (pin) {
makeJob({
messages.pin(
MessagesPinMessageRequest(
peerId = peerId,
messageId = messageId,
conversationMessageId = conversationMessageId
)
)
},
onAnswer = {
val response = it.response ?: return@makeJob
sendEvent(MessagesPin(response.asVkMessage()))
}
)
} else {
makeJob({ messages.unpin(MessagesUnPinMessageRequest(peerId = peerId)) },
onAnswer = {
println("Fast::MessagesHistoryViewModel::unPin::Response::${it.response}")
sendEvent(MessagesUnpin)
}
)
}
}
fun deleteMessage(
peerId: Int,
messagesIds: List<Int>? = null,
conversationsMessagesIds: List<Int>? = null,
isSpam: Boolean? = null,
deleteForAll: Boolean? = null
) = viewModelScope.launch {
makeJob({
messages.delete(
MessagesDeleteRequest(
peerId = peerId,
messagesIds = messagesIds,
conversationsMessagesIds = conversationsMessagesIds,
isSpam = isSpam,
deleteForAll = deleteForAll
)
)
}, onAnswer = { sendEvent(MessagesDelete(messagesIds = messagesIds ?: listOf())) })
}
fun editMessage(
originalMessage: VkMessage,
peerId: Int,
messageId: Int,
message: String? = null,
attachments: List<VkAttachment>? = null
) = viewModelScope.launch {
makeJob(
{
messages.edit(
MessagesEditRequest(
peerId = peerId,
messageId = messageId,
message = message,
attachments = attachments
)
)
},
onAnswer = {
originalMessage.text = message
sendEvent(MessagesEdit(originalMessage))
}
)
}
} }
data class MessagesLoaded( data class MessagesLoaded(
@@ -148,9 +207,23 @@ data class MessagesLoaded(
val messages: List<VkMessage>, val messages: List<VkMessage>,
val profiles: HashMap<Int, VkUser>, val profiles: HashMap<Int, VkUser>,
val groups: HashMap<Int, VkGroup> val groups: HashMap<Int, VkGroup>
) : VKEvent() ) : VkEvent()
data class MessagesMarkAsImportant( data class MessagesMarkAsImportant(
val messagesIds: List<Int>, val messagesIds: List<Int>,
val important: Boolean val important: Boolean
) : VKEvent() ) : VkEvent()
data class MessagesPin(
val message: VkMessage
) : VkEvent()
object MessagesUnpin : VkEvent()
data class MessagesDelete(
val messagesIds: List<Int>
) : VkEvent()
data class MessagesEdit(
val message: VkMessage
) : VkEvent()
@@ -1,6 +1,7 @@
package com.meloda.fast.screens.messages package com.meloda.fast.screens.messages
import android.content.Context import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
@@ -8,9 +9,7 @@ import android.widget.Space
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.widget.LinearLayoutCompat import androidx.appcompat.widget.LinearLayoutCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.setPadding
import coil.load import coil.load
import com.meloda.fast.R import com.meloda.fast.R
import com.meloda.fast.api.VkUtils import com.meloda.fast.api.VkUtils
@@ -20,7 +19,6 @@ import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.VkUser
import com.meloda.fast.api.model.attachments.VkSticker import com.meloda.fast.api.model.attachments.VkSticker
import com.meloda.fast.common.AppGlobal import com.meloda.fast.common.AppGlobal
import com.meloda.fast.util.AndroidUtils
import com.meloda.fast.widget.BoundedLinearLayout import com.meloda.fast.widget.BoundedLinearLayout
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -30,19 +28,21 @@ import kotlin.math.roundToInt
class MessagesPreparator constructor( class MessagesPreparator constructor(
private val context: Context, private val context: Context,
private val root: View? = null,
private val conversation: VkConversation, private val conversation: VkConversation,
private val message: VkMessage, private val message: VkMessage,
private val prevMessage: VkMessage? = null, private val prevMessage: VkMessage? = null,
private val nextMessage: VkMessage? = null, private val nextMessage: VkMessage? = null,
private val bubble: BoundedLinearLayout? = null, private val bubble: BoundedLinearLayout? = null,
private val bubbleStroke: View? = null,
private val text: TextView? = null, private val text: TextView? = null,
private val avatar: ImageView? = null, private val avatar: ImageView? = null,
private val title: TextView? = null, private val title: TextView? = null,
private val spacer: Space? = null, private val spacer: Space? = null,
private val unread: ImageView? = null, private val unread: ImageView? = null,
private val time: TextView? = null, private val time: TextView? = null,
private val textContainer: LinearLayoutCompat? = null,
private val attachmentContainer: LinearLayoutCompat? = null, private val attachmentContainer: LinearLayoutCompat? = null,
private val attachmentSpacer: Space? = null, private val attachmentSpacer: Space? = null,
@@ -65,51 +65,129 @@ class MessagesPreparator constructor(
ContextCompat.getDrawable(context, R.drawable.ic_message_out_background) ContextCompat.getDrawable(context, R.drawable.ic_message_out_background)
private val backgroundMiddleOut = private val backgroundMiddleOut =
ContextCompat.getDrawable(context, R.drawable.ic_message_out_background_middle) ContextCompat.getDrawable(context, R.drawable.ic_message_out_background_middle)
private val backgroundStrokeOut =
ContextCompat.getDrawable(context, R.drawable.ic_message_out_background_stroke) private val rootHighlightedColor =
private val backgroundMiddleStrokeOut = ContextCompat.getColor(context, R.color.n2_100)
ContextCompat.getDrawable(context, R.drawable.ic_message_out_background_middle_stroke)
private var photoClickListener: ((url: String) -> Unit)? = null
fun setPhotoClickListener(unit: ((url: String) -> Unit)?): MessagesPreparator {
this.photoClickListener = unit
return this
}
fun prepare() { fun prepare() {
val messageUser: VkUser? = if (message.isUser()) { val messageUser = VkUtils.getMessageUser(message, profiles)
profiles[message.fromId] val messageGroup = VkUtils.getMessageGroup(message, groups)
} else null
val messageGroup: VkGroup? = if (message.isGroup()) { prepareRootBackground()
groups[message.fromId]
} else null
prepareTime()
prepareUnreadIndicator()
prepareSpacer()
prepareAttachments()
prepareAttachmentsSpacer()
prepareBubbleBackground()
prepareText()
prepareAvatar(
messageUser = messageUser,
messageGroup = messageGroup
)
if (message.isPeerChat()) {
val prevSenderDiff = VkUtils.isPreviousMessageFromDifferentSender(prevMessage, message)
val nextSenderDiff = VkUtils.isPreviousMessageFromDifferentSender(message, nextMessage)
val fiveMinAgo = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message)
val change = (prevMessage?.date ?: 0) - message.date
Log.d(
"Fast::MessagesPreparator",
"text: ${message.text}; prevText: ${prevMessage?.text}; time change: $change; fromDiffSender: $prevSenderDiff; fiveMinAgo: $fiveMinAgo; "
)
title?.isVisible = prevSenderDiff || fiveMinAgo
avatar?.visibility =
if (nextSenderDiff
|| (fiveMinAgo && prevSenderDiff)
|| !prevSenderDiff
|| nextMessage == null
) View.VISIBLE else View.INVISIBLE
} else {
title?.isVisible = false
avatar?.isVisible = false
}
if (title != null) {
val titleString = when {
message.isUser() && messageUser != null -> messageUser.firstName
message.isGroup() && messageGroup != null -> messageGroup.name
else -> null
}
title.text = titleString
}
}
private fun prepareRootBackground() {
if (root != null) {
root.background =
if (message.isSelected) ColorDrawable(rootHighlightedColor)
else null
}
}
private fun prepareTime() {
time?.text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(message.date * 1000L)
}
private fun prepareUnreadIndicator() {
if (unread != null) { if (unread != null) {
unread.isVisible = message.isRead(conversation) unread.isVisible = message.isRead(conversation)
} }
}
if (bubble != null && time != null) { private fun prepareSpacer() {
bubble.setOnClickListener { time.isVisible = !time.isVisible } spacer?.isVisible = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message)
} }
if (attachmentContainer != null) { private fun prepareAttachments() {
if (attachmentContainer != null && textContainer != null) {
if (message.attachments.isNullOrEmpty()) { if (message.attachments.isNullOrEmpty()) {
attachmentContainer.isVisible = false attachmentContainer.isVisible = false
attachmentContainer.removeAllViews() attachmentContainer.removeAllViews()
} else { } else {
attachmentContainer.isVisible = true attachmentContainer.isVisible = true
AttachmentInflater( AttachmentInflater(
context = context, context = context,
container = attachmentContainer, container = attachmentContainer,
textContainer = textContainer,
message = message, message = message,
groups = groups, groups = groups,
profiles = profiles profiles = profiles
).inflate() )
.setPhotoClickListener(photoClickListener)
.inflate()
} }
} }
}
private fun prepareAttachmentsSpacer() {
attachmentSpacer?.isVisible =
!message.attachments.isNullOrEmpty() && text?.isVisible == true
}
private fun prepareBubbleBackground() {
if (bubble != null) { if (bubble != null) {
val padding =
AndroidUtils.px(if (!message.attachments.isNullOrEmpty()) 4 else 15).roundToInt()
bubble.setPadding(padding)
// TODO: 9/23/2021 use external function // TODO: 9/23/2021 use external function
bubble.background = bubble.background =
if (!message.attachments.isNullOrEmpty() && message.attachments!![0] is VkSticker) null if (!message.attachments.isNullOrEmpty() && message.attachments!![0] is VkSticker) null
@@ -125,82 +203,29 @@ class MessagesPreparator constructor(
} }
} }
} }
}
// TODO: 9/23/2021 use external function private fun prepareText() {
bubbleStroke?.background =
if (bubble?.background == null) null else {
if (prevMessage == null || prevMessage.fromId != message.fromId) backgroundStrokeOut
else if (prevMessage.fromId == message.fromId && message.date - prevMessage.date < 60) backgroundMiddleStrokeOut
else backgroundStrokeOut
}
if (bubble != null && text != null) { if (bubble != null && text != null) {
if (message.text == null) { if (message.text == null) {
text.isVisible = false text.isVisible = false
bubble.isVisible = !message.attachments.isNullOrEmpty() bubble.isVisible = !message.attachments.isNullOrEmpty()
bubbleStroke?.isVisible = bubble.isVisible
} else { } else {
text.isVisible = true text.isVisible = true
bubble.isVisible = true bubble.isVisible = true
bubbleStroke?.isVisible = true text.text = VkUtils.prepareMessageText(message.text ?: "")
text.text = VkUtils.prepareMessageText(message.text)
} }
} }
}
private fun prepareAvatar(
messageUser: VkUser? = null,
messageGroup: VkGroup? = null
) {
if (avatar != null) { if (avatar != null) {
val avatarUrl = when { val avatarUrl = VkUtils.getMessageAvatar(message, messageUser, messageGroup)
message.isUser() && messageUser != null && !messageUser.photo200.isNullOrBlank() -> messageUser.photo200
message.isGroup() && messageGroup != null && !messageGroup.photo200.isNullOrBlank() -> messageGroup.photo200
else -> null
}
avatar.load(avatarUrl) { crossfade(100) } avatar.load(avatarUrl) { crossfade(100) }
} }
spacer?.isVisible = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message)
if (message.isPeerChat()) {
val fromDiffSender = VkUtils.isPreviousMessageFromDifferentSender(prevMessage, message)
val fiveMinAgo = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message)
val change = (prevMessage?.date ?: 0) - message.date
Log.d(
"Fast::MessagesPreparator",
"text: ${message.text}; prevText: ${prevMessage?.text}; time change: $change; fromDiffSender: $fromDiffSender; fiveMinAgo: $fiveMinAgo; "
)
title?.isVisible = fromDiffSender || fiveMinAgo
avatar?.isInvisible = fromDiffSender && fiveMinAgo
} else {
title?.isVisible = false
avatar?.isVisible = false
}
if (title != null) {
val titleString = when {
message.isUser() && messageUser != null -> messageUser.firstName
message.isGroup() && messageGroup != null -> messageGroup.name
else -> null
}
title.text = titleString
title.measure(0, 0)
if (bubble != null) {
if (title.isVisible) {
bubble.minimumWidth = title.measuredWidth + 60
} else {
bubble.minimumWidth = 0
}
}
}
attachmentSpacer?.isVisible =
!message.attachments.isNullOrEmpty() && text?.isVisible == true
time?.text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(message.date * 1000L)
} }
} }
@@ -0,0 +1,48 @@
package com.meloda.fast.screens.photos
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.fragment.app.viewModels
import com.meloda.fast.base.BaseViewModelFragment
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class PhotoViewFragment : BaseViewModelFragment<PhotoViewViewModel>() {
override val viewModel: PhotoViewViewModel by viewModels()
// private val photosList: MutableList<VkPhoto> = mutableListOf()
private var photoLink: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
photoLink = requireArguments().getString("photoLink")
// val list: List<*>? = Gson().fromJson(
// requireArguments().getString("photosList"),
// List::class.java
// )
//
// list?.forEach { if (it is VkPhoto) photosList.add(it) }
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ImageView(requireContext())
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
photoLink?.let { viewModel.loadImageFromUrl(it, requireView() as ImageView) }
}
}
@@ -0,0 +1,22 @@
package com.meloda.fast.screens.photos
import android.widget.ImageView
import androidx.lifecycle.viewModelScope
import coil.load
import com.meloda.fast.base.viewmodel.BaseViewModel
import kotlinx.coroutines.launch
class PhotoViewViewModel : BaseViewModel() {
fun loadImageFromUrl(
url: String,
imageView: ImageView
) = viewModelScope.launch {
imageView.load(url)
}
fun saveImageToLocalStorage(url: String) = viewModelScope.launch {
TODO("Not implemented")
}
}
@@ -1,9 +1,9 @@
package com.meloda.fast.service package com.meloda.fast.service
import android.util.Log import android.util.Log
import com.meloda.fast.api.model.request.MessagesGetLongPollServerRequest import com.meloda.fast.api.network.messages.MessagesGetLongPollServerRequest
import com.meloda.fast.api.network.datasource.MessagesDataSource import com.meloda.fast.api.network.messages.MessagesDataSource
import com.meloda.fast.api.network.repo.LongPollRepo import com.meloda.fast.api.network.longpoll.LongPollRepo
import kotlinx.coroutines.* import kotlinx.coroutines.*
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@@ -1,59 +0,0 @@
package com.meloda.fast.util
import java.util.stream.Collectors
object ArrayUtils {
@SafeVarargs
fun <T> asString(vararg array: T): String {
if (array.isEmpty()) {
return ""
}
val builder = StringBuilder(array.size * 12)
builder.append(array[0])
for (i in 1 until array.size) {
builder.append(',')
builder.append(array[i])
}
return builder.toString()
}
fun asString(array: IntArray): String {
if (array.isEmpty()) {
return ""
}
val builder = StringBuilder(array.size * 12)
builder.append(array[0])
for (i in 1 until array.size) {
builder.append(',')
builder.append(array[i])
}
return builder.toString()
}
fun <T> asString(arrayList: ArrayList<T>): String {
return ArrayList<String>().apply {
arrayList.forEach { add(it.toString()) }
}.stream().collect(Collectors.joining(","))
}
fun <T> asString(list: List<T>): String = asString(list.asArrayList())
fun <T> cut(arrayList: ArrayList<T>, offset: Int, count: Int): ArrayList<T> {
if (arrayList.isEmpty()) return arrayListOf()
var lastPosition = offset + count
if (lastPosition > arrayList.size) lastPosition = arrayList.size
return ArrayList(arrayList.subList(offset, lastPosition))
}
fun ByteArray?.isNullOrEmpty() = this == null || this.isEmpty()
fun <E> List<E>.asArrayList(): ArrayList<E> {
return ArrayList(this)
}
}
@@ -1,30 +0,0 @@
package com.meloda.fast.util
import android.content.Context
import android.graphics.Color
import androidx.annotation.ColorInt
import com.meloda.fast.R
object ColorUtils {
@ColorInt
fun getColorAccent(context: Context): Int {
return AndroidUtils.getThemeAttrColor(context, R.attr.colorAccent)
}
@ColorInt
fun getColorPrimary(context: Context): Int {
return AndroidUtils.getThemeAttrColor(context, R.attr.colorPrimary)
}
@JvmOverloads
fun darkenColor(color: Int, darkFactor: Float = 0.75f): Int {
var newColor = color
val hsv = FloatArray(3)
Color.colorToHSV(newColor, hsv)
hsv[2] *= darkFactor
newColor = Color.HSVToColor(hsv)
return newColor
}
}
@@ -1,55 +0,0 @@
package com.meloda.fast.util
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.widget.ImageView
object ImageUtils {
fun loadImage(image: String, imageView: ImageView, placeholder: Drawable?) {
if (image.isEmpty()) return
// if (imageView is SimpleDraweeView) {
// imageView.setImageURI(image)
// return
// }
//
// val picasso = Picasso.get()
// .load(image)
// .priority(Picasso.Priority.LOW)
// if (placeholder != null) picasso.placeholder(placeholder)
//
// picasso.into(imageView)
}
fun loadImage(image: String?, listener: OnLoadListener?) {
if (image.isNullOrEmpty()) return
// val picasso = Picasso.get()
// .load(image)
// .priority(Picasso.Priority.LOW)
//
// val target = object : Target {
// override fun onPrepareLoad(placeHolderDrawable: Drawable?) {
//
// }
//
// override fun onBitmapFailed(e: Exception, errorDrawable: Drawable?) {
// listener?.onError(e)
// }
//
// override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
// listener?.onLoad(bitmap)
// }
// }
// picasso.into(target)
}
interface OnLoadListener {
fun onLoad(bitmap: Bitmap)
fun onError(e: Exception)
}
}
@@ -1,15 +0,0 @@
package com.meloda.fast.util
object TextUtils {
fun getFirstLetterFromString(string: String): String {
for (i in string.indices) {
val char = string[i]
if (char.isLetter()) return char.toString()
}
return ""
}
}
@@ -7,6 +7,8 @@ import java.util.*
object TimeUtils { object TimeUtils {
const val ONE_DAY_IN_SECONDS = 86400
fun removeTime(date: Date): Long { fun removeTime(date: Date): Long {
return Calendar.getInstance().apply { return Calendar.getInstance().apply {
time = date time = date
@@ -1,49 +0,0 @@
package com.meloda.fast.util
import android.content.Context
import com.meloda.fast.util.ArrayUtils.isNullOrEmpty
import com.meloda.fast.R
import com.meloda.fast.io.BytesOutputStream
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
object Utils {
fun getLocalizedThrowable(context: Context, t: Throwable): String {
return context.getString(R.string.error, t.message.toString())
}
fun serialize(source: Any?): ByteArray? {
try {
val bos = BytesOutputStream()
val out = ObjectOutputStream(bos)
out.writeObject(source)
out.close()
return bos.byteArray
} catch (e: IOException) {
e.printStackTrace()
}
return null
}
fun deserialize(source: ByteArray?): Any? {
if (source.isNullOrEmpty()) {
return null
}
try {
val bis = ByteArrayInputStream(source)
val `in` = ObjectInputStream(bis)
val o = `in`.readObject()
`in`.close()
return o
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
}
@@ -10,12 +10,11 @@ import android.widget.TextView
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.meloda.fast.extensions.ContextExtensions.drawable
import com.meloda.fast.extensions.DrawableExtensions.tint
import com.meloda.fast.extensions.FloatExtensions.int
import com.meloda.fast.R import com.meloda.fast.R
import com.meloda.fast.util.AndroidUtils import com.meloda.fast.util.AndroidUtils
import kotlin.math.roundToInt
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
class NoItemsView @JvmOverloads constructor( class NoItemsView @JvmOverloads constructor(
@@ -44,7 +43,7 @@ class NoItemsView @JvmOverloads constructor(
private fun create() { private fun create() {
val a = context.obtainStyledAttributes(attrs, R.styleable.NoItemsView) val a = context.obtainStyledAttributes(attrs, R.styleable.NoItemsView)
minimumWidth = AndroidUtils.px(256).int() minimumWidth = AndroidUtils.px(256).roundToInt()
minimumHeight = minimumWidth minimumHeight = minimumWidth
orientation = VERTICAL orientation = VERTICAL
@@ -53,8 +52,8 @@ class NoItemsView @JvmOverloads constructor(
noItemsPicture = ImageView(context) noItemsPicture = ImageView(context)
val params = imageViewParams val params = imageViewParams
params.height = AndroidUtils.px(64).int() params.height = AndroidUtils.px(64).roundToInt()
params.width = AndroidUtils.px(64).int() params.width = AndroidUtils.px(64).roundToInt()
noItemsPicture.layoutParams = params noItemsPicture.layoutParams = params
@@ -73,10 +72,10 @@ class NoItemsView @JvmOverloads constructor(
noItemsTextView = TextView(context) noItemsTextView = TextView(context)
val textParams = textViewParams val textParams = textViewParams
textParams.width = AndroidUtils.px(256).int() textParams.width = AndroidUtils.px(256).roundToInt()
if (noItemsDrawable != null) { if (noItemsDrawable != null) {
textParams.topMargin = AndroidUtils.px(8).int() textParams.topMargin = AndroidUtils.px(8).roundToInt()
} }
noItemsTextView.layoutParams = textParams noItemsTextView.layoutParams = textParams
@@ -103,7 +102,7 @@ class NoItemsView @JvmOverloads constructor(
} }
fun setNoItemsImage(@DrawableRes resId: Int) { fun setNoItemsImage(@DrawableRes resId: Int) {
setNoItemsImage(context.drawable(resId)) setNoItemsImage(AppCompatResources.getDrawable(context, resId))
} }
fun setNoItemsImage(drawable: Drawable?) { fun setNoItemsImage(drawable: Drawable?) {
@@ -111,7 +110,7 @@ class NoItemsView @JvmOverloads constructor(
} }
fun setNoItemsImageTint(@ColorInt color: Int) { fun setNoItemsImageTint(@ColorInt color: Int) {
noItemsPicture.drawable.tint(color) noItemsPicture.drawable?.setTint(color)
} }
fun setNoItemsText(@StringRes resId: Int) { fun setNoItemsText(@StringRes resId: Int) {
@@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#FFFFFF" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> android:height="24dp"
<path android:fillColor="#FF000000" android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z"/> android:tint="#FFFFFF"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z" />
</vector> </vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:topLeftRadius="30dp"
android:topRightRadius="30dp" />
<solid android:color="@android:color/white" />
</shape>

Some files were not shown because too many files have changed in this diff Show More