Upstream changes (#23)

This commit is contained in:
2024-07-11 02:12:32 +03:00
committed by GitHub
parent 8a6378f509
commit 3503ecffab
906 changed files with 23577 additions and 24115 deletions
+1
View File
@@ -0,0 +1 @@
/build
+57
View File
@@ -0,0 +1,57 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
alias(libs.plugins.kotlin.compose.compiler)
}
group = "com.meloda.app.fast.common"
android {
namespace = "com.meloda.app.fast.common"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.core.ktx)
implementation(libs.preference.ktx)
implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(libs.koin.androidx.compose.navigation)
implementation(libs.coil.compose)
implementation(libs.nanokt.jvm)
implementation(libs.nanokt.android)
implementation(libs.nanokt)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,10 @@
package com.meloda.app.fast.common
object AppConstants {
const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive"
const val API_VERSION = "5.173"
const val URL_OAUTH = "https://oauth.vk.com"
const val URL_API = "https://api.vk.com/method"
}
@@ -0,0 +1,31 @@
package com.meloda.app.fast.common
import androidx.core.net.toUri
import okhttp3.Interceptor
import okhttp3.Response
import java.net.URLEncoder
class AuthInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val builder = chain.request().url.newBuilder()
val uri = builder.build().toUri().toString().toUri()
if (uri.getQueryParameter("v") == null) {
builder.addQueryParameter(
name = "v",
value = URLEncoder.encode(AppConstants.API_VERSION, "utf-8")
)
}
if (UserConfig.accessToken.isNotBlank()) {
builder.addQueryParameter(
"access_token",
URLEncoder.encode(UserConfig.accessToken, "utf-8")
)
}
return chain.proceed(chain.request().newBuilder().apply { url(builder.build()) }.build())
}
}
@@ -0,0 +1,22 @@
package com.meloda.app.fast.common
import android.os.Bundle
import android.os.Parcelable
import androidx.core.os.BundleCompat
import androidx.navigation.NavType
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
inline fun <reified T : Parcelable> customNavType(
isNullableAllowed: Boolean = false,
json: Json = Json
) = object : NavType<T>(isNullableAllowed = isNullableAllowed) {
override fun get(bundle: Bundle, key: String) =
BundleCompat.getParcelable(bundle, key, T::class.java)
override fun parseValue(value: String): T = json.decodeFromString(value)
override fun serializeAsValue(value: T): String = json.encodeToString(value)
override fun put(bundle: Bundle, key: String, value: T) = bundle.putParcelable(key, value)
}
@@ -0,0 +1,29 @@
package com.meloda.app.fast.common
import android.graphics.drawable.Drawable
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
sealed class UiImage {
data class Resource(@DrawableRes val resId: Int) : UiImage()
data class Simple(val drawable: Drawable) : UiImage()
data class Color(@ColorInt val color: Int) : UiImage()
data class ColorResource(@ColorRes val resId: Int) : UiImage()
data class Url(val url: String) : UiImage()
fun extractUrl(): String? = when (this) {
is Url -> this.url
else -> null
}
fun extractResId(): Int = when (this) {
is Resource -> this.resId
else -> throw IllegalStateException("this UiImage is not Resource")
}
}
@@ -0,0 +1,40 @@
package com.meloda.app.fast.common
import android.content.res.Resources
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
sealed class UiText {
data object Empty : UiText()
data class Resource(@StringRes val resId: Int) : UiText()
data class ResourceParams(
@StringRes val value: Int,
val args: List<Any?>,
) : UiText()
data class Simple(val text: String) : UiText()
data class QuantityResource(@PluralsRes val resId: Int, val quantity: Int) : UiText()
}
fun UiText?.parseString(resources: Resources): String? {
return when (this) {
is UiText.Resource -> resources.getString(resId)
is UiText.ResourceParams -> {
val processedArgs = args.map { any ->
when (any) {
is UiText -> any.parseString(resources)
else -> any
}
}
resources.getString(value, *processedArgs.toTypedArray())
}
is UiText.QuantityResource -> resources.getQuantityString(resId, quantity, quantity)
is UiText.Simple -> text
else -> null
}
}
@@ -0,0 +1,39 @@
package com.meloda.app.fast.common
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlin.properties.Delegates
object UserConfig {
private const val ARG_CURRENT_USER_ID = "current_user_id"
private var preferences: SharedPreferences by Delegates.notNull()
fun init(preferences: SharedPreferences) {
this.preferences = preferences
}
var currentUserId: Int = -1
get() = preferences.getInt(ARG_CURRENT_USER_ID, -1)
set(value) {
field = value
preferences.edit { putInt(ARG_CURRENT_USER_ID, value) }
}
var userId: Int = -1
var accessToken: String = ""
var fastToken: String? = ""
var trustedHash: String? = null
fun clear() {
currentUserId = -1
accessToken = ""
fastToken = ""
userId = -1
}
fun isLoggedIn(): Boolean {
return currentUserId > 0 && userId > 0 && accessToken.isNotBlank()
}
}
@@ -0,0 +1,53 @@
package com.meloda.app.fast.common
object VkConstants {
const val GROUP_FIELDS = "description,members_count,counters,status,verified"
const val USER_FIELDS =
"photo_50,photo_100,photo_200,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info,bdate"
const val ALL_FIELDS =
"$USER_FIELDS,$GROUP_FIELDS"
const val LP_VERSION = 10
const val VK_APP_ID = "2274003"
const val VK_SECRET = "hHbZxrka2uZ6jB1inYsH"
const val FAST_GROUP_ID = -119516304
const val FAST_APP_ID = "6964679"
object Auth {
const val SCOPE = "notify," +
"friends," +
"photos," +
"audio," +
"video," +
"docs," +
"status," +
"notes," +
"pages," +
"wall," +
"groups," +
"messages," +
"offline," +
"notifications"
object GrantType {
const val PASSWORD = "password"
}
}
// val restrictedToEditAttachments = listOf<Class<out VkAttachment>>(
// VkCallDomain::class.java,
// VkCuratorDomain::class.java,
// VkEventDomain::class.java,
// VkGiftDomain::class.java,
// VkGraffitiDomain::class.java,
// VkGroupCallDomain::class.java,
// VkStoryDomain::class.java,
// VkAudioMessageDomain::class.java,
// VkWidgetDomain::class.java
// )
}
@@ -0,0 +1,12 @@
package com.meloda.app.fast.common.di
import coil.ImageLoader
import org.koin.dsl.module
val commonModule = module {
single {
ImageLoader.Builder(get())
.crossfade(true)
.build()
}
}
@@ -0,0 +1,151 @@
package com.meloda.app.fast.common.extensions
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
fun Context.restartApp() {
(this as? Activity)?.let { activity ->
activity.finishAffinity()
activity.startActivity(
Intent(
this,
Class.forName("com.meloda.app.fast.MainActivity")
)
)
}
}
inline fun <T> Iterable<T>.findWithIndex(predicate: (T) -> Boolean): Pair<Int, T>? {
val value = firstOrNull(predicate) ?: return null
return indexOf(value).let { index -> if (index == -1) null else index to value }
}
fun <T> MutableList<T>.addIf(element: T, condition: () -> Boolean) {
if (condition.invoke()) add(element)
}
context(ViewModel)
fun <T> Flow<T>.listenValue(action: suspend (T) -> Unit) = listenValue(viewModelScope, action)
fun <T> Flow<T>.listenValue(
coroutineScope: CoroutineScope,
action: suspend (T) -> Unit
): Job = onEach(action::invoke).launchIn(coroutineScope)
fun createTimerFlow(
time: Int,
onStartAction: (suspend () -> Unit)? = null,
onTickAction: (suspend (remainedTime: Int) -> Unit)? = null,
onTimeoutAction: (suspend () -> Unit)? = null,
interval: Duration = 1.seconds
): Flow<Int> = (time downTo 0)
.asSequence()
.asFlow()
.onStart { onStartAction?.invoke() }
.onEach { timeLeft ->
onTickAction?.invoke(timeLeft)
if (timeLeft == 0) {
onTimeoutAction?.invoke()
} else {
delay(interval)
}
}
fun createTimerFlow(
isNeedToEndCondition: suspend () -> Boolean,
onStartAction: (suspend () -> Unit)? = null,
onTickAction: (suspend () -> Unit)? = null,
onEndAction: (suspend () -> Unit)? = null,
interval: Duration = 1.seconds
): Flow<Boolean> = flow {
while (true) {
val isNeedToEnd = isNeedToEndCondition()
emit(isNeedToEnd)
if (isNeedToEnd) break
}
}
.onStart { onStartAction?.invoke() }
.onEach { isNeedToEnd ->
onTickAction?.invoke()
if (isNeedToEnd) {
onEndAction?.invoke()
} else {
delay(interval)
}
}
context(ViewModel)
fun <T> MutableSharedFlow<T>.emitOnMainScope(value: T) = emitOnScope(Dispatchers.Main) { value }
context(ViewModel)
fun <T> MutableSharedFlow<T>.emitOnScope(
coroutineContext: CoroutineContext = EmptyCoroutineContext,
value: () -> T,
) {
viewModelScope.launch(coroutineContext) {
emit(value())
}
}
context(CoroutineScope)
suspend fun <T> MutableSharedFlow<T>.emitWithMain(value: T) {
withContext(Dispatchers.Main) {
emit(value)
}
}
context(ViewModel)
fun <T> MutableStateFlow<T>.updateValue(newValue: T) = this.update { newValue }
fun <T> MutableStateFlow<T>.setValue(function: (T) -> T) {
val newValue = function(value)
update { newValue }
}
fun Any.asInt(): Int {
return when (this) {
is Number -> this.toInt()
else -> throw IllegalArgumentException("Object is not numeric")
}
}
fun <T> Any.toList(mapper: (old: Any) -> T): List<T> {
return when (this) {
is List<*> -> this.mapNotNull { it?.run(mapper) }
else -> emptyList()
}
}
fun isSdkAtLeast(sdkInt: Int, action: (() -> Unit)? = null): Boolean {
return if (Build.VERSION.SDK_INT >= sdkInt) {
action?.invoke()
true
} else {
false
}
}
@@ -0,0 +1,17 @@
package com.meloda.app.fast.common.extensions
inline fun String?.ifEmpty(defaultValue: () -> String?): String? =
if (this?.isEmpty() == true) defaultValue() else this
fun String?.orDots(count: Int = 3): String {
return this ?: ("." * count)
}
operator fun String.times(count: Int): String {
val builder = StringBuilder()
for (i in 0 until count) {
builder.append(this)
}
return builder.toString()
}
@@ -0,0 +1,18 @@
package com.meloda.app.fast.common.extensions.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.compose.navigation.koinNavViewModel
@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(navController: NavController): T {
val navGraphRoute = destination.parent?.route ?: return koinViewModel()
val parentEntry = remember(this) {
navController.getBackStackEntry(navGraphRoute)
}
return koinNavViewModel(viewModelStoreOwner = parentEntry)
}
@@ -0,0 +1,208 @@
package com.meloda.app.fast.common.util
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileOutputStream
private object BuildConfig {
const val DEBUG = true
const val APPLICATION_ID = "com.meloda.app.fast"
}
object AndroidUtils {
fun copyText(
context: Context,
label: String? = "",
text: String,
withToast: Boolean = false
) {
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(ClipData.newPlainText(label, text))
if (withToast && Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
}
}
fun copyImage(
context: Context,
label: String? = "",
imageUri: Uri,
withToast: Boolean = false
) {
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(ClipData.newRawUri(label, imageUri))
if (withToast && Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
}
}
fun bytesToMegabytes(bytes: Double): Double {
return bytes / 1024 / 1024
}
fun bytesToHumanReadableSize(bytes: Double): String = when {
bytes >= 1 shl 30 -> "%.1f GB".format(bytes / (1 shl 30))
bytes >= 1 shl 20 -> "%.1f MB".format(bytes / (1 shl 20))
bytes >= 1 shl 10 -> "%.1f KB".format(bytes / (1 shl 10))
else -> "$bytes B"
}
fun openAppNotificationsSettings(context: Context) {
val packageName = context.packageName
val intent = Intent("android.settings.APP_NOTIFICATION_SETTINGS")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.putExtra("android.provider.extra.APP_PACKAGE", packageName)
} else {
intent.putExtra("app_package", packageName)
intent.putExtra("app_uid", context.applicationInfo.uid)
}
context.startActivity(intent)
}
@Suppress("DEPRECATION")
fun isCanInstallUnknownApps(context: Context): Boolean {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Settings.Secure.getInt(
context.contentResolver,
Settings.Secure.INSTALL_NON_MARKET_APPS
) == 1
} else {
context.packageManager.canRequestPackageInstalls()
}
}
fun openInstallUnknownAppsScreen(context: Context) {
context.startActivity(Intent().apply {
action = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Settings.ACTION_SECURITY_SETTINGS
} else {
data = Uri.parse("package:${BuildConfig.APPLICATION_ID}")
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES
}
})
}
fun getInstallPackageIntent(
context: Context,
providerPath: String,
fileToRead: File,
): Intent {
val intent = Intent(Intent.ACTION_VIEW)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
intent.data = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + providerPath,
fileToRead
)
return intent
}
fun isBatterySaverOn(context: Context): Boolean {
return (context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isPowerSaveMode == true
}
fun getImageToShare(context: Context, existingFile: File): Uri? {
val imageFolder = File(context.cacheDir, "images")
return try {
imageFolder.mkdirs()
val copyToFile = File(imageFolder, "shared_image.png")
if (copyToFile.exists()) {
copyToFile.delete()
}
val file = existingFile.copyTo(copyToFile)
FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", file)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
fun getImageToShare(context: Context, bitmap: Bitmap): Uri? {
val imageFolder = File(context.cacheDir, "images")
return try {
imageFolder.mkdirs()
val file = File(imageFolder, "shared_image.png")
val outputStream = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 90, outputStream)
outputStream.flush()
outputStream.close()
FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.fileprovider", file)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
fun showShareSheet(context: Context, content: ShareContent) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = when (content) {
is ShareContent.Text -> {
putExtra(Intent.EXTRA_TEXT, content.text)
"text/plain"
}
is ShareContent.Image -> {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, content.uri)
"image/png"
}
is ShareContent.TextWithImage -> {
putExtra(Intent.EXTRA_TEXT, content.text)
putExtra(Intent.EXTRA_STREAM, content.imageUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
"image/png"
}
}
}
val contentType = when (content) {
is ShareContent.Text -> "Text"
is ShareContent.Image -> "Image"
is ShareContent.TextWithImage -> "Text with image"
}
val chooserIntent = Intent.createChooser(intent, "Share $contentType")
context.startActivity(chooserIntent)
}
}
sealed class ShareContent {
data class Text(val text: String) : ShareContent()
data class Image(val uri: Uri) : ShareContent()
data class TextWithImage(val text: String, val imageUri: Uri) : ShareContent()
}
@@ -0,0 +1,83 @@
package com.meloda.app.fast.common.util
import android.content.res.Resources
import com.conena.nanokt.jvm.util.dayOfMonth
import com.conena.nanokt.jvm.util.hour
import com.conena.nanokt.jvm.util.hourOfDay
import com.conena.nanokt.jvm.util.millisecond
import com.conena.nanokt.jvm.util.minute
import com.conena.nanokt.jvm.util.month
import com.conena.nanokt.jvm.util.second
import com.conena.nanokt.jvm.util.year
import com.meloda.app.fast.common.R
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
object TimeUtils {
fun removeTime(date: Date): Long {
return Calendar.getInstance().apply {
time = date
hourOfDay = 0
minute = 0
second = 0
millisecond = 0
}.timeInMillis
}
fun getLocalizedDate(resources: Resources, date: Long): String {
val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date }
val pattern = when {
now.year != then.year -> "dd MMM yyyy"
now.month != then.month -> "dd MMMM"
now.dayOfMonth != then.dayOfMonth -> {
if (now.dayOfMonth - then.dayOfMonth == 1) {
return resources.getString(R.string.yesterday)
} else {
"dd MMMM"
}
}
else -> return resources.getString(R.string.today)
}
return SimpleDateFormat(pattern, Locale.getDefault()).format(date)
}
fun getLocalizedTime(resources: Resources, date: Long): String {
val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date }
return when {
now.year != then.year -> {
"${now.year - then.year}${resources.getString(R.string.year_short).lowercase()}"
}
now.month != then.month -> {
"${now.month - then.month}${resources.getString(R.string.month_short).lowercase()}"
}
now.dayOfMonth != then.dayOfMonth -> {
val change = now.dayOfMonth - then.dayOfMonth
if (change % 7 == 0) {
"${change / 7}${resources.getString(R.string.week_short).lowercase()}"
} else {
"$change${resources.getString(R.string.day_short).lowercase()}"
}
}
now.hour == then.hour && now.minute == then.minute -> {
resources.getString(R.string.time_now).lowercase()
}
else -> {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
}
}
}
}
@@ -0,0 +1,721 @@
package com.meloda.app.fast.common.util
//import android.content.Context
//import androidx.compose.ui.graphics.Color
//import androidx.compose.ui.text.AnnotatedString
//import androidx.compose.ui.text.SpanStyle
//import androidx.compose.ui.text.buildAnnotatedString
//import androidx.compose.ui.text.font.FontWeight
//import androidx.compose.ui.text.withStyle
//import com.meloda.app.fast.common.UiImage
//import com.meloda.app.fast.common.UiText
//import com.meloda.app.fast.common.extensions.orDots
//import com.meloda.app.fast.common.parseString
//
//
//@Suppress("MemberVisibilityCanBePrivate")
//object VkUtils {
//
// fun prepareMessageText(text: String, forConversations: Boolean = false): String {
// return text.apply {
// if (forConversations) {
// replace("\n", " ")
// }
//
// replace("&amp;", "&")
// replace("&quot;", "\"")
// replace("<br>", "\n")
// replace("&gt;", ">")
// replace("&lt;", "<")
// replace("<br/>", "\n")
// replace("&ndash;", "-")
// trim()
// }
// }
//
// fun parseAttachments(baseAttachments: List<VkAttachmentItemData>?): List<VkAttachment>? {
// if (baseAttachments.isNullOrEmpty()) return null
//
// val attachments = mutableListOf<VkAttachment>()
//
// for (baseAttachment in baseAttachments) {
// when (baseAttachment.getPreparedType()) {
// AttachmentType.UNKNOWN -> continue
//
// AttachmentType.PHOTO -> {
// val photo = baseAttachment.photo ?: continue
// attachments += photo.toDomain()
// }
//
// AttachmentType.VIDEO -> {
// val video = baseAttachment.video ?: continue
// attachments += video.toDomain()
// }
//
// AttachmentType.AUDIO -> {
// val audio = baseAttachment.audio ?: continue
// attachments += audio.toDomain()
// }
//
// AttachmentType.FILE -> {
// val file = baseAttachment.file ?: continue
// attachments += file.toDomain()
// }
//
// AttachmentType.LINK -> {
// val link = baseAttachment.link ?: continue
// attachments += link.toDomain()
// }
//
// AttachmentType.MINI_APP -> {
// val miniApp = baseAttachment.miniApp ?: continue
// attachments += miniApp.toDomain()
// }
//
// AttachmentType.AUDIO_MESSAGE -> {
// val voiceMessage = baseAttachment.voiceMessage ?: continue
// attachments += voiceMessage.toDomain()
// }
//
// AttachmentType.STICKER -> {
// val sticker = baseAttachment.sticker ?: continue
// attachments += sticker.toDomain()
// }
//
// AttachmentType.GIFT -> {
// val gift = baseAttachment.gift ?: continue
// attachments += gift.toDomain()
// }
//
// AttachmentType.WALL -> {
// val wall = baseAttachment.wall ?: continue
// attachments += wall.toDomain()
// }
//
// AttachmentType.GRAFFITI -> {
// val graffiti = baseAttachment.graffiti ?: continue
// attachments += graffiti.toDomain()
// }
//
// AttachmentType.POLL -> {
// val poll = baseAttachment.poll ?: continue
// attachments += poll.toDomain()
// }
//
// AttachmentType.WALL_REPLY -> {
// val wallReply = baseAttachment.wallReply ?: continue
// attachments += wallReply.toDomain()
// }
//
// AttachmentType.CALL -> {
// val call = baseAttachment.call ?: continue
// attachments += call.toDomain()
// }
//
// AttachmentType.GROUP_CALL_IN_PROGRESS -> {
// val groupCall = baseAttachment.groupCall ?: continue
// attachments += groupCall.toDomain()
// }
//
// AttachmentType.CURATOR -> {
// val curator = baseAttachment.curator ?: continue
// attachments += curator.toDomain()
// }
//
// AttachmentType.EVENT -> {
// val event = baseAttachment.event ?: continue
// attachments += event.toDomain()
// }
//
// AttachmentType.STORY -> {
// val story = baseAttachment.story ?: continue
// attachments += story.toDomain()
// }
//
// AttachmentType.WIDGET -> {
// val widget = baseAttachment.widget ?: continue
// attachments += widget.toDomain()
// }
//
// AttachmentType.ARTIST -> {
// val artist = baseAttachment.artist ?: continue
// attachments += artist.toDomain()
//
// val audios = baseAttachment.audios ?: continue
// audios.map(VkAudioData::toDomain).let(attachments::addAll)
// }
//
// AttachmentType.AUDIO_PLAYLIST -> {
// val audioPlaylist = baseAttachment.audioPlaylist ?: continue
// attachments += audioPlaylist.toDomain()
// }
//
// AttachmentType.PODCAST -> {
// val podcast = baseAttachment.podcast ?: continue
// attachments += podcast.toDomain()
// }
// }
// }
//
// return attachments
// }
//
// fun getActionMessageText(
// context: Context,
// message: VkMessage?,
// youPrefix: String,
// messageUser: VkUserDomain?,
// messageGroup: VkGroupDomain?,
// action: VkMessage.Action?,
// actionUser: VkUserDomain?,
// actionGroup: VkGroupDomain?,
// ): AnnotatedString? {
// return when {
// message == null -> null
// action == null -> null
//
// else -> buildAnnotatedString {
// when (action) {
// VkMessage.Action.CHAT_CREATE -> {
// val text = message.actionText ?: return null
//
// val prefix = when {
// message.fromId == UserConfig.userId -> youPrefix
// message.isGroup() -> messageGroup?.name
// message.isUser() -> messageUser?.toString()
// else -> return null
// } ?: return null
//
// val string = UiText.ResourceParams(
// UiR.string.message_action_chat_created,
// listOf(prefix, text)
// ).parseString(context).orEmpty()
//
// append(string)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
//
// val textStartIndex = string.indexOf(text)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = textStartIndex,
// end = textStartIndex + text.length
// )
// }
//
// VkMessage.Action.CHAT_TITLE_UPDATE -> {
// val text = message.actionText ?: return null
//
// val prefix = when {
// message.fromId == UserConfig.userId -> youPrefix
// message.isGroup() -> messageGroup?.name
// message.isUser() -> messageUser?.toString()
// else -> return null
// } ?: return null
//
// val string = UiText.ResourceParams(
// UiR.string.message_action_chat_renamed,
// listOf(prefix, text)
// ).parseString(context).orEmpty()
//
// append(string)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
//
// val textStartIndex = string.indexOf(text)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = textStartIndex,
// end = textStartIndex + text.length
// )
// }
//
// VkMessage.Action.CHAT_PHOTO_UPDATE -> {
// val prefix = when {
// message.fromId == UserConfig.userId -> youPrefix
// message.isGroup() -> messageGroup?.name
// message.isUser() -> messageUser?.toString()
// else -> return null
// } ?: return null
//
// UiText.ResourceParams(
// UiR.string.message_action_chat_photo_update,
// listOf(prefix)
// ).parseString(context).orEmpty().let(::append)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
// }
//
// VkMessage.Action.CHAT_PHOTO_REMOVE -> {
// val prefix = when {
// message.fromId == UserConfig.userId -> youPrefix
// message.isGroup() -> messageGroup?.name
// message.isUser() -> messageUser?.toString()
// else -> return null
// } ?: return null
//
// UiText.ResourceParams(
// UiR.string.message_action_chat_photo_remove,
// listOf(prefix)
// ).parseString(context).orEmpty().let(::append)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
// }
//
// VkMessage.Action.CHAT_KICK_USER -> {
// val memberId = message.actionMemberId ?: return null
// val isUser = memberId > 0
// val isGroup = memberId < 0
//
// if (isUser && actionUser == null) return null
// if (isGroup && actionGroup == null) return null
//
// if (memberId == message.fromId) {
// val prefix =
// if (memberId == UserConfig.userId) youPrefix
// else actionUser.toString()
//
// UiText.ResourceParams(
// UiR.string.message_action_chat_user_left,
// listOf(prefix)
// ).parseString(context).orEmpty().let(::append)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
// } else {
// val prefix =
// if (message.fromId == UserConfig.userId) youPrefix
// else messageUser?.toString() ?: messageGroup?.toString().orDots()
//
// val postfix =
// if (memberId == UserConfig.userId) youPrefix.lowercase()
// else actionUser.toString()
//
// val string = UiText.ResourceParams(
// UiR.string.message_action_chat_user_kicked,
// listOf(prefix, postfix)
// ).parseString(context).orEmpty()
//
// append(string)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
//
// val postfixStartIndex = string.indexOf(postfix)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = postfixStartIndex,
// end = postfixStartIndex + postfix.length
// )
// }
// }
//
// VkMessage.Action.CHAT_INVITE_USER -> {
// val memberId = message.actionMemberId ?: 0
// val isUser = memberId > 0
// val isGroup = memberId < 0
//
// if (isUser && actionUser == null) return null
// if (isGroup && actionGroup == null) return null
//
// if (memberId == message.fromId) {
// val prefix =
// if (memberId == UserConfig.userId) youPrefix
// else actionUser.toString()
//
// UiText.ResourceParams(
// UiR.string.message_action_chat_user_returned,
// listOf(prefix)
// ).parseString(context).orEmpty().let(::append)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
// } else {
// val prefix =
// if (message.fromId == UserConfig.userId) youPrefix
// else messageUser?.toString() ?: messageGroup?.toString().orDots()
//
// val postfix =
// if (memberId == UserConfig.userId) youPrefix.lowercase()
// else actionUser.toString()
//
// val string = UiText.ResourceParams(
// UiR.string.message_action_chat_user_invited,
// listOf(prefix, postfix)
// ).parseString(context).orEmpty()
//
// append(string)
//
// val postfixStartIndex = string.indexOf(postfix)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = postfixStartIndex,
// end = postfixStartIndex + postfix.length
// )
// }
// }
//
// VkMessage.Action.CHAT_INVITE_USER_BY_LINK -> {
// val prefix = when {
// message.fromId == UserConfig.userId -> youPrefix
// message.isUser() -> messageUser?.toString()
// else -> return null
// } ?: return null
//
// UiText.ResourceParams(
// UiR.string.message_action_chat_user_joined_by_link,
// listOf(prefix)
// ).parseString(context).orEmpty().let(::append)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
// }
//
// VkMessage.Action.CHAT_INVITE_USER_BY_CALL -> {
// val prefix = when {
// message.fromId == UserConfig.userId -> youPrefix
// message.isUser() -> messageUser?.toString()
// else -> return null
// } ?: return null
//
// UiText.ResourceParams(
// UiR.string.message_action_chat_user_joined_by_call,
// listOf(prefix)
// ).parseString(context).orEmpty().let(::append)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
// }
//
// VkMessage.Action.CHAT_INVITE_USER_BY_CALL_LINK -> {
// val prefix = when {
// message.fromId == UserConfig.userId -> youPrefix
// message.isUser() -> messageUser?.toString()
// else -> return null
// } ?: return null
//
// UiText.ResourceParams(
// UiR.string.message_action_chat_user_joined_by_call_link,
// listOf(prefix)
// ).parseString(context).orEmpty().let(::append)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
// }
//
// VkMessage.Action.CHAT_PIN_MESSAGE -> {
// val prefix = when {
// message.fromId == UserConfig.userId -> youPrefix
// message.isGroup() -> messageGroup?.name
// message.isUser() -> messageUser?.toString()
// else -> return null
// } ?: return null
//
// UiText.ResourceParams(
// UiR.string.message_action_chat_pin_message,
// listOf(prefix)
// ).parseString(context).orEmpty().let(::append)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
// }
//
// VkMessage.Action.CHAT_UNPIN_MESSAGE -> {
// val prefix = when {
// message.fromId == UserConfig.userId -> youPrefix
// message.isGroup() -> messageGroup?.name
// message.isUser() -> messageUser?.toString()
// else -> return null
// } ?: return null
//
// UiText.ResourceParams(
// UiR.string.message_action_chat_unpin_message,
// listOf(prefix)
// ).parseString(context).orEmpty().let(::append)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
// }
//
// VkMessage.Action.CHAT_SCREENSHOT -> {
// val prefix = when {
// message.fromId == UserConfig.userId -> youPrefix
// message.isGroup() -> messageGroup?.name
// message.isUser() -> messageUser?.toString()
// else -> return null
// } ?: return null
//
// UiText.ResourceParams(
// UiR.string.message_action_chat_screenshot,
// listOf(prefix)
// ).parseString(context).orEmpty().let(::append)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
// }
//
// VkMessage.Action.CHAT_STYLE_UPDATE -> {
// val prefix = when {
// message.fromId == UserConfig.userId -> youPrefix
// message.isUser() -> messageUser?.toString()
// else -> return null
// } ?: return null
//
// UiText.ResourceParams(
// UiR.string.message_action_chat_style_update,
// listOf(prefix)
// ).parseString(context).orEmpty().let(::append)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.SemiBold),
// start = 0,
// end = prefix.length
// )
// }
// }
// }
// }
// }
//
// fun getForwardsText(context: Context, message: VkMessage?): AnnotatedString? {
// return when {
// message == null -> null
//
// message.hasForwards() -> buildAnnotatedString {
// val forwards = message.forwards.orEmpty()
//
// withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
// append(
// UiText.Resource(
// if (forwards.size == 1) UiR.string.forwarded_message
// else UiR.string.forwarded_messages
// ).parseString(context)
// )
// }
// }
//
// else -> null
// }
// }
//
// fun getAttachmentText(
// getText: (UiText) -> String,
// message: VkMessage?
// ): AnnotatedString? {
// return when {
// message == null -> null
//
// message.geoType != null -> buildAnnotatedString {
// withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
// when (message.geoType) {
// "point" -> getText(UiText.Resource(UiR.string.message_geo_point))
// .let(::append)
//
// else -> getText(UiText.Resource(UiR.string.message_geo))
// .let(::append)
// }
// }
// }
//
// message.hasAttachments() -> buildAnnotatedString {
// val attachments = message.attachments.orEmpty()
//
// withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
// if (attachments.size == 1) {
// getText(getAttachmentUiText(attachments.first())).let(::append)
// } else {
// when {
// isAttachmentsHaveOneType(attachments) -> {
// getText(getAttachmentUiText(attachments.first(), attachments.size))
// .let(::append)
// }
//
// attachments.any { it.type == AttachmentType.ARTIST } -> {
// getText(
// getAttachmentUiText(attachments.first { it.type == AttachmentType.ARTIST })
// ).let(::append)
// }
//
// else -> {
// getText(UiText.Resource(UiR.string.message_attachments_many))
// .let(::append)
// }
// }
// }
// }
// }
//
// else -> null
// }
// }
//
// fun getAttachmentConversationIcon(message: VkMessage?): UiImage? {
// return message?.attachments?.let { attachments ->
// if (attachments.isEmpty()) return null
// if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
// message.geoType?.let {
// return UiImage.Resource(UiR.drawable.ic_map_marker)
// }
//
// getAttachmentIconByType(attachments.first().type)
// } else {
// UiImage.Resource(UiR.drawable.ic_baseline_attach_file_24)
// }
// }
// }
//
//
//
// fun getAttachmentUiText(
// attachment: VkAttachment,
// size: Int = 1,
// ): UiText {
// if (attachment.type.isMultiple()) {
// return when (attachment.type) {
// AttachmentType.PHOTO -> UiR.plurals.attachment_photos
// AttachmentType.VIDEO -> UiR.plurals.attachment_videos
// AttachmentType.AUDIO -> UiR.plurals.attachment_audios
// AttachmentType.FILE -> UiR.plurals.attachment_files
// else -> throw IllegalArgumentException("Unknown multiple type: ${attachment.type}")
// }.let { resId -> UiText.QuantityResource(resId, size) }
// }
//
// return when (attachment.type) {
// AttachmentType.UNKNOWN,
// AttachmentType.PHOTO,
// AttachmentType.VIDEO,
// AttachmentType.AUDIO,
// AttachmentType.FILE -> {
// throw IllegalArgumentException("Unknown multiple type: ${attachment.type}")
// }
//
// AttachmentType.LINK -> UiR.string.message_attachments_link
// AttachmentType.AUDIO_MESSAGE -> UiR.string.message_attachments_audio_message
// AttachmentType.MINI_APP -> UiR.string.message_attachments_mini_app
// AttachmentType.STICKER -> UiR.string.message_attachments_sticker
// AttachmentType.GIFT -> UiR.string.message_attachments_gift
// AttachmentType.WALL -> UiR.string.message_attachments_wall
// AttachmentType.GRAFFITI -> UiR.string.message_attachments_graffiti
// AttachmentType.POLL -> UiR.string.message_attachments_poll
// AttachmentType.WALL_REPLY -> UiR.string.message_attachments_wall_reply
// AttachmentType.CALL -> UiR.string.message_attachments_call
// AttachmentType.GROUP_CALL_IN_PROGRESS -> UiR.string.message_attachments_call_in_progress
// AttachmentType.CURATOR -> UiR.string.message_attachments_curator
// AttachmentType.EVENT -> UiR.string.message_attachments_event
// AttachmentType.STORY -> UiR.string.message_attachments_story
// AttachmentType.WIDGET -> UiR.string.message_attachments_widget
// AttachmentType.ARTIST -> UiR.string.message_attachments_artist
// AttachmentType.AUDIO_PLAYLIST -> UiR.string.message_attachments_audio_playlist
// AttachmentType.PODCAST -> UiR.string.message_attachments_podcast
// }.let(UiText::Resource)
// }
//
// fun getTextWithVisualizedMentions(
// originalText: String,
// mentionColor: Color,
// ): AnnotatedString = buildAnnotatedString {
// val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
//
// val mentions = mutableListOf<MentionIndex>()
//
// var currentIndex = 0
// val replacements = mutableListOf<Pair<IntRange, String>>()
//
// // TODO: 25/04/2024, Danil Nikolaev: check why not working ([id279494346|@iworld2rist] да убери ты Елену Шлипс от меня)
// val result = regex.replace(originalText) { matchResult ->
// val idPrefix = matchResult.groups[1]?.value.orEmpty()
// val startIndex = matchResult.range.first
// val endIndex = matchResult.range.last
//
// val id = matchResult.groups[2]?.value ?: ""
// val text = matchResult.groups[3]?.value ?: ""
//
// val replaced =
// text.substring(startIndex, endIndex + 1)
// .replace("[$idPrefix$id|$text]", text)
//
// val indexRange =
// (startIndex + currentIndex)..startIndex + currentIndex + replaced.length
//
// replacements.add(indexRange to replaced)
//
// mentions += MentionIndex(
// id = id.toIntOrNull() ?: -1,
// idPrefix = idPrefix,
// indexRange = indexRange
// )
//
// currentIndex += replaced.length - (endIndex - startIndex + 1)
//
// replaced
// }
//
// append(result)
//
// mentions.forEach { mention ->
// val startIndex = mention.indexRange.first
// val endIndex = mention.indexRange.last
//
// addStyle(
// style = SpanStyle(color = mentionColor),
// start = startIndex,
// end = endIndex
// )
// addStringAnnotation(
// tag = mention.idPrefix,
// annotation = mention.id.toString(),
// start = startIndex,
// end = endIndex
// )
// }
// }
//
//
//}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="yesterday">Yesterday</string>
<string name="today">Today</string>
<string name="year_short">Y</string>
<string name="month_short">M</string>
<string name="week_short">W</string>
<string name="day_short">D</string>
<string name="time_now">Now</string>
</resources>
+1
View File
@@ -0,0 +1 @@
/build
+42
View File
@@ -0,0 +1,42 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
}
group = "com.meloda.app.fast.data"
android {
namespace = "com.meloda.app.fast.data"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
}
}
dependencies {
api(projects.core.common)
api(projects.core.datastore)
api(projects.core.model)
api(projects.core.network)
api(projects.core.database)
implementation(libs.koin.android)
// TODO: 05/05/2024, Danil Nikolaev: research, maybe remove
implementation(libs.retrofit)
implementation(libs.eithernet)
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,354 @@
package com.meloda.app.fast.data
import android.util.Log
import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.common.VkConstants
import com.meloda.app.fast.common.extensions.asInt
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.toList
import com.meloda.app.fast.data.api.messages.MessagesUseCase
import com.meloda.app.fast.model.ApiEvent
import com.meloda.app.fast.model.InteractionType
import com.meloda.app.fast.model.LongPollEvent
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LongPollUpdatesParser(
private val messagesUseCase: MessagesUseCase
) {
private val job = SupervisorJob()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.d("LongPollUpdatesParser", "error: $throwable")
throwable.printStackTrace()
}
private val coroutineContext: CoroutineContext
get() = Dispatchers.Default + job + exceptionHandler
private val coroutineScope = CoroutineScope(coroutineContext)
private val listenersMap: MutableMap<ApiEvent, MutableCollection<VkEventCallback<*>>> =
mutableMapOf()
fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt()
val eventType: ApiEvent = try {
ApiEvent.parse(eventId)
} catch (e: Exception) {
e.printStackTrace()
Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
return
}
when (eventType) {
ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event)
ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event)
ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event)
ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event)
ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event)
ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event)
ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event)
ApiEvent.PIN_UNPIN_CONVERSATION -> parseConversationPinStateChanged(eventType, event)
ApiEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING,
ApiEvent.PHOTO_UPLOADING,
ApiEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event)
ApiEvent.UNREAD_COUNT_UPDATE -> onNewEvent(eventType, event)
}
}
private fun onNewEvent(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "newEvent: $eventType: $event")
}
private fun parseInteraction(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val interactionType = when (eventType) {
ApiEvent.TYPING -> InteractionType.Typing
ApiEvent.AUDIO_MESSAGE_RECORDING -> InteractionType.VoiceMessage
ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo
ApiEvent.VIDEO_UPLOADING -> InteractionType.Video
ApiEvent.FILE_UPLOADING -> InteractionType.File
else -> return
}
val peerId = event[1].asInt()
val userIds = event[2].toList(Any::asInt).filter { it != UserConfig.userId }
val totalCount = event[3].asInt()
val timestamp = event[4].asInt()
// if userIds contains only account's id, then we don't need to show our status
if (userIds.isEmpty()) return
coroutineScope.launch {
listenersMap[eventType]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.Interaction>)
.onEvent(
LongPollEvent.Interaction(
interactionType = interactionType,
peerId = peerId,
userIds = userIds,
totalCount = totalCount,
timestamp = timestamp
)
)
}
}
}
}
private fun parseConversationPinStateChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt()
val majorId = event[2].asInt()
coroutineScope.launch {
listenersMap[ApiEvent.PIN_UNPIN_CONVERSATION]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkConversationPinStateChangedEvent>)
.onEvent(
LongPollEvent.VkConversationPinStateChangedEvent(
peerId = peerId,
majorId = majorId
)
)
}
}
}
}
private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val messageId = event[1].asInt()
coroutineScope.launch(Dispatchers.IO) {
val newMessageEvent: LongPollEvent.VkMessageNewEvent? =
loadNormalMessage(
eventType,
messageId
)
newMessageEvent?.let { event ->
listenersMap[ApiEvent.MESSAGE_NEW]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageNewEvent>)
.onEvent(event)
}
}
}
}
}
private fun parseMessageEdit(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val messageId = event[1].asInt()
coroutineScope.launch {
val editedMessageEvent: LongPollEvent.VkMessageEditEvent? =
loadNormalMessage(
eventType,
messageId
)
editedMessageEvent?.let { event ->
listenersMap[ApiEvent.MESSAGE_EDIT]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageEditEvent>)
.onEvent(event)
}
}
}
}
}
private fun parseMessageReadIncoming(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt()
val messageId = event[2].asInt()
val unreadCount = event[3].asInt()
coroutineScope.launch {
listenersMap[ApiEvent.MESSAGE_READ_INCOMING]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageReadIncomingEvent>)
.onEvent(
LongPollEvent.VkMessageReadIncomingEvent(
peerId = peerId,
messageId = messageId,
unreadCount = unreadCount
)
)
}
}
}
}
private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt()
val messageId = event[2].asInt()
val unreadCount = event[3].asInt()
coroutineScope.launch {
listenersMap[ApiEvent.MESSAGE_READ_OUTGOING]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageReadOutgoingEvent>)
.onEvent(
LongPollEvent.VkMessageReadOutgoingEvent(
peerId = peerId,
messageId = messageId,
unreadCount = unreadCount
)
)
}
}
}
}
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
private suspend fun <T : LongPollEvent> loadNormalMessage(
eventType: ApiEvent,
messageId: Int
): T? = suspendCoroutine {
coroutineScope.launch(Dispatchers.IO) {
messagesUseCase.getById(
messageId = messageId,
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(this) { state ->
state.processState(
error = { error ->
Log.e("LongPollUpdatesParser", "loadNormalMessage: error: $error")
},
success = { response ->
response?.let { message ->
VkMemoryCache[message.id] = message
messagesUseCase.storeMessage(message)
val resumeValue: LongPollEvent? = when (eventType) {
ApiEvent.MESSAGE_NEW -> LongPollEvent.VkMessageNewEvent(message)
ApiEvent.MESSAGE_EDIT -> LongPollEvent.VkMessageEditEvent(message)
else -> null
}
resumeValue?.let { value -> it.resume(value as T) }
} ?: it.resume(null)
}
)
}
}
}
private fun <T : Any> registerListener(
eventType: ApiEvent,
listener: VkEventCallback<T>
) {
listenersMap.let { map ->
map[eventType] = (map[eventType] ?: mutableListOf()).also { it.add(listener) }
}
}
private fun <T : Any> registerListeners(
eventTypes: List<ApiEvent>,
listener: VkEventCallback<T>
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
}
fun onConversationPinStateChanged(listener: VkEventCallback<LongPollEvent.VkConversationPinStateChangedEvent>) {
registerListener(ApiEvent.PIN_UNPIN_CONVERSATION, listener)
}
fun onConversationPinStateChanged(block: (LongPollEvent.VkConversationPinStateChangedEvent) -> Unit) {
onConversationPinStateChanged(assembleEventCallback(block))
}
fun onMessageIncomingRead(listener: VkEventCallback<LongPollEvent.VkMessageReadIncomingEvent>) {
registerListener(ApiEvent.MESSAGE_READ_INCOMING, listener)
}
fun onMessageIncomingRead(block: (LongPollEvent.VkMessageReadIncomingEvent) -> Unit) {
onMessageIncomingRead(assembleEventCallback(block))
}
fun onMessageOutgoingRead(listener: VkEventCallback<LongPollEvent.VkMessageReadOutgoingEvent>) {
registerListener(ApiEvent.MESSAGE_READ_OUTGOING, listener)
}
fun onMessageOutgoingRead(block: (LongPollEvent.VkMessageReadOutgoingEvent) -> Unit) {
onMessageOutgoingRead(assembleEventCallback(block))
}
fun onNewMessage(listener: VkEventCallback<LongPollEvent.VkMessageNewEvent>) {
registerListener(ApiEvent.MESSAGE_NEW, listener)
}
fun onNewMessage(block: (LongPollEvent.VkMessageNewEvent) -> Unit) {
onNewMessage(assembleEventCallback(block))
}
fun onMessageEdited(listener: VkEventCallback<LongPollEvent.VkMessageEditEvent>) {
registerListener(ApiEvent.MESSAGE_EDIT, listener)
}
fun onMessageEdited(block: (LongPollEvent.VkMessageEditEvent) -> Unit) {
onMessageEdited(assembleEventCallback(block))
}
fun onInteractions(listener: VkEventCallback<LongPollEvent.Interaction>) {
registerListeners(
eventTypes = listOf(
ApiEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING,
ApiEvent.PHOTO_UPLOADING,
ApiEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING
),
listener = listener
)
}
fun onInteractions(block: (LongPollEvent.Interaction) -> Unit) {
onInteractions(assembleEventCallback(block))
}
fun clearListeners() {
listenersMap.clear()
}
}
internal inline fun <R : Any> assembleEventCallback(
crossinline block: (R) -> Unit,
): VkEventCallback<R> {
return VkEventCallback { event -> block.invoke(event) }
}
fun interface VkEventCallback<in T : Any> {
fun onEvent(event: T)
}
@@ -0,0 +1,23 @@
package com.meloda.app.fast.data
import com.meloda.app.fast.model.api.data.LongPollUpdates
import com.meloda.app.fast.model.api.data.VkLongPollData
import kotlinx.coroutines.flow.Flow
interface LongPollUseCase {
fun getLongPollServer(
needPts: Boolean,
version: Int
): Flow<State<VkLongPollData>>
fun getLongPollUpdates(
serverUrl: String,
act: String = "a_check",
key: String,
ts: Int,
wait: Int,
mode: Int,
version: Int
): Flow<State<LongPollUpdates>>
}
@@ -0,0 +1,49 @@
package com.meloda.app.fast.data
import com.meloda.app.fast.data.api.longpoll.LongPollRepository
import com.meloda.app.fast.model.api.data.LongPollUpdates
import com.meloda.app.fast.model.api.data.VkLongPollData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class LongPollUseCaseImpl(
private val repository: LongPollRepository
) : LongPollUseCase {
override fun getLongPollServer(
needPts: Boolean,
version: Int
): Flow<State<VkLongPollData>> = flow {
emit(State.Loading)
val newState = repository.getLongPollServer(
needPts = needPts,
version = version
).mapToState()
emit(newState)
}
override fun getLongPollUpdates(
serverUrl: String,
act: String,
key: String,
ts: Int,
wait: Int,
mode: Int,
version: Int
): Flow<State<LongPollUpdates>> = flow {
emit(State.Loading)
val newState = repository.getLongPollUpdates(
serverUrl,
act = act,
key = key,
ts = ts,
wait = wait,
mode = mode,
version = version
).mapToState()
emit(newState)
}
}
@@ -0,0 +1,76 @@
package com.meloda.app.fast.data
import com.meloda.app.fast.network.OAuthErrorDomain
import com.meloda.app.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
sealed class State<out T> {
data object Idle : State<Nothing>()
data class Success<T>(val data: T) : State<T>()
data object Loading : State<Nothing>()
sealed class Error : State<Nothing>() {
data class ApiError(
val errorCode: Int,
val errorMessage: String,
) : Error()
data object ConnectionError : Error()
data object Unknown : Error()
data object InternalError : Error()
data class OAuthError(val error: OAuthErrorDomain) : Error()
}
fun isLoading(): Boolean = this is Loading
companion object {
val UNKNOWN_ERROR = Error.Unknown
}
}
inline fun <T> State<T>.processState(
error: (error: State.Error) -> (Unit),
success: (data: T) -> (Unit),
idle: (() -> (Unit)) = {},
loading: (() -> (Unit)) = {},
) {
when (this) {
is State.Error -> error(this)
State.Idle -> idle()
State.Loading -> loading()
is State.Success -> success(data)
}
}
inline fun <T, R> Flow<State<T>>.mapSuccess(
crossinline transform: suspend (value: T) -> R
): Flow<R> = filterIsInstance<State.Success<T>>()
.map { state -> transform.invoke(state.data) }
fun RestApiErrorDomain?.toStateApiError(): State.Error = when (this) {
null -> State.Error.ConnectionError
else -> State.Error.ApiError(code, message)
}
fun OAuthErrorDomain?.toStateApiError(): State.Error = when (this) {
null -> State.Error.ConnectionError
else -> State.Error.OAuthError(this)
}
fun <T : Any> ApiResult<T, RestApiErrorDomain>.mapToState() = when (this) {
is ApiResult.Success -> State.Success(this.value)
is ApiResult.Failure.NetworkFailure -> State.Error.ConnectionError
is ApiResult.Failure.UnknownFailure -> State.UNKNOWN_ERROR
is ApiResult.Failure.HttpFailure -> this.error.toStateApiError()
is ApiResult.Failure.ApiFailure -> this.error.toStateApiError()
}
@@ -0,0 +1,47 @@
package com.meloda.app.fast.data
import com.meloda.app.fast.model.api.data.VkMessageData
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.model.api.domain.VkGroupDomain
import com.meloda.app.fast.model.api.domain.VkMessage
import kotlin.math.abs
class VkGroupsMap(
private val groups: List<VkGroupDomain>
) {
private val map: HashMap<Int, VkGroupDomain> by lazy {
HashMap(groups.associateBy(VkGroupDomain::id))
}
fun groups(): List<VkGroupDomain> = map.values.toList()
fun conversationGroup(conversation: VkConversation): VkGroupDomain? =
if (!conversation.peerType.isGroup()) null
else map[abs(conversation.id)]
fun messageActionGroup(message: VkMessage): VkGroupDomain? =
if (message.actionMemberId == null || message.actionMemberId!! >= 0) null
else map[abs(message.actionMemberId!!)]
fun messageActionGroup(message: VkMessageData): VkGroupDomain? =
if (message.action?.memberId == null || message.action!!.memberId!! >= 0) null
else map[abs(message.action!!.memberId!!)]
fun messageGroup(message: VkMessage): VkGroupDomain? =
if (!message.isGroup()) null
else map[abs(message.fromId)]
fun messageGroup(message: VkMessageData): VkGroupDomain? =
if (message.fromId >= 0) null
else map[abs(message.fromId)]
fun group(groupId: Int): VkGroupDomain? = map[abs(groupId)]
companion object {
fun forGroups(groups: List<VkGroupDomain>): VkGroupsMap = VkGroupsMap(groups = groups)
fun List<VkGroupDomain>.toGroupsMap(): VkGroupsMap = forGroups(this)
}
}
@@ -0,0 +1,119 @@
package com.meloda.app.fast.data
import com.meloda.app.fast.model.api.domain.VkContactDomain
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.model.api.domain.VkGroupDomain
import com.meloda.app.fast.model.api.domain.VkMessage
import com.meloda.app.fast.model.api.domain.VkUser
import kotlin.math.abs
object VkMemoryCache {
private val users: HashMap<Int, VkUser> = hashMapOf()
private val groups: HashMap<Int, VkGroupDomain> = hashMapOf()
private val messages: HashMap<Int, VkMessage> = hashMapOf()
private val conversations: HashMap<Int, VkConversation> = hashMapOf()
private val contacts: HashMap<Int, VkContactDomain> = hashMapOf()
fun appendUsers(users: List<VkUser>) {
users.forEach { user -> VkMemoryCache.users[user.id] = user }
}
fun appendGroups(groups: List<VkGroupDomain>) {
groups.forEach { group -> VkMemoryCache.groups[abs(group.id)] = group }
}
fun appendMessages(messages: List<VkMessage>) {
messages.forEach { message -> VkMemoryCache.messages[message.id] = message }
}
fun appendConversations(conversations: List<VkConversation>) {
conversations.forEach { conversation ->
VkMemoryCache.conversations[conversation.id] = conversation
}
}
fun appendContacts(contacts: List<VkContactDomain>) {
contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact }
}
operator fun set(userId: Int, user: VkUser) {
users[userId] = user
}
operator fun set(groupId: Int, group: VkGroupDomain) {
groups[groupId] = group
}
operator fun set(messageId: Int, message: VkMessage) {
messages[messageId] = message
}
operator fun set(conversationId: Int, conversation: VkConversation) {
conversations[conversationId] = conversation
}
operator fun set(contactId: Int, contact: VkContactDomain) {
contacts[contactId] = contact
}
fun getUser(id: Int): VkUser? {
return getUsers(id).firstOrNull()
}
fun getUsers(vararg ids: Int): List<VkUser> {
return getUsers(ids.toList())
}
fun getUsers(ids: List<Int>): List<VkUser> {
return ids.mapNotNull { id -> users[id] }
}
fun getGroup(id: Int): VkGroupDomain? {
return getGroups(id).firstOrNull()
}
fun getGroups(vararg ids: Int): List<VkGroupDomain> {
return getGroups(ids.toList())
}
fun getGroups(ids: List<Int>): List<VkGroupDomain> {
return ids.mapNotNull { id -> groups[id] }
}
fun getMessage(id: Int): VkMessage? {
return getMessages(id).firstOrNull()
}
fun getMessages(vararg ids: Int): List<VkMessage> {
return getMessages(ids.toList())
}
fun getMessages(ids: List<Int>): List<VkMessage> {
return ids.mapNotNull { id -> messages[id] }
}
fun getConversation(id: Int): VkConversation? {
return getConversations(id).firstOrNull()
}
fun getConversations(vararg ids: Int): List<VkConversation> {
return getConversations(ids.toList())
}
fun getConversations(ids: List<Int>): List<VkConversation> {
return ids.mapNotNull { id -> conversations[id] }
}
fun getContact(id: Int): VkContactDomain? {
return getContacts(id).firstOrNull()
}
fun getContacts(vararg ids: Int): List<VkContactDomain> {
return getContacts(ids.toList())
}
fun getContacts(ids: List<Int>): List<VkContactDomain> {
return ids.mapNotNull { id -> contacts[id] }
}
}
@@ -0,0 +1,46 @@
package com.meloda.app.fast.data
import com.meloda.app.fast.model.api.data.VkMessageData
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.model.api.domain.VkMessage
import com.meloda.app.fast.model.api.domain.VkUser
class VkUsersMap(
private val users: List<VkUser>
) {
private val map: HashMap<Int, VkUser> by lazy {
HashMap(users.associateBy(VkUser::id))
}
fun users(): List<VkUser> = map.values.toList()
fun conversationUser(conversation: VkConversation): VkUser? =
if (!conversation.peerType.isUser()) null
else map[conversation.id]
fun messageActionUser(message: VkMessage): VkUser? =
if (message.actionMemberId == null || message.actionMemberId!! <= 0) null
else map[message.actionMemberId]
fun messageActionUser(message: VkMessageData): VkUser? =
if (message.action?.memberId == null || message.action!!.memberId!! <= 0) null
else map[message.action!!.memberId]
fun messageUser(message: VkMessage): VkUser? =
if (!message.isUser()) null
else map[message.fromId]
fun messageUser(message: VkMessageData): VkUser? =
if (message.fromId > 0) map[message.fromId]
else null
fun user(userId: Int): VkUser? = map[userId]
companion object {
fun forUsers(users: List<VkUser>): VkUsersMap = VkUsersMap(users = users)
fun List<VkUser>.toUsersMap(): VkUsersMap = forUsers(this)
}
}
@@ -0,0 +1,15 @@
package com.meloda.app.fast.data.api.account
import com.meloda.app.fast.model.api.requests.AccountSetOfflineRequest
import com.meloda.app.fast.model.api.requests.AccountSetOnlineRequest
interface AccountRepository {
suspend fun setOnline(
params: AccountSetOnlineRequest
): Boolean
suspend fun setOffline(
params: AccountSetOfflineRequest
): Boolean
}
@@ -0,0 +1,19 @@
package com.meloda.app.fast.data.api.account
import com.meloda.app.fast.model.api.requests.AccountSetOfflineRequest
import com.meloda.app.fast.model.api.requests.AccountSetOnlineRequest
import com.meloda.app.fast.network.service.account.AccountService
// TODO: 05/05/2024, Danil Nikolaev: implement
class AccountRepositoryImpl(
private val accountService: AccountService
) : com.meloda.app.fast.data.api.account.AccountRepository {
override suspend fun setOnline(params: AccountSetOnlineRequest): Boolean {
return false
}
override suspend fun setOffline(params: AccountSetOfflineRequest): Boolean {
return false
}
}
@@ -0,0 +1,16 @@
package com.meloda.app.fast.data.api.account
import com.meloda.app.fast.data.State
import kotlinx.coroutines.flow.Flow
interface AccountUseCase {
suspend fun setOnline(
voip: Boolean,
accessToken: String
): Flow<State<Unit>>
suspend fun setOffline(
accessToken: String
): Flow<State<Unit>>
}
@@ -0,0 +1,49 @@
package com.meloda.app.fast.data.api.account
import com.meloda.app.fast.data.State
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
// TODO: 05/05/2024, Danil Nikolaev: implement
class AccountUseCaseImpl(
private val accountRepository: com.meloda.app.fast.data.api.account.AccountRepository
) : com.meloda.app.fast.data.api.account.AccountUseCase {
override suspend fun setOnline(
voip: Boolean,
accessToken: String
): Flow<State<Unit>> = flow {
// emit(com.meloda.app.fast.data.State.Loading)
//
// val newState = accountRepository.setOnline(
// params = AccountSetOnlineRequest(
// voip = voip,
// accessToken = accessToken
// )
// ).fold(
// onSuccess = { response -> com.meloda.app.fast.data.State.Success(response) },
// onNetworkFailure = { com.meloda.app.fast.data.State.Error.ConnectionError },
// onUnknownFailure = { com.meloda.app.fast.data.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
}
override suspend fun setOffline(
accessToken: String
): Flow<com.meloda.app.fast.data.State<Unit>> = flow {
emit(com.meloda.app.fast.data.State.Loading)
// val newState = accountRepository.setOffline(
// params = AccountSetOfflineRequest(accessToken = accessToken)
// ).fold(
// onSuccess = { response -> com.meloda.app.fast.data.State.Success(response) },
// onNetworkFailure = { com.meloda.app.fast.data.State.Error.ConnectionError },
// onUnknownFailure = { com.meloda.app.fast.data.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
}
}
@@ -0,0 +1,26 @@
package com.meloda.app.fast.data.api.audios
import com.meloda.app.fast.model.api.responses.AudiosGetUploadServerResponse
import com.meloda.app.fast.network.ApiResponse
import com.meloda.app.fast.network.RestApiError
import com.meloda.app.fast.network.service.audios.AudiosService
import com.slack.eithernet.ApiResult
import okhttp3.MultipartBody
class AudiosRepository(
private val audiosService: AudiosService
) {
suspend fun getUploadServer(): ApiResult<ApiResponse<AudiosGetUploadServerResponse>, RestApiError> =
audiosService.getUploadServer()
suspend fun upload(url: String, file: MultipartBody.Part) = audiosService.upload(url, file)
suspend fun save(server: Int, audio: String, hash: String) = audiosService.save(
mapOf(
"server" to server.toString(),
"audio" to audio,
"hash" to hash
)
)
}
@@ -0,0 +1,18 @@
package com.meloda.app.fast.data.api.auth
import com.meloda.app.fast.model.api.requests.AuthDirectRequest
import com.meloda.app.fast.model.api.responses.AuthDirectResponse
import com.meloda.app.fast.model.api.responses.SendSmsResponse
import com.meloda.app.fast.network.OAuthErrorDomain
import com.slack.eithernet.ApiResult
interface AuthRepository {
// suspend fun auth(
// params: AuthDirectRequest
// ): ApiResult<AuthDirectResponse, OAuthErrorDomain>
suspend fun sendSms(
validationSid: String
): SendSmsResponse
}
@@ -0,0 +1,35 @@
package com.meloda.app.fast.data.api.auth
import com.meloda.app.fast.model.api.requests.AuthDirectRequest
import com.meloda.app.fast.model.api.responses.AuthDirectResponse
import com.meloda.app.fast.model.api.responses.SendSmsResponse
import com.meloda.app.fast.network.OAuthErrorDomain
import com.meloda.app.fast.network.service.auth.AuthService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AuthRepositoryImpl(
private val authService: AuthService
) : AuthRepository {
// override suspend fun auth(
// params: AuthDirectRequest
// ): ApiResult<AuthDirectResponse, OAuthErrorDomain> {
//
// }
// TODO: 05/05/2024, Danil Nikolaev: implement
override suspend fun sendSms(
validationSid: String
): SendSmsResponse = withContext(Dispatchers.IO) {
SendSmsResponse(
validationSid = null, delay = null, validationType = null, validationResend = null
)
// authService.sendSms(validationSid).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
}
}
@@ -0,0 +1,18 @@
package com.meloda.app.fast.data.api.conversations
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface ConversationsRepository {
suspend fun getConversations(
count: Int?,
offset: Int?
): ApiResult<List<VkConversation>, RestApiErrorDomain>
suspend fun storeConversations(conversations: List<VkConversation>)
suspend fun delete(peerId: Int): ApiResult<Int, RestApiErrorDomain>
suspend fun pin(peerId: Int): ApiResult<Int, RestApiErrorDomain>
suspend fun unpin(peerId: Int): ApiResult<Int, RestApiErrorDomain>
}
@@ -0,0 +1,109 @@
package com.meloda.app.fast.data.api.conversations
import com.meloda.app.fast.common.VkConstants
import com.meloda.app.fast.data.VkGroupsMap
import com.meloda.app.fast.data.VkMemoryCache
import com.meloda.app.fast.data.VkUsersMap
import com.meloda.app.fast.database.dao.ConversationDao
import com.meloda.app.fast.model.api.data.VkContactData
import com.meloda.app.fast.model.api.data.VkGroupData
import com.meloda.app.fast.model.api.data.VkUserData
import com.meloda.app.fast.model.api.data.asDomain
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.model.api.domain.asEntity
import com.meloda.app.fast.model.api.requests.ConversationsDeleteRequest
import com.meloda.app.fast.model.api.requests.ConversationsGetRequest
import com.meloda.app.fast.model.api.requests.ConversationsPinRequest
import com.meloda.app.fast.model.api.requests.ConversationsUnpinRequest
import com.meloda.app.fast.network.RestApiErrorDomain
import com.meloda.app.fast.network.mapApiDefault
import com.meloda.app.fast.network.mapApiResult
import com.meloda.app.fast.network.service.conversations.ConversationsService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ConversationsRepositoryImpl(
private val conversationsService: ConversationsService,
private val conversationDao: ConversationDao
) : ConversationsRepository {
override suspend fun getConversations(
count: Int?,
offset: Int?
): ApiResult<List<VkConversation>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConversationsGetRequest(
count = count,
offset = offset,
fields = VkConstants.ALL_FIELDS,
filter = "all",
extended = true,
startMessageId = null
)
conversationsService.getConversations(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain)
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
response.items.map { item ->
val lastMessage = item.lastMessage?.asDomain()?.let { message ->
message.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
).also { VkMemoryCache[message.id] = it }
}
item.conversation.asDomain(lastMessage).let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
}
}
},
errorMapper = { error ->
error?.toDomain()
}
)
}
override suspend fun storeConversations(conversations: List<VkConversation>) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity))
}
override suspend fun delete(peerId: Int): ApiResult<Int, RestApiErrorDomain> =
withContext(Dispatchers.IO) {
val requestModel = ConversationsDeleteRequest(peerId = peerId)
conversationsService.delete(requestModel.map).mapApiResult(
successMapper = { response -> response.requireResponse().lastDeletedId },
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun pin(
peerId: Int
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConversationsPinRequest(peerId = peerId)
conversationsService.pin(requestModel.map).mapApiDefault()
}
override suspend fun unpin(
peerId: Int
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConversationsUnpinRequest(peerId = peerId)
conversationsService.unpin(requestModel.map).mapApiDefault()
}
}
@@ -0,0 +1,19 @@
package com.meloda.app.fast.data.api.conversations
import com.meloda.app.fast.data.State
import com.meloda.app.fast.model.api.domain.VkConversation
import kotlinx.coroutines.flow.Flow
interface ConversationsUseCase {
fun getConversations(
count: Int?,
offset: Int?,
): Flow<State<List<VkConversation>>>
fun delete(peerId: Int): Flow<State<Int>>
fun changePinState(peerId: Int, pin: Boolean): Flow<State<Int>>
suspend fun storeConversations(conversations: List<VkConversation>)
}
@@ -0,0 +1,31 @@
package com.meloda.app.fast.data.api.files
import com.meloda.app.fast.network.service.files.FilesService
import okhttp3.MultipartBody
class FilesRepository(
private val filesService: FilesService
) {
// TODO: 05/05/2024, Danil Nikolaev: reimplement
// enum class FileType(val value: String) {
// @Json(name = "doc")
// FILE("doc"),
//
// @Json(name = "audio_message")
// AUDIO_MESSAGE("audio_message")
// }
//
// suspend fun getMessagesUploadServer(peerId: Int, type: FileType) =
// filesService.getUploadServer(
// mapOf(
// "peer_id" to peerId.toString(),
// "type" to type.value
// )
// )
suspend fun uploadFile(url: String, file: MultipartBody.Part) = filesService.upload(url, file)
suspend fun saveMessageFile(file: String) = filesService.save(mapOf("file" to file))
}
@@ -0,0 +1,26 @@
package com.meloda.app.fast.data.api.friends
import com.meloda.app.fast.model.FriendsInfo
import com.meloda.app.fast.model.api.domain.VkUser
import com.meloda.app.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface FriendsRepository {
suspend fun getAllFriends(
count: Int?,
offset: Int?
): ApiResult<FriendsInfo, RestApiErrorDomain>
suspend fun getFriends(
count: Int?,
offset: Int?
): ApiResult<List<VkUser>, RestApiErrorDomain>
suspend fun getOnlineFriends(
count: Int?,
offset: Int?
): ApiResult<List<Int>, RestApiErrorDomain>
suspend fun storeUsers(users: List<VkUser>)
}
@@ -0,0 +1,83 @@
package com.meloda.app.fast.data.api.friends
import com.meloda.app.fast.common.VkConstants
import com.meloda.app.fast.data.VkMemoryCache
import com.meloda.app.fast.database.dao.UsersDao
import com.meloda.app.fast.model.FriendsInfo
import com.meloda.app.fast.model.api.data.VkUserData
import com.meloda.app.fast.model.api.domain.VkUser
import com.meloda.app.fast.model.api.domain.asEntity
import com.meloda.app.fast.model.api.requests.GetFriendsRequest
import com.meloda.app.fast.model.api.requests.GetOnlineFriendsRequest
import com.meloda.app.fast.network.RestApiErrorDomain
import com.meloda.app.fast.network.mapApiDefault
import com.meloda.app.fast.network.mapApiResult
import com.meloda.app.fast.network.service.friends.FriendsService
import com.slack.eithernet.ApiResult
import com.slack.eithernet.successOrElse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
class FriendsRepositoryImpl(
private val service: FriendsService,
private val dao: UsersDao
) : FriendsRepository {
override suspend fun getAllFriends(
count: Int?,
offset: Int?
): ApiResult<FriendsInfo, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val friends = async { getFriends(count, offset) }.await()
.successOrElse { failure ->
return@withContext failure
}
val onlineFriends = async { getOnlineFriends(count, offset) }.await()
.successOrElse { failure ->
return@withContext failure
}.mapNotNull { userId -> friends.find { it.id == userId } }
ApiResult.success(FriendsInfo(friends, onlineFriends))
}
override suspend fun getFriends(
count: Int?,
offset: Int?
): ApiResult<List<VkUser>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = GetFriendsRequest(
order = "hints",
count = count,
offset = offset,
fields = VkConstants.USER_FIELDS
)
service.getFriends(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val users = response.items.map(VkUserData::mapToDomain)
VkMemoryCache.appendUsers(users)
users
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun getOnlineFriends(
count: Int?,
offset: Int?
): ApiResult<List<Int>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = GetOnlineFriendsRequest(
order = "hints",
count = count,
offset = offset,
)
service.getOnlineFriends(requestModel.map).mapApiDefault()
}
override suspend fun storeUsers(users: List<VkUser>) = withContext(Dispatchers.IO) {
dao.insertAll(users.map(VkUser::asEntity))
}
}
@@ -0,0 +1,26 @@
package com.meloda.app.fast.data.api.friends
import com.meloda.app.fast.data.State
import com.meloda.app.fast.model.FriendsInfo
import com.meloda.app.fast.model.api.domain.VkUser
import kotlinx.coroutines.flow.Flow
interface FriendsUseCase {
fun getAllFriends(
count: Int?,
offset: Int?
): Flow<State<FriendsInfo>>
fun getFriends(
count: Int?,
offset: Int?
): Flow<State<List<VkUser>>>
fun getOnlineFriends(
count: Int?,
offset: Int?
): Flow<State<List<Int>>>
suspend fun storeUsers(users: List<VkUser>)
}
@@ -0,0 +1,24 @@
package com.meloda.app.fast.data.api.longpoll
import com.meloda.app.fast.model.api.data.LongPollUpdates
import com.meloda.app.fast.model.api.data.VkLongPollData
import com.meloda.app.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface LongPollRepository {
suspend fun getLongPollServer(
needPts: Boolean,
version: Int
): ApiResult<VkLongPollData, RestApiErrorDomain>
suspend fun getLongPollUpdates(
serverUrl: String,
act: String,
key: String,
ts: Int,
wait: Int,
mode: Int,
version: Int
): ApiResult<LongPollUpdates, RestApiErrorDomain>
}
@@ -0,0 +1,57 @@
package com.meloda.app.fast.data.api.longpoll
import com.meloda.app.fast.model.api.data.LongPollUpdates
import com.meloda.app.fast.model.api.data.VkLongPollData
import com.meloda.app.fast.model.api.requests.LongPollGetUpdatesRequest
import com.meloda.app.fast.model.api.requests.MessagesGetLongPollServerRequest
import com.meloda.app.fast.network.RestApiErrorDomain
import com.meloda.app.fast.network.mapApiResult
import com.meloda.app.fast.network.mapResult
import com.meloda.app.fast.network.service.longpoll.LongPollService
import com.meloda.app.fast.network.service.messages.MessagesService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class LongPollRepositoryImpl(
private val longPollService: LongPollService,
private val messagesService: MessagesService
) : LongPollRepository {
override suspend fun getLongPollServer(
needPts: Boolean,
version: Int
): ApiResult<VkLongPollData, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetLongPollServerRequest(
needPts = needPts,
version = version
)
messagesService.getLongPollServer(requestModel.map).mapApiResult(
successMapper = { response -> response.requireResponse() },
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun getLongPollUpdates(
serverUrl: String,
act: String,
key: String,
ts: Int,
wait: Int,
mode: Int,
version: Int
): ApiResult<LongPollUpdates, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = LongPollGetUpdatesRequest(
act = act,
key = key,
ts = ts,
wait = wait,
mode = mode,
version = version
)
longPollService.getResponse(serverUrl, requestModel.map).mapResult(
successMapper = { response -> response },
errorMapper = { error -> error?.toDomain() }
)
}
}
@@ -0,0 +1,16 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.model.database.VkMessageEntity
interface MessagesLocalDataSource {
suspend fun getMessages(
conversationId: Int,
offset: Int?,
count: Int?
): List<VkMessageEntity>
suspend fun getMessage(messageId: Int): VkMessageEntity?
suspend fun storeMessages(messages: List<VkMessageEntity>)
}
@@ -0,0 +1,24 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.database.dao.MessageDao
import com.meloda.app.fast.model.database.VkMessageEntity
// TODO: 05/05/2024, Danil Nikolaev: use paging for room
class MessagesLocalDataSourceImpl(
private val messageDao: MessageDao
) : MessagesLocalDataSource {
override suspend fun getMessages(
conversationId: Int,
offset: Int?,
count: Int?
): List<VkMessageEntity> = messageDao.getAll(conversationId)
override suspend fun getMessage(
messageId: Int
): VkMessageEntity? = messageDao.getById(messageId)
override suspend fun storeMessages(messages: List<VkMessageEntity>) {
messageDao.insertAll(messages)
}
}
@@ -0,0 +1,36 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkMessage
import com.meloda.app.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface MessagesNetworkDataSource {
suspend fun getMessagesHistory(
conversationId: Int,
offset: Int?,
count: Int?,
): ApiResult<MessagesHistoryDomain, RestApiErrorDomain>
suspend fun getMessageById(
messagesIds: List<Int>,
extended: Boolean?,
fields: String?
): ApiResult<VkMessage, RestApiErrorDomain>
suspend fun send(
peerId: Int,
randomId: Int,
message: String?,
replyTo: Int?,
attachments: List<VkAttachment>?
): ApiResult<Int, RestApiErrorDomain>
suspend fun markAsRead(
peerId: Int,
startMessageId: Int?
): ApiResult<Int, RestApiErrorDomain>
suspend fun getMessage(messageId: Int): VkMessage?
}
@@ -0,0 +1,164 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.common.VkConstants
import com.meloda.app.fast.data.VkGroupsMap
import com.meloda.app.fast.data.VkMemoryCache
import com.meloda.app.fast.data.VkUsersMap
import com.meloda.app.fast.model.api.data.VkContactData
import com.meloda.app.fast.model.api.data.VkGroupData
import com.meloda.app.fast.model.api.data.VkUserData
import com.meloda.app.fast.model.api.data.asDomain
import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.model.api.domain.VkMessage
import com.meloda.app.fast.model.api.requests.MessagesGetByIdRequest
import com.meloda.app.fast.model.api.requests.MessagesGetHistoryRequest
import com.meloda.app.fast.model.api.requests.MessagesMarkAsReadRequest
import com.meloda.app.fast.model.api.requests.MessagesSendRequest
import com.meloda.app.fast.network.RestApiErrorDomain
import com.meloda.app.fast.network.mapApiDefault
import com.meloda.app.fast.network.mapApiResult
import com.meloda.app.fast.network.service.messages.MessagesService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class MessagesNetworkDataSourceImpl(
private val messagesService: MessagesService
) : MessagesNetworkDataSource {
override suspend fun getMessagesHistory(
conversationId: Int,
offset: Int?,
count: Int?
): ApiResult<MessagesHistoryDomain, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetHistoryRequest(
count = count,
offset = offset,
peerId = conversationId,
extended = true,
startMessageId = null,
rev = null,
fields = VkConstants.ALL_FIELDS
)
messagesService.getHistory(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain)
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
val messages = response.items.map { item ->
item.asDomain().let { message ->
message.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
).also { VkMemoryCache[message.id] = it }
}
}
val conversations = response.conversations.orEmpty().map { item ->
val message = messages.firstOrNull { it.id == item.lastMessageId }
item.asDomain(message)
.let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
}
}
MessagesHistoryDomain(
messages = messages,
conversations = conversations
)
},
errorMapper = { error ->
error?.toDomain()
}
)
}
override suspend fun getMessageById(
messagesIds: List<Int>,
extended: Boolean?,
fields: String?
): ApiResult<VkMessage, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetByIdRequest(
messagesIds = messagesIds,
extended = extended,
fields = fields
)
messagesService.getById(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val message = response.items.single()
val usersMap =
VkUsersMap.forUsers(response.profiles.orEmpty().map(VkUserData::mapToDomain))
val groupsMap =
VkGroupsMap.forGroups(response.groups.orEmpty().map(VkGroupData::mapToDomain))
message.asDomain().copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
)
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun send(
peerId: Int,
randomId: Int,
message: String?,
replyTo: Int?,
attachments: List<VkAttachment>?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesSendRequest(
peerId = peerId,
randomId = randomId,
message = message,
replyTo = replyTo,
attachments = attachments
)
messagesService.send(requestModel.map).mapApiDefault()
}
override suspend fun markAsRead(
peerId: Int,
startMessageId: Int?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesMarkAsReadRequest(
peerId = peerId,
startMessageId = startMessageId
)
messagesService.markAsRead(requestModel.map).mapApiDefault()
}
override suspend fun getMessage(messageId: Int): VkMessage? = withContext(Dispatchers.IO) {
// TODO: 05/05/2024, Danil Nikolaev: get message
null
}
}
data class MessagesHistoryDomain(
val messages: List<VkMessage>,
val conversations: List<VkConversation>
)
@@ -0,0 +1,75 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkMessage
import com.meloda.app.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.flow.Flow
interface MessagesRepository {
suspend fun getMessagesHistory(
conversationId: Int,
offset: Int?,
count: Int?
): ApiResult<MessagesHistoryDomain, RestApiErrorDomain>
suspend fun getMessageById(
messagesIds: List<Int>,
extended: Boolean?,
fields: String?
): ApiResult<VkMessage, RestApiErrorDomain>
suspend fun send(
peerId: Int,
randomId: Int,
message: String?,
replyTo: Int?,
attachments: List<VkAttachment>?
): ApiResult<Int, RestApiErrorDomain>
suspend fun markAsRead(
peerId: Int,
startMessageId: Int?
): ApiResult<Int, RestApiErrorDomain>
suspend fun getMessage(messageId: Int): Flow<VkMessage?>
suspend fun storeMessages(messages: List<VkMessage>)
// suspend fun getHistory(
// params: MessagesGetHistoryRequest
// ): ApiResult<MessagesGetHistoryResponse, RestApiErrorDomain>
// suspend fun markAsImportant(
// params: MessagesMarkAsImportantRequest
// ): ApiResult<List<Int>, RestApiErrorDomain>
//
// suspend fun pin(
// params: MessagesPinMessageRequest
// ): ApiResult<VkMessageData, RestApiErrorDomain>
//
// suspend fun unpin(
// params: MessagesUnPinMessageRequest
// ): ApiResult<Unit, RestApiErrorDomain>
//
// suspend fun delete(
// params: MessagesDeleteRequest
// ): ApiResult<Unit, RestApiErrorDomain>
//
// suspend fun edit(
// params: MessagesEditRequest
// ): ApiResult<Int, RestApiErrorDomain>
//
// suspend fun getChat(
// params: MessagesGetChatRequest
// ): ApiResult<VkChatData, RestApiErrorDomain>
//
// suspend fun getConversationMembers(
// params: MessagesGetConversationMembersRequest
// ): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain>
//
// suspend fun removeChatUser(
// params: MessagesRemoveChatUserRequest
// ): ApiResult<Int, RestApiErrorDomain>
}
@@ -0,0 +1,201 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkMessage
import com.meloda.app.fast.model.api.domain.asEntity
import com.meloda.app.fast.model.database.asExternalModel
import com.meloda.app.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
// TODO: 05/05/2024, Danil Nikolaev: implement syncing
class MessagesRepositoryImpl(
private val networkDataSource: MessagesNetworkDataSource,
private val localDataSource: MessagesLocalDataSource
) : MessagesRepository {
override suspend fun getMessagesHistory(
conversationId: Int,
offset: Int?,
count: Int?
): ApiResult<MessagesHistoryDomain, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// val localMessages = localDataSource.getMessages(
// conversationId = conversationId,
// offset = offset,
// count = count
// ).map(VkMessageEntity::asExternalModel)
//
// emit(localMessages)
//
// val networkMessages = networkDataSource.getMessagesHistory(
// conversationId = conversationId,
// offset = offset,
// count = count
// )
//
// emit(networkMessages)
networkDataSource.getMessagesHistory(conversationId, offset, count)
}
override suspend fun getMessageById(
messagesIds: List<Int>,
extended: Boolean?,
fields: String?
): ApiResult<VkMessage, RestApiErrorDomain> = withContext(Dispatchers.IO) {
networkDataSource.getMessageById(
messagesIds = messagesIds,
extended = extended,
fields = fields
)
}
override suspend fun send(
peerId: Int,
randomId: Int,
message: String?,
replyTo: Int?,
attachments: List<VkAttachment>?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
networkDataSource.send(
peerId,
randomId,
message,
replyTo,
attachments
)
}
override suspend fun markAsRead(
peerId: Int,
startMessageId: Int?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
networkDataSource.markAsRead(peerId, startMessageId)
}
override suspend fun getMessage(messageId: Int): Flow<VkMessage?> = flow {
val localMessage = localDataSource.getMessage(messageId)?.asExternalModel()
emit(localMessage)
val networkMessage = networkDataSource.getMessage(messageId)
emit(networkMessage)
}
override suspend fun storeMessages(messages: List<VkMessage>) {
localDataSource.storeMessages(messages.map(VkMessage::asEntity))
}
// override suspend fun getHistory(
// params: MessagesGetHistoryRequest
// ): ApiResult<MessagesGetHistoryResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.getHistory(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun send(
// params: MessagesSendRequest
// ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.send(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun markAsImportant(
// params: MessagesMarkAsImportantRequest
// ): ApiResult<List<Int>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.markAsImportant(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun pin(
// params: MessagesPinMessageRequest
// ): ApiResult<VkMessageData, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.pin(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun unpin(
// params: MessagesUnPinMessageRequest
// ): ApiResult<Unit, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.unpin(params.map).mapResult(
// successMapper = {},
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun delete(
// params: MessagesDeleteRequest
// ): ApiResult<Unit, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.delete(params.map).mapResult(
// successMapper = {},
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun edit(
// params: MessagesEditRequest
// ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.edit(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun getById(
// params: MessagesGetByIdRequest
// ): ApiResult<MessagesGetByIdResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.getById(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun markAsRead(
// params: MessagesMarkAsReadRequest
// ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.markAsRead(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun getChat(
// params: MessagesGetChatRequest
// ): ApiResult<VkChatData, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.getChat(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun getConversationMembers(
// params: MessagesGetConversationMembersRequest
// ): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain> =
// withContext(Dispatchers.IO) {
// messagesService.getConversationMembers(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun removeChatUser(
// params: MessagesRemoveChatUserRequest
// ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.removeChatUser(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
}
@@ -0,0 +1,43 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.data.State
import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkMessage
import kotlinx.coroutines.flow.Flow
interface MessagesUseCase {
fun getMessagesHistory(
conversationId: Int,
count: Int?,
offset: Int?
): Flow<State<MessagesHistoryDomain>>
fun getById(
messageId: Int,
extended: Boolean?,
fields: String?
): Flow<State<VkMessage?>>
fun getByIds(
messageIds: List<Int>,
extended: Boolean?,
fields: String?
): Flow<State<List<VkMessage>>>
fun sendMessage(
peerId: Int,
randomId: Int,
message: String?,
replyTo: Int?,
attachments: List<VkAttachment>?
): Flow<State<Int>>
fun markAsRead(
peerId: Int,
startMessageId: Int
): Flow<State<Int>>
suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>)
}
@@ -0,0 +1,15 @@
package com.meloda.app.fast.data.api.oauth
import com.meloda.app.fast.model.api.responses.AuthDirectResponse
interface OAuthRepository {
suspend fun auth(
login: String,
password: String,
forceSms: Boolean,
twoFaCode: String?,
captchaSid: String?,
captchaKey: String?
): AuthDirectResponse
}
@@ -0,0 +1,51 @@
package com.meloda.app.fast.data.api.oauth
import com.meloda.app.fast.common.VkConstants
import com.meloda.app.fast.model.api.requests.AuthDirectRequest
import com.meloda.app.fast.model.api.responses.AuthDirectResponse
import com.meloda.app.fast.network.service.oauth.OAuthService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class OAuthRepositoryImpl(
private val oAuthService: OAuthService,
) : OAuthRepository {
override suspend fun auth(
login: String,
password: String,
forceSms: Boolean,
twoFaCode: String?,
captchaSid: String?,
captchaKey: String?
): AuthDirectResponse = withContext(Dispatchers.IO) {
val requestModel = AuthDirectRequest(
grantType = VkConstants.Auth.GrantType.PASSWORD,
clientId = VkConstants.VK_APP_ID,
clientSecret = VkConstants.VK_SECRET,
username = login,
password = password,
scope = VkConstants.Auth.SCOPE,
twoFaForceSms = forceSms,
twoFaCode = twoFaCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
)
when (val result = oAuthService.auth(requestModel.map)) {
is ApiResult.Success -> result.value
is ApiResult.Failure.HttpFailure -> {
requireNotNull(result.error)
}
else -> throw IllegalStateException("Unknown result")
// is ApiResult.Failure.ApiFailure -> TODO()
// is ApiResult.Failure.HttpFailure -> TODO()
// is ApiResult.Failure.NetworkFailure -> TODO()
// is ApiResult.Failure.UnknownFailure -> TODO()
}
}
}
@@ -0,0 +1,19 @@
package com.meloda.app.fast.data.api.photos
import com.meloda.app.fast.model.api.requests.PhotosSaveMessagePhotoRequest
import com.meloda.app.fast.network.service.photos.PhotosService
import okhttp3.MultipartBody
class PhotosRepository(
private val photosService: PhotosService
) {
suspend fun getMessagesUploadServer(peerId: Int) =
photosService.getUploadServer(mapOf("peer_id" to peerId.toString()))
suspend fun uploadPhoto(url: String, photo: MultipartBody.Part) =
photosService.upload(url, photo)
suspend fun saveMessagePhoto(body: PhotosSaveMessagePhotoRequest) =
photosService.save(body.map)
}
@@ -0,0 +1,8 @@
package com.meloda.app.fast.data.api.users
import com.meloda.app.fast.model.api.data.VkUserData
import com.meloda.app.fast.model.api.requests.UsersGetRequest
interface UsersRepository {
suspend fun getById(params: UsersGetRequest): List<VkUserData>
}
@@ -0,0 +1,16 @@
package com.meloda.app.fast.data.api.users
import com.meloda.app.fast.model.api.data.VkUserData
import com.meloda.app.fast.model.api.requests.UsersGetRequest
import com.meloda.app.fast.network.service.users.UsersService
class UsersRepositoryImpl(
private val usersService: UsersService
) : UsersRepository {
override suspend fun getById(params: UsersGetRequest): List<VkUserData> {
// TODO: 05/05/2024, Danil Nikolaev: implement
return emptyList()
}
}
@@ -0,0 +1,23 @@
package com.meloda.app.fast.data.api.users
import com.meloda.app.fast.data.State
import com.meloda.app.fast.model.api.domain.VkUser
import kotlinx.coroutines.flow.Flow
interface UsersUseCase {
fun getUserById(
userId: Int,
fields: String?,
nomCase: String?
): Flow<State<VkUser?>>
fun getUsersByIds(
userIds: List<Int>,
fields: String?,
nomCase: String?
): Flow<State<List<VkUser>>>
suspend fun storeUser(user: VkUser)
suspend fun storeUsers(users: List<VkUser>)
}
@@ -0,0 +1,67 @@
package com.meloda.app.fast.data.api.users
import com.meloda.app.fast.data.State
import com.meloda.app.fast.model.api.domain.VkUser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
// TODO: 05/05/2024, Danil Nikolaev: implement
class UsersUseCaseImpl(
private val usersRepository: UsersRepository,
) : UsersUseCase {
override fun getUserById(
userId: Int,
fields: String?,
nomCase: String?
): Flow<State<VkUser?>> = flow {
// emit(State.Loading)
//
// val newState = usersRepository.getById(
// UsersGetRequest(
// userIds = listOf(userId),
// fields = fields,
// nomCase = nomCase
// )
// ).fold(
// onSuccess = { response -> State.Success(response.singleOrNull()?.mapToDomain()) },
// onNetworkFailure = { State.Error.ConnectionError },
// onUnknownFailure = { State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
}
override fun getUsersByIds(
userIds: List<Int>,
fields: String?,
nomCase: String?
): Flow<State<List<VkUser>>> = flow {
// emit(State.Loading)
//
// val newState = usersRepository.getById(
// UsersGetRequest(
// userIds = userIds,
// fields = fields,
// nomCase = nomCase
// )
// ).fold(
// onSuccess = { response -> State.Success(response.map(VkUserData::mapToDomain)) },
// onNetworkFailure = { State.Error.ConnectionError },
// onUnknownFailure = { State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
}
override suspend fun storeUser(user: VkUser) {
}
override suspend fun storeUsers(users: List<VkUser>) {
}
}
@@ -0,0 +1,14 @@
package com.meloda.app.fast.data.api.videos
import com.meloda.app.fast.network.service.videos.VideosService
import okhttp3.MultipartBody
class VideosRepository(
private val videosService: VideosService
) {
suspend fun save() = videosService.save()
// TODO: 05/05/2024, Danil Nikolaev: research, maybe remove multipart.body
suspend fun upload(url: String, file: MultipartBody.Part) = videosService.upload(url, file)
}
@@ -0,0 +1,10 @@
package com.meloda.app.fast.data.db
import com.meloda.app.fast.model.database.AccountEntity
interface AccountsRepository {
suspend fun getAccounts(): List<AccountEntity>
suspend fun storeAccounts(accounts: List<AccountEntity>)
}
@@ -0,0 +1,15 @@
package com.meloda.app.fast.data.db
import com.meloda.app.fast.database.dao.AccountDao
import com.meloda.app.fast.model.database.AccountEntity
class AccountsRepositoryImpl(
private val accountDao: AccountDao
) : AccountsRepository {
override suspend fun getAccounts(): List<AccountEntity> = accountDao.getAll()
override suspend fun storeAccounts(
accounts: List<AccountEntity>
) = accountDao.insertAll(accounts)
}
@@ -0,0 +1,78 @@
package com.meloda.app.fast.data.di
import com.meloda.app.fast.common.di.commonModule
import com.meloda.app.fast.data.api.account.AccountRepository
import com.meloda.app.fast.data.api.account.AccountRepositoryImpl
import com.meloda.app.fast.data.api.account.AccountUseCase
import com.meloda.app.fast.data.api.account.AccountUseCaseImpl
import com.meloda.app.fast.data.api.audios.AudiosRepository
import com.meloda.app.fast.data.api.auth.AuthRepository
import com.meloda.app.fast.data.api.auth.AuthRepositoryImpl
import com.meloda.app.fast.data.api.conversations.ConversationsRepository
import com.meloda.app.fast.data.api.conversations.ConversationsRepositoryImpl
import com.meloda.app.fast.data.api.files.FilesRepository
import com.meloda.app.fast.data.api.friends.FriendsRepository
import com.meloda.app.fast.data.api.friends.FriendsRepositoryImpl
import com.meloda.app.fast.data.api.longpoll.LongPollRepository
import com.meloda.app.fast.data.api.longpoll.LongPollRepositoryImpl
import com.meloda.app.fast.data.api.messages.MessagesLocalDataSource
import com.meloda.app.fast.data.api.messages.MessagesLocalDataSourceImpl
import com.meloda.app.fast.data.api.messages.MessagesNetworkDataSource
import com.meloda.app.fast.data.api.messages.MessagesNetworkDataSourceImpl
import com.meloda.app.fast.data.api.messages.MessagesRepository
import com.meloda.app.fast.data.api.messages.MessagesRepositoryImpl
import com.meloda.app.fast.data.api.oauth.OAuthRepository
import com.meloda.app.fast.data.api.oauth.OAuthRepositoryImpl
import com.meloda.app.fast.data.api.photos.PhotosRepository
import com.meloda.app.fast.data.api.users.UsersRepository
import com.meloda.app.fast.data.api.users.UsersRepositoryImpl
import com.meloda.app.fast.data.api.users.UsersUseCase
import com.meloda.app.fast.data.api.users.UsersUseCaseImpl
import com.meloda.app.fast.data.api.videos.VideosRepository
import com.meloda.app.fast.data.db.AccountsRepository
import com.meloda.app.fast.data.db.AccountsRepositoryImpl
import com.meloda.app.fast.database.di.databaseModule
import com.meloda.app.fast.datastore.di.dataStoreModule
import com.meloda.app.fast.network.di.networkModule
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val dataModule = module {
includes(
commonModule,
databaseModule,
dataStoreModule,
networkModule,
)
singleOf(::AccountRepositoryImpl) bind AccountRepository::class
singleOf(::AccountUseCaseImpl) bind AccountUseCase::class
singleOf(::AudiosRepository)
singleOf(::AuthRepositoryImpl) bind AuthRepository::class
singleOf(::ConversationsRepositoryImpl) bind ConversationsRepository::class
singleOf(::FilesRepository)
singleOf(::LongPollRepositoryImpl) bind LongPollRepository::class
singleOf(::MessagesLocalDataSourceImpl) bind MessagesLocalDataSource::class
singleOf(::MessagesNetworkDataSourceImpl) bind MessagesNetworkDataSource::class
singleOf(::MessagesRepositoryImpl) bind MessagesRepository::class
singleOf(::OAuthRepositoryImpl) bind OAuthRepository::class
singleOf(::PhotosRepository)
singleOf(::UsersRepositoryImpl) bind UsersRepository::class
singleOf(::UsersUseCaseImpl) bind UsersUseCase::class
singleOf(::VideosRepository)
singleOf(::AccountsRepositoryImpl) bind AccountsRepository::class
singleOf(::FriendsRepositoryImpl) bind FriendsRepository::class
}
+1
View File
@@ -0,0 +1 @@
/build
+44
View File
@@ -0,0 +1,44 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
}
group = "com.meloda.app.fast.database"
android {
namespace = "com.meloda.app.fast.database"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.generateKotlin", "true")
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
}
}
dependencies {
api(projects.core.model)
implementation(libs.room.ktx)
implementation(libs.room.runtime)
ksp(libs.room.compiler)
implementation(libs.koin.android)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,16 @@
package com.meloda.app.fast.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.meloda.app.fast.database.dao.AccountDao
import com.meloda.app.fast.model.database.AccountEntity
@Database(
entities = [AccountEntity::class],
version = 2
)
abstract class AccountsDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao
}
@@ -0,0 +1,32 @@
package com.meloda.app.fast.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.meloda.app.fast.database.dao.ConversationDao
import com.meloda.app.fast.database.dao.GroupDao
import com.meloda.app.fast.database.dao.MessageDao
import com.meloda.app.fast.database.dao.UsersDao
import com.meloda.app.fast.database.typeconverters.Converters
import com.meloda.app.fast.model.database.VkConversationEntity
import com.meloda.app.fast.model.database.VkGroupEntity
import com.meloda.app.fast.model.database.VkMessageEntity
import com.meloda.app.fast.model.database.VkUserEntity
@Database(
entities = [
VkUserEntity::class,
VkGroupEntity::class,
VkMessageEntity::class,
VkConversationEntity::class
],
version = 5
)
@TypeConverters(Converters::class)
abstract class CacheDatabase : RoomDatabase() {
abstract fun userDao(): UsersDao
abstract fun groupDao(): GroupDao
abstract fun messageDao(): MessageDao
abstract fun conversationDao(): ConversationDao
}
@@ -0,0 +1,15 @@
package com.meloda.app.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.meloda.app.fast.model.database.AccountEntity
@Dao
abstract class AccountDao : EntityDao<AccountEntity> {
@Query("SELECT * FROM accounts")
abstract suspend fun getAll(): List<AccountEntity>
@Query("DELETE FROM accounts WHERE userId = :userId")
abstract suspend fun deleteById(userId: Int)
}
@@ -0,0 +1,30 @@
package com.meloda.app.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import com.meloda.app.fast.model.database.ConversationWithMessage
import com.meloda.app.fast.model.database.VkConversationEntity
@Dao
abstract class ConversationDao : EntityDao<VkConversationEntity> {
@Query("SELECT * FROM conversations")
abstract suspend fun getAll(): List<VkConversationEntity>
@Query("SELECT * FROM conversations WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConversationEntity>
@Query("SELECT * FROM conversations WHERE id IS (:id)")
abstract suspend fun getById(id: Int): VkConversationEntity?
@Transaction
@Query("SELECT * FROM conversations WHERE id IS (:id)")
abstract suspend fun getByIdWithMessage(id: Int): ConversationWithMessage?
@Query("DELETE FROM conversations WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -0,0 +1,20 @@
package com.meloda.app.fast.database.dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
interface EntityDao<T> {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(values: List<T>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(value: T)
@Delete
suspend fun delete(value: T): Int
@Delete
suspend fun deleteAll(values: List<T>): Int
}
@@ -0,0 +1,18 @@
package com.meloda.app.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.meloda.app.fast.model.database.VkGroupEntity
@Dao
abstract class GroupDao : EntityDao<VkGroupEntity> {
@Query("SELECT * FROM groups")
abstract suspend fun getAll(): List<VkGroupEntity>
@Query("SELECT * FROM groups WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkGroupEntity>
@Query("DELETE FROM groups WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -0,0 +1,24 @@
package com.meloda.app.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.meloda.app.fast.model.database.VkMessageEntity
@Dao
abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("SELECT * FROM messages")
abstract suspend fun getAll(): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:conversationId)")
abstract suspend fun getAll(conversationId: Int): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IS (:messageId)")
abstract suspend fun getById(messageId: Int): VkMessageEntity?
@Query("DELETE FROM messages WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -0,0 +1,18 @@
package com.meloda.app.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.meloda.app.fast.model.database.VkUserEntity
@Dao
abstract class UsersDao : EntityDao<VkUserEntity> {
@Query("SELECT * FROM users")
abstract suspend fun getAll(): List<VkUserEntity>
@Query("SELECT * FROM users WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkUserEntity>
@Query("DELETE FROM users WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -0,0 +1,25 @@
package com.meloda.app.fast.database.di
import androidx.room.Room
import com.meloda.app.fast.database.AccountsDatabase
import org.koin.core.scope.Scope
import org.koin.dsl.module
val databaseModule = module {
single {
Room.databaseBuilder(get(), AccountsDatabase::class.java, "accounts").build()
}
single { get<AccountsDatabase>().accountDao() }
single {
Room.databaseBuilder(get(), com.meloda.app.fast.database.CacheDatabase::class.java, "cache")
.fallbackToDestructiveMigration()
.build()
}
single { cacheDB().userDao() }
single { cacheDB().groupDao() }
single { cacheDB().messageDao() }
single { cacheDB().conversationDao() }
}
private fun Scope.cacheDB(): com.meloda.app.fast.database.CacheDatabase = get()
@@ -0,0 +1,21 @@
package com.meloda.app.fast.database.typeconverters
import androidx.room.TypeConverter
class Converters {
@TypeConverter
fun intListToString(list: List<Int>): String = list.joinToString()
@TypeConverter
fun stringToIntList(string: String): List<Int> =
string
.split(", ")
.mapNotNull(String::toIntOrNull)
@TypeConverter
fun stringListToString(list: List<String>): String = list.joinToString()
@TypeConverter
fun stringToStringList(string: String): List<String> = string.split(", ")
}
+1
View File
@@ -0,0 +1 @@
/build
+34
View File
@@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
}
group = "com.meloda.app.fast.datastore"
android {
namespace = "com.meloda.app.fast.datastore"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
}
}
dependencies {
api(projects.core.common)
implementation(libs.koin.android)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,57 @@
package com.meloda.app.fast.datastore
import android.content.res.Configuration
import android.content.res.Resources
import android.os.PowerManager
import androidx.appcompat.app.AppCompatDelegate
fun isUsingDarkMode(
resources: Resources,
powerManager: PowerManager,
): Boolean {
val nightThemeMode: Int = SettingsController.getInt(
SettingsKeys.KEY_APPEARANCE_DARK_THEME,
SettingsKeys.DEFAULT_VALUE_APPEARANCE_DARK_THEME
)
val appForceDarkMode = nightThemeMode == AppCompatDelegate.MODE_NIGHT_YES
val appBatterySaver = nightThemeMode == AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
val systemUiNightMode = resources.configuration.uiMode
val isSystemBatterySaver = powerManager.isPowerSaveMode
val isSystemUsingDarkTheme =
systemUiNightMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
return appForceDarkMode || (appBatterySaver && isSystemBatterySaver) || (!appBatterySaver && isSystemUsingDarkTheme && nightThemeMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
}
fun isUsingDynamicColors(): Boolean = SettingsController.getBoolean(
SettingsKeys.KEY_USE_DYNAMIC_COLORS,
SettingsKeys.DEFAULT_VALUE_USE_DYNAMIC_COLORS
)
fun isUsingAmoledBackground(): Boolean = SettingsController.getBoolean(
SettingsKeys.KEY_APPEARANCE_AMOLED_THEME,
SettingsKeys.DEFAULT_VALUE_APPEARANCE_AMOLED_THEME
)
fun selectedColorScheme(): Int = SettingsController.getInt(
SettingsKeys.KEY_APPEARANCE_COLOR_SCHEME,
SettingsKeys.DEFAULT_VALUE_APPEARANCE_COLOR_SCHEME
)
fun isUsingBlur(): Boolean = SettingsController.getBoolean(
SettingsKeys.KEY_APPEARANCE_BLUR,
SettingsKeys.DEFAULT_VALUE_KEY_APPEARANCE_BLUR
)
fun isDebugSettingsShown(): Boolean = SettingsController.getBoolean(
SettingsKeys.KEY_SHOW_DEBUG_CATEGORY,
false
)
fun isMultiline(): Boolean = SettingsController.getBoolean(
SettingsKeys.KEY_APPEARANCE_MULTILINE,
SettingsKeys.DEFAULT_VALUE_MULTILINE
)
@@ -0,0 +1,53 @@
package com.meloda.app.fast.datastore
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlin.properties.Delegates
object SettingsController {
private var preferences: SharedPreferences by Delegates.notNull()
fun init(preferences: SharedPreferences) {
this.preferences = preferences
}
fun edit(
commit: Boolean = false,
action: SharedPreferences.Editor.() -> Unit
) {
preferences.edit(commit, action)
}
fun getString(key: String, defaultValue: String?): String? {
return preferences.getString(key, defaultValue)
}
fun getBoolean(key: String, defaultValue: Boolean): Boolean {
return preferences.getBoolean(key, defaultValue)
}
fun getInt(key: String, defaultValue: Int): Int {
return preferences.getInt(key, defaultValue)
}
fun getLong(key: String, defaultValue: Long): Long {
return preferences.getLong(key, defaultValue)
}
fun getFloat(key: String, defaultValue: Float): Float {
return preferences.getFloat(key, defaultValue)
}
fun <T> put(key: String, newValue: T?) {
preferences.edit {
when (newValue) {
is String -> putString(key, newValue)
is Boolean -> putBoolean(key, newValue)
is Int -> putInt(key, newValue)
is Long -> putLong(key, newValue)
is Float -> putFloat(key, newValue)
}
}
}
}
@@ -0,0 +1,50 @@
package com.meloda.app.fast.datastore
import androidx.appcompat.app.AppCompatDelegate
object SettingsKeys {
const val KEY_ACCOUNT = "account"
const val KEY_ACCOUNT_LOGOUT = "account_logout"
const val KEY_GENERAL = "general"
const val KEY_USE_CONTACT_NAMES = "general_use_contact_names"
const val DEFAULT_VALUE_USE_CONTACT_NAMES = false
const val KEY_SHOW_EMOJI_BUTTON = "general_show_emoji_button"
const val DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON = false
const val KEY_APPEARANCE = "appearance"
const val KEY_APPEARANCE_MULTILINE = "appearance_multiline"
const val DEFAULT_VALUE_MULTILINE = true
const val KEY_APPEARANCE_DARK_THEME = "appearance_appearance_dark_theme"
const val DEFAULT_VALUE_APPEARANCE_DARK_THEME = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
const val KEY_APPEARANCE_AMOLED_THEME = "appearance_amoled_theme"
const val DEFAULT_VALUE_APPEARANCE_AMOLED_THEME = false
const val KEY_USE_DYNAMIC_COLORS = "appearance_use_dynamic_colors"
const val DEFAULT_VALUE_USE_DYNAMIC_COLORS = false
const val KEY_APPEARANCE_COLOR_SCHEME = "appearance_color_scheme"
const val DEFAULT_VALUE_APPEARANCE_COLOR_SCHEME = 0
const val KEY_APPEARANCE_LANGUAGE = "appearance_language"
const val KEY_APPEARANCE_BLUR = "appearance_blur"
const val DEFAULT_VALUE_KEY_APPEARANCE_BLUR = false
const val KEY_FEATURES_HIDE_KEYBOARD_ON_SCROLL = "features_hide_keyboard_on_scroll"
const val KEY_FEATURES_FAST_TEXT = "features_fast_text"
const val DEFAULT_VALUE_FEATURES_FAST_TEXT = "¯\\_(ツ)_/¯"
const val KEY_FEATURES_LONG_POLL_IN_BACKGROUND = "features_lp_background"
const val DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND = false
const val KEY_VISIBILITY_SEND_ONLINE_STATUS = "visibility_send_online_status"
const val DEFAULT_VALUE_KEY_VISIBILITY_SEND_ONLINE_STATUS = false
const val KEY_DEBUG_PERFORM_CRASH = "debug_perform_crash"
const val KEY_DEBUG_SHOW_CRASH_ALERT = "debug_show_crash_alert"
const val KEY_DEBUG_HIDE_DEBUG_LIST = "debug_hide_debug_list"
const val KEY_SHOW_EXACT_TIME_ON_TIME_STAMP = "wip_show_exact_time_on_time_stamp"
const val KEY_SHOW_NAME_IN_BUBBLES = "debug_show_title_in_bubbles"
const val KEY_SHOW_DATE_UNDER_BUBBLES = "debug_show_date_under_bubbles"
const val KEY_ENABLE_ANIMATIONS_IN_MESSAGES = "debug_enable_animations_in_messages"
const val KEY_SHOW_DEBUG_CATEGORY = "show_debug_category"
const val ID_DMITRY = 37610580
}
@@ -0,0 +1,120 @@
package com.meloda.app.fast.datastore
import android.content.res.Resources
import android.os.PowerManager
import com.meloda.app.fast.datastore.model.ThemeConfig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
interface UserSettings {
val theme: StateFlow<ThemeConfig>
val longPollBackground: StateFlow<Boolean>
val online: StateFlow<Boolean>
val debugSettingsEnabled: StateFlow<Boolean>
val useContactNames: StateFlow<Boolean>
fun updateUsingDarkTheme()
fun useDarkThemeChanged(use: Boolean)
fun useAmoledThemeChanged(use: Boolean)
fun useDynamicColorsChanged(use: Boolean)
fun useBlurChanged(use: Boolean)
fun useMultiline(use: Boolean)
fun setLongPollBackground(background: Boolean)
fun setOnline(use: Boolean)
fun enableDebugSettings(enable: Boolean)
fun onUseContactNamesChanged(use: Boolean)
}
class UserSettingsImpl(
private val resources: Resources,
private val powerManager: PowerManager
) : UserSettings {
override val theme = MutableStateFlow(
ThemeConfig(
usingDarkStyle = isUsingDarkMode(resources, powerManager),
usingDynamicColors = isUsingDynamicColors(),
selectedColorScheme = selectedColorScheme(),
usingAmoledBackground = isUsingAmoledBackground(),
usingBlur = isUsingBlur(),
multiline = isMultiline()
)
)
override val longPollBackground = MutableStateFlow(
SettingsController.getBoolean(
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND,
SettingsKeys.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND
)
)
override val online = MutableStateFlow(
SettingsController.getBoolean(
SettingsKeys.KEY_VISIBILITY_SEND_ONLINE_STATUS,
SettingsKeys.DEFAULT_VALUE_KEY_VISIBILITY_SEND_ONLINE_STATUS
)
)
override val debugSettingsEnabled = MutableStateFlow(
SettingsController.getBoolean(
SettingsKeys.KEY_SHOW_DEBUG_CATEGORY,
false
)
)
override val useContactNames = MutableStateFlow(
SettingsController.getBoolean(
SettingsKeys.KEY_USE_CONTACT_NAMES,
SettingsKeys.DEFAULT_VALUE_USE_CONTACT_NAMES
)
)
override fun updateUsingDarkTheme() {
useDarkThemeChanged(
isUsingDarkMode(
resources = resources,
powerManager = powerManager,
)
)
}
override fun useDarkThemeChanged(use: Boolean) {
theme.value = theme.value.copy(
usingDarkStyle = use
)
}
override fun useAmoledThemeChanged(use: Boolean) {
theme.value = theme.value.copy(
usingAmoledBackground = use
)
}
override fun useDynamicColorsChanged(use: Boolean) {
theme.value = theme.value.copy(usingDynamicColors = use)
}
override fun useBlurChanged(use: Boolean) {
theme.value = theme.value.copy(usingBlur = use)
}
override fun useMultiline(use: Boolean) {
theme.value = theme.value.copy(multiline = use)
}
override fun setLongPollBackground(background: Boolean) {
longPollBackground.value = background
}
override fun setOnline(use: Boolean) {
online.value = use
}
override fun enableDebugSettings(enable: Boolean) {
debugSettingsEnabled.update { enable }
}
override fun onUseContactNamesChanged(use: Boolean) {
useContactNames.update { use }
}
}
@@ -0,0 +1,11 @@
package com.meloda.app.fast.datastore.di
import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.datastore.UserSettingsImpl
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val dataStoreModule = module {
singleOf(::UserSettingsImpl) bind UserSettings::class
}
@@ -0,0 +1,11 @@
package com.meloda.app.fast.datastore.model
data class ThemeConfig(
val usingDarkStyle: Boolean,
val usingDynamicColors: Boolean,
val selectedColorScheme: Int,
val usingAmoledBackground: Boolean,
val usingBlur: Boolean,
val multiline: Boolean,
val bubblesWithPinch: Boolean = true
)
+1
View File
@@ -0,0 +1 @@
/build
+51
View File
@@ -0,0 +1,51 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
}
group = "com.meloda.app.fast.designsystem"
android {
namespace = "com.meloda.app.fast.designsystem"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
}
buildFeatures {
compose = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
// TODO: 05/05/2024, Danil Nikolaev: maybe remove
implementation(projects.core.common)
implementation(projects.core.datastore)
implementation(libs.appcompat)
implementation(libs.accompanist.permissions)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.haze)
implementation(libs.haze.materials)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,178 @@
package com.meloda.app.fast.designsystem
import android.app.Activity
import android.os.Build
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.core.view.WindowCompat
import com.meloda.app.fast.datastore.isUsingAmoledBackground
import com.meloda.app.fast.datastore.isUsingDynamicColors
import com.meloda.app.fast.datastore.model.ThemeConfig
import com.meloda.app.fast.datastore.selectedColorScheme
import com.meloda.app.fast.designsystem.colorschemes.ClassicColorScheme
private val googleSansFonts = FontFamily(
Font(resId = R.font.google_sans_regular),
Font(
resId = R.font.google_sans_italic,
style = FontStyle.Italic
),
Font(
resId = R.font.google_sans_medium,
weight = FontWeight.Medium
),
Font(
resId = R.font.google_sans_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
),
Font(
resId = R.font.google_sans_bold,
weight = FontWeight.Bold
),
Font(
resId = R.font.google_sans_bold_italic,
weight = FontWeight.Bold,
style = FontStyle.Italic
)
)
private val robotoFonts = FontFamily(
Font(
resId = R.font.roboto_thin,
weight = FontWeight.Thin
),
Font(
resId = R.font.roboto_thin_italic,
weight = FontWeight.Thin,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_light,
weight = FontWeight.Light
),
Font(
resId = R.font.roboto_light_italic,
weight = FontWeight.Light,
style = FontStyle.Italic
),
Font(resId = R.font.roboto_regular),
Font(
resId = R.font.roboto_italic,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_medium,
weight = FontWeight.Medium
),
Font(
resId = R.font.roboto_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_bold,
weight = FontWeight.Bold
),
Font(
resId = R.font.roboto_bold_italic,
weight = FontWeight.Bold,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_black,
weight = FontWeight.Black
),
Font(
resId = R.font.roboto_black_italic,
weight = FontWeight.Black,
style = FontStyle.Italic
)
)
val LocalTheme = compositionLocalOf {
ThemeConfig(
usingDarkStyle = false,
usingDynamicColors = false,
selectedColorScheme = 0,
usingAmoledBackground = false,
usingBlur = false,
multiline = false
)
}
@Composable
fun AppTheme(
predefinedColorScheme: ColorScheme? = null,
useDarkTheme: Boolean = isUsingDarkTheme(),
useDynamicColors: Boolean = isUsingDynamicColors(),
selectedColorScheme: Int = selectedColorScheme(),
useAmoledBackground: Boolean = isUsingAmoledBackground(),
content: @Composable () -> Unit
) {
val colorScheme: ColorScheme = when {
useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (useDarkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
else -> {
// TODO: 03/07/2024, Danil Nikolaev: add color picker to settings
when (selectedColorScheme) {
1 -> if (useDarkTheme) darkColorScheme() else lightColorScheme()
else -> if (useDarkTheme) ClassicColorScheme.darkScheme else ClassicColorScheme.lightScheme
}
}
}.let { scheme ->
if (useDarkTheme && useAmoledBackground) {
scheme.copy(
background = Color.Black,
surface = Color.Black
)
} else {
scheme
}
}
val typography = MaterialTheme.typography.copy(
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = googleSansFonts),
displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = googleSansFonts),
displaySmall = MaterialTheme.typography.displaySmall.copy(fontFamily = googleSansFonts),
headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontFamily = googleSansFonts),
headlineMedium = MaterialTheme.typography.headlineMedium.copy(fontFamily = googleSansFonts),
headlineSmall = MaterialTheme.typography.headlineSmall.copy(fontFamily = googleSansFonts),
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = robotoFonts),
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = robotoFonts),
bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = robotoFonts)
)
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !useDarkTheme
}
}
MaterialTheme(
colorScheme = predefinedColorScheme ?: colorScheme,
typography = typography,
content = content
)
}
@@ -0,0 +1,121 @@
package com.meloda.app.fast.designsystem
import android.os.Build
import android.view.autofill.AutofillManager
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.Autofill
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import kotlin.math.roundToInt
fun Modifier.connectNode(handler: AutoFillHandler): Modifier {
return with(handler) { fillBounds() }
}
fun Modifier.defaultFocusChangeAutoFill(handler: AutoFillHandler): Modifier {
return this.then(
Modifier.onFocusChanged {
if (it.isFocused) {
handler.request()
} else {
handler.cancel()
}
}
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun autoFillRequestHandler(
autofillTypes: List<AutofillType> = listOf(),
onFill: (String) -> Unit,
): AutoFillHandler {
val view = LocalView.current
val context = LocalContext.current
var isFillRecently = remember { false }
val autoFillNode = remember {
AutofillNode(
autofillTypes = autofillTypes,
onFill = {
isFillRecently = true
onFill(it)
}
)
}
val autofill = LocalAutofill.current
LocalAutofillTree.current += autoFillNode
return remember {
@RequiresApi(Build.VERSION_CODES.O)
object : AutoFillHandler {
val autofillManager = context.getSystemService(AutofillManager::class.java)
override fun requestManual() {
autofillManager.requestAutofill(
view,
autoFillNode.id,
autoFillNode.boundingBox?.toAndroidRect() ?: error("BoundingBox is not provided yet")
)
}
override fun requestVerifyManual() {
if (isFillRecently) {
isFillRecently = false
requestManual()
}
}
override val autoFill: Autofill?
get() = autofill
override val autoFillNode: AutofillNode
get() = autoFillNode
override fun request() {
autofill?.requestAutofillForNode(autofillNode = autoFillNode)
}
override fun cancel() {
autofill?.cancelAutofillForNode(autofillNode = autoFillNode)
}
override fun Modifier.fillBounds(): Modifier {
return this.then(
Modifier.onGloballyPositioned {
autoFillNode.boundingBox = it.boundsInWindow()
})
}
}
}
}
fun Rect.toAndroidRect(): android.graphics.Rect {
return android.graphics.Rect(
left.roundToInt(),
top.roundToInt(),
right.roundToInt(),
bottom.roundToInt()
)
}
@OptIn(ExperimentalComposeUiApi::class)
interface AutoFillHandler {
val autoFill: Autofill?
val autoFillNode: AutofillNode
fun requestVerifyManual()
fun requestManual()
fun request()
fun cancel()
fun Modifier.fillBounds(): Modifier
}
@@ -0,0 +1,133 @@
package com.meloda.app.fast.designsystem
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import androidx.annotation.FloatRange
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.luminance
/**
* Default alpha levels used by Material components.
*
* See [LocalContentAlpha].
*/
object ContentAlpha {
/**
* A high level of content alpha, used to represent high emphasis text such as input text in a
* selected [TextField].
*/
val high: Float
@Composable
get() = contentAlpha(
highContrastAlpha = HighContrastContentAlpha.high,
lowContrastAlpha = LowContrastContentAlpha.high
)
/**
* A medium level of content alpha, used to represent medium emphasis text such as
* placeholder text in a [TextField].
*/
val medium: Float
@Composable
get() = contentAlpha(
highContrastAlpha = HighContrastContentAlpha.medium,
lowContrastAlpha = LowContrastContentAlpha.medium
)
/**
* A low level of content alpha used to represent disabled components, such as text in a
* disabled [Button].
*/
val disabled: Float
@Composable
get() = contentAlpha(
highContrastAlpha = HighContrastContentAlpha.disabled,
lowContrastAlpha = LowContrastContentAlpha.disabled
)
/**
* This default implementation uses separate alpha levels depending on the luminance of the
* incoming color, and whether the theme is light or dark. This is to ensure correct contrast
* and accessibility on all surfaces.
*
* See [HighContrastContentAlpha] and [LowContrastContentAlpha] for what the levels are
* used for, and under what circumstances.
*/
@Composable
private fun contentAlpha(
@FloatRange(from = 0.0, to = 1.0)
highContrastAlpha: Float,
@FloatRange(from = 0.0, to = 1.0)
lowContrastAlpha: Float
): Float {
val contentColor = LocalContentColor.current
return if (!isUsingDarkTheme()) {
if (contentColor.luminance() > 0.5) highContrastAlpha else lowContrastAlpha
} else {
if (contentColor.luminance() < 0.5) highContrastAlpha else lowContrastAlpha
}
}
}
/**
* CompositionLocal containing the preferred content alpha for a given position in the hierarchy.
* This alpha is used for text and iconography ([Text] and [Icon]) to emphasize / de-emphasize
* different parts of a component. See the Material guide on
* [Text Legibility](https://material.io/design/color/text-legibility.html) for more information on
* alpha levels used by text and iconography.
*
* See [ContentAlpha] for the default levels used by most Material components.
*
* [MaterialTheme] sets this to [ContentAlpha.high] by default, as this is the default alpha for
* body text.
*
* @sample androidx.compose.material.samples.ContentAlphaSample
*/
val LocalContentAlpha = compositionLocalOf { 1f }
/**
* Alpha levels for high luminance content in light theme, or low luminance content in dark theme.
*
* This content will typically be placed on colored surfaces, so it is important that the
* contrast here is higher to meet accessibility standards, and increase legibility.
*
* These levels are typically used for text / iconography in primary colored tabs /
* bottom navigation / etc.
*/
private object HighContrastContentAlpha {
const val high: Float = 1.00f
const val medium: Float = 0.74f
const val disabled: Float = 0.38f
}
/**
* Alpha levels for low luminance content in light theme, or high luminance content in dark theme.
*
* This content will typically be placed on grayscale surfaces, so the contrast here can be lower
* without sacrificing accessibility and legibility.
*
* These levels are typically used for body text on the main surface (white in light theme, grey
* in dark theme) and text / iconography in surface colored tabs / bottom navigation / etc.
*/
private object LowContrastContentAlpha {
const val high: Float = 0.87f
const val medium: Float = 0.60f
const val disabled: Float = 0.38f
}
@@ -0,0 +1,112 @@
package com.meloda.app.fast.designsystem
import android.content.res.Configuration
import android.view.KeyEvent
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.PermissionStatus
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.util.AndroidUtils
import com.meloda.app.fast.datastore.SettingsController
import com.meloda.app.fast.datastore.SettingsKeys
@Composable
fun isUsingDarkTheme(): Boolean {
val nightThemeMode = SettingsController.getInt(
SettingsKeys.KEY_APPEARANCE_DARK_THEME,
SettingsKeys.DEFAULT_VALUE_APPEARANCE_DARK_THEME
)
val appForceDarkMode = nightThemeMode == AppCompatDelegate.MODE_NIGHT_YES
val appBatterySaver = nightThemeMode == AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
val context = LocalContext.current
val systemUiNightMode = context.resources.configuration.uiMode
val isSystemBatterySaver = AndroidUtils.isBatterySaverOn(context)
val isSystemUsingDarkTheme =
systemUiNightMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
return appForceDarkMode || (appBatterySaver && isSystemBatterySaver) || (!appBatterySaver && isSystemUsingDarkTheme && nightThemeMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
}
@Composable
fun UiText?.getString(): String? {
return when (this) {
is UiText.Resource -> {
stringResource(id = resId)
}
is UiText.ResourceParams -> {
val processedArgs = args.map { any ->
when (any) {
is UiText -> any.getString().orEmpty()
else -> any.toString()
}
}.toTypedArray()
stringResource(id = value, *processedArgs)
}
is UiText.QuantityResource -> {
pluralStringResource(id = resId, count = quantity, quantity)
}
is UiText.Simple -> text
else -> null
}
}
fun Modifier.handleTabKey(
action: () -> Boolean
): Modifier = this.onKeyEvent { event ->
if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_TAB) {
action.invoke()
} else false
}
fun Modifier.handleEnterKey(
action: () -> Boolean
): Modifier = this.onKeyEvent { event ->
if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) {
action.invoke()
} else false
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CheckPermission(
showRationale: @Composable () -> Unit,
onDenied: @Composable () -> Unit,
permission: PermissionState,
) {
when (val status = permission.status) {
is PermissionStatus.Denied -> {
if (status.shouldShowRationale) {
showRationale()
} else {
onDenied()
}
}
is PermissionStatus.Granted -> Unit
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun RequestPermission(
permission: PermissionState
) {
LaunchedEffect(Unit) { permission.launchPermissionRequest() }
}
@@ -0,0 +1,59 @@
package com.meloda.app.fast.designsystem
import androidx.compose.runtime.Immutable
@Immutable
class ImmutableList<T>(val values: List<T>) : Iterable<T> {
constructor(size: Int, init: (index: Int) -> T) : this(MutableList(size, init))
operator fun get(index: Int): T? {
values.singleOrNull()
return values[index]
}
inline fun forEach(action: (T) -> Unit) {
for (element in values) action(element)
}
inline fun <R> map(transform: (T) -> R): ImmutableList<R> {
return values.map(transform).toImmutableList()
}
inline fun <R> mapIndexed(transform: (index: Int, T) -> R): ImmutableList<R> {
return values.mapIndexed(transform).toImmutableList()
}
fun singleOrNull(): T? {
return if (values.size == 1) this[0] else null
}
fun isEmpty(): Boolean = values.isEmpty()
fun isNotEmpty(): Boolean = !isEmpty()
inline fun singleOrNull(predicate: (T) -> Boolean): T? {
var single: T? = null
var found = false
for (element in this) {
if (predicate(element)) {
if (found) return null
single = element
found = true
}
}
if (!found) return null
return single
}
companion object {
fun <T> copyOf(collection: Collection<T>): ImmutableList<T> =
ImmutableList(collection.toList())
fun <T> List<T>.toImmutableList(): ImmutableList<T> = ImmutableList(this)
fun <T> empty(): ImmutableList<T> = ImmutableList(emptyList())
}
override fun iterator(): Iterator<T> = values.listIterator()
}
@@ -0,0 +1,20 @@
package com.meloda.app.fast.designsystem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
@Composable
fun LocalContentAlpha(
defaultColor: Color = MaterialTheme.colorScheme.onBackground,
alpha: Float,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalContentColor provides defaultColor.copy(alpha = alpha)
) {
content()
}
}
@@ -0,0 +1,346 @@
package com.meloda.app.fast.designsystem
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.ImmutableList.Companion.toImmutableList
// TODO: 08.04.2023, Danil Nikolaev: review
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MaterialDialog(
onDismissAction: (() -> Unit),
title: UiText? = null,
text: UiText? = null,
confirmText: UiText? = null,
confirmAction: (() -> Unit)? = null,
cancelText: UiText? = null,
cancelAction: (() -> Unit)? = null,
neutralText: UiText? = null,
neutralAction: (() -> Unit)? = null,
itemsSelectionType: ItemsSelectionType = ItemsSelectionType.None,
preSelectedItems: ImmutableList<Int> = ImmutableList.empty(),
items: ImmutableList<UiText> = ImmutableList.empty(),
onItemClick: ((index: Int) -> Unit)? = null,
buttonsInvokeDismiss: Boolean = true,
customContent: (@Composable ColumnScope.() -> Unit)? = null,
) {
var isVisible by remember {
mutableStateOf(true)
}
val onDismissRequest = {
onDismissAction.invoke()
isVisible = false
}
val stringTitles = items.map { it.getString().orEmpty() }
var alertItems by remember {
mutableStateOf(
stringTitles.mapIndexed { index, title ->
DialogItem(
title,
preSelectedItems.contains(index)
)
}
)
}
AppTheme {
if (isVisible) {
// AlertAnimation(visible = isVisible) {
BasicAlertDialog(
onDismissRequest = onDismissRequest
) {
val scrollState = rememberScrollState()
val canScrollBackward by remember { derivedStateOf { scrollState.value > 0 } }
val canScrollForward by remember { derivedStateOf { scrollState.value < scrollState.maxValue } }
Surface(
modifier = Modifier.fillMaxWidth(),
color = AlertDialogDefaults.containerColor,
shape = AlertDialogDefaults.shape,
tonalElevation = AlertDialogDefaults.TonalElevation
) {
Column(modifier = Modifier.padding(bottom = 10.dp)) {
val stringTitle = title?.getString()
if (stringTitle != null) {
Spacer(modifier = Modifier.height(20.dp))
}
Row {
stringTitle?.let { title ->
Spacer(modifier = Modifier.width(24.dp))
Text(
modifier = Modifier.weight(1f),
text = title,
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.width(20.dp))
}
}
if (canScrollBackward) {
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
.verticalScroll(scrollState)
) {
Spacer(modifier = Modifier.height(8.dp))
val stringMessage = text?.getString()
if (stringMessage != null && stringTitle == null) {
Spacer(modifier = Modifier.height(20.dp))
}
Row {
stringMessage?.let { message ->
Spacer(modifier = Modifier.width(24.dp))
Text(
modifier = Modifier.weight(1f),
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(20.dp))
}
}
Spacer(modifier = Modifier.height(8.dp))
if (alertItems.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
AlertItems(
selectionType = itemsSelectionType,
items = alertItems,
onItemClick = { index ->
onItemClick?.invoke(index)
if (itemsSelectionType == ItemsSelectionType.None) {
onDismissRequest.invoke()
} else {
val newItems =
alertItems.mapIndexed { itemIndex, item ->
item.copy(isSelected = itemIndex == index)
}
alertItems = newItems
}
},
onItemCheckedChanged = { index ->
val newItems = alertItems.toMutableList()
val oldItem = newItems[index]
newItems[index] =
oldItem.copy(isSelected = !oldItem.isSelected)
alertItems = newItems.toImmutableList()
}
)
Spacer(modifier = Modifier.height(10.dp))
} else {
customContent?.let { content ->
Spacer(modifier = Modifier.height(4.dp))
content.invoke(this)
Spacer(modifier = Modifier.height(10.dp))
}
}
}
if (canScrollForward) {
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
Row {
Spacer(modifier = Modifier.width(20.dp))
neutralText?.getString()?.let { text ->
TextButton(
onClick = {
if (buttonsInvokeDismiss) {
onDismissRequest.invoke()
} else {
isVisible = false
}
neutralAction?.invoke()
}
) {
Text(text = text)
}
}
Spacer(modifier = Modifier.weight(1f))
cancelText?.getString()?.let { text ->
TextButton(
onClick = {
if (buttonsInvokeDismiss) {
onDismissRequest.invoke()
} else {
isVisible = false
}
cancelAction?.invoke()
}
) {
Text(text = text)
}
}
Spacer(modifier = Modifier.width(2.dp))
confirmText?.getString()?.let { text ->
TextButton(
onClick = {
if (buttonsInvokeDismiss) {
onDismissRequest.invoke()
} else {
isVisible = false
}
confirmAction?.invoke()
}
) {
Text(text = text)
}
}
Spacer(modifier = Modifier.width(20.dp))
}
}
}
}
}
}
}
@Composable
fun AlertAnimation(
visible: Boolean,
content: @Composable AnimatedVisibilityScope.() -> Unit
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(400)) +
scaleIn(animationSpec = tween(400)),
exit = fadeOut(animationSpec = tween(150)),
content = content
)
}
@Preview
@Composable
fun AlertItemsPreview() {
AppTheme {
AlertItems(
selectionType = ItemsSelectionType.None,
items = ImmutableList(5) { index ->
DialogItem(
title = "Item #${index + 1}",
isSelected = index % 2 == 0
)
},
onItemClick = {}
)
}
}
@Composable
private fun AlertItems(
selectionType: ItemsSelectionType,
items: ImmutableList<DialogItem>,
onItemClick: ((index: Int) -> Unit)? = null,
onItemCheckedChanged: ((index: Int) -> Unit)? = null
) {
items.forEachIndexed { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clickable {
if (selectionType == ItemsSelectionType.Multi) {
onItemCheckedChanged?.invoke(index)
} else {
onItemClick?.invoke(index)
}
},
verticalAlignment = Alignment.CenterVertically
) {
// TODO: 29/12/2023, Danil Nikolaev: check onClick & onCheckedChange actions
when (selectionType) {
ItemsSelectionType.Multi -> {
Spacer(modifier = Modifier.width(10.dp))
Checkbox(
checked = item.isSelected,
onCheckedChange = {}
)
}
ItemsSelectionType.Single -> {
Spacer(modifier = Modifier.width(10.dp))
RadioButton(
selected = item.isSelected,
onClick = {}
)
}
ItemsSelectionType.None -> {
Spacer(modifier = Modifier.width(26.dp))
}
}
Spacer(modifier = Modifier.width(4.dp))
Text(
modifier = Modifier.weight(1f),
text = item.title,
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.width(20.dp))
}
}
}
data class DialogItem(
val title: String,
val isSelected: Boolean
)
sealed interface ItemsSelectionType {
data object Single : ItemsSelectionType
data object Multi : ItemsSelectionType
data object None : ItemsSelectionType
}
@@ -0,0 +1,7 @@
package com.meloda.app.fast.designsystem
data class TabItem(
val titleResId: Int?,
val unselectedIconResId: Int?,
val selectedIconResId: Int?
)
@@ -0,0 +1,32 @@
package com.meloda.app.fast.designsystem
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun TextFieldErrorText(
modifier: Modifier = Modifier,
text: String = stringResource(id = R.string.error_empty_field),
withSpacer: Boolean = true
) {
Row {
if (withSpacer) {
Spacer(modifier = Modifier.width(16.dp))
}
Text(
text = text,
modifier = modifier,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
}
@@ -0,0 +1,155 @@
package com.meloda.app.fast.designsystem.colorschemes
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
object ClassicColorScheme {
private val primaryLight = Color(0xFF405F90)
private val onPrimaryLight = Color(0xFFFFFFFF)
private val primaryContainerLight = Color(0xFFD6E3FF)
private val onPrimaryContainerLight = Color(0xFF001B3D)
private val secondaryLight = Color(0xFF555F71)
private val onSecondaryLight = Color(0xFFFFFFFF)
private val secondaryContainerLight = Color(0xFFDAE2F9)
private val onSecondaryContainerLight = Color(0xFF121C2B)
private val tertiaryLight = Color(0xFF6F5575)
private val onTertiaryLight = Color(0xFFFFFFFF)
private val tertiaryContainerLight = Color(0xFFF9D8FD)
private val onTertiaryContainerLight = Color(0xFF28132F)
private val errorLight = Color(0xFFBA1A1A)
private val onErrorLight = Color(0xFFFFFFFF)
private val errorContainerLight = Color(0xFFFFDAD6)
private val onErrorContainerLight = Color(0xFF410002)
private val backgroundLight = Color(0xFFF9F9FF)
private val onBackgroundLight = Color(0xFF191C20)
private val surfaceLight = Color(0xFFF9F9FF)
private val onSurfaceLight = Color(0xFF191C20)
private val surfaceVariantLight = Color(0xFFE0E2EC)
private val onSurfaceVariantLight = Color(0xFF44474E)
private val outlineLight = Color(0xFF74777F)
private val outlineVariantLight = Color(0xFFC4C6CF)
private val scrimLight = Color(0xFF000000)
private val inverseSurfaceLight = Color(0xFF2E3036)
private val inverseOnSurfaceLight = Color(0xFFF0F0F7)
private val inversePrimaryLight = Color(0xFFA9C7FF)
private val surfaceDimLight = Color(0xFFD9D9E0)
private val surfaceBrightLight = Color(0xFFF9F9FF)
private val surfaceContainerLowestLight = Color(0xFFFFFFFF)
private val surfaceContainerLowLight = Color(0xFFF3F3FA)
private val surfaceContainerLight = Color(0xFFEDEDF4)
private val surfaceContainerHighLight = Color(0xFFE7E8EE)
private val surfaceContainerHighestLight = Color(0xFFE2E2E9)
private val primaryDark = Color(0xFFA9C7FF)
private val onPrimaryDark = Color(0xFF08305F)
private val primaryContainerDark = Color(0xFF274777)
private val onPrimaryContainerDark = Color(0xFFD6E3FF)
private val secondaryDark = Color(0xFFBDC7DC)
private val onSecondaryDark = Color(0xFF283141)
private val secondaryContainerDark = Color(0xFF3E4758)
private val onSecondaryContainerDark = Color(0xFFDAE2F9)
private val tertiaryDark = Color(0xFFDCBCE1)
private val onTertiaryDark = Color(0xFF3F2845)
private val tertiaryContainerDark = Color(0xFF563E5C)
private val onTertiaryContainerDark = Color(0xFFF9D8FD)
private val errorDark = Color(0xFFFFB4AB)
private val onErrorDark = Color(0xFF690005)
private val errorContainerDark = Color(0xFF93000A)
private val onErrorContainerDark = Color(0xFFFFDAD6)
private val backgroundDark = Color(0xFF111318)
private val onBackgroundDark = Color(0xFFE2E2E9)
private val surfaceDark = Color(0xFF111318)
private val onSurfaceDark = Color(0xFFE2E2E9)
private val surfaceVariantDark = Color(0xFF44474E)
private val onSurfaceVariantDark = Color(0xFFC4C6CF)
private val outlineDark = Color(0xFF8E9099)
private val outlineVariantDark = Color(0xFF44474E)
private val scrimDark = Color(0xFF000000)
private val inverseSurfaceDark = Color(0xFFE2E2E9)
private val inverseOnSurfaceDark = Color(0xFF2E3036)
private val inversePrimaryDark = Color(0xFF405F90)
private val surfaceDimDark = Color(0xFF111318)
private val surfaceBrightDark = Color(0xFF37393E)
private val surfaceContainerLowestDark = Color(0xFF0C0E13)
private val surfaceContainerLowDark = Color(0xFF191C20)
private val surfaceContainerDark = Color(0xFF1D2024)
private val surfaceContainerHighDark = Color(0xFF282A2F)
private val surfaceContainerHighestDark = Color(0xFF33353A)
val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
)
val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
)
}
@@ -0,0 +1,81 @@
package com.meloda.app.fast.designsystem.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.meloda.app.fast.designsystem.LocalTheme
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun BlurrableTopAppBar(
modifier: Modifier = Modifier,
title: String,
listState: LazyListState?,
hazeState: HazeState = remember { HazeState() }
) {
val currentTheme = LocalTheme.current
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (listState == null || !listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val toolbarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.usingBlur || listState != null && !listState.canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
TopAppBar(
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
alpha = if (currentTheme.usingBlur) toolbarColorAlpha else 1f
)
),
modifier = modifier
.then(
if (currentTheme.usingBlur) {
Modifier.hazeChild(
state = hazeState,
style = HazeMaterials.thick()
)
} else {
Modifier
}
)
.fillMaxWidth(),
)
}
@@ -0,0 +1,21 @@
package com.meloda.app.fast.designsystem.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun FullScreenLoader(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@@ -0,0 +1,27 @@
package com.meloda.app.fast.designsystem.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.meloda.app.fast.designsystem.R
@Composable
fun NoItemsView(
modifier: Modifier = Modifier,
customText: String? = null
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = customText ?: stringResource(id = R.string.no_items),
style = MaterialTheme.typography.titleLarge
)
}
}

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