5 Commits

Author SHA1 Message Date
melod1n 9737a30f9e update Gitea workflow 2026-05-23 08:58:23 +03:00
melod1n 0c298ea2c7 update Gitea workflow 2026-05-23 00:48:53 +03:00
melod1n e83c667e10 update Gitea workflow 2026-05-23 00:16:29 +03:00
melod1n 0c1bc68d7f Update README.md 2026-05-22 22:52:24 +03:00
melod1n 9efa9dc268 update Gitea workflow 2026-05-22 22:51:17 +03:00
90 changed files with 1328 additions and 2748 deletions
-2
View File
@@ -15,5 +15,3 @@ build/
local.properties
.idea
/.kotlin
.hotswan/
.java-version
+3 -1
View File
@@ -79,6 +79,9 @@ android {
}
dependencies {
implementation(libs.acra.email)
implementation(libs.acra.dialog)
implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials)
@@ -92,7 +95,6 @@ dependencies {
implementation(projects.feature.photoviewer)
implementation(projects.feature.createchat)
implementation(projects.core.logger)
implementation(projects.core.common)
implementation(projects.core.ui)
implementation(projects.core.data)
+2 -9
View File
@@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
@@ -38,12 +37,6 @@
</intent-filter>
</activity>
<activity
android:name="dev.meloda.fast.presentation.CrashActivity"
android:exported="false"
android:process=":error_handler"
android:theme="@style/CrashDialogTheme" />
<service
android:name="dev.meloda.fast.service.longpolling.LongPollingService"
android:enabled="true"
@@ -2,6 +2,7 @@ package dev.meloda.fast
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.NotificationCompat
import androidx.core.os.LocaleListCompat
@@ -22,7 +23,6 @@ import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main
@@ -67,8 +67,7 @@ class MainViewModelImpl(
private val getCurrentAccountUseCase: GetCurrentAccountUseCase,
private val loadUserByIdUseCase: LoadUserByIdUseCase,
private val userSettings: UserSettings,
private val longPollController: LongPollController,
private val logger: FastLogger
private val longPollController: LongPollController
) : MainViewModel, ViewModel() {
override val startDestination = MutableStateFlow<Any?>(null)
@@ -204,10 +203,7 @@ class MainViewModelImpl(
viewModelScope.launch(Dispatchers.IO) {
val currentAccount = getCurrentAccountUseCase()
logger.debug(
this@MainViewModelImpl::class,
"loadAccounts(): currentAccount: $currentAccount"
)
Log.d("MainViewModel", "currentAccount: $currentAccount")
listenLongPollState()
@@ -1,8 +1,6 @@
package dev.meloda.fast.common
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.preference.PreferenceManager
import coil.ImageLoader
import coil.ImageLoaderFactory
@@ -10,16 +8,14 @@ import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
import dev.meloda.fast.auth.BuildConfig
import dev.meloda.fast.common.di.applicationModule
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.logger.FastLogLevel
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.presentation.CrashActivity
import org.acra.config.dialog
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext.startKoin
import java.io.File
import java.io.FileOutputStream
import kotlin.system.exitProcess
class AppGlobal : Application(), ImageLoaderFactory {
@@ -31,15 +27,7 @@ class AppGlobal : Application(), ImageLoaderFactory {
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin()
initCrashHandler()
val logLevel =
if (BuildConfig.DEBUG) FastLogLevel.DEBUG
else FastLogLevel.ERROR
get<FastLogger>()
.apply { setLogLevel(logLevel) }
.also { FastLogger.setInstance(it) }
initAcra()
}
override fun newImageLoader(): ImageLoader = get()
@@ -52,36 +40,20 @@ class AppGlobal : Application(), ImageLoaderFactory {
}
}
private fun initCrashHandler() {
val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
val crashLogsDirectory = File(filesDir.absolutePath + "/crashlogs")
if (!crashLogsDirectory.exists()) {
crashLogsDirectory.mkdirs()
private fun initAcra() {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
mailSender {
mailTo = "lischenkodev@gmail.com"
reportAsFile = true
reportFileName = "Crash.txt"
}
val crashLogFileName = "crash_" + System.currentTimeMillis() + ".txt"
val crashLogFile = File(crashLogsDirectory.absolutePath + "/" + crashLogFileName)
FileOutputStream(crashLogFile).use { stream ->
stream.write(throwable.stackTraceToString().toByteArray())
}
if (AppSettings.Debug.showAlertAfterCrash) {
try {
val intent = Intent(this, CrashActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
intent.putExtra("CRASH_LOG_FILE_URI", Uri.fromFile(crashLogFile))
startActivity(intent)
exitProcess(0)
} catch (e: Exception) {
if (e !is RuntimeException) {
defaultExceptionHandler?.uncaughtException(thread, throwable)
}
}
} else {
defaultExceptionHandler?.uncaughtException(thread, throwable)
dialog {
text = "App crashed"
enabled = true
}
}
}
@@ -19,10 +19,8 @@ import dev.meloda.fast.convos.di.createChatModule
import dev.meloda.fast.domain.di.domainModule
import dev.meloda.fast.friends.di.friendsModule
import dev.meloda.fast.languagepicker.di.languagePickerModule
import dev.meloda.fast.logger.loggerModule
import dev.meloda.fast.messageshistory.di.messagesHistoryModule
import dev.meloda.fast.photoviewer.di.photoViewModule
import dev.meloda.fast.presentation.NetworkObserver
import dev.meloda.fast.profile.di.profileModule
import dev.meloda.fast.provider.ApiLanguageProvider
import dev.meloda.fast.service.longpolling.di.longPollModule
@@ -51,8 +49,6 @@ val applicationModule = module {
createChatModule
)
includes(loggerModule)
// TODO: 14/05/2024, Danil Nikolaev: extract all operations with preferences to standalone class
singleOf(PreferenceManager::getDefaultSharedPreferences)
single<Resources> { androidContext().resources }
@@ -73,6 +69,4 @@ val applicationModule = module {
singleOf(::LongPollControllerImpl) bind LongPollController::class
singleOf(::ResourceProviderImpl) bind ResourceProvider::class
singleOf(::NetworkObserver)
}
@@ -1,35 +0,0 @@
package dev.meloda.fast.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog
@Composable
fun AppCrashedDialog(
stacktrace: String,
onDismiss: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier,
) {
var showTrace by rememberSaveable { mutableStateOf(false) }
MaterialDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = stringResource(R.string.title_error),
text = if (showTrace) stacktrace else stringResource(R.string.error_occurred),
confirmText = stringResource(R.string.action_share),
confirmAction = onShare,
cancelText = stringResource(if (showTrace) R.string.action_hide_stacktrace else R.string.action_show_stacktrace),
cancelAction = { showTrace = !showTrace },
neutralText = stringResource(R.string.action_close),
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
}
@@ -1,71 +0,0 @@
package dev.meloda.fast.presentation
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.compose.koinInject
import java.io.File
class CrashActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val crashLogFileUri = intent.getParcelableExtra<Uri>("CRASH_LOG_FILE_URI") ?: run {
finish()
return
}
val crashLogFile = crashLogFileUri.toFile().takeIf(File::exists) ?: run {
finish()
return
}
val stacktrace = crashLogFile.bufferedReader().readText()
setContent {
val userSettings: UserSettings = koinInject()
AppTheme(
useDarkTheme = isNeedToEnableDarkMode(darkMode = userSettings.darkMode.collectAsState().value),
useDynamicColors = userSettings.enableDynamicColors.collectAsState().value,
selectedColorScheme = 0,
useAmoledBackground = userSettings.enableAmoledDark.collectAsState().value,
useSystemFont = userSettings.useSystemFont.collectAsState().value
) {
AppCrashedDialog(
stacktrace = stacktrace,
onDismiss = { finish() },
onShare = {
val uri = FileProvider.getUriForFile(
this,
"$packageName.provider",
crashLogFile
)
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(null, uri)
}
val chooserIntent = Intent.createChooser(sendIntent, null)
chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
startActivity(chooserIntent)
}
)
}
}
}
}
@@ -9,11 +9,12 @@ import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.content.ContextCompat
@@ -23,15 +24,10 @@ import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.service.OnlineService
import dev.meloda.fast.service.longpolling.LongPollingService
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalLogger
import org.koin.android.ext.android.get
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
class MainActivity : AppCompatActivity() {
@@ -68,28 +64,27 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissions()
setContent {
val logger: FastLogger = koinInject()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "onCreate: viewModel: $viewModel")
}
LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent)
onPauseOrDispose {}
}
CompositionLocalProvider(LocalLogger provides logger) {
RootScreen(
toggleLongPollService = { enable, inBackground ->
toggleLongPollService(
enable = enable,
inBackground = inBackground
?: AppSettings.Experimental.longPollInBackground
inBackground = inBackground ?: AppSettings.Experimental.longPollInBackground
)
},
toggleOnlineService = ::toggleOnlineService
)
}
}
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -183,8 +178,6 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
super.onDestroy()
stopServices()
get<LongPollEventsHandler>().onDestroy()
get<NetworkObserver>().onDestroy()
}
companion object {
@@ -38,7 +38,7 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.convos.navigation.convosGraph
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen
@@ -198,20 +198,19 @@ fun MainScreen(
},
)
convosGraph(
handleNavigationIntent = { intent ->
when (intent) {
ConvoNavigationIntent.Back -> {}
ConvoNavigationIntent.Archive -> {}
ConvoNavigationIntent.CreateChat -> onNavigateToCreateChat()
is ConvoNavigationIntent.MessagesHistory -> {
onNavigateToMessagesHistory(intent.convoId)
}
}
},
activity = activity,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also {
it[ConvoGraph] = false
}
}
)
profileScreen(
activity = activity,
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked
)
@@ -1,274 +0,0 @@
package dev.meloda.fast.presentation
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dev.meloda.fast.logger.FastLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
class NetworkObserver(
context: Context,
private val logger: FastLogger
) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val networkStatus = MutableStateFlow(NetworkStatus.UNAVAILABLE)
val networkStatusFlow = networkStatus.asStateFlow()
private val networkState = MutableStateFlow(NetworkState.DISCONNECTED)
val networkStateFlow = networkState.asStateFlow()
private val networks = ConcurrentHashMap<Network, NetworkModel>()
private var clearCallbacks: (() -> Unit)? = null
init {
startListener()
}
private fun syncNetworkState() {
val state = if (networks.values.any { it.isInternetAvailable() }) {
NetworkState.CONNECTED
} else {
NetworkState.DISCONNECTED
}
networkState.value = state
networkStatus.value = when (state) {
NetworkState.CONNECTED -> NetworkStatus.AVAILABLE
NetworkState.DISCONNECTED -> NetworkStatus.UNAVAILABLE
}
log("STATE: $state")
}
private fun startListener() {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
log("onAvailable(): network: $network")
networks[network] = mapNetworkModel(
network = network,
capabilities = connectivityManager.getNetworkCapabilities(network),
properties = connectivityManager.getLinkProperties(network),
status = NetworkStatus.AVAILABLE
)
syncNetworkState()
}
override fun onUnavailable() {
log("onUnavailable()")
networks.clear()
syncNetworkState()
}
override fun onLost(network: Network) {
log("onLost() network: $network")
networks.remove(network)
syncNetworkState()
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
log("onCapabilitiesChanged(): network: $network; caps: $networkCapabilities")
val current = networks[network]
networks[network] = mapNetworkModel(
network = network,
capabilities = networkCapabilities,
from = current,
status = current?.status ?: NetworkStatus.AVAILABLE
)
syncNetworkState()
}
override fun onBlockedStatusChanged(
network: Network,
blocked: Boolean
) {
log("onBlockedStatusChanged(): network: $network; blocked: $blocked")
networks[network] = mapNetworkModel(
network = network,
from = networks[network],
status = if (blocked) NetworkStatus.BLOCKED else NetworkStatus.UNBLOCKED
)
syncNetworkState()
}
override fun onLinkPropertiesChanged(
network: Network,
linkProperties: LinkProperties
) {
log("onLinkPropertiesChanged(): network: $network; props: $linkProperties")
val current = networks[network]
networks[network] = mapNetworkModel(
network = network,
properties = linkProperties,
from = current,
status = current?.status ?: NetworkStatus.AVAILABLE
)
syncNetworkState()
}
override fun onLosing(network: Network, maxMsToLive: Int) {
log("onLosing(): network: $network; maxMsToLive: $maxMsToLive")
val current = networks[network]
networks[network] = mapNetworkModel(
network = network,
maxMsToLive = maxMsToLive.toLong(),
from = current,
status = current?.status ?: NetworkStatus.AVAILABLE
)
syncNetworkState()
}
override fun onReserved(networkCapabilities: NetworkCapabilities) {
log("onReserved(): caps: $networkCapabilities")
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
clearCallbacks = { connectivityManager.unregisterNetworkCallback(callback) }
refreshActiveNetwork()
}
private fun refreshActiveNetwork() {
val network = connectivityManager.activeNetwork
if (network == null) {
networks.clear()
syncNetworkState()
return
}
val capabilities = connectivityManager.getNetworkCapabilities(network)
if (capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true) {
networks[network] = mapNetworkModel(
network = network,
capabilities = capabilities,
properties = connectivityManager.getLinkProperties(network),
status = NetworkStatus.AVAILABLE
)
}
syncNetworkState()
}
private fun log(text: String) {
logger.debug(this::class, text)
}
private fun mapNetworkModel(
network: Network,
capabilities: NetworkCapabilities? = null,
properties: LinkProperties? = null,
status: NetworkStatus? = null,
maxMsToLive: Long? = null,
from: NetworkModel? = null
): NetworkModel {
val caps = capabilities
?: from?.networkCapabilities
?: connectivityManager.getNetworkCapabilities(network)
val networkType = when {
caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> NetworkType.CELLULAR
caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> NetworkType.WIFI
else -> from?.type ?: NetworkType.UNKNOWN
}
val hasInternet = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
?: from?.hasInternet
?: false
val signalStrength =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
caps?.signalStrength
} else {
null
} ?: from?.signalStrength ?: Int.MAX_VALUE
return NetworkModel(
id = network.hashCode(),
type = networkType,
original = network,
hasInternet = hasInternet,
signalStrength = signalStrength,
status = status ?: from?.status ?: NetworkStatus.UNAVAILABLE,
maxMsToLive = maxMsToLive ?: from?.maxMsToLive,
networkCapabilities = caps,
linkProperties = properties
?: from?.linkProperties
?: connectivityManager.getLinkProperties(network)
)
}
fun onDestroy() {
clearCallbacks?.let { unregisterCallback ->
runCatching { unregisterCallback() }
.onFailure { throwable ->
logger.error(
this::class.java,
"Failed to unregister network callback",
throwable
)
}
}
clearCallbacks = null
networks.clear()
syncNetworkState()
}
}
enum class NetworkType {
CELLULAR, WIFI, UNKNOWN
}
data class NetworkModel(
val id: Int,
val type: NetworkType,
val original: Network,
val hasInternet: Boolean,
val signalStrength: Int,
val status: NetworkStatus,
val maxMsToLive: Long?,
val networkCapabilities: NetworkCapabilities?,
val linkProperties: LinkProperties?
) {
fun isStatusOk(): Boolean = status.isOk()
fun isInternetAvailable(): Boolean = hasInternet && isStatusOk()
}
enum class NetworkStatus {
AVAILABLE, UNAVAILABLE, LOST, BLOCKED, UNBLOCKED;
fun isOk(): Boolean = when (this) {
AVAILABLE, UNBLOCKED -> true
UNAVAILABLE, LOST, BLOCKED -> false
}
}
enum class NetworkState { CONNECTED, DISCONNECTED }
@@ -4,6 +4,7 @@ import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.util.Log
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -59,11 +60,9 @@ import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main
import dev.meloda.fast.navigation.mainScreen
import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog
import dev.meloda.fast.settings.model.SettingsNavigationIntent
import dev.meloda.fast.settings.navigation.navigateToSettings
import dev.meloda.fast.settings.navigation.settingsScreen
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalLogger
import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
@@ -84,7 +83,6 @@ fun RootScreen(
toggleLongPollService: (enable: Boolean, inBackground: Boolean?) -> Unit,
toggleOnlineService: (enable: Boolean) -> Unit
) {
val logger = LocalLogger.current
val resources = LocalResources.current
val userSettings: UserSettings = koinInject()
@@ -94,6 +92,10 @@ fun RootScreen(
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "RootScreen(): viewModel: $viewModel")
}
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
val permissionState =
@@ -124,12 +126,13 @@ fun RootScreen(
}
LifecycleResumeEffect(longPollStateToApply) {
logger.debug("RootScreen", "longPollStateToApply: $longPollStateToApply")
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false, null)
Log.d("LongPoll", "recreate()")
}
toggleLongPollService(
@@ -363,31 +366,18 @@ fun RootScreen(
)
settingsScreen(
handleNavigationIntent = { intent ->
when (intent) {
SettingsNavigationIntent.Back -> navController.navigateUp()
SettingsNavigationIntent.Language -> navController.navigateToLanguagePicker()
SettingsNavigationIntent.Restart -> {
onBack = navController::navigateUp,
onLogOutButtonClicked = { navController.navigateToAuth(true) },
onLanguageItemClicked = navController::navigateToLanguagePicker,
onRestartRequired = {
activity?.let {
val intent =
Intent(activity, MainActivity::class.java)
intent.setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP
)
activity.finish()
val intent = Intent(activity, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
activity.startActivity(intent)
}
}
SettingsNavigationIntent.LogOut -> {
navController.navigateToAuth(true)
}
activity.finish()
}
}
)
languagePickerScreen(onBack = navController::navigateUp)
}
@@ -6,9 +6,8 @@ import android.os.IBinder
import android.util.Log
import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.AccountUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.data.processState
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -25,12 +24,11 @@ import kotlin.time.Duration.Companion.minutes
class OnlineService : Service() {
private val logger: FastLogger by inject()
private val job = SupervisorJob()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
logger.error(this::class.java, "CoroutineException", throwable)
Log.d(TAG, "error: $throwable")
throwable.printStackTrace()
}
private val coroutineContext: CoroutineContext
@@ -44,20 +42,17 @@ class OnlineService : Service() {
private var onlineJob: Job? = null
override fun onBind(intent: Intent?): IBinder? {
logger.debug(this::class, "STATE: onBind(): intent: $intent")
Log.d(STATE_TAG, "onBind: intent: $intent")
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (startId > 1) return START_STICKY
logger.debug(
this::class,
"STATE: onStartCommand(): flags: %s; startId: %s;\ninstance: %s"
.format("$flags", "$startId", "$this")
)
Log.d(STATE_TAG, "onStartCommand: flags: $flags; startId: $startId\ninstance: $this")
createTimer()
return START_STICKY
}
@@ -73,13 +68,13 @@ class OnlineService : Service() {
private fun setOnline() {
if (onlineJob != null) return
logger.debug(this::class, "setOnline()")
Log.d(TAG, "setOnline()")
onlineJob = coroutineScope.launch {
val token = UserConfig.fastToken ?: UserConfig.accessToken
if (token.isBlank()) {
logger.debug(this::class, "setOnline(): token is empty")
Log.d(TAG, "setOnline: token is empty")
return@launch
}
@@ -89,10 +84,10 @@ class OnlineService : Service() {
).onEach { state ->
state.processState(
error = { error ->
logger.error(this@OnlineService::class, "setOnline(): ERROR: $error")
Log.w(TAG, "setOnline(): error: $error")
},
success = { response ->
logger.debug(this@OnlineService::class, "setOnline(): response: $response")
Log.d(TAG, "setOnline(): success: $response")
}
)
}.collect()
@@ -101,7 +96,7 @@ class OnlineService : Service() {
}
override fun onDestroy() {
logger.debug(this::class, "onDestroy()")
Log.d(STATE_TAG, "onDestroy")
timerJob?.cancel("OnlineService destroyed")
onlineJob?.cancel("OnlineService destroyed")
@@ -7,6 +7,7 @@ import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.conena.nanokt.android.app.stopForegroundCompat
@@ -18,10 +19,8 @@ import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.ui.R
@@ -41,8 +40,6 @@ import kotlin.time.Duration.Companion.seconds
class LongPollingService : Service() {
private val logger: FastLogger by inject()
private val longPollController: LongPollController by inject()
private val job = SupervisorJob()
@@ -59,7 +56,6 @@ class LongPollingService : Service() {
private val longPollUseCase: LongPollUseCase by inject()
private val updatesParser: LongPollUpdatesParser by inject()
private val eventsHandler: LongPollEventsHandler by inject()
private var currentJob: Job? = null
@@ -67,21 +63,20 @@ class LongPollingService : Service() {
override fun onCreate() {
super.onCreate()
logger.debug(this::class, "STATE: onCreate()")
Log.d(STATE_TAG, "onCreate()")
}
override fun onBind(intent: Intent?): IBinder? {
logger.debug(this::class, "STATE: onBind(): intent: $intent")
Log.d(STATE_TAG, "onBind: intent: $intent")
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (startId > 1) return START_STICKY
logger.debug(
this::class,
"STATE: onStartCommand(): asForeground: %s; flags: %s; startId: %s;\ninstance: %s"
.format("$inBackground", "$flags", "$startId", "$this")
Log.d(
STATE_TAG,
"onStartCommand: asForeground: $inBackground; flags: $flags; startId: $startId;\ninstance: $this"
)
startJob()
@@ -136,15 +131,11 @@ class LongPollingService : Service() {
private fun startPolling(): Job {
if (job.isCompleted || job.isCancelled) {
logger.debug(
this::class,
"startPolling(): Job is already done. isCompleted: %s; isCancelled: %s"
.format("${job.isCompleted}", "${job.isCancelled}")
)
Log.d(STATE_TAG, "Job is completed or cancelled")
throw Exception("Job is over")
}
logger.debug(this::class, "startPolling(): Starting job.")
Log.d(STATE_TAG, "Starting job...")
return coroutineScope.launch(coroutineContext) {
longPollController.updateCurrentState(
@@ -202,7 +193,7 @@ class LongPollingService : Service() {
if (updates == null) {
failCount++
} else {
parseUpdates(updates)
updates.forEach(updatesParser::parseNextUpdate)
}
lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs))
@@ -220,11 +211,11 @@ class LongPollingService : Service() {
).listenValue(coroutineScope) { state ->
state.processState(
success = { response ->
logger.debug(this::class, "getServerInfo(): response: $response")
Log.d(TAG, "getServerInfo: serverInfoResponse: $response")
it.resume(response)
},
error = { error ->
logger.error(this::class, "getServerInfo(): ERROR: $error")
Log.e(TAG, "getServerInfo: $error")
it.resume(null)
}
)
@@ -244,24 +235,19 @@ class LongPollingService : Service() {
).listenValue(coroutineScope) { state ->
state.processState(
success = { response ->
logger.debug(this::class, "getUpdatesResponse(): response: $response")
Log.d(TAG, "lastUpdateResponse: $response")
it.resume(response)
},
error = { error ->
logger.debug(this::class, "getUpdatesResponse(): error: $error")
Log.d(TAG, "getUpdatesResponse: error: $error")
it.resume(null)
}
)
}
}
private suspend fun parseUpdates(updates: List<List<Any>>) {
val parsedUpdates = updates.flatMap { updatesParser.parseNextUpdate(it) }
eventsHandler.handleEvents(parsedUpdates)
}
private fun handleError(throwable: Throwable) {
logger.error(this::class, "CoroutineException", throwable)
Log.e(TAG, "error: $throwable")
if (throwable !is NoAccessTokenException) {
throwable.printStackTrace()
@@ -276,7 +262,7 @@ class LongPollingService : Service() {
}
override fun onDestroy() {
logger.debug(this::class, "STATE: onDestroy()")
Log.d(STATE_TAG, "onDestroy")
longPollController.updateCurrentState(LongPollState.Stopped)
try {
AppSettings.edit { putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true) }
@@ -288,7 +274,7 @@ class LongPollingService : Service() {
}
override fun onTrimMemory(level: Int) {
logger.debug(this::class, "STATE: onTrimMemory(): Level: $level")
Log.d(STATE_TAG, "onTrimMemory. Level: $level")
super.onTrimMemory(level)
}
@@ -1,6 +1,5 @@
package dev.meloda.fast.service.longpolling.di
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.domain.LongPollUseCaseImpl
@@ -11,5 +10,4 @@ import org.koin.dsl.module
val longPollModule = module {
singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class
singleOf(::LongPollUpdatesParser)
singleOf(::LongPollEventsHandler)
}
@@ -2,13 +2,10 @@ package dev.meloda.fast.common.extensions
import android.os.Build
import android.os.Bundle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope
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
@@ -16,7 +13,6 @@ 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 kotlin.reflect.KClass
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -44,16 +40,6 @@ fun <T> MutableList<T>.removeIfCompat(condition: (T) -> Boolean): Boolean {
return removed
}
context(viewModel: ViewModel)
fun <T> Flow<T>.listenValue(action: suspend (T) -> Unit): Job =
listenValue(viewModel.viewModelScope, action)
context(viewModel: ViewModel)
fun <T> MutableSharedFlow<T>.emitOnMain(value: T) {
val flow = this
viewModel.viewModelScope.launch { flow.emit(value) }
}
fun <T> Flow<T>.listenValue(
coroutineScope: CoroutineScope,
action: suspend (T) -> Unit
@@ -1,13 +1,13 @@
package dev.meloda.fast.common.model
enum class NetworkLogLevel(val value: Int) {
enum class LogLevel(val value: Int) {
NONE(0),
BASIC(1),
HEADERS(2),
BODY(3);
companion object {
fun parse(value: Int): NetworkLogLevel = entries.firstOrNull { it.value == value }
fun parse(value: Int): LogLevel = entries.firstOrNull { it.value == value }
?: throw IllegalArgumentException("Unknown log level with value: $value")
}
}
@@ -35,7 +35,7 @@ object VkMemoryCache {
}
fun appendContacts(contacts: List<VkContactDomain>) {
contacts.forEach { contact -> VkMemoryCache[contact.userId] = contact }
contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact }
}
operator fun set(userid: Long, user: VkUser) {
@@ -129,10 +129,6 @@ class ConvosRepositoryImpl(
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
@@ -151,6 +147,10 @@ class ConvosRepositoryImpl(
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
}
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
convos
},
errorMapper = { error ->
@@ -1,12 +1,9 @@
package dev.meloda.fast.data.api.friends
import com.slack.eithernet.ApiResult
import com.slack.eithernet.successOrElse
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.model.FriendsInfo
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity
@@ -16,6 +13,8 @@ import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult
import dev.meloda.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
@@ -52,18 +51,14 @@ class FriendsRepositoryImpl(
order = order,
count = count,
offset = offset,
fields = VkConstants.USER_FIELDS,
extended = true
fields = VkConstants.USER_FIELDS
)
service.getFriends(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val users = response.items.map(VkUserData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
VkMemoryCache.appendUsers(users)
VkMemoryCache.appendContacts(contactsList)
users
},
@@ -1,425 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "5eca3b3da167aaf7e772977a1f4e56e2",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `isOnlineMobile` INTEGER NOT NULL, `onlineAppId` INTEGER, `lastSeen` INTEGER, `lastSeenStatus` TEXT, `birthday` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `photo400Orig` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isOnline",
"columnName": "isOnline",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isOnlineMobile",
"columnName": "isOnlineMobile",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT"
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "screenName",
"columnName": "screenName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cmId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionCmId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `isImportant` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `isSpam` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cmId",
"columnName": "cmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"fieldPath": "isOut",
"columnName": "isOut",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "peerId",
"columnName": "peerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fromId",
"columnName": "fromId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "randomId",
"columnName": "randomId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT"
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT"
},
{
"fieldPath": "actionCmId",
"columnName": "actionCmId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT"
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER"
},
{
"fieldPath": "isImportant",
"columnName": "isImportant",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT"
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT"
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDeleted",
"columnName": "isDeleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isSpam",
"columnName": "isSpam",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "convos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastCmId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "isPhantom",
"columnName": "isPhantom",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCmId",
"columnName": "lastCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReadCmId",
"columnName": "inReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outReadCmId",
"columnName": "outReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inRead",
"columnName": "inRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outRead",
"columnName": "outRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
},
{
"fieldPath": "canChangePin",
"columnName": "canChangePin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canChangeInfo",
"columnName": "canChangeInfo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "majorId",
"columnName": "majorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minorId",
"columnName": "minorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "peerType",
"columnName": "peerType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5eca3b3da167aaf7e772977a1f4e56e2')"
]
}
}
@@ -21,7 +21,7 @@ import dev.meloda.fast.model.database.VkUserEntity
VkConvoEntity::class
],
version = 12
version = 11
)
@TypeConverters(Converters::class)
abstract class CacheDatabase : RoomDatabase() {
@@ -13,7 +13,7 @@ abstract class ConvoDao : EntityDao<VkConvoEntity> {
abstract suspend fun getAll(): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Long>): List<VkConvoEntity>
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getById(id: Long): VkConvoEntity?
@@ -23,23 +23,8 @@ abstract class ConvoDao : EntityDao<VkConvoEntity> {
abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage?
@Query("DELETE FROM convos WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Long>): Int
@Query("UPDATE convos SET inReadCmId = :cmId, unreadCount = :unreadCount WHERE id = :convoId")
abstract suspend fun updateReadIncoming(convoId: Long, cmId: Long, unreadCount: Int): Int
@Query("UPDATE convos SET outReadCmId = :cmId, unreadCount = :unreadCount WHERE id = :convoId")
abstract suspend fun updateReadOutgoing(convoId: Long, cmId: Long, unreadCount: Int): Int
@Query("UPDATE convos SET isArchived = :isArchived WHERE id = :convoId")
abstract suspend fun updateIsArchived(convoId: Long, isArchived: Boolean): Int
@Query("UPDATE convos SET majorId = :majorId WHERE id = :convoId")
abstract suspend fun updateMajorId(convoId: Long, majorId: Int): Int
@Query("UPDATE convos SET minorId = :minorId WHERE id = :convoId")
abstract suspend fun updateMinorId(convoId: Long, minorId: Int): Int
@Query("UPDATE convos SET lastCmId = :cmId WHERE id = :convoId")
abstract suspend fun updateLastCmId(convoId: Long, cmId: Long): Int
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -7,7 +7,7 @@ import dev.meloda.fast.model.database.VkMessageEntity
@Dao
abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("SELECT * FROM messages WHERE isDeleted = 0 AND isSpam = 0")
@Query("SELECT * FROM messages")
abstract suspend fun getAll(): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:convoId)")
@@ -21,13 +21,4 @@ abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("DELETE FROM messages WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
@Query("UPDATE messages SET isDeleted = :isDeleted WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsDeleted(convoId: Long, cmId: Long, isDeleted: Boolean): Int
@Query("UPDATE messages SET isImportant = :isImportant WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsImportant(convoId: Long, cmId: Long, isImportant: Boolean): Int
@Query("UPDATE messages SET isSpam = :isSpam WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsSpam(convoId: Long, cmId: Long, isSpam: Boolean): Int
}
@@ -3,7 +3,7 @@ package dev.meloda.fast.datastore
import android.content.SharedPreferences
import androidx.core.content.edit
import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.NetworkLogLevel
import dev.meloda.fast.common.model.LogLevel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -238,11 +238,11 @@ object AppSettings {
)
set(value) = put(SettingsKeys.KEY_DEBUG_SHOW_CRASH_ALERT, value)
var networkLogLevel: NetworkLogLevel
var networkLogLevel: LogLevel
get() = get(
SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL,
SettingsKeys.DEFAULT_NETWORK_LOG_LEVEL
).let(NetworkLogLevel::parse)
).let(LogLevel::parse)
set(level) = put(SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL, level.value)
var showDebugCategory: Boolean
@@ -1,332 +0,0 @@
package dev.meloda.fast.domain
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
typealias EventListener = (event: LongPollParsedEvent) -> Unit
typealias EventListenerMap = MutableMap<LongPollEvent, MutableList<EventListener>>
class LongPollEventsHandler(
private val logger: FastLogger,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val convoDao: ConvoDao,
private val messageDao: MessageDao,
) {
private val job = SupervisorJob()
private val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
logger.error(this::class, "CoroutineException", throwable)
}
private val coroutineContext: CoroutineContext
get() = Dispatchers.Default + job + exceptionHandler
private val coroutineScope = CoroutineScope(coroutineContext)
private val listenersMap: EventListenerMap = mutableMapOf()
fun handleEvents(events: List<LongPollParsedEvent>) {
coroutineScope.launch {
// TODO: 30.05.2026, Danil Nikolaev: switch to interactors or something else
withContext(Dispatchers.IO) {
events.forEach { handleNextEvent(it) }
}
}
}
private suspend fun handleNextEvent(event: LongPollParsedEvent) {
when (event) {
is LongPollParsedEvent.AudioMessageListened -> {
}
is LongPollParsedEvent.ChatArchived -> {
val affectedRows = convoDao.updateIsArchived(
convoId = event.convo.id,
isArchived = event.convo.isArchived
)
logger.debug(
this::class,
"isArchived ${event.convo.isArchived}: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatCleared -> {
val affectedRows = convoDao.updateLastCmId(
convoId = event.peerId,
cmId = event.toCmId
)
logger.debug(
this::class,
"updateLastCmId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatMajorChanged -> {
val affectedRows = convoDao.updateMajorId(
convoId = event.peerId,
majorId = event.majorId
)
logger.debug(
this::class,
"updateMajorId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatMinorChanged -> {
val affectedRows = convoDao.updateMinorId(
convoId = event.peerId,
minorId = event.minorId
)
logger.debug(
this::class,
"updateMinorId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.Interaction -> {
val eventType = when (event.interactionType) {
InteractionType.Typing -> LongPollEvent.TYPING
InteractionType.VoiceMessage -> LongPollEvent.AUDIO_MESSAGE_RECORDING
InteractionType.Photo -> LongPollEvent.PHOTO_UPLOADING
InteractionType.Video -> LongPollEvent.VIDEO_UPLOADING
InteractionType.File -> LongPollEvent.FILE_UPLOADING
}
emitEvent(eventType, event)
}
is LongPollParsedEvent.MessageCacheClear -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_CACHE_CLEAR, event)
}
is LongPollParsedEvent.MessageDeleted -> {
val affectedRows = messageDao.markAsDeleted(
convoId = event.peerId,
cmId = event.cmId,
isDeleted = true
)
logger.debug(
this::class,
"markDeleted: updated $affectedRows rows."
)
emitEvent(LongPollEvent.MESSAGE_DELETED, event)
}
is LongPollParsedEvent.MessageEdited -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_EDITED, event)
}
is LongPollParsedEvent.MessageMarkedAsImportant -> {
val affectedRows = messageDao.markAsImportant(
convoId = event.peerId,
cmId = event.cmId,
isImportant = event.marked
)
logger.debug(
this::class,
"markImportant: updated $affectedRows rows."
)
emitEvent(LongPollEvent.MARKED_AS_IMPORTANT, event)
}
is LongPollParsedEvent.MessageMarkedAsNotSpam -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MARKED_AS_NOT_SPAM, event)
}
is LongPollParsedEvent.MessageMarkedAsSpam -> {
val affectedRows = messageDao.markAsSpam(
convoId = event.peerId,
cmId = event.cmId,
isSpam = true
)
logger.debug(
this::class,
"markSpam: updated $affectedRows rows."
)
emitEvent(LongPollEvent.MARKED_AS_SPAM, event)
}
is LongPollParsedEvent.MessageRestored -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_RESTORED, event)
}
is LongPollParsedEvent.MessageUpdated -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_UPDATED, event)
}
is LongPollParsedEvent.MessageNew -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_NEW, event)
}
is LongPollParsedEvent.IncomingMessageRead -> {
val affectedRows = convoDao.updateReadIncoming(
convoId = event.peerId,
cmId = event.cmId,
unreadCount = event.unreadCount
)
logger.debug(
this::class,
"inMessageRead: updated $affectedRows rows."
)
emitEvent(LongPollEvent.INCOMING_MESSAGE_READ, event)
}
is LongPollParsedEvent.OutgoingMessageRead -> {
val affectedRows = convoDao.updateReadOutgoing(
convoId = event.peerId,
cmId = event.cmId,
unreadCount = event.unreadCount
)
logger.debug(
this::class,
"outMessageRead: updated $affectedRows rows."
)
emitEvent(LongPollEvent.OUTGOING_MESSAGE_READ, event)
}
is LongPollParsedEvent.UnreadCounter -> {
emitEvent(LongPollEvent.UNREAD_COUNTER_UPDATE, event)
}
}
}
private fun <T : LongPollParsedEvent> emitEvent(eventType: LongPollEvent, event: T) {
listenersMap[eventType]?.forEach { it(event) }
}
private fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: (T) -> Unit
) {
if (listenersMap[eventType] == null) {
listenersMap[eventType] = mutableListOf()
}
@Suppress("UNCHECKED_CAST")
listenersMap[eventType]?.add(listener as EventListener)
}
private fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: (T) -> Unit
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
}
fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_SET_FLAGS, block)
}
fun onMessageMarkAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_IMPORTANT, block)
}
fun onMessageMarkAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_SPAM, block)
}
fun onMessageDelete(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
registerListener(LongPollEvent.MESSAGE_DELETED, block)
}
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, block)
}
fun onMessageMarkAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, block)
}
fun onMessageRestore(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
registerListener(LongPollEvent.MESSAGE_RESTORED, block)
}
fun onMessageNew(block: (LongPollParsedEvent.MessageNew) -> Unit) {
registerListener(LongPollEvent.MESSAGE_NEW, block)
}
fun onMessageEdit(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
registerListener(LongPollEvent.MESSAGE_EDITED, block)
}
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
registerListener(LongPollEvent.INCOMING_MESSAGE_READ, block)
}
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, block)
}
fun onChatClear(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
registerListener(LongPollEvent.CHAT_CLEARED, block)
}
fun onChatMajorChange(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, block)
}
fun onChatMinorChange(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, block)
}
fun onChatArchive(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
registerListener(LongPollEvent.CHAT_ARCHIVED, block)
}
fun onInteraction(block: (LongPollParsedEvent.Interaction) -> Unit) {
registerListeners(
eventTypes = listOf(
LongPollEvent.TYPING,
LongPollEvent.AUDIO_MESSAGE_RECORDING,
LongPollEvent.PHOTO_UPLOADING,
LongPollEvent.VIDEO_UPLOADING,
LongPollEvent.FILE_UPLOADING
),
listener = block
)
}
fun onDestroy() {
listenersMap.clear()
}
}
@@ -1,5 +1,6 @@
package dev.meloda.fast.domain
import android.util.Log
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.asInt
import dev.meloda.fast.common.extensions.asLong
@@ -7,10 +8,10 @@ import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.toList
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.ConvoFlags
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.MessageFlags
import dev.meloda.fast.model.api.domain.VkConvo
@@ -21,12 +22,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LongPollUpdatesParser(
private val logger: FastLogger,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase
) {
@@ -34,7 +35,8 @@ class LongPollUpdatesParser(
private val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
logger.error(this::class, "CoroutineException", throwable)
Log.e("LongPollUpdatesParser", "error: $throwable")
throwable.printStackTrace()
}
private val coroutineContext: CoroutineContext
@@ -42,14 +44,14 @@ class LongPollUpdatesParser(
private val coroutineScope = CoroutineScope(coroutineContext)
suspend fun parseNextUpdate(event: List<Any>): List<LongPollParsedEvent> {
private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
mutableMapOf()
fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt()
return when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> {
logger.debug(this::class, "parseNextUpdate(): unknownEvent: $event")
emptyList()
}
when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event)
ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event)
@@ -75,11 +77,8 @@ class LongPollUpdatesParser(
}
}
private fun parseMessageSetFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageSetFlags(): $eventType: $event")
private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val flags = event[2].asInt()
@@ -97,6 +96,13 @@ class LongPollUpdatesParser(
marked = true
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.SPAM -> {
@@ -105,6 +111,13 @@ class LongPollUpdatesParser(
cmId = cmId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsSpam>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.DELETED -> {
@@ -123,6 +136,13 @@ class LongPollUpdatesParser(
)
}
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_DELETED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageDeleted>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.AUDIO_LISTENED -> {
@@ -131,27 +151,30 @@ class LongPollUpdatesParser(
cmId = cmId
)
eventsToSend += eventToSend
}
MessageFlags.UNREAD -> Unit
MessageFlags.OUTGOING -> Unit
MessageFlags.FROM_GROUP_CHAT -> Unit
MessageFlags.CANCEL_SPAM -> Unit
MessageFlags.DELETED_FOR_ALL -> Unit
MessageFlags.DO_NOT_SHOW_NOTIFICATION -> Unit
MessageFlags.MESSAGE_WITH_REPLY -> Unit
MessageFlags.REACTION -> Unit
listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.AudioMessageListened>)
?.onEvent(eventToSend)
}
}
}
return eventsToSend
else -> Unit
}
}
private suspend fun parseMessageClearFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageClearFlags(): $eventType: $event")
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(eventToSend)
}
}
}
}
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val flags = event[2].asInt()
@@ -161,9 +184,7 @@ class LongPollUpdatesParser(
val parsedFlags = MessageFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
val message = loadMessage(peerId = peerId, cmId = cmId)
coroutineScope.launch {
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
@@ -173,53 +194,82 @@ class LongPollUpdatesParser(
marked = false
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.SPAM -> {
if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) {
if (message != null) {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_NOT_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsNotSpam>)
?.onEvent(eventToSend)
}
}
}
}
}
}
MessageFlags.DELETED -> {
if (message != null) {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_RESTORED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageRestored>)
?.onEvent(eventToSend)
}
}
}
}
}
MessageFlags.UNREAD -> Unit
MessageFlags.OUTGOING -> Unit
MessageFlags.AUDIO_LISTENED -> Unit
MessageFlags.FROM_GROUP_CHAT -> Unit
MessageFlags.CANCEL_SPAM -> Unit
MessageFlags.DELETED_FOR_ALL -> Unit
MessageFlags.DO_NOT_SHOW_NOTIFICATION -> Unit
MessageFlags.MESSAGE_WITH_REPLY -> Unit
MessageFlags.REACTION -> Unit
else -> Unit
}
}
continuation.resume(eventsToSend)
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
vkEventCallback.onEvent(eventToSend)
}
}
}
}
}
private suspend fun parseMessageNew(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageNew(): $eventType: $event")
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message = async { loadMessage(peerId = peerId, cmId = cmId) }.await()
val message =
async { loadMessage(peerId = peerId, cmId = cmId) }.await()
val convo =
async {
@@ -230,84 +280,88 @@ class LongPollUpdatesParser(
)
}.await()
if (message != null) {
val event = LongPollParsedEvent.MessageNew(
message?.let {
listenersMap[LongPollEvent.MESSAGE_NEW]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.NewMessage>)
.onEvent(
LongPollParsedEvent.NewMessage(
message = message,
inArchive = convo?.isArchived == true
// TODO: 03-Apr-25, Danil Nikolaev:
// load user settings about restoring chats with
// enabled notifications from archive
)
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
)
}
}
}
}
}
private suspend fun parseMessageEdit(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageEdit(): $eventType: $event")
private fun parseMessageEdit(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val peerId = event[3].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message = loadMessage(peerId = peerId, cmId = cmId)
if (message != null) {
val event = LongPollParsedEvent.MessageEdited(message)
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_EDITED]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageEdited>)
.onEvent(LongPollParsedEvent.MessageEdited(message))
}
}
}
}
}
private fun parseMessageReadIncoming(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageReadIncoming(): $eventType: $event")
private fun parseMessageReadIncoming(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val unreadCount = event[3].asInt()
val event = LongPollParsedEvent.IncomingMessageRead(
listenersMap[LongPollEvent.INCOMING_MESSAGE_READ]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.IncomingMessageRead>)
.onEvent(
LongPollParsedEvent.IncomingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
return listOf(event)
)
}
}
}
private fun parseMessageReadOutgoing(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageReadOutgoing(): $eventType: $event")
private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val unreadCount = event[3].asInt()
val event = LongPollParsedEvent.OutgoingMessageRead(
listenersMap[LongPollEvent.OUTGOING_MESSAGE_READ]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.OutgoingMessageRead>)
.onEvent(
LongPollParsedEvent.OutgoingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
return listOf(event)
)
}
}
}
private suspend fun parseChatClearFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseChatClearFlags(): $eventType: $event")
private fun parseChatClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val flags = event[2].asInt()
@@ -336,32 +390,33 @@ class LongPollUpdatesParser(
archived = false
)
eventsToSend += eventToSend
}
ConvoFlags.DISABLE_PUSH -> Unit
ConvoFlags.DISABLE_SOUND -> Unit
ConvoFlags.INCOMING_CHAT_REQUEST -> Unit
ConvoFlags.DECLINED_CHAT_REQUEST -> Unit
ConvoFlags.MENTION -> Unit
ConvoFlags.HIDE_CHAT_FROM_SEARCH -> Unit
ConvoFlags.BUSINESS_CHAT -> Unit
ConvoFlags.MARKED_MESSAGE -> Unit
ConvoFlags.DO_NOT_NOTIFY_MENTIONS_ALL_ONLINE -> Unit
ConvoFlags.DO_NOT_NOTIFY_ALL_MENTIONS -> Unit
ConvoFlags.MARKED_AS_UNREAD -> Unit
ConvoFlags.CALL_IN_PROGRESS -> Unit
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
}
continuation.resume(eventsToSend)
else -> Unit
}
}
private suspend fun parseChatSetFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseChatSetFlags(): $eventType: $event")
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.CHAT_CLEAR_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(
eventToSend
)
}
}
}
}
}
private fun parseChatSetFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val flags = event[2].asInt()
@@ -390,80 +445,90 @@ class LongPollUpdatesParser(
archived = true
)
eventsToSend += eventToSend
}
ConvoFlags.DISABLE_PUSH -> Unit
ConvoFlags.DISABLE_SOUND -> Unit
ConvoFlags.INCOMING_CHAT_REQUEST -> Unit
ConvoFlags.DECLINED_CHAT_REQUEST -> Unit
ConvoFlags.MENTION -> Unit
ConvoFlags.HIDE_CHAT_FROM_SEARCH -> Unit
ConvoFlags.BUSINESS_CHAT -> Unit
ConvoFlags.MARKED_MESSAGE -> Unit
ConvoFlags.DO_NOT_NOTIFY_MENTIONS_ALL_ONLINE -> Unit
ConvoFlags.DO_NOT_NOTIFY_ALL_MENTIONS -> Unit
ConvoFlags.MARKED_AS_UNREAD -> Unit
ConvoFlags.CALL_IN_PROGRESS -> Unit
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
}
continuation.resume(eventsToSend)
else -> Unit
}
}
private fun parseMessagesDeleted(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessagesDeleted(): $eventType: $event")
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.CHAT_SET_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(
eventToSend
)
}
}
}
}
}
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val event = LongPollParsedEvent.ChatCleared(
listenersMap[LongPollEvent.CHAT_CLEARED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatCleared>)
.onEvent(
LongPollParsedEvent.ChatCleared(
peerId = peerId,
toCmId = cmId
)
return listOf(event)
)
}
}
}
private fun parseChatMajorChanged(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseChatMajorChanged(): $eventType: $event")
private fun parseChatMajorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val majorId = event[2].asInt()
val event = LongPollParsedEvent.ChatMajorChanged(
listenersMap[LongPollEvent.CHAT_MAJOR_CHANGED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMajorChanged>)
.onEvent(
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = majorId,
)
return listOf(event)
)
}
}
}
private fun parseChatMinorChanged(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseChatMinorChanged(): $eventType: $event")
private fun parseChatMinorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val minorId = event[2].asInt()
val event = LongPollParsedEvent.ChatMinorChanged(
listenersMap[LongPollEvent.CHAT_MINOR_CHANGED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMinorChanged>)
.onEvent(
LongPollParsedEvent.ChatMinorChanged(
peerId = peerId,
minorId = minorId,
)
return listOf(event)
)
}
}
}
private fun parseInteraction(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseInteraction(): $eventType: $event")
private fun parseInteraction(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val interactionType = when (eventType) {
ApiEvent.TYPING -> InteractionType.Typing
@@ -471,7 +536,16 @@ class LongPollUpdatesParser(
ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo
ApiEvent.VIDEO_UPLOADING -> InteractionType.Video
ApiEvent.FILE_UPLOADING -> InteractionType.File
else -> return emptyList()
else -> return
}
val longPollEvent: LongPollEvent = when (eventType) {
ApiEvent.TYPING -> LongPollEvent.TYPING
ApiEvent.AUDIO_MESSAGE_RECORDING -> LongPollEvent.AUDIO_MESSAGE_RECORDING
ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING
ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING
ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING
else -> return
}
val peerId = event[1].asLong()
@@ -480,24 +554,26 @@ class LongPollUpdatesParser(
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 emptyList()
if (userIds.isEmpty()) return
val event = LongPollParsedEvent.Interaction(
listenersMap[longPollEvent]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.Interaction>)
.onEvent(
LongPollParsedEvent.Interaction(
interactionType = interactionType,
peerId = peerId,
userIds = userIds,
totalCount = totalCount,
timestamp = timestamp
)
return listOf(event)
)
}
}
}
private fun parseUnreadCounterUpdate(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseUnreadCounterUpdate(): $eventType: $event")
private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val unreadCount = event[1].asInt()
val unreadUnmutedCount = event[2].asInt()
@@ -507,7 +583,11 @@ class LongPollUpdatesParser(
val archiveUnreadUnmutedCount = event[8].asInt()
val archiveMentionsCount = event[9].asInt()
val event = LongPollParsedEvent.UnreadCounter(
listenersMap[LongPollEvent.UNREAD_COUNTER_UPDATE]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.UnreadCounter>)
.onEvent(
LongPollParsedEvent.UnreadCounter(
unread = unreadCount,
unreadUnmuted = unreadUnmutedCount,
showOnlyMuted = showOnlyMuted,
@@ -516,45 +596,45 @@ class LongPollUpdatesParser(
archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount
)
return listOf(event)
)
}
}
}
private suspend fun parseMessageUpdated(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageUpdated(): $eventType: $event")
private fun parseMessageUpdated(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message = loadMessage(peerId = peerId, cmId = cmId)
if (message != null) {
val event = LongPollParsedEvent.MessageUpdated(message)
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_UPDATED]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageUpdated>)
.onEvent(LongPollParsedEvent.MessageUpdated(message))
}
}
}
}
}
private suspend fun parseMessageCacheClear(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageCacheClear(): $eventType: $event")
private fun parseMessageCacheClear(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val messageId = event[1].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message = loadMessage(messageId = messageId)
if (message != null) {
val event = LongPollParsedEvent.MessageCacheClear(message)
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
loadMessage(messageId = messageId)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_CACHE_CLEAR]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageCacheClear>)
.onEvent(LongPollParsedEvent.MessageCacheClear(message))
}
}
}
}
}
@@ -563,7 +643,7 @@ class LongPollUpdatesParser(
peerId: Long? = null,
cmId: Long? = null,
messageId: Long? = null
): VkMessage? = suspendCancellableCoroutine { continuation ->
): VkMessage? = suspendCoroutine { continuation ->
require((peerId != null && cmId != null) || messageId != null)
coroutineScope.launch(Dispatchers.IO) {
@@ -577,10 +657,7 @@ class LongPollUpdatesParser(
).listenValue(this) { state ->
state.processState(
error = { error ->
logger.error(
this@LongPollUpdatesParser::class,
"loadMessage(): ERROR: $error"
)
Log.e("LongPollUpdatesParser", "loadMessage: error: $error")
continuation.resume(null)
},
success = { response ->
@@ -600,7 +677,7 @@ class LongPollUpdatesParser(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConvo? = suspendCancellableCoroutine { continuation ->
): VkConvo? = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) {
convoUseCase.getById(
peerIds = listOf(peerId),
@@ -609,10 +686,7 @@ class LongPollUpdatesParser(
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
logger.error(
this@LongPollUpdatesParser::class,
"loadConvo(): ERROR: $error"
)
Log.e("LongPollUpdatesParser", "loadConvo: error: $error")
continuation.resume(null)
},
success = { response ->
@@ -627,4 +701,107 @@ class LongPollUpdatesParser(
}
}
}
@Suppress("UNCHECKED_CAST")
private fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: VkEventCallback<T>
) {
listenersMap.let { map ->
map[eventType] = (map[eventType] ?: mutableListOf())
.also {
it.add(listener as VkEventCallback<LongPollParsedEvent>)
}
}
}
private fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: VkEventCallback<T>
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
}
fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block))
}
fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
}
fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block))
}
fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
}
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block))
}
fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
}
fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block))
}
fun onNewMessage(block: (LongPollParsedEvent.NewMessage) -> Unit) {
registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
}
fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block))
}
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
}
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block))
}
fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
}
fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block))
}
fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
}
fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block))
}
fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) {
registerListeners(
eventTypes = listOf(
LongPollEvent.TYPING,
LongPollEvent.AUDIO_MESSAGE_RECORDING,
LongPollEvent.PHOTO_UPLOADING,
LongPollEvent.VIDEO_UPLOADING,
LongPollEvent.FILE_UPLOADING
),
listener = assembleEventCallback(block)
)
}
}
internal inline fun <R : LongPollParsedEvent> assembleEventCallback(
crossinline block: (R) -> Unit,
): VkEventCallback<R> {
return VkEventCallback { event -> block.invoke(event) }
}
fun interface VkEventCallback<in T : LongPollParsedEvent> {
fun onEvent(event: T)
}
@@ -52,7 +52,7 @@ fun VkConvo.extractTitle(
} else {
val userName = user?.let { user ->
if (useContactName) {
VkMemoryCache.getContact(user.id)?.name ?: user.fullName
VkMemoryCache.getContact(user.id)?.name
} else {
user.fullName
}
-1
View File
@@ -1 +0,0 @@
/build
-11
View File
@@ -1,11 +0,0 @@
plugins {
alias(libs.plugins.fast.android.library)
}
android {
namespace = "dev.meloda.fast.logger"
}
dependencies {
implementation(libs.koin.android)
}
@@ -1,17 +0,0 @@
package dev.meloda.fast.logger;
enum class FastLogLevel {
VERBOSE,
DEBUG,
INFO,
WARNING,
ERROR,
ASSERT;
companion object {
fun parse(value: Int): FastLogLevel {
if (value !in 0..5) throw IllegalArgumentException("Unknown LogLevel value $value")
return entries.first { it.ordinal == value }
}
}
}
@@ -1,108 +0,0 @@
package dev.meloda.fast.logger
import android.util.Log
import kotlin.reflect.KClass
class FastLogger {
companion object {
@Volatile
private lateinit var instance: FastLogger
fun setInstance(logger: FastLogger) {
if (::instance.isInitialized) {
throw IllegalStateException("FastLogger has already been initialized.")
}
instance = logger
}
fun getInstance(): FastLogger {
if (!::instance.isInitialized) {
throw UninitializedPropertyAccessException("FastLogger is not initialized.")
}
return instance
}
}
private var logLevel: FastLogLevel = FastLogLevel.ERROR
fun setLogLevel(logLevel: FastLogLevel) {
Log.v(this::class.java.simpleName, "Set LogLevel from ${this.logLevel} to $logLevel")
this.logLevel = logLevel
}
fun verbose(clazz: Class<*>, message: String, throwable: Throwable? = null) {
verbose(clazz.simpleName, message, throwable)
}
fun verbose(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.VERBOSE)) {
Log.v(tag, message, throwable)
}
}
fun debug(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
debug(clazz.java, message, throwable)
}
fun debug(clazz: Class<*>, message: String, throwable: Throwable? = null) {
debug(clazz.simpleName, message, throwable)
}
fun debug(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.DEBUG)) {
Log.d(tag, message, throwable)
}
}
fun info(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
info(clazz.java, message, throwable)
}
fun info(clazz: Class<*>, message: String, throwable: Throwable? = null) {
info(clazz.simpleName, message, throwable)
}
fun info(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.INFO)) {
Log.i(tag, message, throwable)
}
}
fun warning(clazz: Class<*>, message: String, throwable: Throwable? = null) {
warning(clazz.simpleName, message, throwable)
}
fun warning(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.WARNING)) {
Log.w(tag, message, throwable)
}
}
fun error(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
error(clazz.java, message, throwable)
}
fun error(clazz: Class<*>, message: String, throwable: Throwable? = null) {
error(clazz.simpleName, message, throwable)
}
fun error(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.ERROR)) {
Log.e(tag, message, throwable)
}
}
fun assert(clazz: Class<*>, message: String, throwable: Throwable? = null) {
assert(clazz.simpleName, message, throwable)
}
fun assert(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.ASSERT)) {
Log.wtf(tag, message, throwable)
}
}
private fun shouldLog(level: FastLogLevel): Boolean = level.ordinal >= logLevel.ordinal
}
@@ -1,8 +0,0 @@
package dev.meloda.fast.logger
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val loggerModule = module {
singleOf(::FastLogger)
}
@@ -5,7 +5,7 @@ import dev.meloda.fast.model.api.domain.VkMessage
sealed interface LongPollParsedEvent {
data class MessageNew(
data class NewMessage(
val message: VkMessage,
val inArchive: Boolean
) : LongPollParsedEvent
@@ -1,5 +1,7 @@
package dev.meloda.fast.model.api.data
import android.util.Log
enum class AttachmentType(var value: String) {
UNKNOWN("unknown"),
PHOTO("photo"),
@@ -40,6 +42,10 @@ enum class AttachmentType(var value: String) {
it.value == value
} ?: UNKNOWN
if (parsedValue == UNKNOWN) {
Log.e("AttachmentType", "Unknown attachment type: $value")
}
return parsedValue
}
}
@@ -117,6 +117,5 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
pinnedAt = pinnedAt,
isPinned = isPinned == true,
formatData = formatData?.asDomain(),
isSpam = false,
isDeleted = false
isSpam = false
)
@@ -56,6 +56,5 @@ data class VkPinnedMessageData(
isPinned = true,
isSpam = false,
formatData = null,
isDeleted = false
)
}
@@ -36,8 +36,6 @@ data class VkMessage(
val group: VkGroupDomain?,
val actionUser: VkUser?,
val actionGroup: VkGroupDomain?,
val isDeleted: Boolean
) {
fun isPeerChat() = peerId > 2_000_000_000
@@ -113,7 +111,7 @@ fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
actionCmId = actionCmId,
actionMessage = actionMessage,
updateTime = updateTime,
isImportant = isImportant,
important = isImportant,
forwardIds = forwards.orEmpty().map(VkMessage::id),
// TODO: 05/05/2024, Danil Nikolaev: save attachments
attachments = emptyList(),
@@ -121,6 +119,4 @@ fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
geoType = geoType,
pinnedAt = pinnedAt,
isPinned = isPinned,
isDeleted = isDeleted,
isSpam = isSpam
)
@@ -4,8 +4,7 @@ data class GetFriendsRequest(
val order: String?,
val count: Int?,
val offset: Int?,
val fields: String?,
val extended: Boolean?
val fields: String?
) {
val map
@@ -15,7 +14,6 @@ data class GetFriendsRequest(
count?.let { this["count"] = it.toString() }
offset?.let { this["offset"] = it.toString() }
fields?.let { this["fields"] = it }
extended?.let { this["extended"] = it.toString() }
}
}
@@ -1,13 +1,11 @@
package dev.meloda.fast.model.api.responses
import dev.meloda.fast.model.api.data.VkUserData
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkUserData
@JsonClass(generateAdapter = true)
data class GetFriendsResponse(
@Json(name = "count") val count: Int,
@Json(name = "items") val items: List<VkUserData>,
@Json(name = "contacts") val contacts: List<VkContactData>?
@Json(name = "items") val items: List<VkUserData>
)
@@ -21,15 +21,13 @@ data class VkMessageEntity(
val actionCmId: Long?,
val actionMessage: String?,
val updateTime: Int?,
val isImportant: Boolean,
val important: Boolean,
val forwardIds: List<Long>?,
val attachments: List<String>?, // TODO: 01/05/2024, Danil Nikolaev: how to store???
val replyMessageId: Long?,
val geoType: String?,
val pinnedAt: Int?,
val isPinned: Boolean,
val isDeleted: Boolean,
val isSpam: Boolean
val isPinned: Boolean
)
fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
@@ -47,7 +45,7 @@ fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
actionCmId = actionCmId,
actionMessage = actionMessage,
updateTime = updateTime,
isImportant = isImportant,
isImportant = important,
forwards = emptyList(),//forwards.orEmpty().map(VkMessageEntity::asExternalModel),
// TODO: 05/05/2024, Danil Nikolaev: restore attachments
attachments = attachments.orEmpty().map { VkUnknownAttachment },
@@ -61,5 +59,4 @@ fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
isPinned = isPinned,
isSpam = false,
formatData = null,
isDeleted = isDeleted
)
-1
View File
@@ -15,7 +15,6 @@ dependencies {
api(projects.core.common)
api(projects.core.model)
api(projects.core.datastore)
api(projects.core.logger)
implementation(libs.moshi.kotlin)
implementation(libs.koin.android)
@@ -1,10 +1,10 @@
package dev.meloda.fast.network
import android.util.Log
import com.slack.eithernet.ApiException
import com.slack.eithernet.errorType
import com.slack.eithernet.toType
import com.squareup.moshi.JsonDataException
import dev.meloda.fast.logger.FastLogger
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
@@ -16,10 +16,7 @@ import java.lang.reflect.Type
*
* допускает Unit как SuccessType в случае невозможности каста ответа в ErrorType
*/
class ResponseConverterFactory(
private val converter: JsonConverter,
private val logger: FastLogger
) : Converter.Factory() {
class ResponseConverterFactory(private val converter: JsonConverter) : Converter.Factory() {
override fun responseBodyConverter(
type: Type,
@@ -32,7 +29,6 @@ class ResponseConverterFactory(
successType = type,
errorRaw = errorRaw,
converter = converter,
logger = logger
)
}
@@ -40,7 +36,6 @@ class ResponseConverterFactory(
private val successType: Type,
private val errorRaw: Class<*>,
private val converter: JsonConverter,
private val logger: FastLogger
) : Converter<ResponseBody, Any?> {
override fun convert(value: ResponseBody): Any? {
val string = value.string()
@@ -58,7 +53,7 @@ class ResponseConverterFactory(
},
onFailure = { failure ->
if (failure is JsonDataException) {
logger.error(this::class, "convert(): ERROR", failure)
Log.d("ResponseBodyConverter", "convertJsonDataException: $failure")
throw ApiException(
RestApiError(
errorCode = -1,
@@ -73,11 +68,10 @@ class ResponseConverterFactory(
converter.fromJson(errorRaw, string)
}.fold(
onSuccess = { errorModel ->
logger.debug(this::class, "convert(): errorModel: $errorModel")
Log.d("ResponseBodyConverter", "convert: $errorModel")
throw ApiException(errorModel)
},
onFailure = { exception ->
logger.error(this::class, "convert(): INNER: ERROR", exception)
if (!isUnit) {
throw exception
} else {
@@ -7,7 +7,6 @@ import com.slack.eithernet.integration.retrofit.ApiResultConverterFactory
import com.squareup.moshi.Moshi
import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.network.JsonConverter
import dev.meloda.fast.network.MoshiConverter
import dev.meloda.fast.network.OAuthResultCallFactory
@@ -124,12 +123,7 @@ private fun Scope.buildRetrofit(client: OkHttpClient): Retrofit {
.baseUrl("${AppConstants.URL_API}/")
.addConverterFactory(ApiResultConverterFactory)
.addCallAdapterFactory(ApiResultCallAdapterFactory)
.addConverterFactory(
ResponseConverterFactory(
get<JsonConverter>(),
get<FastLogger>()
)
)
.addConverterFactory(ResponseConverterFactory(get<JsonConverter>()))
.addConverterFactory(MoshiConverterFactory.create(get()))
.client(client)
.build()
@@ -1,9 +1,9 @@
package dev.meloda.fast.network.interceptor
import android.util.Log
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.CaptchaTokenResult
import dev.meloda.fast.logger.FastLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -14,7 +14,11 @@ import org.json.JSONObject
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference
class Error14HandlingInterceptor(private val logger: FastLogger) : Interceptor {
class Error14HandlingInterceptor(
// private val domains: Set<String> = emptySet(),
) : Interceptor {
private val cookie = AtomicReference<String?>(null)
private companion object {
private const val CAPTCHA_ERROR_CODE = 14
@@ -22,8 +26,6 @@ class Error14HandlingInterceptor(private val logger: FastLogger) : Interceptor {
private val executor = Executors.newSingleThreadExecutor()
}
private val cookie = AtomicReference<String?>(null)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().withCookie()
val response = chain.proceed(request)
@@ -39,23 +41,23 @@ class Error14HandlingInterceptor(private val logger: FastLogger) : Interceptor {
executor.submit {
AppSettings.setCaptchaRedirectUri(redirectUri)
logger.debug(this::class, "passCaptchaAndGetToken: $redirectUri")
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $redirectUri")
var job: Job? = null
job = AppSettings.getCaptchaResultFlow()
.listenValue(CoroutineScope(Dispatchers.IO)) {
logger.debug(this::class, "passCaptchaAndGetToken: $it")
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $it")
if (it != CaptchaTokenResult.Initial) {
synchronized(tokenResult) {
logger.debug(
this::class,
Log.d(
"Error14Interceptor",
"passCaptchaAndGetToken: SYNCHRONIZED: $it"
)
tokenResult.set(wrapResult(it))
tokenResult.notifyAll()
job?.cancel()
logger.debug(
this::class,
Log.d(
"Error14Interceptor",
"passCaptchaAndGetToken: NULL RESULT"
)
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
@@ -69,7 +71,7 @@ class Error14HandlingInterceptor(private val logger: FastLogger) : Interceptor {
tokenResult.wait()
}
logger.debug(this::class, "passCaptchaAndGetToken: GET VALUE")
Log.d("Error14Interceptor", "passCaptchaAndGetToken: GET VALUE")
tokenResult.get().getOrThrow()
}
}
-1
View File
@@ -12,7 +12,6 @@ android {
dependencies {
api(projects.core.common)
api(projects.core.model)
api(projects.core.logger)
implementation(projects.core.presentation)
implementation(libs.haze)
@@ -1,6 +0,0 @@
package dev.meloda.fast.ui.common
import androidx.compose.runtime.compositionLocalOf
import dev.meloda.fast.logger.FastLogger
val LocalLogger = compositionLocalOf { FastLogger.getInstance() }
@@ -1,7 +1,6 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Indication
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -21,8 +20,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -33,32 +30,27 @@ fun FastIconButton(
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
containerColor: Color = colors.containerColor(enabled),
contentColor: Color = colors.contentColor(enabled),
size: Dp = IconButtonTokens.StateLayerSize,
shape: Shape = IconButtonTokens.StateLayerShape,
alignment: Alignment = Alignment.Center,
interactionSource: MutableInteractionSource? = null,
indication: Indication = ripple(),
content: @Composable () -> Unit
) {
Box(
modifier =
modifier
.minimumInteractiveComponentSize()
.size(size)
.clip(shape)
.background(containerColor)
.size(IconButtonTokens.StateLayerSize)
.clip(IconButtonTokens.StateLayerShape)
.background(color = colors.containerColor(enabled))
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled,
interactionSource = interactionSource,
indication = indication
indication = ripple()
),
contentAlignment = alignment
contentAlignment = Alignment.Center
) {
CompositionLocalProvider(LocalContentColor provides contentColor) { content() }
val contentColor = colors.contentColor(enabled)
CompositionLocalProvider(LocalContentColor provides contentColor, content = content)
}
}
@@ -1,116 +0,0 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.util.ImmutableList
data class SegmentedButtonItem(
val key: String,
val iconResId: Int
)
@Composable
fun SegmentedButtonsRow(
items: ImmutableList<SegmentedButtonItem>,
onClick: (index: Int) -> Unit,
modifier: Modifier = Modifier,
containerShape: CornerBasedShape = RoundedCornerShape(24.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant,
borderSize: Dp = 1.dp,
iconContainerWidth: Dp = 42.dp,
iconContainerHeight: Dp = 36.dp,
iconSize: Dp = 18.dp,
showDividers: Boolean = true
) {
SegmentedButtonsRow(
modifier = modifier.sizeIn(maxHeight = iconContainerHeight + borderSize),
items = items.mapIndexed { index, item ->
{
val first = index == 0
val last = index == items.lastIndex
if (showDividers && !first) {
VerticalDivider(modifier = Modifier.padding(vertical = iconContainerHeight / 4))
}
SegmentedButton(
onClick = { onClick(index) },
iconResId = item.iconResId,
modifier = Modifier.size(
iconContainerWidth,
iconContainerHeight
),
iconSize = iconSize,
shape = containerShape.copy(
topStart = if (!first) CornerSize(0.dp) else containerShape.topStart,
bottomStart = if (!first) CornerSize(0.dp) else containerShape.bottomStart,
topEnd = if (!last) CornerSize(0.dp) else containerShape.topEnd,
bottomEnd = if (!last) CornerSize(0.dp) else containerShape.bottomEnd
)
)
}
},
containerShape = containerShape,
containerColor = containerColor,
borderColor = borderColor,
borderSize = borderSize
)
}
@Composable
fun SegmentedButtonsRow(
items: ImmutableList<@Composable () -> Unit>,
modifier: Modifier = Modifier,
containerShape: CornerBasedShape = RoundedCornerShape(24.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant,
borderSize: Dp = 1.dp,
) {
Row(
modifier = modifier
.background(containerColor, containerShape)
.border(borderSize, borderColor, containerShape)
) {
items.forEach { it.invoke() }
}
}
@Composable
fun SegmentedButton(
onClick: () -> Unit,
iconResId: Int,
modifier: Modifier = Modifier,
iconSize: Dp = 18.dp,
shape: Shape = CircleShape
) {
FastIconButton(
onClick = onClick,
modifier = modifier,
shape = shape
) {
Icon(
modifier = Modifier.size(iconSize),
painter = painterResource(iconResId),
contentDescription = null
)
}
}
@@ -1,7 +1,6 @@
package dev.meloda.fast.ui.util
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Immutable
class ImmutableList<T>(val values: List<T>) : Collection<T> {
@@ -58,9 +57,3 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements))
inline fun <T> buildImmutableList(builderAction: MutableList<T>.() -> Unit): ImmutableList<T> {
val mutableList = mutableListOf<T>()
mutableList.apply(builderAction)
return mutableList.toImmutableList()
}
-3
View File
@@ -306,7 +306,4 @@
<string name="confirm_chat_create_empty_with_title">Are you sure you want to create chat «%s» only with yourself?</string>
<string name="title_edit_message">Edit message</string>
<string name="action_close">Close</string>
<string name="action_hide_stacktrace">Hide stacktrace</string>
<string name="action_show_stacktrace">Show stacktrace</string>
</resources>
-9
View File
@@ -2,13 +2,4 @@
<resources>
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar" />
<style name="CrashDialogTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources>
@@ -1,6 +1,7 @@
package dev.meloda.fast.auth.captcha.presentation
import android.graphics.Bitmap
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
@@ -31,9 +32,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalLogger
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.FullScreenDialog
import dev.meloda.fast.ui.components.MaterialDialog
@@ -47,8 +46,6 @@ fun CaptchaScreen(
onBack: () -> Unit = {},
onResult: (String) -> Unit = {}
) {
val logger = LocalLogger.current
if (captchaRedirectUri != null) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
@@ -117,10 +114,7 @@ fun CaptchaScreen(
view: WebView?,
request: WebResourceRequest?
): Boolean {
logger.info(
"CaptchaScreen",
"WebViewClient(): shouldOverrideUrlLoading(): request: $request"
)
Log.i(TAG, "shouldOverrideUrlLoading: $request")
return false
}
@@ -154,8 +148,7 @@ fun CaptchaScreen(
// TODO: 03/05/2026, Danil Nikolaev: show error
}
},
onCloseRequested = { showExitAlert = true },
logger = logger
onCloseRequested = { showExitAlert = true }
),
"AndroidBridge"
)
@@ -183,18 +176,19 @@ fun CaptchaScreen(
class WebCaptchaListener(
private val onSuccessTokenReceived: (String) -> Unit,
private val onCloseRequested: (String) -> Unit,
private val logger: FastLogger
private val onCloseRequested: (String) -> Unit
) {
private val tag = "WebCaptchaListener"
@JavascriptInterface
fun VKCaptchaGetResult(arg: String) {
onSuccessTokenReceived(arg)
logger.info(this::class, "VKCaptchaGetResult(): arg: $arg")
Log.i(tag, "VKCaptchaGetResult($arg)")
}
@JavascriptInterface
fun VKCaptchaCloseCaptcha(arg: String) {
onCloseRequested(arg)
logger.info(this::class, "VKCaptchaCloseCaptcha(): arg: $arg")
Log.i(tag, "VKCaptchaCloseCaptcha($arg)")
}
}
@@ -2,6 +2,7 @@ package dev.meloda.fast.auth.login
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.meloda.fast.auth.login.model.CaptchaArguments
@@ -27,7 +28,6 @@ import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.OAuthUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.database.AccountEntity
import dev.meloda.fast.network.OAuthErrorDomain
import kotlinx.coroutines.CoroutineExceptionHandler
@@ -48,8 +48,7 @@ class LoginViewModel(
private val accountsRepository: AccountsRepository,
private val loginValidator: LoginValidator,
private val longPollController: LongPollController,
private val userSettings: UserSettings,
private val logger: FastLogger
private val userSettings: UserSettings
) : ViewModel() {
private val _screenState = MutableStateFlow(LoginScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
@@ -190,7 +189,7 @@ class LoginViewModel(
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
logger.error(this::class, "getSilentToken(): ERROR: $error")
Log.d("LoginViewModelImpl", "login: error: $error")
_screenState.updateValue { copy(isLoading = false) }
@@ -1,5 +1,6 @@
package dev.meloda.fast.chatmaterials.util
import android.util.Log
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.common.util.AndroidUtils
import dev.meloda.fast.model.api.data.AttachmentType
@@ -134,5 +135,8 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? =
)
}
else -> null
else -> {
Log.w("ChatMaterialMapper", "Unsupported type: $type")
null
}
}
@@ -3,7 +3,6 @@ package dev.meloda.fast.convos
import android.content.Context
import android.content.res.Resources
import android.os.Bundle
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
@@ -16,38 +15,38 @@ import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.extensions.updateValue
import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.model.InteractionJob
import dev.meloda.fast.convos.model.NewInteractionException
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@Immutable
class ConvosViewModel(
eventsHandler: LongPollEventsHandler,
val filter: ConvosFilter,
updatesParser: LongPollUpdatesParser,
private val filter: ConvosFilter,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val resources: Resources,
@@ -56,176 +55,154 @@ class ConvosViewModel(
private val applicationContext: Context,
private val loadConvosByIdUseCase: LoadConvosByIdUseCase
) : ViewModel() {
private val _screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
private val screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val screenStateFlow get() = screenState.asStateFlow()
private val _navigation = MutableStateFlow<ConvoNavigation?>(null)
val navigation = _navigation.asStateFlow()
private val navigationIntent = MutableStateFlow<ConvoNavigationIntent?>(null)
val navigationIntentFlow get() = navigationIntent.asStateFlow()
private val _dialog = MutableStateFlow<ConvoDialog?>(null)
val dialog = _dialog.asStateFlow()
private val convos: MutableList<VkConvo> = mutableListOf()
private val _convos = MutableStateFlow<List<VkConvo>>(emptyList())
val convos = _convos.asStateFlow()
private val pinnedConvosCount get() = convos.count(VkConvo::isPinned)
private val _uiConvos = MutableStateFlow<List<UiConvo>>(emptyList())
val uiConvos = _uiConvos.asStateFlow()
private var currentOffset = 0
private val pinnedConvosCount = convos.map { convos ->
convos.count(VkConvo::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
private val _baseError = MutableStateFlow<BaseError?>(null)
val baseError = _baseError.asStateFlow()
private val _currentOffset = MutableStateFlow(0)
val currentOffset = _currentOffset.asStateFlow()
private val _canPaginate = MutableStateFlow(false)
val canPaginate = _canPaginate.asStateFlow()
private val expandedConvoId = MutableStateFlow(0L)
private val useContactNames: Boolean get() = userSettings.useContactNames.value
private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init {
_screenState.updateValue { copy(isArchive = filter == ConvosFilter.ARCHIVE) }
loadConvos()
eventsHandler.onMessageNew(::handleNewMessage)
eventsHandler.onMessageEdit(::handleEditedMessage)
eventsHandler.onMessageIncomingRead(::handleReadIncomingMessage)
eventsHandler.onMessageOutgoingRead(::handleReadOutgoingMessage)
eventsHandler.onInteraction(::handleInteraction)
eventsHandler.onChatMajorChange(::handleChatMajorChanged)
eventsHandler.onChatMinorChange(::handleChatMinorChanged)
eventsHandler.onChatClear(::handleChatClearing)
eventsHandler.onChatArchive(::handleChatArchived)
updatesParser.onNewMessage(::handleNewMessage)
updatesParser.onMessageEdited(::handleEditedMessage)
updatesParser.onMessageIncomingRead(::handleReadIncomingMessage)
updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage)
updatesParser.onInteractions(::handleInteraction)
updatesParser.onChatMajorChanged(::handleChatMajorChanged)
updatesParser.onChatMinorChanged(::handleChatMinorChanged)
updatesParser.onChatCleared(::handleChatClearing)
updatesParser.onChatArchived(::handleChatArchived)
userSettings.useContactNames.listenValue(viewModelScope) {
syncUiConvos()
}
}
fun handleIntent(intent: ConvoIntent) {
when (intent) {
ConvoIntent.ArchiveClick -> {
navigationIntent.setValue { ConvoNavigationIntent.Archive }
}
ConvoIntent.Back -> {
navigationIntent.setValue { ConvoNavigationIntent.Back }
}
ConvoIntent.ConsumeScrollToTop -> Unit
ConvoIntent.CreateChatClick -> {
navigationIntent.setValue { ConvoNavigationIntent.CreateChat }
}
ConvoIntent.ErrorActionButtonClick -> {
onRefresh()
}
is ConvoIntent.ItemClick -> {
onConvoItemClick(intent.convoId)
}
is ConvoIntent.ItemLongClick -> {
onConvoItemLongClick(intent.convoId)
}
is ConvoIntent.OptionItemClick -> {
onOptionClicked(intent.option)
}
ConvoIntent.PaginationConditionsMet -> {
onPaginationConditionsMet()
}
ConvoIntent.Refresh -> {
onRefresh()
}
is ConvoIntent.SetScrollIndex -> {
setScrollIndex(intent.index)
}
is ConvoIntent.SetScrollOffset -> {
setScrollOffset(intent.offset)
}
is ConvoIntent.Dialog -> {
when (intent) {
is ConvoIntent.Dialog.Cancel -> Unit
is ConvoIntent.Dialog.Confirm -> onDialogConfirmed(intent.bundle)
ConvoIntent.Dialog.Dismiss -> onDialogDismissed()
}
}
}
}
fun onNavigationConsumed() {
navigationIntent.setValue { null }
_navigation.setValue { null }
}
private fun onDialogConfirmed(bundle: Bundle?) {
val dialog = screenState.value.dialog ?: return
onDialogDismissed()
val convo = with(screenState.value) {
convos.find { it.id == expandedConvoId }
} ?: return
fun onDialogConfirmed(dialog: ConvoDialog, bundle: Bundle) {
onDialogDismissed(dialog)
when (dialog) {
is ConvoDialog.Delete -> {
deleteConvo(convo.id)
is ConvoDialog.ConvoDelete -> {
deleteConvo(dialog.convoId)
}
is ConvoDialog.Pin -> {
pinConvo(convo.id, true)
is ConvoDialog.ConvoPin -> {
pinConvo(dialog.convoId, true)
}
is ConvoDialog.Unpin -> {
pinConvo(convo.id, false)
is ConvoDialog.ConvoUnpin -> {
pinConvo(dialog.convoId, false)
}
is ConvoDialog.Archive -> {
archiveConvo(convo.id, true)
is ConvoDialog.ConvoArchive -> {
archiveConvo(dialog.convoId, true)
}
is ConvoDialog.Unarchive -> {
archiveConvo(convo.id, false)
is ConvoDialog.ConvoUnarchive -> {
archiveConvo(dialog.convoId, false)
}
}
collapseConvos(false)
expandedConvoId.setValue { 0 }
syncUiConvos()
}
private fun onDialogDismissed() {
screenState.updateValue { copy(dialog = null) }
fun onDialogDismissed(dialog: ConvoDialog) {
_dialog.setValue { null }
}
private fun onPaginationConditionsMet() {
currentOffset = convos.size
fun onDialogItemPicked(dialog: ConvoDialog, bundle: Bundle) {
when (dialog) {
is ConvoDialog.ConvoDelete -> Unit
is ConvoDialog.ConvoPin -> Unit
is ConvoDialog.ConvoUnpin -> Unit
is ConvoDialog.ConvoArchive -> Unit
is ConvoDialog.ConvoUnarchive -> Unit
}
}
fun onErrorButtonClicked() {
when (baseError.value) {
null -> Unit
is BaseError.ConnectionError,
is BaseError.InternalError,
is BaseError.SimpleError,
is BaseError.UnknownError -> onRefresh()
else -> Unit
}
}
fun onPaginationConditionsMet() {
_currentOffset.update { convos.value.size }
loadConvos()
}
private fun onErrorConsumed() {
screenState.updateValue { copy(error = null) }
}
private fun onRefresh() {
fun onRefresh() {
onErrorConsumed()
loadConvos(offset = 0)
}
private fun onConvoItemClick(convoId: Long) {
fun onConvoItemClick(convo: UiConvo) {
collapseConvos()
navigationIntent.setValue { ConvoNavigationIntent.MessagesHistory(convoId) }
_navigation.setValue { ConvoNavigation.MessagesHistory(peerId = convo.id) }
}
private fun onConvoItemLongClick(convoId: Long) {
val isExpanded = screenState.value.convos.find { it.id == convoId }?.isExpanded == true
screenState.updateValue { copy(expandedConvoId = if (isExpanded) 0L else convoId) }
fun onConvoItemLongClick(convo: UiConvo) {
expandedConvoId.setValue {
if (convo.isExpanded) 0
else convo.id
}
syncUiConvos()
}
private fun onOptionClicked(option: ConvoOption) {
val convo =
screenState.value.convos.find { it.id == screenState.value.expandedConvoId } ?: return
fun onOptionClicked(
convo: UiConvo,
option: ConvoOption
) {
when (option) {
ConvoOption.Delete -> setDialog(ConvoDialog.Delete)
ConvoOption.Delete -> {
_dialog.setValue { ConvoDialog.ConvoDelete(convo.id) }
}
ConvoOption.MarkAsRead -> {
val lastMessageId =
screenState.value.convos.find { it.id == screenState.value.expandedConvoId }?.lastMessageId
if (lastMessageId != null) {
convo.lastMessageId?.let { lastMessageId ->
readConvo(
peerId = convo.id,
startMessageId = lastMessageId
@@ -234,39 +211,48 @@ class ConvosViewModel(
}
}
ConvoOption.Pin -> setDialog(ConvoDialog.Pin)
ConvoOption.Unpin -> setDialog(ConvoDialog.Unpin)
ConvoOption.Archive -> setDialog(ConvoDialog.Archive)
ConvoOption.Unarchive -> setDialog(ConvoDialog.Unarchive)
ConvoOption.Pin -> {
_dialog.setValue { ConvoDialog.ConvoPin(convo.id) }
}
ConvoOption.Unpin -> {
_dialog.setValue { ConvoDialog.ConvoUnpin(convo.id) }
}
ConvoOption.Archive -> {
_dialog.setValue { ConvoDialog.ConvoArchive(convo.id) }
}
ConvoOption.Unarchive -> {
_dialog.setValue { ConvoDialog.ConvoUnarchive(convo.id) }
}
}
}
private fun setScrollIndex(index: Int) {
screenState.setValue { old -> old.copy(scrollIndex = index) }
fun onErrorConsumed() {
_baseError.setValue { null }
}
private fun setScrollOffset(offset: Int) {
screenState.setValue { old -> old.copy(scrollOffset = offset) }
fun setScrollIndex(index: Int) {
_screenState.setValue { old -> old.copy(scrollIndex = index) }
}
private fun setDialog(dialog: ConvoDialog?) {
screenState.updateValue { copy(dialog = dialog) }
fun setScrollOffset(offset: Int) {
_screenState.setValue { old -> old.copy(scrollOffset = offset) }
}
private fun replaceConvos(newConvos: List<VkConvo>) {
convos.clear()
convos.addAll(newConvos)
fun onCreateChatButtonClicked() {
_navigation.setValue { ConvoNavigation.CreateChat }
}
private fun collapseConvos(sync: Boolean = true) {
screenState.updateValue { copy(expandedConvoId = null) }
if (sync) {
private fun collapseConvos() {
expandedConvoId.setValue { 0 }
syncUiConvos()
}
}
private fun loadConvos(offset: Int = currentOffset) {
private fun loadConvos(
offset: Int = currentOffset.value
) {
convoUseCase.getConvos(
count = LOAD_COUNT,
offset = offset,
@@ -274,20 +260,23 @@ class ConvosViewModel(
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
screenState.updateValue { copy(error = VkUtils.parseError(error)) }
val newBaseError = VkUtils.parseError(error)
_baseError.update { newBaseError }
},
success = { response ->
val newConvos = if (offset == 0) {
response
val convos = response
val fullConvos = if (offset == 0) {
convos
} else {
convos.plus(response)
this.convos.value.plus(convos)
}
val itemsCountSufficient = response.size == LOAD_COUNT
val paginationExhausted = !itemsCountSufficient && convos.isNotEmpty()
val paginationExhausted = !itemsCountSufficient &&
this.convos.value.isNotEmpty()
screenState.updateValue {
_screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted)
}
@@ -304,14 +293,13 @@ class ConvosViewModel(
convoUseCase.storeConvos(response)
replaceConvos(newConvos)
screenState.updateValue { copy(canPaginate = itemsCountSufficient) }
_convos.emit(fullConvos)
syncUiConvos()
_canPaginate.setValue { itemsCountSufficient }
}
)
screenState.setValue { old ->
_screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
@@ -325,17 +313,17 @@ class ConvosViewModel(
state.processState(
error = {},
success = {
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@processState
newConvos.removeAt(convoIndex)
replaceConvos(newConvos.sorted())
_convos.update { newConvos.sorted() }
syncUiConvos()
}
)
screenState.emit(screenStateFlow.value.copy(isLoading = state.isLoading()))
_screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
}
}
@@ -349,7 +337,7 @@ class ConvosViewModel(
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = if (pin) {
pinnedConvosCount.plus(1) * 16
pinnedConvosCount.value.plus(1) * 16
} else {
0
}
@@ -358,7 +346,7 @@ class ConvosViewModel(
}
)
screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
_screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
}
}
@@ -368,7 +356,7 @@ class ConvosViewModel(
state.processState(
error = {},
success = {
convos.find { it.id == peerId }?.let { convo ->
convos.value.find { it.id == peerId }?.let { convo ->
handleChatArchived(
LongPollParsedEvent.ChatArchived(
convo = convo,
@@ -382,10 +370,10 @@ class ConvosViewModel(
}
// TODO: 03-Apr-25, Danil Nikolaev: handle business messages
private fun handleNewMessage(event: LongPollParsedEvent.MessageNew) {
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == message.peerId }
@@ -403,8 +391,8 @@ class ConvosViewModel(
val convo = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message)
newConvos.add(pinnedConvosCount, convo)
replaceConvos(newConvos.sorted())
newConvos.add(pinnedConvosCount.value, convo)
_convos.update { newConvos.sorted() }
syncUiConvos()
}
)
@@ -440,17 +428,19 @@ class ConvosViewModel(
newConvos[convoIndex] = newConvo
} else {
newConvos.removeAt(convoIndex)
newConvos.add(pinnedConvosCount, newConvo)
val toPosition = pinnedConvosCount.value
newConvos.add(toPosition, newConvo)
}
replaceConvos(newConvos.sorted())
_convos.update { newConvos.sorted() }
syncUiConvos()
}
}
private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) { // диалога нет в списке
@@ -462,14 +452,13 @@ class ConvosViewModel(
lastMessageId = message.id,
lastCmId = message.cmId
)
replaceConvos(newConvos)
_convos.update { newConvos }
syncUiConvos()
}
}
private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) {
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -483,13 +472,13 @@ class ConvosViewModel(
unreadCount = event.unreadCount
)
replaceConvos(newConvos)
_convos.update { newConvos }
syncUiConvos()
}
}
private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) {
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -503,7 +492,7 @@ class ConvosViewModel(
unreadCount = event.unreadCount
)
replaceConvos(newConvos)
_convos.update { newConvos }
syncUiConvos()
}
}
@@ -513,7 +502,7 @@ class ConvosViewModel(
val peerId = event.peerId
val userIds = event.userIds
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
val convoAndIndex =
newConvos.findWithIndex { it.id == peerId }
@@ -524,7 +513,7 @@ class ConvosViewModel(
interactionIds = userIds
)
replaceConvos(newConvos)
_convos.update { newConvos }
syncUiConvos()
interactionsTimers[peerId]?.let { interactionJob ->
@@ -556,7 +545,7 @@ class ConvosViewModel(
private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } ?: return
@@ -566,7 +555,7 @@ class ConvosViewModel(
interactionIds = emptyList()
)
replaceConvos(newConvos)
_convos.update { newConvos }
syncUiConvos()
interactionJob.timerJob.cancel()
@@ -574,7 +563,7 @@ class ConvosViewModel(
}
private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) {
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -584,13 +573,13 @@ class ConvosViewModel(
newConvos[convoIndex] =
newConvos[convoIndex].copy(majorId = event.majorId)
replaceConvos(newConvos.sorted())
_convos.setValue { newConvos.sorted() }
syncUiConvos()
}
}
private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) {
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -600,23 +589,22 @@ class ConvosViewModel(
newConvos[convoIndex] =
newConvos[convoIndex].copy(minorId = event.minorId)
replaceConvos(newConvos.sorted())
_convos.setValue { newConvos.sorted() }
syncUiConvos()
}
}
private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) {
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
// TODO: 30.05.2026, Danil Nikolaev: reimplement
newConvos.removeAt(convoIndex)
replaceConvos(newConvos.sorted())
_convos.setValue { newConvos.sorted() }
syncUiConvos()
}
}
@@ -624,7 +612,7 @@ class ConvosViewModel(
private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) {
val convo = event.convo
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
@@ -639,7 +627,7 @@ class ConvosViewModel(
newConvos.removeAt(index)
}
replaceConvos(newConvos)
_convos.update { newConvos }
syncUiConvos()
}
@@ -650,10 +638,10 @@ class ConvosViewModel(
newConvos.removeAt(index)
} else {
newConvos.add(pinnedConvosCount, convo)
newConvos.add(pinnedConvosCount.value, convo)
}
replaceConvos(newConvos.sorted())
_convos.update { newConvos.sorted() }
syncUiConvos()
}
}
@@ -667,7 +655,7 @@ class ConvosViewModel(
state.processState(
error = {},
success = {
val newConvos = convos.toMutableList()
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@listenValue
@@ -675,7 +663,7 @@ class ConvosViewModel(
newConvos[convoIndex] =
newConvos[convoIndex].copy(inRead = startMessageId)
replaceConvos(newConvos)
_convos.update { newConvos }
syncUiConvos()
}
)
@@ -707,44 +695,47 @@ class ConvosViewModel(
}
private fun syncUiConvos(): List<UiConvo> {
val convos = convos.value
val newUiConvos = convos.map { convo ->
val options: ImmutableList<ConvoOption> = buildImmutableList {
if (!convo.isRead() && convo.lastMessage != null && convo.lastMessage?.isOut == false) {
add(ConvoOption.MarkAsRead)
val options = mutableListOf<ConvoOption>()
convo.lastMessage?.run {
if (!convo.isRead() && !this.isOut) {
options += ConvoOption.MarkAsRead
}
}
val convosSize = this.convos.value.size
val pinnedCount = pinnedConvosCount.value
val canPinOneMoreDialog =
convosSize > 4 && pinnedCount < 5 && !convo.isPinned()
if (convo.isPinned()) {
add(ConvoOption.Unpin)
}
if (convos.size > 4 && pinnedConvosCount < 5 && !convo.isPinned()) {
add(ConvoOption.Pin)
options += ConvoOption.Unpin
} else if (canPinOneMoreDialog) {
options += ConvoOption.Pin
}
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
ConvosFilter.ARCHIVE -> add(ConvoOption.Unarchive)
ConvosFilter.ARCHIVE -> ConvoOption.Unarchive
ConvosFilter.ALL,
ConvosFilter.UNREAD -> {
if (convo.id != UserConfig.userId) {
add(ConvoOption.Archive)
}
}
}
ConvosFilter.UNREAD,
ConvosFilter.ALL -> ConvoOption.Archive
add(ConvoOption.Delete)
}
ConvosFilter.BUSINESS_NOTIFY -> null
}?.let(options::add)
options += ConvoOption.Delete
convo.asPresentation(
resources = resources,
useContactName = userSettings.useContactNames.value,
isExpanded = screenState.value.expandedConvoId == convo.id,
options = options
useContactName = useContactNames,
isExpanded = expandedConvoId.value == convo.id,
options = options.toImmutableList()
)
}
screenState.updateValue { copy(convos = newUiConvos.toImmutableList()) }
_uiConvos.setValue { newUiConvos }
return newUiConvos
}
@@ -25,7 +25,7 @@ val convosModule = module {
private fun Scope.createConvosViewModel(filter: ConvosFilter): ConvosViewModel {
return ConvosViewModel(
filter = filter,
eventsHandler = get(),
updatesParser = get(),
convoUseCase = get(),
messagesUseCase = get(),
resources = get(),
@@ -4,9 +4,9 @@ import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoDialog {
data object Pin : ConvoDialog()
data object Unpin : ConvoDialog()
data object Delete : ConvoDialog()
data object Archive : ConvoDialog()
data object Unarchive : ConvoDialog()
data class ConvoPin(val convoId: Long) : ConvoDialog()
data class ConvoUnpin(val convoId: Long) : ConvoDialog()
data class ConvoDelete(val convoId: Long) : ConvoDialog()
data class ConvoArchive(val convoId: Long) : ConvoDialog()
data class ConvoUnarchive(val convoId: Long) : ConvoDialog()
}
@@ -1,29 +0,0 @@
package dev.meloda.fast.convos.model
import android.os.Bundle
import dev.meloda.fast.ui.model.vk.ConvoOption
sealed class ConvoIntent {
data class ItemClick(val convoId: Long) : ConvoIntent()
data class ItemLongClick(val convoId: Long) : ConvoIntent()
data class OptionItemClick(val option: ConvoOption) : ConvoIntent()
data object PaginationConditionsMet : ConvoIntent()
data object Back : ConvoIntent()
data object Refresh : ConvoIntent()
data object CreateChatClick : ConvoIntent()
data object ArchiveClick : ConvoIntent()
data class SetScrollIndex(val index: Int) : ConvoIntent()
data class SetScrollOffset(val offset: Int) : ConvoIntent()
data object ErrorActionButtonClick : ConvoIntent()
data object ConsumeScrollToTop : ConvoIntent()
sealed class Dialog : ConvoIntent() {
data object Dismiss : Dialog()
data class Confirm(val bundle: Bundle? = null) : Dialog()
data class Cancel(val bundle: Bundle? = null) : Dialog()
}
}
@@ -0,0 +1,11 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoNavigation {
data class MessagesHistory(val peerId: Long) : ConvoNavigation()
data object CreateChat : ConvoNavigation()
}
@@ -1,9 +0,0 @@
package dev.meloda.fast.convos.model
sealed class ConvoNavigationIntent {
data object Back : ConvoNavigationIntent()
data class MessagesHistory(val convoId: Long) : ConvoNavigationIntent()
data object CreateChat : ConvoNavigationIntent()
data object Archive : ConvoNavigationIntent()
}
@@ -1,10 +1,6 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
@Immutable
data class ConvosScreenState(
@@ -14,13 +10,7 @@ data class ConvosScreenState(
val profileImageUrl: String?,
val scrollIndex: Int,
val scrollOffset: Int,
val canPaginate: Boolean,
val expandedConvoId: Long?,
val convos: ImmutableList<UiConvo>,
val dialog: ConvoDialog?,
// TODO: 30.05.2026, Danil Nikolaev: remove
val error: BaseError?
val isArchive: Boolean
) {
companion object {
@@ -31,11 +21,7 @@ data class ConvosScreenState(
profileImageUrl = null,
scrollIndex = 0,
scrollOffset = 0,
canPaginate = false,
expandedConvoId = null,
convos = emptyImmutableList(),
dialog = null,
error = null
isArchive = false
)
}
}
@@ -1,16 +1,12 @@
package dev.meloda.fast.convos.navigation
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.presentation.ConvosRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.ui.extensions.getOrThrow
import dev.meloda.fast.ui.theme.LocalNavController
@@ -28,56 +24,44 @@ object Convos
object Archive
fun NavGraphBuilder.convosGraph(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Long) -> Unit,
onNavigateToCreateChat: () -> Unit,
onScrolledToTop: () -> Unit
) {
navigation<ConvoGraph>(
startDestination = Convos
) {
composable<Convos> {
ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
viewModel = with(activity) {
getViewModel(named(ConvosFilter.ALL))
val convosViewModel: ConvosViewModel = with(activity) {
getViewModel(qualifier = named(ConvosFilter.ALL))
}
composable<Convos> {
val navController = LocalNavController.getOrThrow()
ConvosRoute(
viewModel = convosViewModel,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onNavigateToArchive = { navController.navigate(Archive) },
onScrolledToTop = onScrolledToTop
)
}
composable<Archive> {
ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
viewModel = with(activity) {
getViewModel<ConvosViewModel>(named(ConvosFilter.ARCHIVE))
}
)
}
}
}
@Composable
private fun ConvosRootRoute(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
viewModel: ConvosViewModel
) {
val navController = LocalNavController.getOrThrow()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
val navigationIntent by viewModel.navigationIntentFlow.collectAsStateWithLifecycle()
LaunchedEffect(navigationIntent) {
navigationIntent?.let {
when (navigationIntent) {
ConvoNavigationIntent.Back -> navController.navigateUp()
ConvoNavigationIntent.Archive -> navController.navigate(Archive)
else -> handleNavigationIntent(it)
}
viewModel.onNavigationConsumed()
}
}
ConvosRoute(
handleIntent = viewModel::handleIntent,
screenState = screenState,
isArchive = viewModel.filter == ConvosFilter.ARCHIVE,
viewModel = with(activity) {
getViewModel<ConvosViewModel>(
qualifier = named(ConvosFilter.ARCHIVE)
)
},
onBack = navController::navigateUp,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onScrolledToTop = onScrolledToTop
)
}
}
}
@@ -1,78 +1,82 @@
package dev.meloda.fast.convos.presentation
import android.os.Bundle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.core.os.bundleOf
import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog
@Composable
fun HandleDialogs(
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState,
dialog: ConvoDialog?,
onConfirmed: (ConvoDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (ConvoDialog) -> Unit = {},
onItemPicked: (ConvoDialog, Bundle) -> Unit = { _, _ -> }
) {
when (screenState.dialog) {
when (dialog) {
null -> Unit
is ConvoDialog.Archive -> {
is ConvoDialog.ConvoArchive -> {
MaterialDialog(
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_archive_convo),
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_archive),
cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_archive_fill_round_24)
)
}
is ConvoDialog.Unarchive -> {
is ConvoDialog.ConvoUnarchive -> {
MaterialDialog(
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_unarchive_convo),
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_unarchive),
cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_unarchive_fill_round_24)
)
}
is ConvoDialog.Delete -> {
is ConvoDialog.ConvoDelete -> {
val errorColor = MaterialTheme.colorScheme.error
MaterialDialog(
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
onDismissRequest = { onDismissed(dialog) },
icon = ImageVector.vectorResource(R.drawable.ic_delete_fill_round_24),
iconTint = errorColor,
title = stringResource(id = R.string.confirm_delete_convo),
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_delete),
confirmContainerColor = errorColor,
cancelText = stringResource(id = R.string.cancel),
)
}
is ConvoDialog.Pin -> {
is ConvoDialog.ConvoPin -> {
MaterialDialog(
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
onDismissRequest = { onDismissed(dialog) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_fill_round_24),
title = stringResource(id = R.string.confirm_pin_convo),
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_pin),
cancelText = stringResource(id = R.string.cancel),
)
}
is ConvoDialog.Unpin -> {
is ConvoDialog.ConvoUnpin -> {
MaterialDialog(
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
onDismissRequest = { onDismissed(dialog) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_off_fill_round_24),
title = stringResource(id = R.string.confirm_unpin_convo),
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_unpin),
cancelText = stringResource(id = R.string.cancel),
)
@@ -62,9 +62,9 @@ val BirthdayColor = Color(0xffb00b69)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConvoItem(
onItemClick: (convoId: Long) -> Unit,
onItemLongClick: (convoId: Long) -> Unit,
onOptionClicked: (ConvoOption) -> Unit,
onItemClick: (UiConvo) -> Unit,
onItemLongClick: (convo: UiConvo) -> Unit,
onOptionClicked: (UiConvo, ConvoOption) -> Unit,
maxLines: Int,
isUserAccount: Boolean,
convo: UiConvo,
@@ -81,9 +81,9 @@ fun ConvoItem(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onItemClick(convo.id) },
onClick = { onItemClick(convo) },
onLongClick = {
onItemLongClick(convo.id)
onItemLongClick(convo)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
)
@@ -281,7 +281,7 @@ fun ConvoItem(
val builder =
AnnotatedString.Builder(convo.message.text)
convo.message.spanStyles.forEach { spanStyleRange ->
convo.message.spanStyles.map { spanStyleRange ->
val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary)
@@ -378,7 +378,7 @@ fun ConvoItem(
}
ElevatedAssistChip(
onClick = { onOptionClicked(option) },
onClick = { onOptionClicked(convo, option) },
leadingIcon = {
option.icon.getResourcePainter()?.let { painter ->
Icon(
@@ -36,12 +36,12 @@ import kotlinx.coroutines.launch
fun ConvosList(
modifier: Modifier = Modifier,
convos: ImmutableList<UiConvo>,
onConvosClick: (Long) -> Unit,
onConvosLongClick: (Long) -> Unit,
onConvosClick: (UiConvo) -> Unit,
onConvosLongClick: (UiConvo) -> Unit,
screenState: ConvosScreenState,
state: LazyListState,
maxLines: Int,
onOptionClicked: (ConvoOption) -> Unit,
onOptionClicked: (UiConvo, ConvoOption) -> Unit,
padding: PaddingValues
) {
val theme = LocalThemeConfig.current
@@ -1,23 +1,79 @@
package dev.meloda.fast.convos.presentation
import androidx.compose.runtime.Composable
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable
fun ConvosRoute(
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState,
isArchive: Boolean,
viewModel: ConvosViewModel,
onBack: (() -> Unit)? = null,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onNavigateToCreateChat: (() -> Unit)? = null,
onNavigateToArchive: (() -> Unit)? = null,
onScrolledToTop: () -> Unit,
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
val convos by viewModel.uiConvos.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val shouldBeConsumed: Boolean = when (val navigation = navigationEvent) {
null -> false
is ConvoNavigation.CreateChat -> {
onNavigateToCreateChat?.invoke()
true
}
is ConvoNavigation.MessagesHistory -> {
onNavigateToMessagesHistory(navigation.peerId)
true
}
}
if (shouldBeConsumed) viewModel.onNavigationConsumed()
}
ConvosScreen(
handleIntent = handleIntent,
onBack = { onBack?.invoke() },
screenState = screenState,
isArchive = isArchive,
convos = convos.toImmutableList(),
baseError = baseError,
canPaginate = canPaginate,
onConvoItemClicked = viewModel::onConvoItemClick,
onConvoItemLongClicked = viewModel::onConvoItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh,
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
onArchiveActionClicked = { onNavigateToArchive?.invoke() },
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset,
onConsumeReselection = onScrolledToTop,
onErrorViewButtonClicked = {
if (baseError in listOf(BaseError.AccountBlocked, BaseError.SessionExpired)) {
onError(requireNotNull(baseError))
} else {
viewModel.onErrorButtonClicked()
}
}
)
HandleDialogs(
handleIntent = handleIntent,
screenState = screenState,
dialog = dialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
)
}
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton
@@ -52,40 +53,53 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.skydoves.compose.stability.runtime.TraceRecomposition
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalReselectedTab
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.buildImmutableList
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlin.time.Duration.Companion.milliseconds
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
ExperimentalMaterial3ExpressiveApi::class,
ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class,
)
@Composable
fun ConvosScreen(
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState,
isArchive: Boolean,
screenState: ConvosScreenState = ConvosScreenState.EMPTY,
convos: ImmutableList<UiConvo> = emptyImmutableList(),
baseError: BaseError? = null,
canPaginate: Boolean = false,
onBack: () -> Unit = {},
onConvoItemClicked: (convo: UiConvo) -> Unit = {},
onConvoItemLongClicked: (convo: UiConvo) -> Unit = {},
onOptionClicked: (UiConvo, ConvoOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onArchiveActionClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {},
onConsumeReselection: () -> Unit = {},
onErrorViewButtonClicked: () -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
val maxLines = if (currentTheme.enableMultiline) 2 else 1
@@ -98,33 +112,33 @@ fun ConvosScreen(
val currentTabReselected = LocalReselectedTab.current[ConvoGraph] == true
LaunchedEffect(currentTabReselected) {
if (currentTabReselected) {
if (isArchive) {
handleIntent(ConvoIntent.Back)
if (screenState.isArchive) {
onBack.invoke()
} else {
if (listState.firstVisibleItemIndex > 14) {
listState.scrollToItem(14)
}
listState.animateScrollToItem(0)
handleIntent(ConvoIntent.ConsumeScrollToTop)
onConsumeReselection()
}
}
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L.milliseconds)
.collectLatest { handleIntent(ConvoIntent.SetScrollIndex(it)) }
.debounce(500L)
.collectLatest(setScrollIndex)
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(500L.milliseconds)
.collectLatest { handleIntent(ConvoIntent.SetScrollOffset(it)) }
.debounce(500L)
.collectLatest(setScrollOffset)
}
val paginationConditionMet by remember(screenState.canPaginate, listState) {
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
screenState.canPaginate &&
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
@@ -132,7 +146,7 @@ fun ConvosScreen(
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
handleIntent(ConvoIntent.PaginationConditionsMet)
onPaginationConditionsMet()
}
}
@@ -167,7 +181,7 @@ fun ConvosScreen(
text = stringResource(
id = when {
screenState.isLoading -> R.string.title_loading
isArchive -> R.string.title_archive
screenState.isArchive -> R.string.title_archive
else -> R.string.title_convos
}
),
@@ -177,8 +191,8 @@ fun ConvosScreen(
)
},
navigationIcon = {
if (isArchive) {
IconButton(onClick = { handleIntent(ConvoIntent.Back) }) {
if (screenState.isArchive) {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back_round_24),
contentDescription = null
@@ -187,38 +201,46 @@ fun ConvosScreen(
}
},
actions = {
val dropDownItems: List<@Composable () -> Unit> = buildList {}
val items = buildImmutableList {
if (!isArchive) {
add(SegmentedButtonItem("archive", R.drawable.ic_archive_round_24))
if (!screenState.isArchive) {
IconButton(onClick = onArchiveActionClicked) {
Icon(
painter = painterResource(R.drawable.ic_archive_round_24),
contentDescription = null
)
}
}
val dropDownItems = mutableListOf<@Composable () -> Unit>()
if (AppSettings.General.showManualRefreshOptions) {
add(SegmentedButtonItem("refresh", R.drawable.ic_refresh_round_24))
}
if (dropDownItems.isNotEmpty()) {
add(SegmentedButtonItem("more", R.drawable.ic_more_vert_round_24))
}
}
SegmentedButtonsRow(
modifier = Modifier.padding(end = 8.dp),
items = items,
onClick = { index ->
when (items[index].key) {
"archive" -> handleIntent(ConvoIntent.ArchiveClick)
"refresh" -> handleIntent(ConvoIntent.Refresh)
"more" -> dropDownMenuExpanded = true
else -> Unit
}
dropDownItems += {
DropdownMenuItem(
onClick = {
onRefresh()
dropDownMenuExpanded = false
},
text = {
Text(text = stringResource(id = R.string.action_refresh))
},
leadingIcon = {
Icon(
painter = painterResource(R.drawable.ic_refresh_round_24),
contentDescription = null
)
}
)
}
}
if (dropDownItems.isNotEmpty()) {
IconButton(onClick = { dropDownMenuExpanded = true }) {
Icon(
painter = painterResource(R.drawable.ic_more_vert_round_24),
contentDescription = null
)
}
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
@@ -227,7 +249,6 @@ fun ConvosScreen(
) {
dropDownItems.forEach { it.invoke() }
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
@@ -247,7 +268,7 @@ fun ConvosScreen(
)
val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && screenState.convos.isNotEmpty() }
derivedStateOf { screenState.isLoading && convos.isNotEmpty() }
}
AnimatedVisibility(showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -258,14 +279,14 @@ fun ConvosScreen(
}
},
floatingActionButton = {
if (!isArchive) {
if (!screenState.isArchive) {
val offsetY by animateIntAsState(
targetValue = if (listState.isScrollingUp()) 0 else 600
)
Column {
FloatingActionButton(
onClick = { handleIntent(ConvoIntent.CreateChatClick) },
onClick = onCreateChatButtonClicked,
modifier = Modifier.offset {
IntOffset(0, offsetY)
}
@@ -282,15 +303,14 @@ fun ConvosScreen(
}
) { padding ->
when {
// TODO: 30.05.2026, Danil Nikolaev: move to UI State
screenState.error != null -> {
baseError != null -> {
VkErrorView(
baseError = screenState.error,
onButtonClick = { handleIntent(ConvoIntent.ErrorActionButtonClick) }
baseError = baseError,
onButtonClick = onErrorViewButtonClicked
)
}
screenState.isLoading && screenState.convos.isEmpty() -> FullScreenContainedLoader()
screenState.isLoading && convos.isEmpty() -> FullScreenContainedLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
@@ -303,7 +323,7 @@ fun ConvosScreen(
.padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = { handleIntent(ConvoIntent.Refresh) },
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
@@ -315,9 +335,9 @@ fun ConvosScreen(
}
) {
ConvosList(
convos = screenState.convos,
onConvosClick = { handleIntent(ConvoIntent.ItemClick(it)) },
onConvosLongClick = { handleIntent(ConvoIntent.ItemLongClick(it)) },
convos = convos,
onConvosClick = onConvoItemClicked,
onConvosLongClick = onConvoItemLongClicked,
screenState = screenState,
state = listState,
maxLines = maxLines,
@@ -326,14 +346,14 @@ fun ConvosScreen(
} else {
Modifier
}.fillMaxSize(),
onOptionClicked = { handleIntent(ConvoIntent.OptionItemClick(it)) },
onOptionClicked = onOptionClicked,
padding = padding
)
if (screenState.convos.isEmpty()) {
if (convos.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = { handleIntent(ConvoIntent.Refresh) }
onButtonClick = onRefresh
)
}
}
@@ -21,11 +21,11 @@ fun NavGraphBuilder.friendsScreen(
onMessageClicked: (userId: Long) -> Unit,
onScrolledToTop: () -> Unit
) {
composable<Friends> {
val friendsViewModel: FriendsViewModel = activity.getViewModel<FriendsViewModelImpl>()
val onlineFriendsViewModel =
activity.getViewModel<OnlineFriendsViewModelImpl>()
composable<Friends> {
FriendsRoute(
friendsViewModel = friendsViewModel,
onlineFriendsViewModel = onlineFriendsViewModel,
@@ -38,7 +38,6 @@ import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -48,6 +47,7 @@ fun FriendsScreen(
orderType: String,
padding: PaddingValues,
tabIndex: Int,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userid: Long) -> Unit = {},
setCanScrollBackward: (Boolean) -> Unit = {},
@@ -100,13 +100,13 @@ fun FriendsScreen(
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(250L.milliseconds)
.debounce(250L)
.collectLatest(viewModel::setScrollIndex)
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(250L.milliseconds)
.debounce(250L)
.collectLatest(viewModel::setScrollOffset)
}
@@ -9,11 +9,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
@@ -33,6 +34,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -45,14 +47,11 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.components.SelectionType
import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -190,24 +189,16 @@ fun FriendsRoute(
),
modifier = Modifier.fillMaxWidth(),
actions = {
val items = buildImmutableList {
add(
SegmentedButtonItem(
"filter",
R.drawable.ic_filter_list_round_24
)
IconButton(
onClick = {
showOrderDialog = true
}
) {
Icon(
painter = painterResource(R.drawable.ic_filter_list_round_24),
contentDescription = null
)
}
SegmentedButtonsRow(
modifier = Modifier.padding(end = 8.dp),
items = items,
onClick = { index ->
when (items[index].key) {
"filter" -> showOrderDialog = true
}
}
)
}
)
PrimaryTabRow(
@@ -243,6 +234,7 @@ fun FriendsRoute(
orderType = orderType,
padding = padding,
tabIndex = index,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setCanScrollBackward = { canScrollBackward = it },
@@ -6,6 +6,7 @@ import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
@@ -39,7 +40,7 @@ import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.GetMessageReadPeersUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar
@@ -84,7 +85,7 @@ class MessagesHistoryViewModelImpl(
private val userSettings: UserSettings,
private val loadConvosByIdUseCase: LoadConvosByIdUseCase,
private val getMessageReadPeersUseCase: GetMessageReadPeersUseCase,
eventsHandler: LongPollEventsHandler,
updatesParser: LongPollUpdatesParser,
savedStateHandle: SavedStateHandle
) : MessagesHistoryViewModel, ViewModel() {
@@ -124,15 +125,15 @@ class MessagesHistoryViewModelImpl(
loadConvo()
loadMessagesHistory()
eventsHandler.onMessageNew(::handleNewMessage)
eventsHandler.onMessageEdit(::handleEditedMessage)
eventsHandler.onMessageIncomingRead(::handleReadIncomingEvent)
eventsHandler.onMessageOutgoingRead(::handleReadOutgoingEvent)
eventsHandler.onMessageDelete(::handleMessageDeleted)
eventsHandler.onMessageRestore(::handleMessageRestored)
eventsHandler.onMessageMarkAsImportant(::handleMessageMarkedAsImportant)
eventsHandler.onMessageMarkAsSpam(::handleMessageMarkedAsSpam)
eventsHandler.onMessageMarkAsNotSpam(::handleMessageMarkedAsNotSpam)
updatesParser.onNewMessage(::handleNewMessage)
updatesParser.onMessageEdited(::handleEditedMessage)
updatesParser.onMessageIncomingRead(::handleReadIncomingEvent)
updatesParser.onMessageOutgoingRead(::handleReadOutgoingEvent)
updatesParser.onMessageDeleted(::handleMessageDeleted)
updatesParser.onMessageRestored(::handleMessageRestored)
updatesParser.onMessageMarkedAsImportant(::handleMessageMarkedAsImportant)
updatesParser.onMessageMarkedAsSpam(::handleMessageMarkedAsSpam)
updatesParser.onMessageMarkedAsNotSpam(::handleMessageMarkedAsNotSpam)
}
override fun onNavigationConsumed() {
@@ -681,9 +682,11 @@ class MessagesHistoryViewModelImpl(
}
}
private fun handleNewMessage(event: LongPollParsedEvent.MessageNew) {
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
Log.d("MessagesHistoryViewModel", "handleNewMessage: $message")
if (message.peerId != screenState.value.convoId) return
if (messages.value.indexOfFirstOrNull { it.id == message.id } != null) return
@@ -832,6 +835,8 @@ class MessagesHistoryViewModelImpl(
}
private fun loadConvo() {
Log.d("MessagesHistoryViewModelImpl", "loadConvo()")
loadConvosByIdUseCase(
peerIds = listOf(screenState.value.convoId),
extended = true,
@@ -899,6 +904,8 @@ class MessagesHistoryViewModelImpl(
}
private fun loadMessagesHistory(offset: Int = currentOffset.value) {
Log.d("MessagesHistoryViewModel", "loadMessagesHistory: $offset")
messagesUseCase.getMessagesHistory(
convoId = screenState.value.convoId,
count = MESSAGES_LOAD_COUNT,
@@ -1030,7 +1037,6 @@ class MessagesHistoryViewModelImpl(
isSpam = false,
pinnedAt = null,
formatData = formatData,
isDeleted = false
)
formatData = formatData.copy(items = emptyList())
sendingMessages += newMessage
@@ -1072,6 +1078,8 @@ class MessagesHistoryViewModelImpl(
state.processState(
any = { sendingMessages.remove(newMessage) },
error = { error ->
Log.d("MessagesHistoryViewModelImpl", "sendMessage: ERROR: $error")
val failedId = -500_000L - failedMessages.size
val newFailedMessage = newMessage.copy(id = failedId)
failedMessages += newFailedMessage
@@ -1135,6 +1143,8 @@ class MessagesHistoryViewModelImpl(
) ?: return
// TODO: 13/03/2026, Danil Nikolaev: check if message is exact same, then do not edit
Log.d("MessagesHistoryViewModelImpl", "editMessage: $newMessage")
}
private fun markAsImportant(
@@ -1,6 +1,7 @@
package dev.meloda.fast.messageshistory.presentation
import android.content.Intent
import android.util.Log
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@@ -128,6 +129,7 @@ fun MessagesList(
when (attachment) {
is VkPhotoDomain -> {
val maxSize = attachment.getMaxSize()
Log.d("MessagesList", "onPhotoLongClicked. Max size: ${maxSize?.url}")
}
}
}
@@ -64,6 +64,7 @@ fun DynamicPreviewGrid(
val spacingPx = with(LocalDensity.current) { spacing.toPx() }
val rows = previews.chunked(3)
Log.d("ROWS", "DynamicPreviewGrid: ${rows.size}")
Column(verticalArrangement = Arrangement.spacedBy(spacing)) {
rows.forEachIndexed { outerIndex, row ->
@@ -5,60 +5,69 @@ import androidx.lifecycle.viewModelScope
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.data.State
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.profile.model.ProfileScreenState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
class ProfileViewModel(
interface ProfileViewModel {
val screenState: StateFlow<ProfileScreenState>
val baseError: StateFlow<BaseError?>
}
class ProfileViewModelImpl(
private val getLocalUserByIdUseCase: GetLocalUserByIdUseCase,
private val loadUserByIdUseCase: LoadUserByIdUseCase,
private val logger: FastLogger
) : ViewModel() {
private val loadUserByIdUseCase: LoadUserByIdUseCase
) : ViewModel(), ProfileViewModel {
private val screenState = MutableStateFlow(ProfileScreenState.EMPTY)
val screenStateFlow get() = screenState.asStateFlow()
override val screenState = MutableStateFlow(ProfileScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
init {
getLocalAccountInfo()
}
private fun getLocalAccountInfo() {
logger.debug(this@ProfileViewModel::class, "START")
emit(screenState.value.copy(isLoading = true))
getLocalUserByIdUseCase(UserConfig.userId).listenValue { state ->
logger.debug(this@ProfileViewModel::class, "LOADED: $state")
emit(screenState.value.copy(isLoading = false))
getLocalUserByIdUseCase(UserConfig.userId)
.listenValue(viewModelScope) { state ->
state.processState(
error = {
logger.debug(this@ProfileViewModel::class, "ERROR")
emit(screenState.value.copy(avatarUrl = null, fullName = null))
error = { error ->
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
screenState.setValue { old ->
old.copy(
avatarUrl = null,
fullName = null
)
}
},
success = { user ->
logger.debug(this@ProfileViewModel::class, "SUCCESS")
emit(
screenState.value.copy(
screenState.setValue { old ->
old.copy(
avatarUrl = user?.photo200,
fullName = user?.fullName
)
)
}
},
any = ::loadAccountInfo
)
}
}
private fun emit(state: ProfileScreenState) {
screenState.setValue { state }
}
private fun loadAccountInfo() {
loadUserByIdUseCase(
userId = null,
@@ -1,9 +1,9 @@
package dev.meloda.fast.profile.di
import dev.meloda.fast.profile.ProfileViewModel
import dev.meloda.fast.profile.ProfileViewModelImpl
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val profileModule = module {
viewModelOf(::ProfileViewModel)
viewModelOf(::ProfileViewModelImpl)
}
@@ -1,11 +1,11 @@
package dev.meloda.fast.profile.navigation
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.profile.ProfileViewModel
import dev.meloda.fast.profile.ProfileViewModelImpl
import dev.meloda.fast.profile.presentation.ProfileRoute
import kotlinx.serialization.Serializable
import org.koin.androidx.viewmodel.ext.android.getViewModel
@@ -15,17 +15,19 @@ object Profile
fun NavGraphBuilder.profileScreen(
activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit,
onPhotoClicked: (url: String) -> Unit
) {
val viewModel: ProfileViewModel = with(activity) {
getViewModel<ProfileViewModelImpl>()
}
composable<Profile> {
val viewModel: ProfileViewModel = activity.getViewModel()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
ProfileRoute(
screenState = screenState,
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked,
viewModel = viewModel
)
}
}
@@ -14,12 +14,15 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -28,21 +31,28 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.profile.ProfileViewModel
import dev.meloda.fast.profile.ProfileViewModelImpl
import dev.meloda.fast.profile.model.ProfileScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.util.buildImmutableList
import org.koin.androidx.compose.koinViewModel
@Composable
fun ProfileRoute(
screenState: ProfileScreenState,
onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit,
onPhotoClicked: (url: String) -> Unit,
viewModel: ProfileViewModel = koinViewModel<ProfileViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
ProfileScreen(
screenState = screenState,
baseError = baseError,
onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked
)
@@ -53,6 +63,7 @@ fun ProfileRoute(
@Composable
fun ProfileScreen(
screenState: ProfileScreenState = ProfileScreenState.EMPTY,
baseError: BaseError? = null,
onSettingsButtonClicked: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}
) {
@@ -61,19 +72,12 @@ fun ProfileScreen(
TopAppBar(
title = {},
actions = {
val items = buildImmutableList {
add(SegmentedButtonItem("settings", R.drawable.ic_settings_round_24))
}
SegmentedButtonsRow(
modifier = Modifier.padding(end = 8.dp),
items = items,
onClick = { index ->
when (items[index].key) {
"settings" -> onSettingsButtonClicked()
}
}
IconButton(onClick = onSettingsButtonClicked) {
Icon(
painter = painterResource(R.drawable.ic_settings_round_24),
contentDescription = null
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
@@ -11,10 +11,9 @@ import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.extensions.updateValue
import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.LogLevel
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.common.model.NetworkLogLevel
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.data.UserConfig
@@ -25,22 +24,19 @@ import dev.meloda.fast.datastore.SettingsKeys
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.database.AccountEntity
import dev.meloda.fast.settings.model.HapticType
import dev.meloda.fast.settings.model.SettingsDialog
import dev.meloda.fast.settings.model.SettingsEffect
import dev.meloda.fast.settings.model.SettingsIntent
import dev.meloda.fast.settings.model.SettingsItem
import dev.meloda.fast.settings.model.SettingsNavigationIntent
import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.settings.model.TextProvider
import dev.meloda.fast.ui.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class SettingsViewModel(
@@ -49,76 +45,35 @@ class SettingsViewModel(
private val getCurrentAccountUseCase: GetCurrentAccountUseCase,
private val userSettings: UserSettings,
private val resources: Resources,
private val longPollController: LongPollController,
private val logger: FastLogger
private val longPollController: LongPollController
) : ViewModel() {
private val screenState = MutableStateFlow(SettingsScreenState.EMPTY)
val screenStateFlow get() = screenState.asStateFlow()
private val _screenState = MutableStateFlow(SettingsScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
private val screenEffect = MutableSharedFlow<SettingsEffect>(extraBufferCapacity = 1)
val screenEffectFlow = screenEffect.asSharedFlow()
private val _hapticType = MutableStateFlow<HapticType?>(null)
val hapticType = _hapticType.asStateFlow()
private val settings = mutableListOf<SettingsItem<*>>()
private var showDebugCategory: Boolean = userSettings.showDebugCategory.value
private val _dialog = MutableStateFlow<SettingsDialog?>(null)
val dialog = _dialog.asStateFlow()
private val _isNeedToRestart = MutableStateFlow(false)
val isNeedToRestart = _isNeedToRestart.asStateFlow()
private val settings = MutableStateFlow<List<SettingsItem<*>>>(emptyList())
init {
createSettings()
}
fun handleIntent(intent: SettingsIntent) {
when (intent) {
SettingsIntent.BackClick -> {
screenEffect.tryEmit(SettingsEffect.Navigate(SettingsNavigationIntent.Back))
}
is SettingsIntent.ItemClick -> {
onSettingsItemClicked(intent.key)
}
is SettingsIntent.ItemLongClick -> {
onSettingsItemLongClicked(intent.key)
}
is SettingsIntent.ItemValueChanged -> {
onSettingsItemChanged(intent.key, intent.newValue)
}
is SettingsIntent.Dialog -> {
when (intent) {
SettingsIntent.Dialog.CancelClick -> Unit
is SettingsIntent.Dialog.ConfirmClick -> {
onDialogConfirmed(intent.bundle)
}
SettingsIntent.Dialog.Dismiss -> {
onDialogDismissed()
}
is SettingsIntent.Dialog.ItemPick -> {
onDialogItemPicked(intent.bundle)
}
}
}
}
}
private fun setDialog(dialog: SettingsDialog?) {
screenState.updateValue { copy(dialog = dialog) }
}
private fun onDialogConfirmed(bundle: Bundle?) {
val dialog = screenState.value.dialog ?: return
onDialogDismissed()
fun onDialogConfirmed(dialog: SettingsDialog, bundle: Bundle) {
onDialogDismissed(dialog)
when (dialog) {
is SettingsDialog.LogOut -> onLogOutAlertPositiveClick()
is SettingsDialog.PerformCrash -> onPerformCrashPositiveButtonClicked()
is SettingsDialog.ImportAuthData -> {
if (bundle == null) return
val accessToken = bundle.getString("ACCESS_TOKEN") ?: return
val exchangeToken = bundle.getString("EXCHANGE_TOKEN")
val trustedHash = bundle.getString("TRUSTED_HASH")
@@ -134,17 +89,10 @@ class SettingsViewModel(
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
logger.error(
this@SettingsViewModel::class,
"importAuthInfo(): loadUserById(): ERROR: $error"
)
UserConfig.accessToken = oldToken
},
success = { user ->
if (user == null) {
UserConfig.accessToken = oldToken
return@listenValue
}
if (user == null) return@listenValue
UserConfig.currentUserId = user.id
@@ -165,9 +113,7 @@ class SettingsViewModel(
accountsRepository.storeAccounts(listOf(account))
screenEffect.tryEmit(
SettingsEffect.Navigate(SettingsNavigationIntent.Restart)
)
_isNeedToRestart.setValue { true }
}
)
}
@@ -178,8 +124,7 @@ class SettingsViewModel(
}
}
private fun onDialogDismissed() {
val dialog = screenState.value.dialog ?: return
fun onDialogDismissed(dialog: SettingsDialog) {
when (dialog) {
is SettingsDialog.LogOut -> Unit
is SettingsDialog.PerformCrash -> Unit
@@ -187,11 +132,10 @@ class SettingsViewModel(
is SettingsDialog.ExportAuthData -> Unit
}
setDialog(null)
_dialog.setValue { null }
}
private fun onDialogItemPicked(bundle: Bundle?) {
val dialog = screenState.value.dialog ?: return
fun onDialogItemPicked(dialog: SettingsDialog, bundle: Bundle) {
when (dialog) {
is SettingsDialog.LogOut -> Unit
is SettingsDialog.PerformCrash -> Unit
@@ -200,8 +144,10 @@ class SettingsViewModel(
}
}
private fun onLogOutAlertPositiveClick() {
fun onLogOutAlertPositiveClick() {
viewModelScope.launch(Dispatchers.IO) {
val tasks = listOf(
async {
accountsRepository.storeAccounts(
listOf(
AccountEntity(
@@ -213,39 +159,40 @@ class SettingsViewModel(
)
)
)
},
async { UserConfig.clear() }
)
UserConfig.clear()
screenEffect.tryEmit(SettingsEffect.Navigate(SettingsNavigationIntent.LogOut))
tasks.awaitAll()
}
}
private fun onPerformCrashPositiveButtonClicked() {
fun onPerformCrashPositiveButtonClicked() {
throw Exception("Test exception")
}
private fun onSettingsItemClicked(key: String) {
fun onSettingsItemClicked(key: String) {
when (key) {
SettingsKeys.KEY_ACCOUNT_LOGOUT -> {
setDialog(SettingsDialog.LogOut)
_dialog.setValue { SettingsDialog.LogOut }
}
SettingsKeys.KEY_DEBUG_PERFORM_CRASH -> {
setDialog(SettingsDialog.PerformCrash)
_dialog.setValue { SettingsDialog.PerformCrash }
}
SettingsKeys.KEY_DEBUG_IMPORT_AUTH_DATA -> {
setDialog(SettingsDialog.ImportAuthData)
_dialog.setValue { SettingsDialog.ImportAuthData }
}
SettingsKeys.KEY_DEBUG_EXPORT_AUTH_DATA -> {
setDialog(
_dialog.setValue {
SettingsDialog.ExportAuthData(
accessToken = UserConfig.accessToken,
exchangeToken = UserConfig.exchangeToken,
trustedHash = UserConfig.trustedHash
)
)
}
}
SettingsKeys.KEY_DEBUG_HIDE_DEBUG_LIST -> {
@@ -256,13 +203,13 @@ class SettingsViewModel(
createSettings()
screenEffect.tryEmit(SettingsEffect.PerformHaptic(HapticType.REJECT))
showDebugCategory = false
_hapticType.update { HapticType.REJECT }
_screenState.setValue { old -> old.copy(showDebugOptions = false) }
}
}
}
private fun onSettingsItemLongClicked(key: String) {
fun onSettingsItemLongClicked(key: String) {
when (key) {
SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> {
if (AppSettings.Debug.showDebugCategory) return
@@ -272,18 +219,18 @@ class SettingsViewModel(
createSettings()
screenEffect.tryEmit(SettingsEffect.PerformHaptic(HapticType.LONG_PRESS))
showDebugCategory = true
_hapticType.update { HapticType.LONG_PRESS }
_screenState.setValue { old -> old.copy(showDebugOptions = true) }
}
}
}
private fun onSettingsItemChanged(key: String, newValue: Any?) {
settings.findWithIndex { it.key == key }?.let { (index, item) ->
fun onSettingsItemChanged(key: String, newValue: Any?) {
settings.value.findWithIndex { it.key == key }?.let { (index, item) ->
item.updateValue(newValue)
item.updateText()
screenState.setValue { old ->
_screenState.setValue { old ->
old.copy(
settings = old.settings.toMutableList().apply {
this[index] = item.asPresentation(resources)
@@ -364,6 +311,10 @@ class SettingsViewModel(
}
}
fun onHapticPerformed() {
_hapticType.update { null }
}
private fun createSettings() {
val accountVisible = UserConfig.isLoggedIn()
val accountTitle = SettingsItem.Title(
@@ -546,10 +497,10 @@ class SettingsViewModel(
)
val logLevelValues = listOf(
NetworkLogLevel.NONE to UiText.Simple("None"),
NetworkLogLevel.BASIC to UiText.Simple("Basic"),
NetworkLogLevel.HEADERS to UiText.Simple("Headers"),
NetworkLogLevel.BODY to UiText.Simple("Body")
LogLevel.NONE to UiText.Simple("None"),
LogLevel.BASIC to UiText.Simple("Basic"),
LogLevel.HEADERS to UiText.Simple("Headers"),
LogLevel.BODY to UiText.Simple("Body")
).toMap()
val debugNetworkLogLevel = SettingsItem.ListItem(
@@ -558,11 +509,10 @@ class SettingsViewModel(
valueClass = Int::class,
defaultValue = SettingsKeys.DEFAULT_NETWORK_LOG_LEVEL,
titles = logLevelValues.values.toList(),
values = logLevelValues.keys.toList().map(NetworkLogLevel::value)
values = logLevelValues.keys.toList().map(LogLevel::value)
).apply {
textProvider = TextProvider { item ->
val textValue =
logLevelValues[NetworkLogLevel.parse(item.value)].parseString(resources)
val textValue = logLevelValues[LogLevel.parse(item.value)].parseString(resources)
UiText.Simple("Current value: $textValue")
}
@@ -652,10 +602,12 @@ class SettingsViewModel(
}
private fun emitSettings(newSettings: List<SettingsItem<*>>) {
settings.clear()
settings.addAll(newSettings)
settings.update { newSettings }
val uiSettings = newSettings.map { it.asPresentation(resources) }
screenState.setValue { old -> old.copy(settings = uiSettings) }
val uiSettings = newSettings.map { item ->
item.asPresentation(resources)
}
_screenState.setValue { old -> old.copy(settings = uiSettings) }
}
}
@@ -1,6 +0,0 @@
package dev.meloda.fast.settings.model
sealed interface SettingsEffect {
data class Navigate(val intent: SettingsNavigationIntent) : SettingsEffect
data class PerformHaptic(val type: HapticType) : SettingsEffect
}
@@ -1,18 +0,0 @@
package dev.meloda.fast.settings.model
import android.os.Bundle
sealed class SettingsIntent {
data object BackClick : SettingsIntent()
data class ItemClick(val key: String) : SettingsIntent()
data class ItemLongClick(val key: String) : SettingsIntent()
data class ItemValueChanged(val key: String, val newValue: Any?) : SettingsIntent()
sealed class Dialog : SettingsIntent() {
data object Dismiss : Dialog()
data class ConfirmClick(val bundle: Bundle? = null) : Dialog()
data object CancelClick : Dialog()
data class ItemPick(val bundle: Bundle? = null) : Dialog()
}
}
@@ -1,13 +1,13 @@
package dev.meloda.fast.settings.model
import android.content.res.Resources
import androidx.compose.runtime.Stable
import androidx.compose.runtime.Immutable
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.datastore.AppSettings
import kotlin.reflect.KClass
@Stable
@Immutable
sealed class SettingsItem<T>(
val key: String,
value: T,
@@ -1,8 +0,0 @@
package dev.meloda.fast.settings.model
sealed class SettingsNavigationIntent {
data object Back : SettingsNavigationIntent()
data object Language : SettingsNavigationIntent()
data object Restart : SettingsNavigationIntent()
data object LogOut : SettingsNavigationIntent()
}
@@ -6,13 +6,13 @@ import dev.meloda.fast.datastore.AppSettings
@Immutable
data class SettingsScreenState(
val settings: List<UiItem>,
val dialog: SettingsDialog?
val showDebugOptions: Boolean
) {
companion object {
val EMPTY: SettingsScreenState = SettingsScreenState(
settings = emptyList(),
dialog = null
showDebugOptions = AppSettings.Debug.showDebugCategory
)
}
}
@@ -1,49 +1,26 @@
package dev.meloda.fast.settings.navigation
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.settings.SettingsViewModel
import dev.meloda.fast.settings.model.SettingsEffect
import dev.meloda.fast.settings.model.SettingsNavigationIntent
import dev.meloda.fast.settings.presentation.SettingsRoute
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
@Serializable
object Settings
fun NavGraphBuilder.settingsScreen(
handleNavigationIntent: (SettingsNavigationIntent) -> Unit
onBack: () -> Unit,
onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit,
onRestartRequired: () -> Unit,
) {
composable<Settings> {
val view = LocalView.current
val viewModel: SettingsViewModel = koinViewModel()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.screenEffectFlow.onEach { effect ->
when (effect) {
is SettingsEffect.Navigate -> handleNavigationIntent(effect.intent)
is SettingsEffect.PerformHaptic -> {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(effect.type.getHaptic())
}
}
}
}.collect()
}
SettingsRoute(
handleIntent = viewModel::handleIntent,
screenState = screenState,
onBack = onBack,
onLogOutButtonClicked = onLogOutButtonClicked,
onLanguageItemClicked = onLanguageItemClicked,
onRestartRequired = onRestartRequired
)
}
}
@@ -3,6 +3,7 @@ package dev.meloda.fast.settings.presentation
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -25,18 +26,20 @@ import androidx.core.os.bundleOf
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.datastore.SettingsKeys
import dev.meloda.fast.settings.model.SettingsDialog
import dev.meloda.fast.settings.model.SettingsIntent
import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.R
@Composable
fun HandleDialogs(
handleIntent: (SettingsIntent.Dialog) -> Unit,
screenState: SettingsScreenState,
dialog: SettingsDialog?,
onConfirmed: (SettingsDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (SettingsDialog) -> Unit = {},
onItemPicked: (SettingsDialog, Bundle) -> Unit = { _, _ -> }
) {
val dialog = screenState.dialog ?: return
if (dialog == null) return
val context = LocalContext.current
@@ -45,13 +48,13 @@ fun HandleDialogs(
val isEasterEgg = UserConfig.userId == SettingsKeys.ID_DMITRY
MaterialDialog(
onDismissRequest = { handleIntent(SettingsIntent.Dialog.Dismiss) },
onDismissRequest = { onDismissed(dialog) },
title = stringResource(
id = if (isEasterEgg) R.string.easter_egg_log_out_dmitry
else R.string.sign_out_confirm_title
),
text = stringResource(id = R.string.sign_out_confirm),
confirmAction = { handleIntent(SettingsIntent.Dialog.ConfirmClick()) },
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(
id = if (isEasterEgg) R.string.easter_egg_log_out_dmitry
else R.string.action_sign_out
@@ -63,10 +66,10 @@ fun HandleDialogs(
is SettingsDialog.PerformCrash -> {
MaterialDialog(
onDismissRequest = { handleIntent(SettingsIntent.Dialog.Dismiss) },
onDismissRequest = { onDismissed(dialog) },
title = "Perform crash",
text = "App will be crashed. Are you sure?",
confirmAction = { handleIntent(SettingsIntent.Dialog.ConfirmClick()) },
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.yes),
cancelText = stringResource(id = R.string.cancel),
actionInvokeDismiss = ActionInvokeDismiss.Always
@@ -85,18 +88,17 @@ fun HandleDialogs(
}
MaterialDialog(
onDismissRequest = { handleIntent(SettingsIntent.Dialog.Dismiss) },
onDismissRequest = { onDismissed(dialog) },
title = "Import auth data",
confirmAction = {
handleIntent(
SettingsIntent.Dialog.ConfirmClick(
onConfirmed(
dialog,
bundleOf(
"ACCESS_TOKEN" to accessToken,
"EXCHANGE_TOKEN" to exchangeToken.ifEmpty { null },
"TRUSTED_HASH" to trustedHash.ifEmpty { null }
)
)
)
},
confirmText = "Import",
cancelText = stringResource(R.string.cancel)
@@ -196,18 +198,17 @@ fun HandleDialogs(
}
MaterialDialog(
onDismissRequest = { handleIntent(SettingsIntent.Dialog.Dismiss) },
onDismissRequest = { onDismissed(dialog) },
title = "Export auth data",
confirmAction = {
handleIntent(
SettingsIntent.Dialog.ConfirmClick(
onConfirmed(
dialog,
bundleOf(
"ACCESS_TOKEN" to accessToken,
"EXCHANGE_TOKEN" to exchangeToken.ifEmpty { null },
"TRUSTED_HASH" to trustedHash.ifEmpty { null }
)
)
)
},
confirmText = stringResource(R.string.ok),
) {
@@ -268,8 +269,7 @@ fun HandleDialogs(
"Auth data copied to clipboard. Be careful with this data. If another person gets it, your account will be at risk",
Toast.LENGTH_LONG
).show()
handleIntent(SettingsIntent.Dialog.Dismiss)
onDismissed(dialog)
},
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
@@ -1,21 +1,65 @@
package dev.meloda.fast.settings.presentation
import androidx.compose.runtime.Composable
import dev.meloda.fast.settings.model.SettingsIntent
import dev.meloda.fast.settings.model.SettingsScreenState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.datastore.SettingsKeys
import dev.meloda.fast.settings.SettingsViewModel
import dev.meloda.fast.settings.model.SettingsDialog
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun SettingsRoute(
handleIntent: (SettingsIntent) -> Unit,
screenState: SettingsScreenState,
onBack: () -> Unit,
onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit,
onRestartRequired: () -> Unit,
viewModel: SettingsViewModel = koinViewModel()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val hapticType by viewModel.hapticType.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val isNeedToRestart by viewModel.isNeedToRestart.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToRestart) {
if (isNeedToRestart) {
onRestartRequired()
}
}
SettingsScreen(
handleIntent = handleIntent,
screenState = screenState,
hapticType = hapticType,
onBack = onBack,
onHapticPerformed = viewModel::onHapticPerformed,
onSettingsItemClicked = { key ->
when (key) {
SettingsKeys.KEY_APPEARANCE_LANGUAGE -> {
onLanguageItemClicked()
}
else -> viewModel.onSettingsItemClicked(key)
}
},
onSettingsItemLongClicked = viewModel::onSettingsItemLongClicked,
onSettingsItemValueChanged = viewModel::onSettingsItemChanged
)
HandleDialogs(
handleIntent = handleIntent,
screenState = screenState,
dialog = dialog,
onConfirmed = { dialog, bundle ->
when (dialog) {
is SettingsDialog.LogOut -> {
onLogOutButtonClicked()
}
else -> Unit
}
viewModel.onDialogConfirmed(dialog, bundle)
},
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
)
}
@@ -21,10 +21,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -34,7 +34,8 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.settings.model.SettingsIntent
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.settings.model.HapticType
import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.settings.model.UiItem
import dev.meloda.fast.settings.presentation.item.ListItem
@@ -42,8 +43,8 @@ import dev.meloda.fast.settings.presentation.item.SwitchItem
import dev.meloda.fast.settings.presentation.item.TextFieldItem
import dev.meloda.fast.settings.presentation.item.TitleItem
import dev.meloda.fast.settings.presentation.item.TitleTextItem
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.R
@OptIn(
@@ -52,19 +53,23 @@ import dev.meloda.fast.ui.theme.LocalThemeConfig
)
@Composable
fun SettingsScreen(
handleIntent: (SettingsIntent) -> Unit,
screenState: SettingsScreenState = SettingsScreenState.EMPTY,
hapticType: HapticType? = null,
onBack: () -> Unit = {},
onHapticPerformed: () -> Unit = {},
onSettingsItemClicked: (key: String) -> Unit = {},
onSettingsItemLongClicked: (key: String) -> Unit = {},
onSettingsItemValueChanged: (key: String, newValue: Any?) -> Unit = { _, _ -> }
) {
val onSettingsItemClicked by rememberUpdatedState { key: String ->
handleIntent(SettingsIntent.ItemClick(key))
}
val view = LocalView.current
val onSettingsItemLongClicked by rememberUpdatedState { key: String ->
handleIntent(SettingsIntent.ItemLongClick(key))
LaunchedEffect(hapticType) {
if (hapticType != null) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(hapticType.getHaptic())
}
onHapticPerformed()
}
val onSettingsItemValueChanged by rememberUpdatedState { key: String, newValue: Any? ->
handleIntent(SettingsIntent.ItemValueChanged(key, newValue))
}
val themeConfig = LocalThemeConfig.current
@@ -85,7 +90,7 @@ fun SettingsScreen(
)
},
navigationIcon = {
IconButton(onClick = { handleIntent(SettingsIntent.BackClick) }) {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(id = R.drawable.ic_arrow_back_round_24),
contentDescription = "Back button"
+4
View File
@@ -36,9 +36,13 @@ nanokt = "1.3.0"
androidx-navigation = "2.9.8"
serialization = "1.11.0"
acra = "5.13.1"
okhttp = "5.3.2"
[libraries]
acra-email = { module = "ch.acra:acra-mail", version.ref = "acra" }
acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
-1
View File
@@ -55,4 +55,3 @@ include(":feature:friends")
include(":feature:profile")
include(":feature:createchat")
include(":core:presentation")
include(":core:logger")