diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b98f7521..48456bdd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,10 +19,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: set up JDK 17 + - name: set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' cache: gradle diff --git a/.github/workflows/create_release.yml b/.github/workflows/create_release.yml index 9b34291a..ba73166d 100644 --- a/.github/workflows/create_release.yml +++ b/.github/workflows/create_release.yml @@ -17,10 +17,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: set up JDK 17 + - name: set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' cache: gradle diff --git a/README.md b/README.md index 41d622cd..191205b9 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,19 @@ Unofficial messenger for russian social network VKontakte - [ ] View archived conversations - [ ] Archive & unarchive conversations - [x] Friends list - - [ ] Sort alphabetically, by priority or random - - [ ] Separate tab with only friends who are online + - [x] Sort alphabetically, by priority or random + - [x] Separate tab with only friends who are online - [x] Settings screen - [ ] TODO - [x] Chat screen - - [ ] Pagination + - [x] Pagination - [x] Manual refresh - [x] Message bubbles - [x] Text - - [ ] Date + - [x] Date + - [x] Read status + - [x] Edit status + - [x] Sending status - [ ] Message's attachments - [ ] Photo - [ ] Video @@ -35,19 +38,19 @@ Unofficial messenger for russian social network VKontakte - [ ] Link - [ ] TODO - [x] Send messages - - [ ] Pinned message - - [ ] Pin & unpin messages + - [x] Pinned message + - [x] Pin & unpin messages - [ ] Reply to message - - [ ] Delete message - - [ ] Select multiple messages - - [ ] Delete + - [x] Delete message + - [x] Select multiple messages + - [x] Delete - [ ] Forward - [ ] Forward in current chat - [ ] Send attachments to chat - [ ] TODO - [x] Chat materials (attachments) - [x] Separate tabs for each attachment type - - [ ] Pagination + - [x] Pagination - [x] Manual refresh - [x] View attachments - [x] Open photo diff --git a/app/src/main/kotlin/dev/meloda/fast/MainViewModel.kt b/app/src/main/kotlin/dev/meloda/fast/MainViewModel.kt index be50f87b..7c0a3cc4 100644 --- a/app/src/main/kotlin/dev/meloda/fast/MainViewModel.kt +++ b/app/src/main/kotlin/dev/meloda/fast/MainViewModel.kt @@ -24,6 +24,7 @@ import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.domain.GetCurrentAccountUseCase import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.model.BaseError +import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.navigation.Main import dev.meloda.fast.settings.navigation.Settings import kotlinx.coroutines.Dispatchers @@ -36,14 +37,13 @@ interface MainViewModel { val startDestination: StateFlow val isNeedToReplaceWithAuth: StateFlow + val currentUser: StateFlow val isNeedToShowNotificationsDeniedDialog: StateFlow val isNeedToShowNotificationsRationaleDialog: StateFlow val isNeedToCheckNotificationsPermission: StateFlow val isNeedToRequestNotifications: StateFlow - val profileImageUrl: StateFlow - fun onError(error: BaseError) fun onNavigatedToAuth() @@ -59,6 +59,8 @@ interface MainViewModel { fun onNotificationsDeniedDialogDismissed() fun onNotificationsRationaleDialogDismissed() fun onNotificationsRationaleDialogCancelClicked() + + fun onUserAuthenticated() } class MainViewModelImpl( @@ -70,24 +72,24 @@ class MainViewModelImpl( override val startDestination = MutableStateFlow(null) override val isNeedToReplaceWithAuth = MutableStateFlow(false) + override val currentUser = MutableStateFlow(null) override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false) override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false) override val isNeedToCheckNotificationsPermission = MutableStateFlow(false) override val isNeedToRequestNotifications = MutableStateFlow(false) - override val profileImageUrl = MutableStateFlow(null) - private var openNotificationsSettings = false private var openAppSettings = false override fun onError(error: BaseError) { when (error) { - BaseError.SessionExpired -> { + BaseError.SessionExpired, + BaseError.AccountBlocked -> { isNeedToReplaceWithAuth.update { true } } - is BaseError.SimpleError -> Unit // TODO: 21-Mar-25, Danil Nikolaev: show error in ui + else -> Unit // TODO: 21-Mar-25, Danil Nikolaev: show error in ui } } @@ -170,17 +172,20 @@ class MainViewModelImpl( disableBackgroundLongPoll() } + override fun onUserAuthenticated() { + loadProfile() + } + private fun loadProfile() { loadUserByIdUseCase(userId = null) .listenValue(viewModelScope) { state -> state.processState( error = { error -> - profileImageUrl.emit(null) + currentUser.emit(null) }, success = { response -> val user = response ?: return@listenValue - - profileImageUrl.emit(user.photo100) + currentUser.emit(user) } ) } diff --git a/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt b/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt index 01d0feae..04a219f3 100644 --- a/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt +++ b/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt @@ -51,7 +51,6 @@ val applicationModule = module { createChatModule ) - // TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors // TODO: 14/05/2024, Danil Nikolaev: extract all operations with preferences to standalone class singleOf(PreferenceManager::getDefaultSharedPreferences) single { androidContext().resources } diff --git a/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt b/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt index f4ab6d59..6fe89038 100644 --- a/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt +++ b/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt @@ -2,8 +2,7 @@ package dev.meloda.fast.navigation import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import dev.meloda.fast.MainViewModel -import dev.meloda.fast.conversations.navigation.Conversations +import dev.meloda.fast.conversations.navigation.ConversationsGraph import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BottomNavigationItem @@ -22,11 +21,10 @@ object Main fun NavGraphBuilder.mainScreen( onError: (BaseError) -> Unit, onSettingsButtonClicked: () -> Unit, - onConversationClicked: (conversationId: Int) -> Unit, + onNavigateToMessagesHistory: (conversationId: Long) -> Unit, onPhotoClicked: (url: String) -> Unit, - onMessageClicked: (userId: Int) -> Unit, - onCreateChatClicked: () -> Unit, - viewModel: MainViewModel + onMessageClicked: (userid: Long) -> Unit, + onNavigateToCreateChat: () -> Unit ) { val navigationItems = ImmutableList.of( BottomNavigationItem( @@ -39,7 +37,7 @@ fun NavGraphBuilder.mainScreen( titleResId = UiR.string.title_conversations, selectedIconResId = UiR.drawable.baseline_chat_24, unselectedIconResId = UiR.drawable.outline_chat_24, - route = Conversations + route = ConversationsGraph ), BottomNavigationItem( titleResId = UiR.string.title_profile, @@ -54,11 +52,10 @@ fun NavGraphBuilder.mainScreen( navigationItems = navigationItems, onError = onError, onSettingsButtonClicked = onSettingsButtonClicked, - onConversationItemClicked = onConversationClicked, + onNavigateToMessagesHistory = onNavigateToMessagesHistory, onPhotoClicked = onPhotoClicked, onMessageClicked = onMessageClicked, - onCreateChatClicked = onCreateChatClicked, - viewModel = viewModel + onNavigateToCreateChat = onNavigateToCreateChat ) } } diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt index aa576c6e..5e8e9fef 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt @@ -38,6 +38,7 @@ import dev.meloda.fast.common.LongPollController import dev.meloda.fast.common.model.LongPollState import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.UserSettings +import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.service.OnlineService import dev.meloda.fast.service.longpolling.LongPollingService import dev.meloda.fast.ui.model.DeviceSize @@ -46,6 +47,7 @@ import dev.meloda.fast.ui.model.ThemeConfig import dev.meloda.fast.ui.theme.AppTheme import dev.meloda.fast.ui.theme.LocalSizeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig +import dev.meloda.fast.ui.theme.LocalUser import dev.meloda.fast.ui.util.isNeedToEnableDarkMode import org.koin.androidx.compose.koinViewModel import org.koin.compose.KoinContext @@ -98,6 +100,8 @@ class MainActivity : AppCompatActivity() { val viewModel: MainViewModel = koinViewModel() + val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle() + LifecycleResumeEffect(true) { viewModel.onAppResumed(intent) onPauseOrDispose {} @@ -133,7 +137,8 @@ class MainActivity : AppCompatActivity() { } } - LaunchedEffect(longPollStateToApply) { + LifecycleResumeEffect(longPollStateToApply) { + Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply") if (longPollStateToApply != LongPollState.Background) { if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched() && longPollCurrentState != longPollStateToApply @@ -147,6 +152,8 @@ class MainActivity : AppCompatActivity() { inBackground = longPollStateToApply == LongPollState.Background ) } + + onPauseOrDispose {} } val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle() @@ -202,6 +209,7 @@ class MainActivity : AppCompatActivity() { val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle() val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle() val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle() + val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle() val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode) @@ -214,7 +222,7 @@ class MainActivity : AppCompatActivity() { setDarkMode, useSystemFont ) { - mutableStateOf( + derivedStateOf { ThemeConfig( darkMode = setDarkMode, dynamicColors = dynamicColors, @@ -222,14 +230,16 @@ class MainActivity : AppCompatActivity() { amoledDark = amoledDark, enableBlur = enableBlur, enableMultiline = enableMultiline, - useSystemFont = useSystemFont + useSystemFont = useSystemFont, + enableAnimations = enableAnimations ) - ) + } } CompositionLocalProvider( LocalThemeConfig provides themeConfig, - LocalSizeConfig provides sizeConfig + LocalSizeConfig provides sizeConfig, + LocalUser provides currentUser ) { AppTheme( useDarkTheme = themeConfig.darkMode, @@ -292,12 +302,19 @@ class MainActivity : AppCompatActivity() { } } + private val longPollingServiceIntent by lazy { + Intent(this, LongPollingService::class.java) + } + private val onlineServiceIntent by lazy { + Intent(this, OnlineService::class.java) + } + private fun toggleLongPollService( enable: Boolean, inBackground: Boolean = AppSettings.Experimental.longPollInBackground ) { if (enable) { - val longPollIntent = Intent(this, LongPollingService::class.java) + val longPollIntent = longPollingServiceIntent if (inBackground) { ContextCompat.startForegroundService(this, longPollIntent) @@ -305,15 +322,15 @@ class MainActivity : AppCompatActivity() { startService(longPollIntent) } } else { - stopService(Intent(this, LongPollingService::class.java)) + stopService(longPollingServiceIntent) } } private fun toggleOnlineService(enable: Boolean) { if (enable) { - startService(Intent(this, OnlineService::class.java)) + startService(onlineServiceIntent) } else { - stopService(Intent(this, OnlineService::class.java)) + stopService(onlineServiceIntent) } } diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt index bf93b82f..15a5c5b1 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon @@ -16,6 +15,7 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -25,9 +25,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController @@ -36,8 +36,9 @@ 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.MainViewModel -import dev.meloda.fast.conversations.navigation.conversationsScreen +import dev.meloda.fast.conversations.navigation.Conversations +import dev.meloda.fast.conversations.navigation.conversationsGraph +import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.friends.navigation.friendsScreen import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BottomNavigationItem @@ -45,7 +46,10 @@ import dev.meloda.fast.navigation.MainGraph import dev.meloda.fast.profile.navigation.profileScreen import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalHazeState +import dev.meloda.fast.ui.theme.LocalNavController +import dev.meloda.fast.ui.theme.LocalReselectedTab import dev.meloda.fast.ui.theme.LocalThemeConfig +import dev.meloda.fast.ui.theme.LocalUser import dev.meloda.fast.ui.util.ImmutableList @OptIn(ExperimentalHazeMaterialsApi::class) @@ -54,38 +58,47 @@ fun MainScreen( navigationItems: ImmutableList, onError: (BaseError) -> Unit = {}, onSettingsButtonClicked: () -> Unit = {}, - onConversationItemClicked: (conversationId: Int) -> Unit = {}, + onNavigateToMessagesHistory: (conversationId: Long) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {}, - onMessageClicked: (userId: Int) -> Unit = {}, - onCreateChatClicked: () -> Unit = {}, - viewModel: MainViewModel + onMessageClicked: (userid: Long) -> Unit = {}, + onNavigateToCreateChat: () -> Unit = {} ) { - val currentTheme = LocalThemeConfig.current + val theme = LocalThemeConfig.current val hazeState = remember { HazeState() } val navController = rememberNavController() - val profileImageUrl by viewModel.profileImageUrl.collectAsStateWithLifecycle() - var selectedItemIndex by rememberSaveable { mutableIntStateOf(1) } + val user = LocalUser.current + val profileImageUrl by remember(user) { + derivedStateOf { user?.photo100 } + } + + var tabReselected by remember { + mutableStateOf( + navigationItems.associate { + it.route to false + } + ) + } + Scaffold( bottomBar = { NavigationBar( modifier = Modifier + .fillMaxWidth() .then( - if (currentTheme.enableBlur) { + if (theme.enableBlur) { Modifier.hazeEffect( state = hazeState, style = HazeMaterials.thick() ) } else Modifier - ) - .fillMaxWidth(), - containerColor = NavigationBarDefaults.containerColor.copy( - alpha = if (currentTheme.enableBlur) 0f else 1f - ) + ), + containerColor = if (theme.enableBlur) Color.Transparent + else NavigationBarDefaults.containerColor ) { navigationItems.forEachIndexed { index, item -> NavigationBarItem( @@ -100,6 +113,10 @@ fun MainScreen( inclusive = true } } + } else { + tabReselected = tabReselected.toMutableMap().also { + it[navigationItems[index].route] = true + } } }, icon = { @@ -123,9 +140,7 @@ fun MainScreen( .size(24.dp) .clip(CircleShape) .alpha(if (isLoading) 0f else 1f), - onSuccess = { - isLoading = false - } + onSuccess = { isLoading = false } ) } else { Icon( @@ -146,11 +161,12 @@ fun MainScreen( Box( modifier = Modifier .fillMaxSize() - .padding(bottom = if (currentTheme.enableBlur) 0.dp else padding.calculateBottomPadding()) ) { CompositionLocalProvider( LocalHazeState provides hazeState, - LocalBottomPadding provides if (currentTheme.enableBlur) padding.calculateBottomPadding() else 0.dp + LocalBottomPadding provides padding.calculateBottomPadding(), + LocalReselectedTab provides tabReselected, + LocalNavController provides navController ) { NavHost( navController = navController, @@ -165,22 +181,28 @@ fun MainScreen( ) { friendsScreen( onError = onError, - navController = navController, onPhotoClicked = onPhotoClicked, - onMessageClicked = onMessageClicked + onMessageClicked = onMessageClicked, + onScrolledToTop = { + tabReselected = tabReselected.toMutableMap().also { + it[Friends] = false + } + }, ) - conversationsScreen( + conversationsGraph( onError = onError, - onConversationItemClicked = onConversationItemClicked, - onPhotoClicked = onPhotoClicked, - onCreateChatClicked = onCreateChatClicked, - navController = navController, + onNavigateToMessagesHistory = onNavigateToMessagesHistory, + onNavigateToCreateChat = onNavigateToCreateChat, + onScrolledToTop = { + tabReselected = tabReselected.toMutableMap().also { + it[Conversations] = false + } + } ) profileScreen( onError = onError, onSettingsButtonClicked = onSettingsButtonClicked, - onPhotoClicked = onPhotoClicked, - navController = navController + onPhotoClicked = onPhotoClicked ) } } diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt index b796f63d..a0a318a2 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext @@ -38,6 +39,8 @@ import dev.meloda.fast.photoviewer.navigation.photoViewScreen 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.theme.LocalNavController +import dev.meloda.fast.ui.theme.LocalNavRootController @Composable fun RootScreen( @@ -111,51 +114,59 @@ fun RootScreen( } if (startDestination != null) { - NavHost( - navController = navController, - startDestination = requireNotNull(startDestination), - enterTransition = { fadeIn(animationSpec = tween(200)) }, - exitTransition = { fadeOut(animationSpec = tween(200)) } + CompositionLocalProvider( + LocalNavRootController provides navController, + LocalNavController provides navController ) { - authNavGraph( - onNavigateToMain = navController::navigateToMain, - navController = navController - ) - mainScreen( - onError = viewModel::onError, - onSettingsButtonClicked = navController::navigateToSettings, - onConversationClicked = navController::navigateToMessagesHistory, - onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }, - onMessageClicked = navController::navigateToMessagesHistory, - onCreateChatClicked = navController::navigateToCreateChat, - viewModel = viewModel - ) + NavHost( + navController = navController, + startDestination = requireNotNull(startDestination), + enterTransition = { fadeIn(animationSpec = tween(200)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) } + ) { + authNavGraph( + onNavigateToMain = { + viewModel.onUserAuthenticated() + navController.navigateToMain() + }, + navController = navController + ) - messagesHistoryScreen( - onError = viewModel::onError, - onBack = navController::navigateUp, - onChatMaterialsDropdownItemClicked = navController::navigateToChatMaterials - ) - chatMaterialsScreen( - onBack = navController::navigateUp, - onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) } - ) - createChatScreen( - onChatCreated = { conversationId -> - navController.popBackStack() - navController.navigateToMessagesHistory(conversationId) - }, - navController = navController - ) + mainScreen( + onError = viewModel::onError, + onSettingsButtonClicked = navController::navigateToSettings, + onNavigateToMessagesHistory = navController::navigateToMessagesHistory, + onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }, + onMessageClicked = navController::navigateToMessagesHistory, + onNavigateToCreateChat = navController::navigateToCreateChat + ) - settingsScreen( - onBack = navController::navigateUp, - onLogOutButtonClicked = { navController.navigateToAuth(true) }, - onLanguageItemClicked = navController::navigateToLanguagePicker - ) - languagePickerScreen(onBack = navController::navigateUp) + messagesHistoryScreen( + onError = viewModel::onError, + onBack = navController::navigateUp, + onNavigateToChatMaterials = navController::navigateToChatMaterials + ) + chatMaterialsScreen( + onBack = navController::navigateUp, + onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) } + ) + createChatScreen( + onChatCreated = { conversationId -> + navController.popBackStack() + navController.navigateToMessagesHistory(conversationId) + }, + navController = navController + ) - photoViewScreen(onBack = navController::navigateUp) + settingsScreen( + onBack = navController::navigateUp, + onLogOutButtonClicked = { navController.navigateToAuth(true) }, + onLanguageItemClicked = navController::navigateToLanguagePicker + ) + languagePickerScreen(onBack = navController::navigateUp) + + photoViewScreen(onBack = navController::navigateUp) + } } } } diff --git a/app/src/main/kotlin/dev/meloda/fast/service/longpolling/LongPollingService.kt b/app/src/main/kotlin/dev/meloda/fast/service/longpolling/LongPollingService.kt index 4a9089ab..d6bf0dce 100644 --- a/app/src/main/kotlin/dev/meloda/fast/service/longpolling/LongPollingService.kt +++ b/app/src/main/kotlin/dev/meloda/fast/service/longpolling/LongPollingService.kt @@ -30,11 +30,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import kotlin.time.Duration.Companion.seconds class LongPollingService : Service() { @@ -42,17 +44,11 @@ class LongPollingService : Service() { private val job = SupervisorJob() - private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - Log.e(TAG, "error: $throwable") - - if (throwable !is NoAccessTokenException) { - throwable.printStackTrace() + private val exceptionHandler = + CoroutineExceptionHandler { _, throwable -> + handleError(throwable) } - longPollController.updateCurrentState(LongPollState.Exception) - longPollController.setStateToApply(LongPollState.Exception) - } - private val coroutineContext: CoroutineContext get() = Dispatchers.IO + job + exceptionHandler @@ -63,6 +59,8 @@ class LongPollingService : Service() { private var currentJob: Job? = null + private val inBackground get() = AppSettings.Experimental.longPollInBackground + override fun onCreate() { super.onCreate() Log.d(STATE_TAG, "onCreate()") @@ -76,21 +74,12 @@ class LongPollingService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (startId > 1) return START_STICKY - val inBackground = AppSettings.Experimental.longPollInBackground - Log.d( STATE_TAG, "onStartCommand: asForeground: $inBackground; flags: $flags; startId: $startId;\ninstance: $this" ) - if (currentJob != null) { - currentJob?.cancel() - currentJob = null - } - - coroutineScope.launch { - currentJob = startPolling().also { it.join() } - } + startJob() val openCategorySettingsIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) @@ -108,11 +97,6 @@ class LongPollingService : Service() { PendingIntent.FLAG_IMMUTABLE ) - longPollController.updateCurrentState( - if (inBackground) LongPollState.Background - else LongPollState.InApp - ) - if (inBackground) { val notification = NotificationsUtils.createNotification( @@ -134,17 +118,33 @@ class LongPollingService : Service() { return START_STICKY } + private fun startJob() { + if (currentJob != null) { + currentJob?.cancel() + currentJob = null + } + + coroutineScope.launch { + currentJob = startPolling().also { it.join() } + } + } + private fun startPolling(): Job { if (job.isCompleted || job.isCancelled) { - Log.d(STATE_TAG, "job is completed or cancelled") + Log.d(STATE_TAG, "Job is completed or cancelled") throw Exception("Job is over") } - Log.d(STATE_TAG, "job started") + Log.d(STATE_TAG, "Starting job...") + + return coroutineScope.launch(coroutineContext) { + longPollController.updateCurrentState( + if (inBackground) LongPollState.Background + else LongPollState.InApp + ) - return coroutineScope.launch { if (UserConfig.accessToken.isEmpty()) { - throw NoAccessTokenException + throw NoAccessTokenException() } var serverInfo = getServerInfo() @@ -246,10 +246,24 @@ class LongPollingService : Service() { } } + private fun handleError(throwable: Throwable) { + Log.e(TAG, "error: $throwable") + + if (throwable !is NoAccessTokenException) { + throwable.printStackTrace() + } + + coroutineScope.launch { + delay(5.seconds) + startJob() + } + + longPollController.updateCurrentState(LongPollState.Exception) + } + override fun onDestroy() { Log.d(STATE_TAG, "onDestroy") longPollController.updateCurrentState(LongPollState.Stopped) - updatesParser.clearListeners() try { AppSettings.edit { putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true) } job.cancel() @@ -276,4 +290,4 @@ class LongPollingService : Service() { } private data class LongPollException(override val message: String) : Throwable() -private data object NoAccessTokenException : Throwable() +private class NoAccessTokenException : Throwable() diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 262012c5..b63df73d 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -7,13 +7,13 @@ plugins { group = "dev.meloda.fast.buildlogic" java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlin { compilerOptions { - jvmTarget = JvmTarget.JVM_17 + jvmTarget = JvmTarget.JVM_21 } } diff --git a/build-logic/convention/src/main/kotlin/dev/meloda/fast/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/dev/meloda/fast/KotlinAndroid.kt index c0ce7a51..d1238e06 100644 --- a/build-logic/convention/src/main/kotlin/dev/meloda/fast/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/dev/meloda/fast/KotlinAndroid.kt @@ -23,8 +23,8 @@ internal fun Project.configureKotlinAndroid( } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } } @@ -33,8 +33,8 @@ internal fun Project.configureKotlinAndroid( internal fun Project.configureKotlinJvm() { extensions.configure { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } configureKotlin() @@ -49,7 +49,7 @@ private inline fun Project.configureKotlin() = is KotlinJvmProjectExtension -> compilerOptions else -> TODO("Unsupported project extension $this ${T::class}") }.apply { - jvmTarget = JvmTarget.JVM_17 + jvmTarget = JvmTarget.JVM_21 allWarningsAsErrors = warningsAsErrors.toBoolean() freeCompilerArgs.addAll( "-opt-in=kotlin.RequiresOptIn", diff --git a/core/common/src/main/kotlin/dev/meloda/fast/common/AppConstants.kt b/core/common/src/main/kotlin/dev/meloda/fast/common/AppConstants.kt index a7a0bff9..9460b9c4 100644 --- a/core/common/src/main/kotlin/dev/meloda/fast/common/AppConstants.kt +++ b/core/common/src/main/kotlin/dev/meloda/fast/common/AppConstants.kt @@ -4,7 +4,7 @@ object AppConstants { const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive" - const val API_VERSION = "5.173" + const val API_VERSION = "5.238" const val URL_OAUTH = "https://oauth.vk.com" const val URL_API = "https://api.vk.com/method" diff --git a/core/common/src/main/kotlin/dev/meloda/fast/common/VkConstants.kt b/core/common/src/main/kotlin/dev/meloda/fast/common/VkConstants.kt index f01bc8a8..a9b5250b 100644 --- a/core/common/src/main/kotlin/dev/meloda/fast/common/VkConstants.kt +++ b/core/common/src/main/kotlin/dev/meloda/fast/common/VkConstants.kt @@ -5,12 +5,12 @@ object VkConstants { const val GROUP_FIELDS = "description,members_count,counters,status,verified" const val USER_FIELDS = - "photo_50,photo_100,photo_200,photo_400_orig,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info,bdate" + "photo_50,photo_100,photo_200,photo_400_orig,status,screen_name,online_info,last_seen,verified,sex,bdate" const val ALL_FIELDS = "$USER_FIELDS,$GROUP_FIELDS" - const val LP_VERSION = 10 + const val LP_VERSION = 19 const val VK_APP_ID = "2274003" const val VK_SECRET = "hHbZxrka2uZ6jB1inYsH" @@ -18,6 +18,11 @@ object VkConstants { const val FAST_GROUP_ID = -119516304 const val FAST_APP_ID = "6964679" + const val MESSENGER_APP_ID = 51453752 + const val MESSENGER_APP_SECRET = "4UyuCUsdK8pVCNoeQuGi" + + const val MESSENGER_APP_SCOPE = 1454174 + object Auth { const val SCOPE = "notify," + "friends," + diff --git a/core/common/src/main/kotlin/dev/meloda/fast/common/extensions/Extensions.kt b/core/common/src/main/kotlin/dev/meloda/fast/common/extensions/Extensions.kt index eee07f7d..583d1c34 100644 --- a/core/common/src/main/kotlin/dev/meloda/fast/common/extensions/Extensions.kt +++ b/core/common/src/main/kotlin/dev/meloda/fast/common/extensions/Extensions.kt @@ -75,6 +75,11 @@ fun MutableStateFlow.setValue(function: (T) -> T) { update { newValue } } +fun MutableStateFlow.updateValue(block: T.() -> T) { + val newValue = block(value) + update { newValue } +} + fun Any.asInt(): Int { return when (this) { is Number -> this.toInt() @@ -83,6 +88,14 @@ fun Any.asInt(): Int { } } +fun Any.asLong(): Long { + return when(this) { + is Number -> this.toLong() + + else -> throw IllegalArgumentException("Object is not numeric") + } +} + fun Any.toList(mapper: (old: Any) -> T): List { return when (this) { is List<*> -> this.mapNotNull { it?.run(mapper) } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/State.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/State.kt index ca3d40a9..9a2253af 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/State.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/State.kt @@ -25,8 +25,6 @@ sealed class State { data object InternalError : Error() data class OAuthError(val error: OAuthErrorDomain) : Error() - - data class TestError(val message: String) : Error() } fun isLoading(): Boolean = this is Loading @@ -38,16 +36,16 @@ sealed class State { } inline fun State.processState( - error: (error: State.Error) -> (Unit), - success: (data: T) -> (Unit), + error: (error: State.Error) -> Unit, + success: (data: T) -> Unit, idle: (() -> (Unit)) = {}, loading: (() -> (Unit)) = {}, any: () -> Unit = {} ) { when (this) { is State.Error -> { - error(this) any() + error(this) } State.Idle -> idle() @@ -55,17 +53,47 @@ inline fun State.processState( State.Loading -> loading() is State.Success -> { - success(data) any() + success(data) } } } +fun OAuthErrorDomain?.toStateApiError(): State.Error { + if (this == null) return State.Error.ConnectionError + return State.Error.OAuthError(this) +} + fun RestApiErrorDomain?.toStateApiError(): State.Error = when (this) { null -> State.Error.ConnectionError else -> State.Error.ApiError(VkErrorCode.parse(code), message) } +fun ApiResult.asState() = when (this) { + is ApiResult.Success -> State.Success(this.value) + + is ApiResult.Failure.NetworkFailure -> State.Error.ConnectionError + is ApiResult.Failure.UnknownFailure -> State.UNKNOWN_ERROR + is ApiResult.Failure.HttpFailure -> this.error.toStateApiError() + is ApiResult.Failure.ApiFailure -> this.error.toStateApiError() +} + +fun ApiResult.asState(successMapper: (T) -> N) = + when (this) { + is ApiResult.Success -> State.Success(successMapper(this.value)) + + is ApiResult.Failure.NetworkFailure -> State.Error.ConnectionError + is ApiResult.Failure.UnknownFailure -> State.UNKNOWN_ERROR + is ApiResult.Failure.HttpFailure -> this.error.toStateApiError() + is ApiResult.Failure.ApiFailure -> this.error.toStateApiError() + } + +fun ApiResult.success(): T = + when (this) { + is ApiResult.Success -> value + else -> throw IllegalArgumentException() + } + fun ApiResult.mapToState() = when (this) { is ApiResult.Success -> State.Success(this.value) diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/UserConfig.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/UserConfig.kt index 377e32d7..f8fbce7b 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/UserConfig.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/UserConfig.kt @@ -6,17 +6,18 @@ object UserConfig { private const val ARG_CURRENT_USER_ID = "current_user_id" - var currentUserId: Int = -1 - get() = AppSettings.getInt(ARG_CURRENT_USER_ID, -1) + var currentUserId: Long = -1 + get() = AppSettings.getLong(ARG_CURRENT_USER_ID, -1) set(value) { field = value - AppSettings.edit { putInt(ARG_CURRENT_USER_ID, value) } + AppSettings.edit { putLong(ARG_CURRENT_USER_ID, value) } } - var userId: Int = -1 + var userId: Long = -1 var accessToken: String = "" var fastToken: String? = "" var trustedHash: String? = null + var exchangeToken: String? = null fun clear() { currentUserId = -1 diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/VkGroupsMap.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/VkGroupsMap.kt index 7722c6a3..6857719e 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/VkGroupsMap.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/VkGroupsMap.kt @@ -10,7 +10,7 @@ class VkGroupsMap( private val groups: List ) { - private val map: HashMap by lazy { + private val map: HashMap by lazy { HashMap(groups.associateBy(VkGroupDomain::id)) } @@ -36,7 +36,7 @@ class VkGroupsMap( if (message.fromId >= 0) null else map[abs(message.fromId)] - fun group(groupId: Int): VkGroupDomain? = map[abs(groupId)] + fun group(groupId: Long): VkGroupDomain? = map[abs(groupId)] companion object { diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/VkMemoryCache.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/VkMemoryCache.kt index 2a1d491e..8c5e8ebb 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/VkMemoryCache.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/VkMemoryCache.kt @@ -1,5 +1,6 @@ package dev.meloda.fast.data +import dev.meloda.fast.data.UserConfig.userId import dev.meloda.fast.model.api.domain.VkContactDomain import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkGroupDomain @@ -9,11 +10,11 @@ import kotlin.math.abs object VkMemoryCache { - private val users: HashMap = hashMapOf() - private val groups: HashMap = hashMapOf() - private val messages: HashMap = hashMapOf() - private val conversations: HashMap = hashMapOf() - private val contacts: HashMap = hashMapOf() + private val users: HashMap = hashMapOf() + private val groups: HashMap = hashMapOf() + private val messages: HashMap = hashMapOf() + private val conversations: HashMap = hashMapOf() + private val contacts: HashMap = hashMapOf() fun appendUsers(users: List) { users.forEach { user -> VkMemoryCache.users[user.id] = user } @@ -37,83 +38,83 @@ object VkMemoryCache { contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact } } - operator fun set(userId: Int, user: VkUser) { + operator fun set(userid: Long, user: VkUser) { users[userId] = user } - operator fun set(groupId: Int, group: VkGroupDomain) { + operator fun set(groupId: Long, group: VkGroupDomain) { groups[groupId] = group } - operator fun set(messageId: Int, message: VkMessage) { + operator fun set(messageId: Long, message: VkMessage) { messages[messageId] = message } - operator fun set(conversationId: Int, conversation: VkConversation) { + operator fun set(conversationId: Long, conversation: VkConversation) { conversations[conversationId] = conversation } - operator fun set(contactId: Int, contact: VkContactDomain) { + operator fun set(contactId: Long, contact: VkContactDomain) { contacts[contactId] = contact } - fun getUser(id: Int): VkUser? { + fun getUser(id: Long): VkUser? { return getUsers(id).firstOrNull() } - fun getUsers(vararg ids: Int): List { + fun getUsers(vararg ids: Long): List { return getUsers(ids.toList()) } - fun getUsers(ids: List): List { + fun getUsers(ids: List): List { return ids.mapNotNull { id -> users[id] } } - fun getGroup(id: Int): VkGroupDomain? { + fun getGroup(id: Long): VkGroupDomain? { return getGroups(id).firstOrNull() } - fun getGroups(vararg ids: Int): List { + fun getGroups(vararg ids: Long): List { return getGroups(ids.toList()) } - fun getGroups(ids: List): List { + fun getGroups(ids: List): List { return ids.mapNotNull { id -> groups[id] } } - fun getMessage(id: Int): VkMessage? { + fun getMessage(id: Long): VkMessage? { return getMessages(id).firstOrNull() } - fun getMessages(vararg ids: Int): List { + fun getMessages(vararg ids: Long): List { return getMessages(ids.toList()) } - fun getMessages(ids: List): List { + fun getMessages(ids: List): List { return ids.mapNotNull { id -> messages[id] } } - fun getConversation(id: Int): VkConversation? { + fun getConversation(id: Long): VkConversation? { return getConversations(id).firstOrNull() } - fun getConversations(vararg ids: Int): List { + fun getConversations(vararg ids: Long): List { return getConversations(ids.toList()) } - fun getConversations(ids: List): List { + fun getConversations(ids: List): List { return ids.mapNotNull { id -> conversations[id] } } - fun getContact(id: Int): VkContactDomain? { + fun getContact(id: Long): VkContactDomain? { return getContacts(id).firstOrNull() } - fun getContacts(vararg ids: Int): List { + fun getContacts(vararg ids: Long): List { return getContacts(ids.toList()) } - fun getContacts(ids: List): List { + fun getContacts(ids: List): List { return ids.mapNotNull { id -> contacts[id] } } } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/VkUsersMap.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/VkUsersMap.kt index b867eac3..ba46bf11 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/VkUsersMap.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/VkUsersMap.kt @@ -1,5 +1,6 @@ package dev.meloda.fast.data +import dev.meloda.fast.data.UserConfig.userId import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkMessage @@ -9,7 +10,7 @@ class VkUsersMap( private val users: List ) { - private val map: HashMap by lazy { + private val map: HashMap by lazy { HashMap(users.associateBy(VkUser::id)) } @@ -35,7 +36,7 @@ class VkUsersMap( if (message.fromId > 0) map[message.fromId] else null - fun user(userId: Int): VkUser? = map[userId] + fun user(userid: Long): VkUser? = map[userId] companion object { diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/VkUtils.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/VkUtils.kt new file mode 100644 index 00000000..c3034d48 --- /dev/null +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/VkUtils.kt @@ -0,0 +1,34 @@ +package dev.meloda.fast.data + +import dev.meloda.fast.model.BaseError +import dev.meloda.fast.network.VkErrorCode + +object VkUtils { + + fun parseError(error: State.Error): BaseError? { + return when (error) { + is State.Error.ApiError -> { + when (error.errorCode) { + VkErrorCode.USER_AUTHORIZATION_FAILED -> { + if (error.errorMessage.startsWith( + "User authorization failed: user is blocked." + ) + ) { + BaseError.AccountBlocked + } else { + BaseError.SessionExpired + } + } + + else -> BaseError.SimpleError(message = error.errorMessage) + } + } + + State.Error.ConnectionError -> BaseError.ConnectionError + State.Error.InternalError -> BaseError.InternalError + State.Error.UnknownError -> BaseError.UnknownError + + else -> null + } + } +} diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/auth/AuthRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/auth/AuthRepository.kt index 08215d90..f6f1cbef 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/auth/AuthRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/auth/AuthRepository.kt @@ -1,12 +1,32 @@ package dev.meloda.fast.data.api.auth +import com.slack.eithernet.ApiResult +import dev.meloda.fast.model.api.responses.ExchangeSilentTokenResponse +import dev.meloda.fast.model.api.responses.GetAnonymTokenResponse +import dev.meloda.fast.model.api.responses.GetExchangeTokenResponse import dev.meloda.fast.model.api.responses.ValidatePhoneResponse import dev.meloda.fast.network.RestApiErrorDomain -import com.slack.eithernet.ApiResult interface AuthRepository { + suspend fun logout(): ApiResult + suspend fun validatePhone( validationSid: String ): ApiResult + + suspend fun getAnonymToken( + clientId: String, + clientSecret: String + ): ApiResult + + suspend fun exchangeSilentToken( + anonymToken: String, + silentToken: String, + silentUuid: String + ): ApiResult + + suspend fun getExchangeToken( + accessToken: String + ): ApiResult } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/auth/AuthRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/auth/AuthRepositoryImpl.kt index b9af106e..5ecb9bda 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/auth/AuthRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/auth/AuthRepositoryImpl.kt @@ -1,10 +1,17 @@ package dev.meloda.fast.data.api.auth +import com.slack.eithernet.ApiResult +import dev.meloda.fast.common.VkConstants +import dev.meloda.fast.model.api.requests.ExchangeSilentTokenRequest +import dev.meloda.fast.model.api.requests.GetAnonymTokenRequest +import dev.meloda.fast.model.api.requests.GetExchangeTokenRequest +import dev.meloda.fast.model.api.responses.ExchangeSilentTokenResponse +import dev.meloda.fast.model.api.responses.GetAnonymTokenResponse +import dev.meloda.fast.model.api.responses.GetExchangeTokenResponse import dev.meloda.fast.model.api.responses.ValidatePhoneResponse import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.service.auth.AuthService -import com.slack.eithernet.ApiResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -12,9 +19,50 @@ class AuthRepositoryImpl( private val service: AuthService ) : AuthRepository { + override suspend fun logout(): ApiResult = + withContext(Dispatchers.IO) { + service.logout( + clientId = VkConstants.MESSENGER_APP_ID.toString(), + clientSecret = VkConstants.MESSENGER_APP_SECRET + ).mapApiDefault() + } + override suspend fun validatePhone( validationSid: String ): ApiResult = withContext(Dispatchers.IO) { service.validatePhone(validationSid).mapApiDefault() } + + override suspend fun getAnonymToken( + clientId: String, + clientSecret: String + ): ApiResult = withContext(Dispatchers.IO) { + val requestModel = GetAnonymTokenRequest( + clientId = clientId, + clientSecret = clientSecret + ) + + service.getAnonymToken(requestModel.map).mapApiDefault() + } + + override suspend fun exchangeSilentToken( + anonymToken: String, + silentToken: String, + silentUuid: String + ): ApiResult = withContext(Dispatchers.IO) { + val requestModel = ExchangeSilentTokenRequest( + anonymToken = anonymToken, + silentToken = silentToken, + silentUuid = silentUuid + ) + + service.exchangeSilentToken(requestModel.map).mapApiDefault() + } + + override suspend fun getExchangeToken( + accessToken: String + ): ApiResult = withContext(Dispatchers.IO) { + val requestModel = GetExchangeTokenRequest(accessToken = accessToken) + service.getExchangeToken(requestModel.map).mapApiDefault() + } } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepository.kt index 7a9c3dd1..22743633 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepository.kt @@ -1,22 +1,30 @@ package dev.meloda.fast.data.api.conversations import com.slack.eithernet.ApiResult +import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.network.RestApiErrorDomain interface ConversationsRepository { + suspend fun storeConversations(conversations: List) + suspend fun getConversations( count: Int?, - offset: Int? + offset: Int?, + filter: ConversationsFilter ): ApiResult, RestApiErrorDomain> suspend fun getConversationsById( - peerIds: List + peerIds: List, + extended: Boolean? = null, + fields: String? = null ): ApiResult, RestApiErrorDomain> - suspend fun storeConversations(conversations: List) - suspend fun delete(peerId: Int): ApiResult - suspend fun pin(peerId: Int): ApiResult - suspend fun unpin(peerId: Int): ApiResult + suspend fun delete(peerId: Long): ApiResult + suspend fun pin(peerId: Long): ApiResult + suspend fun unpin(peerId: Long): ApiResult + suspend fun reorderPinned(peerIds: List): ApiResult + suspend fun archive(peerId: Long): ApiResult + suspend fun unarchive(peerId: Long): ApiResult } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepositoryImpl.kt index 85d0be97..28b1141d 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepositoryImpl.kt @@ -6,37 +6,50 @@ import dev.meloda.fast.data.VkGroupsMap import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkUsersMap import dev.meloda.fast.database.dao.ConversationDao +import dev.meloda.fast.database.dao.GroupDao +import dev.meloda.fast.database.dao.MessageDao +import dev.meloda.fast.database.dao.UserDao +import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.api.data.VkContactData import dev.meloda.fast.model.api.data.VkGroupData import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.data.asDomain import dev.meloda.fast.model.api.domain.VkConversation +import dev.meloda.fast.model.api.domain.VkGroupDomain +import dev.meloda.fast.model.api.domain.VkMessage +import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.asEntity -import dev.meloda.fast.model.api.requests.ConversationsDeleteRequest import dev.meloda.fast.model.api.requests.ConversationsGetRequest -import dev.meloda.fast.model.api.requests.ConversationsPinRequest -import dev.meloda.fast.model.api.requests.ConversationsUnpinRequest import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.service.conversations.ConversationsService import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class ConversationsRepositoryImpl( private val conversationsService: ConversationsService, + private val messageDao: MessageDao, + private val userDao: UserDao, + private val groupDao: GroupDao, private val conversationDao: ConversationDao ) : ConversationsRepository { + override suspend fun storeConversations(conversations: List) { + conversationDao.insertAll(conversations.map(VkConversation::asEntity)) + } + override suspend fun getConversations( count: Int?, - offset: Int? + offset: Int?, + filter: ConversationsFilter ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { val requestModel = ConversationsGetRequest( count = count, offset = offset, fields = VkConstants.ALL_FIELDS, - filter = "all", + filter = filter, extended = true, startMessageId = null ) @@ -56,7 +69,7 @@ class ConversationsRepositoryImpl( VkMemoryCache.appendGroups(groupsList) VkMemoryCache.appendContacts(contactsList) - response.items.map { item -> + val conversations = response.items.map { item -> val lastMessage = item.lastMessage?.asDomain()?.let { message -> message.copy( user = usersMap.messageUser(message), @@ -72,6 +85,17 @@ class ConversationsRepositoryImpl( ).also { VkMemoryCache[conversation.id] = it } } } + + val messages = conversations.mapNotNull(VkConversation::lastMessage) + + launch(Dispatchers.IO) { + conversationDao.insertAll(conversations.map(VkConversation::asEntity)) + messageDao.insertAll(messages.map(VkMessage::asEntity)) + userDao.insertAll(profilesList.map(VkUser::asEntity)) + groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) + } + + conversations }, errorMapper = { error -> error?.toDomain() @@ -80,13 +104,16 @@ class ConversationsRepositoryImpl( } override suspend fun getConversationsById( - peerIds: List + peerIds: List, + extended: Boolean?, + fields: String? ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { - val requestParams = mapOf( - "peer_ids" to peerIds.joinToString(separator = ","), - "extended" to "1", - "fields" to VkConstants.ALL_FIELDS - ) + val requestParams = mutableMapOf( + "peer_ids" to peerIds.joinToString(separator = ",") + ).apply { + extended?.let { this["extended"] = if (it) "1" else "0" } + fields?.let { this["fields"] = it } + } conversationsService.getConversationsById(requestParams).mapApiResult( successMapper = { apiResponse -> @@ -99,11 +126,7 @@ class ConversationsRepositoryImpl( val usersMap = VkUsersMap.forUsers(profilesList) val groupsMap = VkGroupsMap.forGroups(groupsList) - VkMemoryCache.appendUsers(profilesList) - VkMemoryCache.appendGroups(groupsList) - VkMemoryCache.appendContacts(contactsList) - - response.items.map { item -> + val conversations = response.items.map { item -> item.asDomain().let { conversation -> conversation.copy( user = usersMap.conversationUser(conversation), @@ -111,6 +134,18 @@ class ConversationsRepositoryImpl( ).also { VkMemoryCache[conversation.id] = it } } } + + launch(Dispatchers.IO) { + conversationDao.insertAll(conversations.map(VkConversation::asEntity)) + userDao.insertAll(profilesList.map(VkUser::asEntity)) + groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) + } + + VkMemoryCache.appendUsers(profilesList) + VkMemoryCache.appendGroups(groupsList) + VkMemoryCache.appendContacts(contactsList) + + conversations }, errorMapper = { error -> error?.toDomain() @@ -118,31 +153,43 @@ class ConversationsRepositoryImpl( ) } - override suspend fun storeConversations(conversations: List) { - conversationDao.insertAll(conversations.map(VkConversation::asEntity)) - } - - override suspend fun delete(peerId: Int): ApiResult = + override suspend fun delete(peerId: Long): ApiResult = withContext(Dispatchers.IO) { - val requestModel = ConversationsDeleteRequest(peerId = peerId) - - conversationsService.delete(requestModel.map).mapApiResult( + conversationsService.delete(mapOf("peer_id" to peerId.toString())).mapApiResult( successMapper = { response -> response.requireResponse().lastDeletedId }, errorMapper = { error -> error?.toDomain() } ) } override suspend fun pin( - peerId: Int + peerId: Long ): ApiResult = withContext(Dispatchers.IO) { - val requestModel = ConversationsPinRequest(peerId = peerId) - conversationsService.pin(requestModel.map).mapApiDefault() + conversationsService.pin(mapOf("peer_id" to peerId.toString())).mapApiDefault() } override suspend fun unpin( - peerId: Int + peerId: Long ): ApiResult = withContext(Dispatchers.IO) { - val requestModel = ConversationsUnpinRequest(peerId = peerId) - conversationsService.unpin(requestModel.map).mapApiDefault() + conversationsService.unpin(mapOf("peer_id" to peerId.toString())).mapApiDefault() + } + + override suspend fun reorderPinned( + peerIds: List + ): ApiResult = withContext(Dispatchers.IO) { + conversationsService + .reorderPinned(mapOf("peer_ids" to peerIds.joinToString(","))) + .mapApiDefault() + } + + override suspend fun archive( + peerId: Long + ): ApiResult = withContext(Dispatchers.IO) { + conversationsService.archive(mapOf("peer_id" to peerId.toString())).mapApiDefault() + } + + override suspend fun unarchive( + peerId: Long + ): ApiResult = withContext(Dispatchers.IO) { + conversationsService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault() } } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/files/FilesRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/files/FilesRepository.kt index 748dd3a2..69822c50 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/files/FilesRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/files/FilesRepository.kt @@ -16,7 +16,7 @@ class FilesRepository( // AUDIO_MESSAGE("audio_message") // } // -// suspend fun getMessagesUploadServer(peerId: Int, type: FileType) = +// suspend fun getMessagesUploadServer(peerid: Long, type: FileType) = // filesService.getUploadServer( // mapOf( // "peer_id" to peerId.toString(), diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/friends/FriendsRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/friends/FriendsRepository.kt index 0f37d866..361a8e1c 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/friends/FriendsRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/friends/FriendsRepository.kt @@ -8,11 +8,13 @@ import com.slack.eithernet.ApiResult interface FriendsRepository { suspend fun getAllFriends( + order: String, count: Int?, offset: Int? ): ApiResult suspend fun getFriends( + order: String, count: Int?, offset: Int? ): ApiResult, RestApiErrorDomain> @@ -20,7 +22,7 @@ interface FriendsRepository { suspend fun getOnlineFriends( count: Int?, offset: Int? - ): ApiResult, RestApiErrorDomain> + ): ApiResult, RestApiErrorDomain> suspend fun storeUsers(users: List) } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/friends/FriendsRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/friends/FriendsRepositoryImpl.kt index 3723e688..4719d010 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/friends/FriendsRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/friends/FriendsRepositoryImpl.kt @@ -2,7 +2,7 @@ package dev.meloda.fast.data.api.friends import dev.meloda.fast.common.VkConstants import dev.meloda.fast.data.VkMemoryCache -import dev.meloda.fast.database.dao.UsersDao +import dev.meloda.fast.database.dao.UserDao import dev.meloda.fast.model.FriendsInfo import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.domain.VkUser @@ -21,14 +21,15 @@ import kotlinx.coroutines.withContext class FriendsRepositoryImpl( private val service: FriendsService, - private val dao: UsersDao + private val dao: UserDao ) : FriendsRepository { override suspend fun getAllFriends( + order: String, count: Int?, offset: Int? ): ApiResult = withContext(Dispatchers.IO) { - val friends = async { getFriends(count, offset) }.await() + val friends = async { getFriends(order, count, offset) }.await() .successOrElse { failure -> return@withContext failure } @@ -42,11 +43,12 @@ class FriendsRepositoryImpl( } override suspend fun getFriends( + order: String, count: Int?, offset: Int? ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { val requestModel = GetFriendsRequest( - order = "hints", + order = order, count = count, offset = offset, fields = VkConstants.USER_FIELDS @@ -67,7 +69,7 @@ class FriendsRepositoryImpl( override suspend fun getOnlineFriends( count: Int?, offset: Int? - ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { + ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { val requestModel = GetOnlineFriendsRequest( order = "hints", count = count, diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt index a9713a38..c997ac8b 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt @@ -1,82 +1,112 @@ package dev.meloda.fast.data.api.messages import com.slack.eithernet.ApiResult +import dev.meloda.fast.model.api.data.VkChatData import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkMessage +import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse +import dev.meloda.fast.model.api.responses.MessagesSendResponse import dev.meloda.fast.network.RestApiErrorDomain interface MessagesRepository { + suspend fun storeMessages(messages: List) + suspend fun getHistory( - conversationId: Int, + conversationId: Long, offset: Int?, count: Int? ): ApiResult suspend fun getById( - messagesIds: List, + peerCmIds: List?, + peerId: Long?, + messagesIds: List?, + cmIds: List?, extended: Boolean?, fields: String? ): ApiResult, RestApiErrorDomain> suspend fun send( - peerId: Int, - randomId: Int, + peerId: Long, + randomId: Long, message: String?, - replyTo: Int?, + replyTo: Long?, attachments: List? - ): ApiResult + ): ApiResult suspend fun markAsRead( - peerId: Int, - startMessageId: Int? + peerId: Long, + startMessageId: Long? ): ApiResult suspend fun getHistoryAttachments( - peerId: Int, + peerId: Long, count: Int?, offset: Int?, attachmentTypes: List, - conversationMessageId: Int + cmId: Long ): ApiResult, RestApiErrorDomain> suspend fun createChat( - userIds: List?, + userIds: List?, title: String? + ): ApiResult + + suspend fun pin( + peerId: Long, + messageId: Long? = null, + cmId: Long? = null + ): ApiResult + + suspend fun unpin( + peerId: Long ): ApiResult - suspend fun storeMessages(messages: List) + suspend fun markAsImportant( + peerId: Long, + messageIds: List? = null, + cmIds: List? = null, + important: Boolean + ): ApiResult, RestApiErrorDomain> -// suspend fun markAsImportant( -// params: MessagesMarkAsImportantRequest -// ): ApiResult, RestApiErrorDomain> -// -// suspend fun pin( -// params: MessagesPinMessageRequest -// ): ApiResult -// -// suspend fun unpin( -// params: MessagesUnPinMessageRequest -// ): ApiResult -// -// suspend fun delete( -// params: MessagesDeleteRequest -// ): ApiResult -// -// suspend fun edit( -// params: MessagesEditRequest -// ): ApiResult -// -// suspend fun getChat( -// params: MessagesGetChatRequest -// ): ApiResult -// -// suspend fun getConversationMembers( -// params: MessagesGetConversationMembersRequest -// ): ApiResult -// -// suspend fun removeChatUser( -// params: MessagesRemoveChatUserRequest -// ): ApiResult + suspend fun delete( + peerId: Long, + messageIds: List?, + cmIds: List?, + spam: Boolean, + deleteForAll: Boolean + ): ApiResult, RestApiErrorDomain> + + suspend fun edit( + peerId: Long, + messageId: Long? = null, + cmId: Long? = null, + message: String? = null, + lat: Float? = null, + long: Float? = null, + attachments: List? = null, + notParseLinks: Boolean = false, + keepSnippets: Boolean = true, + keepForwardedMessages: Boolean = true + ): ApiResult + + suspend fun getChat( + chatId: Long, + fields: String? = null + ): ApiResult + + suspend fun getConversationMembers( + peerId: Long, + offset: Int? = null, + count: Int? = null, + extended: Boolean? = null, + fields: String? = null + ): ApiResult + + suspend fun removeChatUser( + chatId: Long, + memberId: Long + ): ApiResult } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt index 0dafa7fe..a0c4c186 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt @@ -5,36 +5,57 @@ import dev.meloda.fast.common.VkConstants import dev.meloda.fast.data.VkGroupsMap import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkUsersMap +import dev.meloda.fast.database.dao.ConversationDao +import dev.meloda.fast.database.dao.GroupDao import dev.meloda.fast.database.dao.MessageDao +import dev.meloda.fast.database.dao.UserDao import dev.meloda.fast.model.api.data.VkAttachmentHistoryMessageData +import dev.meloda.fast.model.api.data.VkChatData import dev.meloda.fast.model.api.data.VkContactData import dev.meloda.fast.model.api.data.VkGroupData import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.data.asDomain import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage +import dev.meloda.fast.model.api.domain.VkConversation +import dev.meloda.fast.model.api.domain.VkGroupDomain import dev.meloda.fast.model.api.domain.VkMessage +import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest +import dev.meloda.fast.model.api.requests.MessagesDeleteRequest +import dev.meloda.fast.model.api.requests.MessagesEditRequest import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest +import dev.meloda.fast.model.api.requests.MessagesGetChatRequest +import dev.meloda.fast.model.api.requests.MessagesGetConversationMembersRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest +import dev.meloda.fast.model.api.requests.MessagesMarkAsImportantRequest import dev.meloda.fast.model.api.requests.MessagesMarkAsReadRequest +import dev.meloda.fast.model.api.requests.MessagesPinMessageRequest +import dev.meloda.fast.model.api.requests.MessagesRemoveChatUserRequest import dev.meloda.fast.model.api.requests.MessagesSendRequest +import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest +import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse +import dev.meloda.fast.model.api.responses.MessagesSendResponse import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.service.messages.MessagesService import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class MessagesRepositoryImpl( private val messagesService: MessagesService, private val messageDao: MessageDao, + private val userDao: UserDao, + private val groupDao: GroupDao, + private val conversationDao: ConversationDao ) : MessagesRepository { override suspend fun getHistory( - conversationId: Int, + conversationId: Long, offset: Int?, count: Int? ): ApiResult = withContext(Dispatchers.IO) { @@ -85,6 +106,13 @@ class MessagesRepositoryImpl( } } + launch(Dispatchers.IO) { + conversationDao.insertAll(conversations.map(VkConversation::asEntity)) + messageDao.insertAll(messages.map(VkMessage::asEntity)) + userDao.insertAll(profilesList.map(VkUser::asEntity)) + groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) + } + MessagesHistoryInfo( messages = messages, conversations = conversations @@ -97,12 +125,18 @@ class MessagesRepositoryImpl( } override suspend fun getById( - messagesIds: List, + peerCmIds: List?, + peerId: Long?, + messagesIds: List?, + cmIds: List?, extended: Boolean?, fields: String? ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { val requestModel = MessagesGetByIdRequest( + peerCmIds = peerCmIds, + peerId = peerId, messagesIds = messagesIds, + cmIds = cmIds, extended = extended, fields = fields ) @@ -112,12 +146,15 @@ class MessagesRepositoryImpl( val response = apiResponse.requireResponse() val messages = response.items - val usersMap = - VkUsersMap.forUsers(response.profiles.orEmpty().map(VkUserData::mapToDomain)) - val groupsMap = - VkGroupsMap.forGroups(response.groups.orEmpty().map(VkGroupData::mapToDomain)) - messages.map { message -> + val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain) + val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain) + val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain) + + val usersMap = VkUsersMap.forUsers(profilesList) + val groupsMap = VkGroupsMap.forGroups(groupsList) + + val domainMessages = messages.map { message -> message.asDomain().copy( user = usersMap.messageUser(message), group = groupsMap.messageGroup(message), @@ -125,18 +162,30 @@ class MessagesRepositoryImpl( actionGroup = groupsMap.messageActionGroup(message) ) } + + launch(Dispatchers.IO) { + messageDao.insertAll(domainMessages.map(VkMessage::asEntity)) + userDao.insertAll(profilesList.map(VkUser::asEntity)) + groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) + } + + VkMemoryCache.appendUsers(profilesList) + VkMemoryCache.appendGroups(groupsList) + VkMemoryCache.appendContacts(contactsList) + + domainMessages }, errorMapper = { error -> error?.toDomain() } ) } override suspend fun send( - peerId: Int, - randomId: Int, + peerId: Long, + randomId: Long, message: String?, - replyTo: Int?, + replyTo: Long?, attachments: List? - ): ApiResult = withContext(Dispatchers.IO) { + ): ApiResult = withContext(Dispatchers.IO) { val requestModel = MessagesSendRequest( peerId = peerId, randomId = randomId, @@ -149,8 +198,8 @@ class MessagesRepositoryImpl( } override suspend fun markAsRead( - peerId: Int, - startMessageId: Int? + peerId: Long, + startMessageId: Long? ): ApiResult = withContext(Dispatchers.IO) { val requestModel = MessagesMarkAsReadRequest( peerId = peerId, @@ -161,11 +210,11 @@ class MessagesRepositoryImpl( } override suspend fun getHistoryAttachments( - peerId: Int, + peerId: Long, count: Int?, offset: Int?, attachmentTypes: List, - conversationMessageId: Int + cmId: Long ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { val requestModel = MessagesGetHistoryAttachmentsRequest( @@ -175,7 +224,7 @@ class MessagesRepositoryImpl( offset = offset, preserveOrder = true, attachmentTypes = attachmentTypes, - conversationMessageId = conversationMessageId, + conversationMessageId = cmId, fields = VkConstants.ALL_FIELDS ) @@ -191,6 +240,11 @@ class MessagesRepositoryImpl( VkMemoryCache.appendGroups(groupsList) VkMemoryCache.appendContacts(contactsList) + launch(Dispatchers.IO) { + userDao.insertAll(profilesList.map(VkUser::asEntity)) + groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) + } + response.items.map(VkAttachmentHistoryMessageData::toDomain) }, errorMapper = { error -> @@ -200,9 +254,9 @@ class MessagesRepositoryImpl( } override suspend fun createChat( - userIds: List?, + userIds: List?, title: String? - ): ApiResult = withContext(Dispatchers.IO) { + ): ApiResult = withContext(Dispatchers.IO) { val requestModel = MessagesCreateChatRequest( userIds = userIds, title = title @@ -216,81 +270,134 @@ class MessagesRepositoryImpl( ) } + override suspend fun pin( + peerId: Long, + messageId: Long?, + cmId: Long? + ): ApiResult = withContext(Dispatchers.IO) { + val requestModel = MessagesPinMessageRequest( + peerId = peerId, + messageId = messageId, + conversationMessageId = cmId + ) + + messagesService.pin(requestModel.map).mapApiResult( + successMapper = { apiResponse -> + apiResponse.requireResponse().asDomain() + }, + errorMapper = { error -> error?.toDomain() } + ) + } + + override suspend fun unpin( + peerId: Long + ): ApiResult = withContext(Dispatchers.IO) { + val requestModel = MessagesUnpinMessageRequest(peerId = peerId) + messagesService.unpin(requestModel.map).mapApiDefault() + } + + override suspend fun markAsImportant( + peerId: Long, + messageIds: List?, + cmIds: List?, + important: Boolean + ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { + val requestModel = MessagesMarkAsImportantRequest( + messagesIds = messageIds.orEmpty(), + important = important + ) + messagesService.markAsImportant(requestModel.map).mapApiDefault() + } + + override suspend fun delete( + peerId: Long, + messageIds: List?, + cmIds: List?, + spam: Boolean, + deleteForAll: Boolean + ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { + val requestModel = MessagesDeleteRequest( + peerId = peerId, + messagesIds = messageIds, + conversationsMessagesIds = cmIds, + isSpam = spam, + deleteForAll = deleteForAll + ) + messagesService.delete(requestModel.map).mapApiDefault() + } + override suspend fun storeMessages(messages: List) { messageDao.insertAll(messages.map(VkMessage::asEntity)) } -// override suspend fun markAsImportant( -// params: MessagesMarkAsImportantRequest -// ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { -// messagesService.markAsImportant(params.map).mapResult( -// successMapper = { response -> response.requireResponse() }, -// errorMapper = { error -> error?.toDomain() } -// ) -// } -// -// override suspend fun pin( -// params: MessagesPinMessageRequest -// ): ApiResult = withContext(Dispatchers.IO) { -// messagesService.pin(params.map).mapResult( -// successMapper = { response -> response.requireResponse() }, -// errorMapper = { error -> error?.toDomain() } -// ) -// } -// -// override suspend fun unpin( -// params: MessagesUnPinMessageRequest -// ): ApiResult = withContext(Dispatchers.IO) { -// messagesService.unpin(params.map).mapResult( -// successMapper = {}, -// errorMapper = { error -> error?.toDomain() } -// ) -// } -// -// override suspend fun delete( -// params: MessagesDeleteRequest -// ): ApiResult = withContext(Dispatchers.IO) { -// messagesService.delete(params.map).mapResult( -// successMapper = {}, -// errorMapper = { error -> error?.toDomain() } -// ) -// } -// -// override suspend fun edit( -// params: MessagesEditRequest -// ): ApiResult = withContext(Dispatchers.IO) { -// messagesService.edit(params.map).mapResult( -// successMapper = { response -> response.requireResponse() }, -// errorMapper = { error -> error?.toDomain() } -// ) -// } -// -// override suspend fun getChat( -// params: MessagesGetChatRequest -// ): ApiResult = withContext(Dispatchers.IO) { -// messagesService.getChat(params.map).mapResult( -// successMapper = { response -> response.requireResponse() }, -// errorMapper = { error -> error?.toDomain() } -// ) -// } -// -// override suspend fun getConversationMembers( -// params: MessagesGetConversationMembersRequest -// ): ApiResult = -// withContext(Dispatchers.IO) { -// messagesService.getConversationMembers(params.map).mapResult( -// successMapper = { response -> response.requireResponse() }, -// errorMapper = { error -> error?.toDomain() } -// ) -// } -// -// override suspend fun removeChatUser( -// params: MessagesRemoveChatUserRequest -// ): ApiResult = withContext(Dispatchers.IO) { -// messagesService.removeChatUser(params.map).mapResult( -// successMapper = { response -> response.requireResponse() }, -// errorMapper = { error -> error?.toDomain() } -// ) -// } -} + override suspend fun edit( + peerId: Long, + messageId: Long?, + cmId: Long?, + message: String?, + lat: Float?, + long: Float?, + attachments: List?, + notParseLinks: Boolean, + keepSnippets: Boolean, + keepForwardedMessages: Boolean + ): ApiResult = withContext(Dispatchers.IO) { + val requestModel = MessagesEditRequest( + peerId = peerId, + messageId = messageId, + cmId = cmId, + message = message, + lat = lat, + long = long, + attachments = attachments, + notParseLinks = notParseLinks, + keepSnippets = keepSnippets, + keepForwardedMessages = keepForwardedMessages + ) + messagesService.edit(requestModel.map).mapApiDefault() + } + + override suspend fun getChat( + chatId: Long, + fields: String? + ): ApiResult = withContext(Dispatchers.IO) { + val requestModel = MessagesGetChatRequest( + chatId = chatId, + fields = fields + ) + + messagesService.getChat(requestModel.map).mapApiDefault() + } + + override suspend fun getConversationMembers( + peerId: Long, + offset: Int?, + count: Int?, + extended: Boolean?, + fields: String? + ): ApiResult = + withContext(Dispatchers.IO) { + val requestModel = MessagesGetConversationMembersRequest( + peerId = peerId, + offset = offset, + count = count, + extended = extended, + fields = fields + ) + + messagesService.getConversationMembers(requestModel.map).mapApiDefault() + } + + override suspend fun removeChatUser( + chatId: Long, + memberId: Long + ): ApiResult = withContext(Dispatchers.IO) { + val requestModel = MessagesRemoveChatUserRequest( + chatId = chatId, + memberId = memberId + ) + + messagesService.removeChatUser(requestModel.map).mapApiDefault() + } +} diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepository.kt index 4c4e3723..8cc3c7a1 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepository.kt @@ -1,6 +1,9 @@ package dev.meloda.fast.data.api.oauth +import com.slack.eithernet.ApiResult import dev.meloda.fast.model.api.responses.AuthDirectResponse +import dev.meloda.fast.model.api.responses.GetSilentTokenResponse +import dev.meloda.fast.network.OAuthErrorDomain interface OAuthRepository { @@ -11,5 +14,14 @@ interface OAuthRepository { validationCode: String?, captchaSid: String?, captchaKey: String? - ): AuthDirectResponse + ): ApiResult + + suspend fun getSilentToken( + login: String, + password: String, + forceSms: Boolean, + validationCode: String?, + captchaSid: String?, + captchaKey: String?, + ): ApiResult } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepositoryImpl.kt index 7292b610..4e22ae69 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepositoryImpl.kt @@ -1,10 +1,16 @@ package dev.meloda.fast.data.api.oauth +import com.slack.eithernet.ApiResult import dev.meloda.fast.common.VkConstants import dev.meloda.fast.model.api.requests.AuthDirectRequest import dev.meloda.fast.model.api.responses.AuthDirectResponse +import dev.meloda.fast.model.api.responses.GetSilentTokenResponse +import dev.meloda.fast.network.OAuthErrorDomain +import dev.meloda.fast.network.ValidationType +import dev.meloda.fast.network.VkOAuthError +import dev.meloda.fast.network.VkOAuthErrorType +import dev.meloda.fast.network.mapResult import dev.meloda.fast.network.service.oauth.OAuthService -import com.slack.eithernet.ApiResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -18,37 +24,190 @@ class OAuthRepositoryImpl( forceSms: Boolean, validationCode: String?, captchaSid: String?, - captchaKey: String? - ): AuthDirectResponse = withContext(Dispatchers.IO) { + captchaKey: String?, + ): ApiResult = withContext(Dispatchers.IO) { val requestModel = AuthDirectRequest( grantType = VkConstants.Auth.GrantType.PASSWORD, - clientId = VkConstants.VK_APP_ID, - clientSecret = VkConstants.VK_SECRET, + clientId = VkConstants.MESSENGER_APP_ID.toString(), + clientSecret = VkConstants.MESSENGER_APP_SECRET, username = login, password = password, - scope = VkConstants.Auth.SCOPE, + scope = VkConstants.MESSENGER_APP_SCOPE.toString(), validationForceSms = forceSms, validationCode = validationCode, captchaSid = captchaSid, captchaKey = captchaKey, ) - when (val result = oAuthService.auth(requestModel.map)) { - is ApiResult.Success -> result.value + oAuthService.auth(requestModel.map).mapResult( + successMapper = { + it + }, + errorMapper = { response -> + val error = response?.error?.let(VkOAuthError::parse) + val errorType = response?.errorType?.let(VkOAuthErrorType::parse) - is ApiResult.Failure.HttpFailure -> { - requireNotNull(result.error) + when (error) { + null -> OAuthErrorDomain.UnknownError + + VkOAuthError.FLOOD_CONTROL -> OAuthErrorDomain.TooManyTriesError + + VkOAuthError.NEED_VALIDATION -> { + if (response.banInfo != null) { + val info = requireNotNull(response.banInfo) + + OAuthErrorDomain.UserBannedError( + memberName = info.memberName, + message = info.message, + accessToken = info.accessToken, + restoreUrl = info.restoreUrl + ) + } else { + OAuthErrorDomain.ValidationRequiredError( + description = response.errorDescription.orEmpty(), + validationType = response.validationType.orEmpty() + .let(ValidationType::parse), + validationSid = response.validationSid.orEmpty(), + phoneMask = response.phoneMask.orEmpty(), + redirectUri = response.redirectUri.orEmpty(), + validationResend = response.validationResend, + restoreIfCannotGetCode = response.restoreIfCannotGetCode + ) + } + } + + VkOAuthError.NEED_CAPTCHA -> { + OAuthErrorDomain.CaptchaRequiredError( + captchaSid = response.captchaSid.orEmpty(), + captchaImageUrl = response.captchaImage.orEmpty() + ) + } + + VkOAuthError.INVALID_CLIENT -> { + OAuthErrorDomain.InvalidCredentialsError + } + + VkOAuthError.INVALID_REQUEST -> { + when (errorType) { + null -> OAuthErrorDomain.UnknownError + + VkOAuthErrorType.WRONG_OTP -> { + OAuthErrorDomain.WrongValidationCode + } + + VkOAuthErrorType.WRONG_OTP_FORMAT -> { + OAuthErrorDomain.WrongValidationCodeFormat + } + + VkOAuthErrorType.PASSWORD_BRUTEFORCE_ATTEMPT -> { + OAuthErrorDomain.TooManyTriesError + } + + VkOAuthErrorType.USERNAME_OR_PASSWORD_IS_INCORRECT -> { + OAuthErrorDomain.InvalidCredentialsError + } + } + } + + VkOAuthError.UNKNOWN -> OAuthErrorDomain.UnknownError + } } - - is ApiResult.Failure.ApiFailure -> TODO() - - is ApiResult.Failure.NetworkFailure -> { - // TODO: 13/07/2024, Danil Nikolaev: implement showing network error - TODO() - } - is ApiResult.Failure.UnknownFailure -> TODO() - - else -> throw IllegalStateException("Unknown result") - } + ) } + + override suspend fun getSilentToken( + login: String, + password: String, + forceSms: Boolean, + validationCode: String?, + captchaSid: String?, + captchaKey: String?, + ): ApiResult = + withContext(Dispatchers.IO) { + val requestModel = AuthDirectRequest( + grantType = VkConstants.Auth.GrantType.PASSWORD, + clientId = VkConstants.MESSENGER_APP_ID.toString(), + clientSecret = VkConstants.MESSENGER_APP_SECRET, + username = login, + password = password, + scope = VkConstants.MESSENGER_APP_SCOPE.toString(), + validationForceSms = forceSms, + validationCode = validationCode, + captchaSid = captchaSid, + captchaKey = captchaKey, + ) + + oAuthService.getSilentToken(requestModel.map).mapResult( + successMapper = { it }, + errorMapper = { response -> + val error = response?.error?.let(VkOAuthError::parse) + val errorType = response?.errorType?.let(VkOAuthErrorType::parse) + + when (error) { + null -> OAuthErrorDomain.UnknownError + + VkOAuthError.FLOOD_CONTROL -> OAuthErrorDomain.TooManyTriesError + + VkOAuthError.NEED_VALIDATION -> { + if (response.banInfo != null) { + val info = requireNotNull(response.banInfo) + + OAuthErrorDomain.UserBannedError( + memberName = info.memberName, + message = info.message, + accessToken = info.accessToken, + restoreUrl = info.restoreUrl + ) + } else { + OAuthErrorDomain.ValidationRequiredError( + description = response.errorDescription.orEmpty(), + validationType = response.validationType.orEmpty() + .let(ValidationType::parse), + validationSid = response.validationSid.orEmpty(), + phoneMask = response.phoneMask.orEmpty(), + redirectUri = response.redirectUri.orEmpty(), + validationResend = response.validationResend, + restoreIfCannotGetCode = response.restoreIfCannotGetCode + ) + } + } + + VkOAuthError.NEED_CAPTCHA -> { + OAuthErrorDomain.CaptchaRequiredError( + captchaSid = response.captchaSid.orEmpty(), + captchaImageUrl = response.captchaImage.orEmpty() + ) + } + + VkOAuthError.INVALID_CLIENT -> { + OAuthErrorDomain.InvalidCredentialsError + } + + VkOAuthError.INVALID_REQUEST -> { + when (errorType) { + null -> OAuthErrorDomain.UnknownError + + VkOAuthErrorType.WRONG_OTP -> { + OAuthErrorDomain.WrongValidationCode + } + + VkOAuthErrorType.WRONG_OTP_FORMAT -> { + OAuthErrorDomain.WrongValidationCodeFormat + } + + VkOAuthErrorType.PASSWORD_BRUTEFORCE_ATTEMPT -> { + OAuthErrorDomain.TooManyTriesError + } + + VkOAuthErrorType.USERNAME_OR_PASSWORD_IS_INCORRECT -> { + OAuthErrorDomain.InvalidCredentialsError + } + } + } + + VkOAuthError.UNKNOWN -> OAuthErrorDomain.UnknownError + } + } + ) + } } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/photos/PhotosRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/photos/PhotosRepository.kt index efea4f13..30eab6a8 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/photos/PhotosRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/photos/PhotosRepository.kt @@ -8,7 +8,7 @@ class PhotosRepository( private val photosService: PhotosService ) { - suspend fun getMessagesUploadServer(peerId: Int) = + suspend fun getMessagesUploadServer(peerId: Long) = photosService.getUploadServer(mapOf("peer_id" to peerId.toString())) suspend fun uploadPhoto(url: String, photo: MultipartBody.Part) = diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/users/UsersRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/users/UsersRepository.kt index eac02481..ce5712b9 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/users/UsersRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/users/UsersRepository.kt @@ -1,18 +1,18 @@ package dev.meloda.fast.data.api.users +import com.slack.eithernet.ApiResult import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.network.RestApiErrorDomain -import com.slack.eithernet.ApiResult interface UsersRepository { suspend fun get( - userIds: List?, + userIds: List?, fields: String?, nomCase: String? ): ApiResult, RestApiErrorDomain> - suspend fun getLocalUsers(userIds: List): List + suspend fun getLocalUsers(userIds: List): List suspend fun storeUsers(users: List) } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/users/UsersRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/users/UsersRepositoryImpl.kt index d206e66e..03ed23eb 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/users/UsersRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/users/UsersRepositoryImpl.kt @@ -1,7 +1,8 @@ package dev.meloda.fast.data.api.users +import com.slack.eithernet.ApiResult import dev.meloda.fast.data.VkMemoryCache -import dev.meloda.fast.database.dao.UsersDao +import dev.meloda.fast.database.dao.UserDao import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.asEntity @@ -11,18 +12,17 @@ import dev.meloda.fast.model.database.asExternalModel import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.service.users.UsersService -import com.slack.eithernet.ApiResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class UsersRepositoryImpl( private val service: UsersService, - private val dao: UsersDao + private val dao: UserDao ) : UsersRepository { override suspend fun get( - userIds: List?, + userIds: List?, fields: String?, nomCase: String? ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { @@ -38,7 +38,9 @@ class UsersRepositoryImpl( val users = response.map(VkUserData::mapToDomain) - launch { storeUsers(users) } + launch(Dispatchers.IO) { + storeUsers(users) + } VkMemoryCache.appendUsers(users) @@ -51,7 +53,7 @@ class UsersRepositoryImpl( } override suspend fun getLocalUsers( - userIds: List + userIds: List ): List = withContext(Dispatchers.IO) { dao.getAllByIds(userIds).map(VkUserEntity::asExternalModel) } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/db/AccountsRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/db/AccountsRepository.kt index 97c5fe67..40478fed 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/db/AccountsRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/db/AccountsRepository.kt @@ -6,7 +6,7 @@ interface AccountsRepository { suspend fun getAccounts(): List - suspend fun getAccountById(userId: Int): AccountEntity? + suspend fun getAccountById(userId: Long): AccountEntity? suspend fun storeAccounts(accounts: List) } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/db/AccountsRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/db/AccountsRepositoryImpl.kt index ae5e254b..5e77e94d 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/db/AccountsRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/db/AccountsRepositoryImpl.kt @@ -9,7 +9,7 @@ class AccountsRepositoryImpl( override suspend fun getAccounts(): List = accountDao.getAll() - override suspend fun getAccountById(userId: Int): AccountEntity? = + override suspend fun getAccountById(userId: Long): AccountEntity? = accountDao.getById(userId) override suspend fun storeAccounts( diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/di/DataModule.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/di/DataModule.kt index 10e878af..d091e805 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/di/DataModule.kt @@ -65,7 +65,6 @@ val dataModule = module { singleOf(::FriendsRepositoryImpl) bind FriendsRepository::class - // TODO: 11/08/2024, Danil Nikolaev: find a better solution single(named("token_interceptor")) { AccessTokenInterceptor() } diff --git a/core/database/schemas/dev.meloda.fast.database.AccountsDatabase/2.json b/core/database/schemas/dev.meloda.fast.database.AccountsDatabase/2.json index 116a04f5..5f5007c3 100644 --- a/core/database/schemas/dev.meloda.fast.database.AccountsDatabase/2.json +++ b/core/database/schemas/dev.meloda.fast.database.AccountsDatabase/2.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 2, - "identityHash": "3ebd234270e36902d3d461af38664869", + "identityHash": "ca007bca2ab4a9b901662792042770ad", "entities": [ { "tableName": "accounts", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, `trustedHash` TEXT, PRIMARY KEY(`userId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, `trustedHash` TEXT, `exchangeToken` TEXT, PRIMARY KEY(`userId`))", "fields": [ { "fieldPath": "userId", @@ -31,6 +31,12 @@ "columnName": "trustedHash", "affinity": "TEXT", "notNull": false + }, + { + "fieldPath": "exchangeToken", + "columnName": "exchangeToken", + "affinity": "TEXT", + "notNull": false } ], "primaryKey": { @@ -46,7 +52,7 @@ "views": [], "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, '3ebd234270e36902d3d461af38664869')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ca007bca2ab4a9b901662792042770ad')" ] } } \ No newline at end of file diff --git a/core/database/src/main/kotlin/dev/meloda/fast/database/AccountsDatabase.kt b/core/database/src/main/kotlin/dev/meloda/fast/database/AccountsDatabase.kt index 0f31410e..e2576c65 100644 --- a/core/database/src/main/kotlin/dev/meloda/fast/database/AccountsDatabase.kt +++ b/core/database/src/main/kotlin/dev/meloda/fast/database/AccountsDatabase.kt @@ -7,7 +7,7 @@ import dev.meloda.fast.model.database.AccountEntity @Database( entities = [AccountEntity::class], - version = 2 + version = 3 ) abstract class AccountsDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao diff --git a/core/database/src/main/kotlin/dev/meloda/fast/database/CacheDatabase.kt b/core/database/src/main/kotlin/dev/meloda/fast/database/CacheDatabase.kt index 1e91bded..c4abb7f2 100644 --- a/core/database/src/main/kotlin/dev/meloda/fast/database/CacheDatabase.kt +++ b/core/database/src/main/kotlin/dev/meloda/fast/database/CacheDatabase.kt @@ -6,7 +6,7 @@ import androidx.room.TypeConverters import dev.meloda.fast.database.dao.ConversationDao import dev.meloda.fast.database.dao.GroupDao import dev.meloda.fast.database.dao.MessageDao -import dev.meloda.fast.database.dao.UsersDao +import dev.meloda.fast.database.dao.UserDao import dev.meloda.fast.database.typeconverters.Converters import dev.meloda.fast.model.database.VkConversationEntity import dev.meloda.fast.model.database.VkGroupEntity @@ -21,11 +21,11 @@ import dev.meloda.fast.model.database.VkUserEntity VkConversationEntity::class ], - version = 7 + version = 10 ) @TypeConverters(Converters::class) abstract class CacheDatabase : RoomDatabase() { - abstract fun userDao(): UsersDao + abstract fun userDao(): UserDao abstract fun groupDao(): GroupDao abstract fun messageDao(): MessageDao abstract fun conversationDao(): ConversationDao diff --git a/core/database/src/main/kotlin/dev/meloda/fast/database/dao/AccountDao.kt b/core/database/src/main/kotlin/dev/meloda/fast/database/dao/AccountDao.kt index 2ed58fa8..bbbdddb7 100644 --- a/core/database/src/main/kotlin/dev/meloda/fast/database/dao/AccountDao.kt +++ b/core/database/src/main/kotlin/dev/meloda/fast/database/dao/AccountDao.kt @@ -11,8 +11,8 @@ abstract class AccountDao : EntityDao { abstract suspend fun getAll(): List @Query("SELECT * FROM accounts WHERE userId = :userId") - abstract suspend fun getById(userId: Int): AccountEntity? + abstract suspend fun getById(userId: Long): AccountEntity? @Query("DELETE FROM accounts WHERE userId = :userId") - abstract suspend fun deleteById(userId: Int) + abstract suspend fun deleteById(userId: Long) } diff --git a/core/database/src/main/kotlin/dev/meloda/fast/database/dao/ConversationDao.kt b/core/database/src/main/kotlin/dev/meloda/fast/database/dao/ConversationDao.kt index a435fb94..8f40b279 100644 --- a/core/database/src/main/kotlin/dev/meloda/fast/database/dao/ConversationDao.kt +++ b/core/database/src/main/kotlin/dev/meloda/fast/database/dao/ConversationDao.kt @@ -16,11 +16,11 @@ abstract class ConversationDao : EntityDao { abstract suspend fun getAllByIds(ids: List): List @Query("SELECT * FROM conversations WHERE id IS (:id)") - abstract suspend fun getById(id: Int): VkConversationEntity? + abstract suspend fun getById(id: Long): VkConversationEntity? @Transaction @Query("SELECT * FROM conversations WHERE id IS (:id)") - abstract suspend fun getByIdWithMessage(id: Int): ConversationWithMessage? + abstract suspend fun getByIdWithMessage(id: Long): ConversationWithMessage? @Query("DELETE FROM conversations WHERE rowid IN (:ids)") abstract suspend fun deleteByIds(ids: List): Int diff --git a/core/database/src/main/kotlin/dev/meloda/fast/database/dao/MessageDao.kt b/core/database/src/main/kotlin/dev/meloda/fast/database/dao/MessageDao.kt index 3a5d844d..68e9b634 100644 --- a/core/database/src/main/kotlin/dev/meloda/fast/database/dao/MessageDao.kt +++ b/core/database/src/main/kotlin/dev/meloda/fast/database/dao/MessageDao.kt @@ -11,13 +11,13 @@ abstract class MessageDao : EntityDao { abstract suspend fun getAll(): List @Query("SELECT * FROM messages WHERE peerId IS (:conversationId)") - abstract suspend fun getAll(conversationId: Int): List + abstract suspend fun getAll(conversationId: Long): List @Query("SELECT * FROM messages WHERE id IN (:ids)") abstract suspend fun getAllByIds(ids: List): List @Query("SELECT * FROM messages WHERE id IS (:messageId)") - abstract suspend fun getById(messageId: Int): VkMessageEntity? + abstract suspend fun getById(messageId: Long): VkMessageEntity? @Query("DELETE FROM messages WHERE id IN (:ids)") abstract suspend fun deleteByIds(ids: List): Int diff --git a/core/database/src/main/kotlin/dev/meloda/fast/database/dao/UsersDao.kt b/core/database/src/main/kotlin/dev/meloda/fast/database/dao/UserDao.kt similarity index 65% rename from core/database/src/main/kotlin/dev/meloda/fast/database/dao/UsersDao.kt rename to core/database/src/main/kotlin/dev/meloda/fast/database/dao/UserDao.kt index a2ca9b23..086936a0 100644 --- a/core/database/src/main/kotlin/dev/meloda/fast/database/dao/UsersDao.kt +++ b/core/database/src/main/kotlin/dev/meloda/fast/database/dao/UserDao.kt @@ -5,14 +5,14 @@ import androidx.room.Query import dev.meloda.fast.model.database.VkUserEntity @Dao -abstract class UsersDao : EntityDao { +abstract class UserDao : EntityDao { @Query("SELECT * FROM users") abstract suspend fun getAll(): List @Query("SELECT * FROM users WHERE id IN (:ids)") - abstract suspend fun getAllByIds(ids: List): List + abstract suspend fun getAllByIds(ids: List): List @Query("DELETE FROM users WHERE id IN (:ids)") - abstract suspend fun deleteByIds(ids: List): Int + abstract suspend fun deleteByIds(ids: List): Int } diff --git a/core/database/src/main/kotlin/dev/meloda/fast/database/di/DatabaseModule.kt b/core/database/src/main/kotlin/dev/meloda/fast/database/di/DatabaseModule.kt index ff7adfb8..251e807c 100644 --- a/core/database/src/main/kotlin/dev/meloda/fast/database/di/DatabaseModule.kt +++ b/core/database/src/main/kotlin/dev/meloda/fast/database/di/DatabaseModule.kt @@ -2,17 +2,21 @@ package dev.meloda.fast.database.di import androidx.room.Room import dev.meloda.fast.database.AccountsDatabase +import dev.meloda.fast.database.CacheDatabase +import dev.meloda.fast.database.di.migration.migrationFrom2To3 import org.koin.core.scope.Scope import org.koin.dsl.module val databaseModule = module { single { - Room.databaseBuilder(get(), AccountsDatabase::class.java, "accounts").build() + Room.databaseBuilder(get(), AccountsDatabase::class.java, "accounts") + .addMigrations(migrationFrom2To3) + .build() } single { get().accountDao() } single { - Room.databaseBuilder(get(), dev.meloda.fast.database.CacheDatabase::class.java, "cache") + Room.databaseBuilder(get(), CacheDatabase::class.java, "cache") .fallbackToDestructiveMigration() .build() } @@ -22,4 +26,4 @@ val databaseModule = module { single { cacheDB().conversationDao() } } -private fun Scope.cacheDB(): dev.meloda.fast.database.CacheDatabase = get() +private fun Scope.cacheDB(): CacheDatabase = get() diff --git a/core/database/src/main/kotlin/dev/meloda/fast/database/di/migration/MigrationFrom2To3.kt b/core/database/src/main/kotlin/dev/meloda/fast/database/di/migration/MigrationFrom2To3.kt new file mode 100644 index 00000000..1099d79a --- /dev/null +++ b/core/database/src/main/kotlin/dev/meloda/fast/database/di/migration/MigrationFrom2To3.kt @@ -0,0 +1,14 @@ +package dev.meloda.fast.database.di.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val migrationFrom2To3 = object : Migration( + startVersion = 2, + endVersion = 3 +) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE accounts ADD COLUMN exchangeToken TEXT DEFAULT null") + } +} diff --git a/core/database/src/main/kotlin/dev/meloda/fast/database/typeconverters/Converters.kt b/core/database/src/main/kotlin/dev/meloda/fast/database/typeconverters/Converters.kt index 589e705b..fd439916 100644 --- a/core/database/src/main/kotlin/dev/meloda/fast/database/typeconverters/Converters.kt +++ b/core/database/src/main/kotlin/dev/meloda/fast/database/typeconverters/Converters.kt @@ -13,6 +13,15 @@ class Converters { .split(", ") .mapNotNull(String::toIntOrNull) + @TypeConverter + fun longListToString(list: List): String = list.joinToString() + + @TypeConverter + fun stringToLongList(string: String): List = + string + .split(", ") + .mapNotNull(String::toLongOrNull) + @TypeConverter fun stringListToString(list: List): String = list.joinToString() diff --git a/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/SettingsKeys.kt b/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/SettingsKeys.kt index 4d869d65..8f69cdf8 100644 --- a/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/SettingsKeys.kt +++ b/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/SettingsKeys.kt @@ -45,7 +45,7 @@ object SettingsKeys { const val KEY_ENABLE_HAPTIC = "enable_haptic" const val DEFAULT_ENABLE_HAPTIC = true const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level" - const val DEFAULT_NETWORK_LOG_LEVEL = 0 + const val DEFAULT_NETWORK_LOG_LEVEL = 3 const val KEY_USE_SYSTEM_FONT = "use_system_font" const val DEFAULT_USE_SYSTEM_FONT = false const val KEY_MORE_ANIMATIONS = "more_animations" @@ -53,5 +53,5 @@ object SettingsKeys { const val KEY_SHOW_DEBUG_CATEGORY = "show_debug_category" - const val ID_DMITRY = 37610580 + const val ID_DMITRY = 37610580L } diff --git a/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/UserSettings.kt b/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/UserSettings.kt index a8585cef..080b4aa9 100644 --- a/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/UserSettings.kt +++ b/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/UserSettings.kt @@ -24,6 +24,7 @@ interface UserSettings { val showEmojiButton: StateFlow val showTimeInActionMessages: StateFlow val useSystemFont: StateFlow + val enableAnimations: StateFlow val showDebugCategory: StateFlow fun onUseContactNamesChanged(use: Boolean) @@ -68,6 +69,7 @@ class UserSettingsImpl : UserSettings { override val showTimeInActionMessages = MutableStateFlow(AppSettings.Experimental.showTimeInActionMessages) override val useSystemFont = MutableStateFlow(AppSettings.Appearance.useSystemFont) + override val enableAnimations = MutableStateFlow(AppSettings.Experimental.moreAnimations) override val showDebugCategory = MutableStateFlow(AppSettings.Debug.showDebugCategory) override fun onUseContactNamesChanged(use: Boolean) { diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/AuthUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/AuthUseCase.kt index 21c3c43a..897a6bb6 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/AuthUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/AuthUseCase.kt @@ -1,12 +1,32 @@ package dev.meloda.fast.domain import dev.meloda.fast.data.State +import dev.meloda.fast.model.api.responses.ExchangeSilentTokenResponse +import dev.meloda.fast.model.api.responses.GetAnonymTokenResponse +import dev.meloda.fast.model.api.responses.GetExchangeTokenResponse import dev.meloda.fast.model.api.responses.ValidatePhoneResponse import kotlinx.coroutines.flow.Flow -interface AuthUseCase { +interface AuthUseCase : BaseUseCase { + + fun logout(): Flow> fun validatePhone( validationSid: String ): Flow> + + suspend fun getAnonymToken( + clientId: String, + clientSecret: String + ): Flow> + + suspend fun exchangeSilentToken( + anonymToken: String, + silentToken: String, + silentUuid: String + ): Flow> + + suspend fun getExchangeToken( + accessToken: String + ): Flow> } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/AuthUseCaseImpl.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/AuthUseCaseImpl.kt index 89676bcb..bb279844 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/AuthUseCaseImpl.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/AuthUseCaseImpl.kt @@ -3,16 +3,44 @@ package dev.meloda.fast.domain import dev.meloda.fast.data.State import dev.meloda.fast.data.api.auth.AuthRepository import dev.meloda.fast.data.mapToState +import dev.meloda.fast.model.api.responses.ExchangeSilentTokenResponse +import dev.meloda.fast.model.api.responses.GetAnonymTokenResponse +import dev.meloda.fast.model.api.responses.GetExchangeTokenResponse import dev.meloda.fast.model.api.responses.ValidatePhoneResponse import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow class AuthUseCaseImpl(private val repository: AuthRepository) : AuthUseCase { - override fun validatePhone(validationSid: String): Flow> = flow { - emit(State.Loading) + override fun logout(): Flow> = flowNewState { repository.logout().mapToState() } - val newState = repository.validatePhone(validationSid).mapToState() - emit(newState) + override fun validatePhone(validationSid: String): Flow> = + flowNewState { repository.validatePhone(validationSid = validationSid).mapToState() } + + override suspend fun getAnonymToken( + clientId: String, + clientSecret: String + ): Flow> = flowNewState { + repository.getAnonymToken( + clientId = clientId, + clientSecret = clientSecret + ).mapToState() + } + + override suspend fun exchangeSilentToken( + anonymToken: String, + silentToken: String, + silentUuid: String + ): Flow> = flowNewState { + repository.exchangeSilentToken( + anonymToken = anonymToken, + silentToken = silentToken, + silentUuid = silentUuid + ).mapToState() + } + + override suspend fun getExchangeToken( + accessToken: String + ): Flow> = flowNewState { + repository.getExchangeToken(accessToken = accessToken).mapToState() } } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/BaseUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/BaseUseCase.kt new file mode 100644 index 00000000..c3aa674f --- /dev/null +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/BaseUseCase.kt @@ -0,0 +1,16 @@ +package dev.meloda.fast.domain + +import dev.meloda.fast.data.State +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow + +interface BaseUseCase { + + suspend fun FlowCollector>.emitState(stateBlock: suspend () -> State) { + emit(State.Loading) + emit(stateBlock()) + } + + fun flowNewState(stateBlock: suspend () -> State) = + flow { emitState(stateBlock) } +} diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/ConversationsUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/ConversationsUseCase.kt index d8d86b4e..895866aa 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/ConversationsUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/ConversationsUseCase.kt @@ -1,19 +1,29 @@ package dev.meloda.fast.domain import dev.meloda.fast.data.State +import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.api.domain.VkConversation import kotlinx.coroutines.flow.Flow -interface ConversationsUseCase { - - fun getConversations( - count: Int?, - offset: Int?, - ): Flow>> - - fun delete(peerId: Int): Flow> - - fun changePinState(peerId: Int, pin: Boolean): Flow> +interface ConversationsUseCase : BaseUseCase { suspend fun storeConversations(conversations: List) + + fun getConversations( + count: Int? = null, + offset: Int? = null, + filter: ConversationsFilter + ): Flow>> + + fun getById( + peerIds: List, + extended: Boolean? = null, + fields: String? = null + ): Flow>> + + fun delete(peerId: Long): Flow> + + fun changePinState(peerId: Long, pin: Boolean): Flow> + + fun changeArchivedState(peerId: Long, archive: Boolean): Flow> } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/ConversationsUseCaseImpl.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/ConversationsUseCaseImpl.kt index 88c6e70d..6574153d 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/ConversationsUseCaseImpl.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/ConversationsUseCaseImpl.kt @@ -3,116 +3,69 @@ package dev.meloda.fast.domain import dev.meloda.fast.data.State import dev.meloda.fast.data.api.conversations.ConversationsRepository import dev.meloda.fast.data.mapToState +import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.api.domain.VkConversation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext class ConversationsUseCaseImpl( private val repository: ConversationsRepository, ) : ConversationsUseCase { - // override fun getConversations( -// count: Int?, -// offset: Int?, -// fields: String, -// filter: String, -// extended: Boolean?, -// startMessageId: Int? -// ): Flow> = flow { -// emit(dev.meloda.fast.network.State.Loading) -// -// val newState = conversationsRepository.getConversations( -// params = ConversationsGetRequest( -// count = count, -// offset = offset, -// fields = fields, -// filter = filter, -// extended = extended, -// startMessageId = startMessageId -// ) -// ).fold( -// onSuccess = { response -> dev.meloda.fast.network.State.Success(response.toDomain()) }, -// onNetworkFailure = { dev.meloda.fast.network.State.Error.ConnectionError }, -// onUnknownFailure = { dev.meloda.fast.network.State.UNKNOWN_ERROR }, -// onHttpFailure = { result -> result.error.toStateApiError() }, -// onApiFailure = { result -> result.error.toStateApiError() } -// ) -// emit(newState) -// } -// - - // -// override fun pin(peerId: Int): Flow> = flow { -// emit(dev.meloda.fast.network.State.Loading) -// -// val newState = conversationsRepository.pin( -// ConversationsPinRequest(peerId = peerId) -// ).fold( -// onSuccess = { dev.meloda.fast.network.State.Success(Unit) }, -// onNetworkFailure = { dev.meloda.fast.network.State.Error.ConnectionError }, -// onUnknownFailure = { dev.meloda.fast.network.State.UNKNOWN_ERROR }, -// onHttpFailure = { result -> result.error.toStateApiError() }, -// onApiFailure = { result -> result.error.toStateApiError() } -// ) -// emit(newState) -// } -// -// override fun unpin(peerId: Int): Flow> = flow { -// emit(dev.meloda.fast.network.State.Loading) -// -// val newState = conversationsRepository.unpin( -// ConversationsUnpinRequest(peerId = peerId) -// ).fold( -// onSuccess = { dev.meloda.fast.network.State.Success(Unit) }, -// onNetworkFailure = { dev.meloda.fast.network.State.Error.ConnectionError }, -// onUnknownFailure = { dev.meloda.fast.network.State.UNKNOWN_ERROR }, -// onHttpFailure = { result -> result.error.toStateApiError() }, -// onApiFailure = { result -> result.error.toStateApiError() } -// ) -// emit(newState) -// } -// -// override suspend fun storeConversations(conversations: List) { -// conversationsDao.insertAll(conversations.map(VkConversationDomain::mapToDb)) -// } -// -// override suspend fun storeGroups(groups: List) { -// groupsDao.insertAll(groups.map(VkGroupDomain::mapToDB)) -// } - override fun getConversations( - count: Int?, - offset: Int? - ): Flow>> = flow { - emit(State.Loading) - - val newState = repository.getConversations(count, offset).mapToState() - emit(newState) - } - override suspend fun storeConversations( conversations: List ) = withContext(Dispatchers.IO) { repository.storeConversations(conversations) } - override fun delete(peerId: Int): Flow> = flow { - emit(State.Loading) - - val newState = repository.delete(peerId = peerId).mapToState() - emit(newState) + override fun getConversations( + count: Int?, + offset: Int?, + filter: ConversationsFilter + ): Flow>> = flowNewState { + repository.getConversations( + count = count, + offset = offset, + filter = filter + ).mapToState() } - override fun changePinState(peerId: Int, pin: Boolean): Flow> = flow { - emit(State.Loading) + override fun getById( + peerIds: List, + extended: Boolean?, + fields: String? + ): Flow>> = flowNewState { + repository.getConversationsById( + peerIds = peerIds, + extended = extended, + fields = fields + ).mapToState() + } - val newState = if (pin) { + override fun delete(peerId: Long): Flow> = flowNewState { + repository.delete(peerId = peerId).mapToState() + } + + override fun changePinState( + peerId: Long, + pin: Boolean + ): Flow> = flowNewState { + if (pin) { repository.pin(peerId) } else { repository.unpin(peerId) }.mapToState() + } - emit(newState) + override fun changeArchivedState( + peerId: Long, + archive: Boolean + ): Flow> = flowNewState { + if (archive) { + repository.archive(peerId) + } else { + repository.unarchive(peerId) + }.mapToState() } } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/FriendsUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/FriendsUseCase.kt index d70c2acb..731c6aab 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/FriendsUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/FriendsUseCase.kt @@ -8,11 +8,13 @@ import kotlinx.coroutines.flow.Flow interface FriendsUseCase { fun getAllFriends( + order: String = "hints", count: Int?, offset: Int? ): Flow> fun getFriends( + order: String = "hints", count: Int?, offset: Int? ): Flow>> @@ -20,7 +22,7 @@ interface FriendsUseCase { fun getOnlineFriends( count: Int?, offset: Int? - ): Flow>> + ): Flow>> suspend fun storeUsers(users: List) } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/FriendsUseCaseImpl.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/FriendsUseCaseImpl.kt index 19d22bf5..7790af9b 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/FriendsUseCaseImpl.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/FriendsUseCaseImpl.kt @@ -11,25 +11,32 @@ import kotlinx.coroutines.flow.flow class FriendsUseCaseImpl(private val repository: FriendsRepository) : FriendsUseCase { - override fun getAllFriends(count: Int?, offset: Int?): Flow> = flow { + override fun getAllFriends(order: String, count: Int?, offset: Int?): Flow> = flow { emit(State.Loading) - val newState = repository.getAllFriends(count, offset).mapToState() + val newState = repository.getAllFriends(order, count, offset).mapToState() emit(newState) } override fun getFriends( - count: Int?, offset: Int? + order: String, + count: Int?, + offset: Int? ): Flow>> = flow { emit(State.Loading) - val newState = repository.getFriends(count, offset).mapToState() + val newState = repository.getFriends( + order = order, + count = count, + offset = offset + ).mapToState() + emit(newState) } override fun getOnlineFriends( count: Int?, offset: Int? - ): Flow>> = flow { + ): Flow>> = flow { emit(State.Loading) val newState = repository.getOnlineFriends(count, offset).mapToState() diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUserByIdUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUserByIdUseCase.kt index 37d322dc..ea43539c 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUserByIdUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUserByIdUseCase.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.flow class GetLocalUserByIdUseCase(private val repository: UsersRepository) { - operator fun invoke(userId: Int): Flow> = flow { + operator fun invoke(userId: Long): Flow> = flow { emit(State.Loading) val newState = kotlin.runCatching { @@ -21,7 +21,7 @@ class GetLocalUserByIdUseCase(private val repository: UsersRepository) { emit(newState) } - suspend fun proceed(userId: Int): VkUser? { + suspend fun proceed(userId: Long): VkUser? { return repository.getLocalUsers(userIds = listOf(userId)).singleOrNull() } } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUsersByIdsUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUsersByIdsUseCase.kt index e371323a..ceb31c9a 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUsersByIdsUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUsersByIdsUseCase.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.flow class GetLocalUsersByIdsUseCase(private val repository: UsersRepository) { - operator fun invoke(userIds: List): Flow>> = flow { + operator fun invoke(userIds: List): Flow>> = flow { emit(State.Loading) val newState = kotlin.runCatching { diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadConversationsByIdUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadConversationsByIdUseCase.kt index 309430ab..be9f2830 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadConversationsByIdUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadConversationsByIdUseCase.kt @@ -5,19 +5,21 @@ import dev.meloda.fast.data.api.conversations.ConversationsRepository import dev.meloda.fast.data.mapToState import dev.meloda.fast.model.api.domain.VkConversation import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow class LoadConversationsByIdUseCase( private val conversationsRepository: ConversationsRepository -) { +) : BaseUseCase { - operator fun invoke(peerIds: List): Flow>> = flow { - emit(State.Loading) - - val newState = conversationsRepository - .getConversationsById(peerIds = peerIds) - .mapToState() - - emit(newState) + operator fun invoke( + peerIds: List, + extended: Boolean? = null, + fields: String? = null + ): Flow>> = flowNewState { + conversationsRepository + .getConversationsById( + peerIds = peerIds, + extended = extended, + fields = fields, + ).mapToState() } } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadUserByIdUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadUserByIdUseCase.kt index a8b93a8a..ad776240 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadUserByIdUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadUserByIdUseCase.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.flow class LoadUserByIdUseCase(private val repository: UsersRepository) { operator fun invoke( - userId: Int?, + userId: Long?, fields: String = VkConstants.USER_FIELDS, nomCase: String? = null ): Flow> = flow { diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadUsersByIdsUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadUsersByIdsUseCase.kt index 0f845ba6..49f523e2 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadUsersByIdsUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadUsersByIdsUseCase.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.flow class LoadUsersByIdsUseCase(private val repository: UsersRepository) { operator fun invoke( - userIds: List?, + userIds: List?, fields: String = VkConstants.USER_FIELDS, nomCase: String? = null ): Flow>> = flow { diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt index e66b4734..5223a06e 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt @@ -3,21 +3,24 @@ 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 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.VkMemoryCache import dev.meloda.fast.data.processState import dev.meloda.fast.model.ApiEvent +import dev.meloda.fast.model.ConversationFlags 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.VkConversation import dev.meloda.fast.model.api.domain.VkMessage import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.coroutines.CoroutineContext @@ -25,6 +28,7 @@ import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine class LongPollUpdatesParser( + private val conversationsUseCase: ConversationsUseCase, private val messagesUseCase: MessagesUseCase ) { private val job = SupervisorJob() @@ -68,6 +72,458 @@ class LongPollUpdatesParser( ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event) ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event) + ApiEvent.MESSAGE_UPDATED -> parseMessageUpdated(eventType, event) + ApiEvent.MESSAGE_CACHE_CLEAR -> parseMessageCacheClear(eventType, event) + } + } + + private fun parseMessageSetFlags(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType: $event") + + val cmId = event[1].asLong() + val flags = event[2].asInt() + val peerId = event[3].asLong() + + val eventsToSend = mutableListOf() + + val parsedFlags = MessageFlags.parse(flags) + parsedFlags.forEach { flag -> + when (flag) { + MessageFlags.IMPORTANT -> { + val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant( + peerId = peerId, + cmId = cmId, + marked = true + ) + eventsToSend += eventToSend + + listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as? VkEventCallback) + ?.onEvent(eventToSend) + } + } + } + + MessageFlags.SPAM -> { + val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam( + peerId = peerId, + cmId = cmId + ) + eventsToSend += eventToSend + + listenersMap[LongPollEvent.MARKED_AS_SPAM]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as? VkEventCallback) + ?.onEvent(eventToSend) + } + } + } + + MessageFlags.DELETED -> { + val eventToSend = + if (parsedFlags.contains(MessageFlags.DELETED_FOR_ALL)) { + LongPollParsedEvent.MessageDeleted( + peerId = peerId, + cmId = cmId, + forAll = true + ) + } else { + LongPollParsedEvent.MessageDeleted( + peerId = peerId, + cmId = cmId, + forAll = false + ) + } + eventsToSend += eventToSend + + listenersMap[LongPollEvent.MESSAGE_DELETED]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as? VkEventCallback) + ?.onEvent(eventToSend) + } + } + } + + MessageFlags.AUDIO_LISTENED -> { + val eventToSend = LongPollParsedEvent.AudioMessageListened( + peerId = peerId, + cmId = cmId + ) + eventsToSend += eventToSend + + listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as? VkEventCallback) + ?.onEvent(eventToSend) + } + } + } + + else -> Unit + } + } + + eventsToSend.forEach { eventToSend -> + listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as? VkEventCallback)?.onEvent(eventToSend) + } + } + } + } + + private fun parseMessageClearFlags(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType: $event") + + val cmId = event[1].asLong() + val flags = event[2].asInt() + val peerId = event[3].asLong() + + val eventsToSend = mutableListOf() + + val parsedFlags = MessageFlags.parse(flags) + + coroutineScope.launch { + parsedFlags.forEach { flag -> + when (flag) { + MessageFlags.IMPORTANT -> { + val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant( + peerId = peerId, + cmId = cmId, + marked = false + ) + eventsToSend += eventToSend + + listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as? VkEventCallback) + ?.onEvent(eventToSend) + } + } + } + + MessageFlags.SPAM -> { + if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) { + 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) + ?.onEvent(eventToSend) + } + } + } + } + } + } + + MessageFlags.DELETED -> { + 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) + ?.onEvent(eventToSend) + } + } + } + } + } + + else -> Unit + } + } + + eventsToSend.forEach { eventToSend -> + listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.let { listeners -> + listeners.map { vkEventCallback -> + vkEventCallback.onEvent(eventToSend) + } + } + } + } + } + + private fun parseMessageNew(eventType: ApiEvent, event: List) { + 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 conversation = + async { + loadConversation( + peerId = peerId, + extended = true, + fields = VkConstants.ALL_FIELDS + ) + }.await() + + message?.let { + listenersMap[LongPollEvent.MESSAGE_NEW]?.let { + it.map { vkEventCallback -> + (vkEventCallback as VkEventCallback) + .onEvent( + LongPollParsedEvent.NewMessage( + message = message, + inArchive = conversation?.isArchived == true + // TODO: 03-Apr-25, Danil Nikolaev: + // load user settings about restoring chats with + // enabled notifications from archive + ) + ) + } + } + } + } + } + + private fun parseMessageEdit(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType: $event") + + val cmId = event[1].asLong() + val peerId = event[3].asLong() + + coroutineScope.launch(Dispatchers.IO) { + loadMessage( + peerId = peerId, + cmId = cmId + )?.let { message -> + listenersMap[LongPollEvent.MESSAGE_EDITED]?.let { + it.map { vkEventCallback -> + (vkEventCallback as VkEventCallback) + .onEvent(LongPollParsedEvent.MessageEdited(message)) + } + } + } + } + } + + private fun parseMessageReadIncoming(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType: $event") + val peerId = event[1].asLong() + val cmId = event[2].asLong() + val unreadCount = event[3].asInt() + + listenersMap[LongPollEvent.INCOMING_MESSAGE_READ]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as VkEventCallback) + .onEvent( + LongPollParsedEvent.IncomingMessageRead( + peerId = peerId, + cmId = cmId, + unreadCount = unreadCount + ) + ) + } + } + } + + private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType: $event") + val peerId = event[1].asLong() + val cmId = event[2].asLong() + val unreadCount = event[3].asInt() + + listenersMap[LongPollEvent.OUTGOING_MESSAGE_READ]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as VkEventCallback) + .onEvent( + LongPollParsedEvent.OutgoingMessageRead( + peerId = peerId, + cmId = cmId, + unreadCount = unreadCount + ) + ) + } + } + } + + private fun parseChatClearFlags(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType: $event") + + val peerId = event[1].asLong() + val flags = event[2].asInt() + + val eventsToSend = mutableListOf() + + val parsedFlags = ConversationFlags.parse(flags) + + coroutineScope.launch(Dispatchers.IO) { + parsedFlags.forEach { flag -> + when (flag) { + ConversationFlags.ARCHIVED -> { + val conversation = loadConversation( + peerId = peerId, + extended = true, + fields = VkConstants.ALL_FIELDS + ) ?: return@forEach + + val message = loadMessage( + peerId = peerId, + cmId = conversation.lastCmId + ) + + val eventToSend = LongPollParsedEvent.ChatArchived( + conversation = conversation.copy(lastMessage = message), + archived = false + ) + eventsToSend += eventToSend + + listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as? VkEventCallback) + ?.onEvent(eventToSend) + } + } + } + + else -> Unit + } + } + + eventsToSend.forEach { eventToSend -> + listenersMap[LongPollEvent.CHAT_CLEAR_FLAGS]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as? VkEventCallback)?.onEvent( + eventToSend + ) + } + } + } + } + } + + private fun parseChatSetFlags(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType: $event") + + val peerId = event[1].asLong() + val flags = event[2].asInt() + + val eventsToSend = mutableListOf() + + val parsedFlags = ConversationFlags.parse(flags) + + coroutineScope.launch(Dispatchers.IO) { + parsedFlags.forEach { flag -> + when (flag) { + ConversationFlags.ARCHIVED -> { + val conversation = loadConversation( + peerId = peerId, + extended = true, + fields = VkConstants.ALL_FIELDS + ) ?: return@forEach + + val message = loadMessage( + peerId = peerId, + cmId = conversation.lastCmId + ) + + val eventToSend = LongPollParsedEvent.ChatArchived( + conversation = conversation.copy(lastMessage = message), + archived = true + ) + eventsToSend += eventToSend + + listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as? VkEventCallback) + ?.onEvent(eventToSend) + } + } + } + + else -> Unit + } + } + + eventsToSend.forEach { eventToSend -> + listenersMap[LongPollEvent.CHAT_SET_FLAGS]?.let { listeners -> + listeners.map { vkEventCallback -> + (vkEventCallback as? VkEventCallback)?.onEvent( + eventToSend + ) + } + } + } + } + } + + private fun parseMessagesDeleted(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType: $event") + + val peerId = event[1].asLong() + val cmId = event[2].asLong() + + listenersMap[LongPollEvent.CHAT_CLEARED]?.let { listeners -> + listeners.forEach { vkEventCallback -> + (vkEventCallback as VkEventCallback) + .onEvent( + LongPollParsedEvent.ChatCleared( + peerId = peerId, + toCmId = cmId + ) + ) + } + } + } + + private fun parseChatMajorChanged(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType: $event") + + val peerId = event[1].asLong() + val majorId = event[2].asInt() + + listenersMap[LongPollEvent.CHAT_MAJOR_CHANGED]?.let { listeners -> + listeners.forEach { vkEventCallback -> + (vkEventCallback as VkEventCallback) + .onEvent( + LongPollParsedEvent.ChatMajorChanged( + peerId = peerId, + majorId = majorId, + ) + ) + } + } + } + + private fun parseChatMinorChanged(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType: $event") + + val peerId = event[1].asLong() + val minorId = event[2].asInt() + + listenersMap[LongPollEvent.CHAT_MINOR_CHANGED]?.let { listeners -> + listeners.forEach { vkEventCallback -> + (vkEventCallback as VkEventCallback) + .onEvent( + LongPollParsedEvent.ChatMinorChanged( + peerId = peerId, + minorId = minorId, + ) + ) + } } } @@ -92,8 +548,8 @@ class LongPollUpdatesParser( else -> return } - val peerId = event[1].asInt() - val userIds = event[2].toList(Any::asInt).filter { it != UserConfig.userId } + val peerId = event[1].asLong() + val userIds = event[2].toList(Any::asLong).filter { it != UserConfig.userId } val totalCount = event[3].asInt() val timestamp = event[4].asInt() @@ -145,325 +601,57 @@ class LongPollUpdatesParser( } } - private fun parseMessageSetFlags(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") + private fun parseMessageUpdated(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType $event") - val messageId = event[1].asInt() - val flags = event[2].asInt() - val peerId = event[3].asInt() - - val eventsToSend = mutableListOf() - - val parsedFlags = MessageFlags.parse(flags) - parsedFlags.forEach { flag -> - when (flag) { - MessageFlags.IMPORTANT -> { // marked as important - val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant( - peerId = peerId, - messageId = messageId, - marked = true - ) - eventsToSend += eventToSend - - listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners -> - listeners.map { vkEventCallback -> - (vkEventCallback as? VkEventCallback) - ?.onEvent(eventToSend) - } - } - } - - MessageFlags.SPAM -> { // marked as spam - val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam( - peerId = peerId, - messageId = messageId - ) - eventsToSend += eventToSend - - listenersMap[LongPollEvent.MARKED_AS_SPAM]?.let { listeners -> - listeners.map { vkEventCallback -> - (vkEventCallback as? VkEventCallback) - ?.onEvent(eventToSend) - } - } - } - - MessageFlags.DELETED -> { - val eventToSend = - if (parsedFlags.contains(MessageFlags.DELETED_FOR_ALL)) { // deleted for all - LongPollParsedEvent.MessageDeleted( - peerId = peerId, - messageId = messageId, - forAll = true - ) - } else { // deleted only for me - LongPollParsedEvent.MessageDeleted( - peerId = peerId, - messageId = messageId, - forAll = false - ) - } - eventsToSend += eventToSend - - listenersMap[LongPollEvent.MESSAGE_DELETED]?.let { listeners -> - listeners.map { vkEventCallback -> - (vkEventCallback as? VkEventCallback) - ?.onEvent(eventToSend) - } - } - } - - MessageFlags.AUDIO_LISTENED -> { // audio message listened - val eventToSend = LongPollParsedEvent.AudioMessageListened( - peerId = peerId, - messageId = messageId - ) - eventsToSend += eventToSend - - listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]?.let { listeners -> - listeners.map { vkEventCallback -> - (vkEventCallback as? VkEventCallback) - ?.onEvent(eventToSend) - } - } - } - - else -> Unit - } - } - - eventsToSend.forEach { eventToSend -> - listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners -> - listeners.map { vkEventCallback -> - (vkEventCallback as? VkEventCallback)?.onEvent(eventToSend) - } - } - } - } - - private fun parseMessageClearFlags(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val messageId = event[1].asInt() - val flags = event[2].asInt() - val peerId = event[3].asInt() - - val eventsToSend = mutableListOf() - - val parsedFlags = MessageFlags.parse(flags) - - coroutineScope.launch { - parsedFlags.forEach { flag -> - when (flag) { - MessageFlags.IMPORTANT -> { // not important anymore - val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant( - peerId = peerId, - messageId = messageId, - marked = false - ) - eventsToSend += eventToSend - - listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners -> - listeners.map { vkEventCallback -> - (vkEventCallback as? VkEventCallback) - ?.onEvent(eventToSend) - } - } - } - - MessageFlags.SPAM -> { - if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) { // not spam anymore - withContext(Dispatchers.IO) { - val message = loadMessage(messageId) - message?.let { - val eventToSend = - LongPollParsedEvent.MessageMarkedAsNotSpam(message = message) - eventsToSend += eventToSend - - listenersMap[LongPollEvent.MARKED_AS_NOT_SPAM]?.let { listeners -> - listeners.map { vkEventCallback -> - (vkEventCallback as? VkEventCallback) - ?.onEvent(eventToSend) - } - } - } - } - } - } - - MessageFlags.DELETED -> { // restored - withContext(Dispatchers.IO) { - val message = loadMessage(messageId) - message?.let { - val eventToSend = - LongPollParsedEvent.MessageRestored(message = message) - eventsToSend += eventToSend - - listenersMap[LongPollEvent.MESSAGE_RESTORED]?.let { listeners -> - listeners.map { vkEventCallback -> - (vkEventCallback as? VkEventCallback) - ?.onEvent(eventToSend) - } - } - } - } - } - - else -> Unit - } - } - - eventsToSend.forEach { eventToSend -> - listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.let { listeners -> - listeners.map { vkEventCallback -> - vkEventCallback.onEvent(eventToSend) - } - } - } - } - } - - private fun parseMessageNew(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - val messageId = event[1].asInt() + val cmId = event[1].asLong() + val peerId = event[4].asLong() coroutineScope.launch(Dispatchers.IO) { - loadMessage(messageId)?.let { message -> - listenersMap[LongPollEvent.MESSAGE_NEW]?.let { + loadMessage( + peerId = peerId, + cmId = cmId + )?.let { message -> + listenersMap[LongPollEvent.MESSAGE_UPDATED]?.let { it.map { vkEventCallback -> - (vkEventCallback as VkEventCallback) - .onEvent(LongPollParsedEvent.NewMessage(message)) + (vkEventCallback as VkEventCallback) + .onEvent(LongPollParsedEvent.MessageUpdated(message)) } } } } } - private fun parseMessageEdit(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - val messageId = event[1].asInt() + private fun parseMessageCacheClear(eventType: ApiEvent, event: List) { + Log.d("LongPollUpdatesParser", "$eventType $event") + + val messageId = event[1].asLong() coroutineScope.launch(Dispatchers.IO) { - loadMessage(messageId)?.let { message -> - listenersMap[LongPollEvent.MESSAGE_EDITED]?.let { + loadMessage(messageId = messageId)?.let { message -> + listenersMap[LongPollEvent.MESSAGE_CACHE_CLEAR]?.let { it.map { vkEventCallback -> - (vkEventCallback as VkEventCallback) - .onEvent(LongPollParsedEvent.MessageEdited(message)) + (vkEventCallback as VkEventCallback) + .onEvent(LongPollParsedEvent.MessageCacheClear(message)) } } } } } - private fun parseMessageReadIncoming(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - val peerId = event[1].asInt() - val messageId = event[2].asInt() - val unreadCount = event[3].asInt() + private suspend fun loadMessage( + peerId: Long? = null, + cmId: Long? = null, + messageId: Long? = null + ): VkMessage? = suspendCoroutine { continuation -> + require((peerId != null && cmId != null) || messageId != null) - listenersMap[LongPollEvent.INCOMING_MESSAGE_READ]?.let { listeners -> - listeners.map { vkEventCallback -> - (vkEventCallback as VkEventCallback) - .onEvent( - LongPollParsedEvent.IncomingMessageRead( - peerId = peerId, - messageId = messageId, - unreadCount = unreadCount - ) - ) - } - } - } - - private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - val peerId = event[1].asInt() - val messageId = event[2].asInt() - val unreadCount = event[3].asInt() - - listenersMap[LongPollEvent.OUTGOING_MESSAGE_READ]?.let { listeners -> - listeners.map { vkEventCallback -> - (vkEventCallback as VkEventCallback) - .onEvent( - LongPollParsedEvent.OutgoingMessageRead( - peerId = peerId, - messageId = messageId, - unreadCount = unreadCount - ) - ) - } - } - } - - private fun parseChatClearFlags(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - } - - private fun parseChatSetFlags(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - } - - private fun parseMessagesDeleted(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val peerId = event[1].asInt() - val messageId = event[2].asInt() - - listenersMap[LongPollEvent.CHAT_CLEARED]?.let { listeners -> - listeners.forEach { vkEventCallback -> - (vkEventCallback as VkEventCallback) - .onEvent( - LongPollParsedEvent.ChatCleared( - peerId = peerId, - toMessageId = messageId - ) - ) - } - } - } - - private fun parseChatMajorChanged(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val peerId = event[1].asInt() - val majorId = event[2].asInt() - - listenersMap[LongPollEvent.CHAT_MAJOR_CHANGED]?.let { listeners -> - listeners.forEach { vkEventCallback -> - (vkEventCallback as VkEventCallback) - .onEvent( - LongPollParsedEvent.ChatMajorChanged( - peerId = peerId, - majorId = majorId, - ) - ) - } - } - } - - private fun parseChatMinorChanged(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val peerId = event[1].asInt() - val minorId = event[2].asInt() - - listenersMap[LongPollEvent.CHAT_MINOR_CHANGED]?.let { listeners -> - listeners.forEach { vkEventCallback -> - (vkEventCallback as VkEventCallback) - .onEvent( - LongPollParsedEvent.ChatMinorChanged( - peerId = peerId, - minorId = minorId, - ) - ) - } - } - } - - private suspend fun loadMessage(messageId: Int): VkMessage? = suspendCoroutine { continuation -> coroutineScope.launch(Dispatchers.IO) { messagesUseCase.getById( - messageIds = listOf(messageId), + peerCmIds = null, + peerId = peerId, + messageIds = messageId?.let(::listOf), + cmIds = cmId?.let(::listOf), extended = true, fields = VkConstants.ALL_FIELDS ).listenValue(this) { state -> @@ -478,9 +666,6 @@ class LongPollUpdatesParser( return@listenValue } - VkMemoryCache[message.id] = message - messagesUseCase.storeMessage(message) - continuation.resume(message) } ) @@ -488,6 +673,35 @@ class LongPollUpdatesParser( } } + private suspend fun loadConversation( + peerId: Long, + extended: Boolean = false, + fields: String? = null + ): VkConversation? = suspendCoroutine { continuation -> + coroutineScope.launch(Dispatchers.IO) { + conversationsUseCase.getById( + peerIds = listOf(peerId), + extended = extended, + fields = fields + ).listenValue(coroutineScope) { state -> + state.processState( + error = { error -> + Log.e("LongPollUpdatesParser", "loadConversation: error: $error") + continuation.resume(null) + }, + success = { response -> + val conversation = response.singleOrNull() ?: run { + continuation.resume(null) + return@listenValue + } + + continuation.resume(conversation) + } + ) + } + } + } + @Suppress("UNCHECKED_CAST") private fun registerListener( eventType: LongPollEvent, @@ -564,6 +778,10 @@ class LongPollUpdatesParser( 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( @@ -576,10 +794,6 @@ class LongPollUpdatesParser( listener = assembleEventCallback(block) ) } - - fun clearListeners() { - listenersMap.clear() - } } internal inline fun assembleEventCallback( diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt index 7275c87d..3694a50e 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt @@ -5,48 +5,77 @@ import dev.meloda.fast.data.api.messages.MessagesHistoryInfo import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkMessage +import dev.meloda.fast.model.api.responses.MessagesSendResponse import kotlinx.coroutines.flow.Flow -interface MessagesUseCase { +interface MessagesUseCase : BaseUseCase { + + suspend fun storeMessage(message: VkMessage) + suspend fun storeMessages(messages: List) fun getMessagesHistory( - conversationId: Int, + conversationId: Long, count: Int?, offset: Int? ): Flow> fun getById( - messageIds: List, + peerCmIds: List?, + peerId: Long?, + messageIds: List?, + cmIds: List?, extended: Boolean?, fields: String? ): Flow>> fun sendMessage( - peerId: Int, - randomId: Int, + peerId: Long, + randomId: Long, message: String?, - replyTo: Int?, + replyTo: Long?, attachments: List? - ): Flow> + ): Flow> fun markAsRead( - peerId: Int, - startMessageId: Int + peerId: Long, + startMessageId: Long ): Flow> fun getHistoryAttachments( - peerId: Int, - count: Int?, - offset: Int?, + peerId: Long, + count: Int? = null, + offset: Int? = null, attachmentTypes: List, - conversationMessageId: Int + cmId: Long ): Flow>> fun createChat( - userIds: List?, - title: String? + userIds: List? = null, + title: String + ): Flow> + + fun pin( + peerId: Long, + messageId: Long? = null, + cmId: Long? = null + ): Flow> + + fun unpin( + peerId: Long ): Flow> - suspend fun storeMessage(message: VkMessage) - suspend fun storeMessages(messages: List) + fun markAsImportant( + peerId: Long, + messageIds: List? = null, + cmIds: List? = null, + important: Boolean + ): Flow>> + + fun delete( + peerId: Long, + messageIds: List? = null, + cmIds: List? = null, + spam: Boolean = false, + deleteForAll: Boolean = false + ): Flow>> } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt index 3c6c9f52..166e39f4 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt @@ -7,107 +7,13 @@ import dev.meloda.fast.data.mapToState import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkMessage +import dev.meloda.fast.model.api.responses.MessagesSendResponse import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow class MessagesUseCaseImpl( - private val repository: MessagesRepository + private val repository: MessagesRepository, ) : MessagesUseCase { - override fun getMessagesHistory( - conversationId: Int, - count: Int?, - offset: Int? - ): Flow> = flow { - emit(State.Loading) - - val newState = repository.getHistory( - conversationId = conversationId, - offset = offset, - count = count - ).mapToState() - - emit(newState) - } - - override fun getById( - messageIds: List, - extended: Boolean?, - fields: String? - ): Flow>> = flow { - emit(State.Loading) - - val newState = repository.getById( - messagesIds = messageIds, - extended = extended, - fields = fields - ).mapToState() - - emit(newState) - } - - override fun sendMessage( - peerId: Int, - randomId: Int, - message: String?, - replyTo: Int?, - attachments: List? - ): Flow> = flow { - emit(State.Loading) - - val newState = repository.send( - peerId = peerId, - randomId = randomId, - message = message, - replyTo = replyTo, - attachments = attachments - ).mapToState() - - emit(newState) - } - - override fun markAsRead( - peerId: Int, - startMessageId: Int - ): Flow> = flow { - emit(State.Loading) - - val newState = repository.markAsRead( - peerId = peerId, - startMessageId = startMessageId - ).mapToState() - - emit(newState) - } - - override fun getHistoryAttachments( - peerId: Int, - count: Int?, - offset: Int?, - attachmentTypes: List, - conversationMessageId: Int - ): Flow>> = flow { - emit(State.Loading) - - val newState = repository.getHistoryAttachments( - peerId = peerId, - count = count, - offset = offset, - attachmentTypes = attachmentTypes, - conversationMessageId = conversationMessageId - ).mapToState() - - emit(newState) - } - - override fun createChat(userIds: List?, title: String?): Flow> = flow { - emit(State.Loading) - - val newState = repository.createChat(userIds, title).mapToState() - - emit(newState) - } - override suspend fun storeMessage(message: VkMessage) { repository.storeMessages(listOf(message)) } @@ -115,4 +21,129 @@ class MessagesUseCaseImpl( override suspend fun storeMessages(messages: List) { repository.storeMessages(messages) } + + override fun getMessagesHistory( + conversationId: Long, + count: Int?, + offset: Int? + ): Flow> = flowNewState { + repository.getHistory( + conversationId = conversationId, + offset = offset, + count = count + ).mapToState() + } + + override fun getById( + peerCmIds: List?, + peerId: Long?, + messageIds: List?, + cmIds: List?, + extended: Boolean?, + fields: String? + ): Flow>> = flowNewState { + repository.getById( + peerCmIds = peerCmIds, + peerId = peerId, + messagesIds = messageIds, + cmIds = cmIds, + extended = extended, + fields = fields + ).mapToState() + } + + override fun sendMessage( + peerId: Long, + randomId: Long, + message: String?, + replyTo: Long?, + attachments: List? + ): Flow> = flowNewState { + repository.send( + peerId = peerId, + randomId = randomId, + message = message, + replyTo = replyTo, + attachments = attachments + ).mapToState() + } + + override fun markAsRead( + peerId: Long, + startMessageId: Long + ): Flow> = flowNewState { + repository.markAsRead( + peerId = peerId, + startMessageId = startMessageId + ).mapToState() + } + + override fun getHistoryAttachments( + peerId: Long, + count: Int?, + offset: Int?, + attachmentTypes: List, + cmId: Long + ): Flow>> = flowNewState { + repository.getHistoryAttachments( + peerId = peerId, + count = count, + offset = offset, + attachmentTypes = attachmentTypes, + cmId = cmId + ).mapToState() + } + + override fun createChat( + userIds: List?, + title: String + ): Flow> = flowNewState { + repository.createChat(userIds, title).mapToState() + } + + override fun pin( + peerId: Long, + messageId: Long?, + cmId: Long? + ): Flow> = flowNewState { + repository.pin( + peerId = peerId, + messageId = messageId, + cmId = cmId + ).mapToState() + } + + override fun unpin(peerId: Long): Flow> = flowNewState { + repository.unpin(peerId = peerId).mapToState() + } + + override fun markAsImportant( + peerId: Long, + messageIds: List?, + cmIds: List?, + important: Boolean + ): Flow>> = flowNewState { + repository.markAsImportant( + peerId = peerId, + messageIds = messageIds, + cmIds = cmIds, + important = important + ).mapToState() + } + + override fun delete( + peerId: Long, + messageIds: List?, + cmIds: List?, + spam: Boolean, + deleteForAll: Boolean + ): Flow>> = flowNewState { + repository.delete( + peerId = peerId, + messageIds = messageIds, + cmIds = cmIds, + spam = spam, + deleteForAll = deleteForAll + ).mapToState() + } } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCase.kt index 4f8737ef..5a9d6b6f 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCase.kt @@ -2,6 +2,7 @@ package dev.meloda.fast.domain import dev.meloda.fast.data.State import dev.meloda.fast.model.AuthInfo +import dev.meloda.fast.model.api.responses.GetSilentTokenResponse import kotlinx.coroutines.flow.Flow interface OAuthUseCase { @@ -14,4 +15,13 @@ interface OAuthUseCase { captchaSid: String?, captchaKey: String? ): Flow> + + fun getSilentToken( + login: String, + password: String, + forceSms: Boolean, + validationCode: String?, + captchaSid: String?, + captchaKey: String? + ): Flow> } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCaseImpl.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCaseImpl.kt index 082f2c2d..e6e3da62 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCaseImpl.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCaseImpl.kt @@ -2,11 +2,9 @@ package dev.meloda.fast.domain import dev.meloda.fast.data.State import dev.meloda.fast.data.api.oauth.OAuthRepository +import dev.meloda.fast.data.asState import dev.meloda.fast.model.AuthInfo -import dev.meloda.fast.network.OAuthErrorDomain -import dev.meloda.fast.network.ValidationType -import dev.meloda.fast.network.VkOAuthError -import dev.meloda.fast.network.VkOAuthErrorType +import dev.meloda.fast.model.api.responses.GetSilentTokenResponse import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -24,109 +22,45 @@ class OAuthUseCaseImpl( ): Flow> = flow { emit(State.Loading) - val response = oAuthRepository.auth( + val newState = oAuthRepository.auth( login = login, password = password, + forceSms = forceSms, validationCode = validationCode, captchaSid = captchaSid, - captchaKey = captchaKey, - forceSms = forceSms - ) - - kotlin.runCatching { - val error = response.error?.let(VkOAuthError::parse) - val errorType = response.errorType?.let(VkOAuthErrorType::parse) - - val newState = when (error) { - null -> { - State.Success( - AuthInfo( - userId = response.userId, - accessToken = response.accessToken, - validationHash = response.validationHash - ) - ) - } - - VkOAuthError.FLOOD_CONTROL -> { - State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError) - } - - VkOAuthError.NEED_VALIDATION -> { - if (response.banInfo != null) { - val info = requireNotNull(response.banInfo) - - State.Error.OAuthError( - OAuthErrorDomain.UserBannedError( - memberName = info.memberName, - message = info.message, - accessToken = info.accessToken, - restoreUrl = info.restoreUrl - ) - ) - } else { - State.Error.OAuthError( - OAuthErrorDomain.ValidationRequiredError( - description = response.errorDescription.orEmpty(), - validationType = response.validationType.orEmpty() - .let(ValidationType::parse), - validationSid = response.validationSid.orEmpty(), - phoneMask = response.phoneMask.orEmpty(), - redirectUri = response.redirectUri.orEmpty(), - validationResend = response.validationResend, - restoreIfCannotGetCode = response.restoreIfCannotGetCode - ) - ) - } - } - - VkOAuthError.NEED_CAPTCHA -> { - State.Error.OAuthError( - OAuthErrorDomain.CaptchaRequiredError( - captchaSid = response.captchaSid.orEmpty(), - captchaImageUrl = response.captchaImage.orEmpty() - ) - ) - } - - VkOAuthError.INVALID_CLIENT -> { - State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError) - } - - VkOAuthError.INVALID_REQUEST -> { - when (errorType) { - null -> State.Error.OAuthError(OAuthErrorDomain.UnknownError) - - VkOAuthErrorType.WRONG_OTP -> { - State.Error.OAuthError(OAuthErrorDomain.WrongValidationCode) - } - - VkOAuthErrorType.WRONG_OTP_FORMAT -> { - State.Error.OAuthError(OAuthErrorDomain.WrongValidationCodeFormat) - } - - VkOAuthErrorType.PASSWORD_BRUTEFORCE_ATTEMPT -> { - State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError) - } - - VkOAuthErrorType.USERNAME_OR_PASSWORD_IS_INCORRECT -> { - State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError) - } - } - } - - VkOAuthError.UNKNOWN -> { - State.Error.OAuthError(OAuthErrorDomain.UnknownError) - } - } - - emit(newState) - }.fold( - onSuccess = { - }, - onFailure = { - emit(State.Error.TestError(it.stackTraceToString())) + captchaKey = captchaKey + ).asState( + successMapper = { + AuthInfo( + userId = it.userId!!, + accessToken = it.accessToken!!, + validationHash = it.validationHash!! + ) } ) + + emit(newState) + } + + override fun getSilentToken( + login: String, + password: String, + forceSms: Boolean, + validationCode: String?, + captchaSid: String?, + captchaKey: String? + ): Flow> = flow { + emit(State.Loading) + + val newState = oAuthRepository.getSilentToken( + login = login, + password = password, + forceSms = forceSms, + validationCode = validationCode, + captchaSid = captchaSid, + captchaKey = captchaKey + ).asState() + + emit(newState) } } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/ApiEvent.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/ApiEvent.kt index 1e2cbc14..49a3c4d9 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/ApiEvent.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/ApiEvent.kt @@ -1,15 +1,17 @@ package dev.meloda.fast.model enum class ApiEvent(val value: Int) { - MESSAGE_SET_FLAGS(2), - MESSAGE_CLEAR_FLAGS(3), - MESSAGE_NEW(4), - MESSAGE_EDIT(5), - MESSAGE_READ_INCOMING(6), - MESSAGE_READ_OUTGOING(7), + MESSAGE_SET_FLAGS(10002), + MESSAGE_CLEAR_FLAGS(10003), + MESSAGE_NEW(10004), + MESSAGE_EDIT(10005), + MESSAGE_READ_INCOMING(10006), + MESSAGE_READ_OUTGOING(10007), CHAT_CLEAR_FLAGS(10), CHAT_SET_FLAGS(12), - MESSAGES_DELETED(13), + MESSAGES_DELETED(10013), + MESSAGE_UPDATED(10018), + MESSAGE_CACHE_CLEAR(10019), CHAT_MAJOR_CHANGED(20), CHAT_MINOR_CHANGED(21), TYPING(63), diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/AuthInfo.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/AuthInfo.kt index 5afa9ba2..5b568175 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/AuthInfo.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/AuthInfo.kt @@ -1,7 +1,7 @@ package dev.meloda.fast.model data class AuthInfo( - val userId: Int?, - val accessToken: String?, - val validationHash: String? + val userId: Long, + val accessToken: String, + val validationHash: String ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/BaseError.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/BaseError.kt index 86cc6e2f..963c989a 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/BaseError.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/BaseError.kt @@ -6,6 +6,10 @@ import androidx.compose.runtime.Immutable sealed class BaseError { data object SessionExpired : BaseError() + data object AccountBlocked : BaseError() + data object ConnectionError : BaseError() + data object InternalError : BaseError() + data object UnknownError : BaseError() data class SimpleError(val message: String) : BaseError() } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/ConversationFlags.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/ConversationFlags.kt index 19ab4de6..fcae5e05 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/ConversationFlags.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/ConversationFlags.kt @@ -13,5 +13,20 @@ enum class ConversationFlags(val value: Int) { DO_NOT_NOTIFY_ALL_MENTIONS(524288), MARKED_AS_UNREAD(1048576), ARCHIVED(8388608), - CALL_IN_PROGRESS(16777216), + CALL_IN_PROGRESS(16777216); + + companion object { + + fun parse(mask: Int): List { + val flags = mutableListOf() + + ConversationFlags.entries.forEach { flag -> + if (mask and flag.value > 0) { + flags.add(flag) + } + } + + return flags + } + } } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/ConversationsFilter.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/ConversationsFilter.kt new file mode 100644 index 00000000..9e0ebfd2 --- /dev/null +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/ConversationsFilter.kt @@ -0,0 +1,5 @@ +package dev.meloda.fast.model + +enum class ConversationsFilter { + ALL, UNREAD, ARCHIVE, BUSINESS_NOTIFY +} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/FriendsInfo.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/FriendsInfo.kt index 2364476b..34e6aa5a 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/FriendsInfo.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/FriendsInfo.kt @@ -4,5 +4,5 @@ import dev.meloda.fast.model.api.domain.VkUser data class FriendsInfo( val friends: List, - val onlineFriendsIds: List + val onlineFriendsIds: List ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/LongPollEvent.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/LongPollEvent.kt index 2c25bc13..8622af44 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/LongPollEvent.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/LongPollEvent.kt @@ -21,7 +21,10 @@ enum class LongPollEvent { MARKED_AS_SPAM, MARKED_AS_NOT_SPAM, MESSAGE_DELETED, + MESSAGE_UPDATED, + MESSAGE_CACHE_CLEAR, MESSAGE_RESTORED, AUDIO_MESSAGE_LISTENED, - CHAT_CLEARED + CHAT_CLEARED, + CHAT_ARCHIVED } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/LongPollParsedEvent.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/LongPollParsedEvent.kt index 822a6cf8..4095ee8f 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/LongPollParsedEvent.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/LongPollParsedEvent.kt @@ -1,39 +1,47 @@ package dev.meloda.fast.model +import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkMessage sealed interface LongPollParsedEvent { - data class NewMessage(val message: VkMessage) : LongPollParsedEvent + data class NewMessage( + val message: VkMessage, + val inArchive: Boolean + ) : LongPollParsedEvent data class MessageEdited(val message: VkMessage) : LongPollParsedEvent + data class MessageUpdated(val message: VkMessage) : LongPollParsedEvent + + data class MessageCacheClear(val message: VkMessage) : LongPollParsedEvent + data class IncomingMessageRead( - val peerId: Int, - val messageId: Int, + val peerId: Long, + val cmId: Long, val unreadCount: Int, ) : LongPollParsedEvent data class OutgoingMessageRead( - val peerId: Int, - val messageId: Int, + val peerId: Long, + val cmId: Long, val unreadCount: Int, ) : LongPollParsedEvent data class ChatMajorChanged( - val peerId: Int, + val peerId: Long, val majorId: Int, ) : LongPollParsedEvent data class ChatMinorChanged( - val peerId: Int, + val peerId: Long, val minorId: Int ) : LongPollParsedEvent data class Interaction( val interactionType: InteractionType, - val peerId: Int, - val userIds: List, + val peerId: Long, + val userIds: List, val totalCount: Int, val timestamp: Int ) : LongPollParsedEvent @@ -49,14 +57,14 @@ sealed interface LongPollParsedEvent { ) : LongPollParsedEvent data class MessageMarkedAsImportant( - val peerId: Int, - val messageId: Int, + val peerId: Long, + val cmId: Long, val marked: Boolean ) : LongPollParsedEvent data class MessageMarkedAsSpam( - val peerId: Int, - val messageId: Int + val peerId: Long, + val cmId: Long ) : LongPollParsedEvent data class MessageMarkedAsNotSpam( @@ -64,8 +72,8 @@ sealed interface LongPollParsedEvent { ) : LongPollParsedEvent data class MessageDeleted( - val peerId: Int, - val messageId: Int, + val peerId: Long, + val cmId: Long, val forAll: Boolean ) : LongPollParsedEvent @@ -74,12 +82,17 @@ sealed interface LongPollParsedEvent { ) : LongPollParsedEvent data class AudioMessageListened( - val peerId: Int, - val messageId: Int + val peerId: Long, + val cmId: Long ) : LongPollParsedEvent data class ChatCleared( - val peerId: Int, - val toMessageId: Int - ): LongPollParsedEvent + val peerId: Long, + val toCmId: Long + ) : LongPollParsedEvent + + data class ChatArchived( + val conversation: VkConversation, + val archived: Boolean + ) : LongPollParsedEvent } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/AttachmentType.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/AttachmentType.kt index 9b4d5124..a4ea83a7 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/AttachmentType.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/AttachmentType.kt @@ -27,7 +27,11 @@ enum class AttachmentType(var value: String) { AUDIO_PLAYLIST("audio_playlist"), PODCAST("podcast"), NARRATIVE("narrative"), - ARTICLE("article"); + ARTICLE("article"), + VIDEO_MESSAGE("video_message"), + GROUP_CHAT_STICKER("ugc_sticker"), + STICKER_PACK_PREVIEW("sticker_pack_preview") + ; fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkArticleData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkArticleData.kt index fc6de91d..aba4164d 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkArticleData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkArticleData.kt @@ -6,7 +6,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkArticleData( - @Json(name = "id") val id: Int + @Json(name = "id") val id: Long ) : VkAttachmentData { fun toDomain(): VkArticleDomain = VkArticleDomain( diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAttachmentHistoryMessageData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAttachmentHistoryMessageData.kt index f59b9978..27c11a35 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAttachmentHistoryMessageData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAttachmentHistoryMessageData.kt @@ -1,15 +1,15 @@ package dev.meloda.fast.model.api.data -import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage @JsonClass(generateAdapter = true) data class VkAttachmentHistoryMessageData( - @Json(name = "message_id") val messageId: Int, + @Json(name = "message_id") val messageId: Long, @Json(name = "date") val date: Int, - @Json(name = "cmid") val conversationMessageId: Int, - @Json(name = "from_id") val fromId: Int, + @Json(name = "cmid") val conversationMessageId: Long, + @Json(name = "from_id") val fromId: Long, @Json(name = "position") val position: Int, @Json(name = "attachment") val attachment: VkAttachmentItemData ) { diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAttachmentItemData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAttachmentItemData.kt index 7959d170..47db4f55 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAttachmentItemData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAttachmentItemData.kt @@ -1,9 +1,9 @@ package dev.meloda.fast.model.api.data -import dev.meloda.fast.model.api.domain.VkAttachment -import dev.meloda.fast.model.api.domain.VkUnknownAttachment import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkAttachment +import dev.meloda.fast.model.api.domain.VkUnknownAttachment @JsonClass(generateAdapter = true) data class VkAttachmentItemData( @@ -32,7 +32,10 @@ data class VkAttachmentItemData( @Json(name = "audio_playlist") val audioPlaylist: VkAudioPlaylistData?, @Json(name = "podcast") val podcast: VkPodcastData?, @Json(name = "narrative") val narrative: VkNarrativeData?, - @Json(name = "article") val article: VkArticleData? + @Json(name = "article") val article: VkArticleData?, + @Json(name = "video_message") val videoMessage: VkVideoMessageData?, + @Json(name = "ugc_sticker") val groupSticker: VkGroupStickerData?, + @Json(name = "sticker_pack_preview") val stickerPackPreview: VkStickerPackPreviewData? ) { fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) { AttachmentType.UNKNOWN -> VkUnknownAttachment @@ -60,5 +63,8 @@ data class VkAttachmentItemData( AttachmentType.PODCAST -> podcast?.toDomain() AttachmentType.NARRATIVE -> narrative?.toDomain() AttachmentType.ARTICLE -> article?.toDomain() + AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain() + AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain() + AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain() } ?: VkUnknownAttachment } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioData.kt index 3bb2c562..fba8829e 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioData.kt @@ -1,33 +1,33 @@ package dev.meloda.fast.model.api.data -import dev.meloda.fast.model.api.domain.VkAudioDomain import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkAudioDomain @JsonClass(generateAdapter = true) data class VkAudioData( - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "title") val title: String, @Json(name = "artist") val artist: String, @Json(name = "duration") val duration: Int, @Json(name = "url") val url: String, @Json(name = "date") val date: Int, - @Json(name = "owner_id") val ownerId: Int, + @Json(name = "owner_id") val ownerId: Long, @Json(name = "access_key") val accessKey: String?, @Json(name = "is_explicit") val isExplicit: Boolean, @Json(name = "is_focus_track") val isFocusTrack: Boolean, @Json(name = "is_licensed") val isLicensed: Boolean?, - @Json(name = "genre_id") val genreId: Int?, + @Json(name = "genre_id") val genreId: Long?, @Json(name = "album") val album: Album?, ) : VkAttachmentData { @JsonClass(generateAdapter = true) data class Album( - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "title") val title: String, - @Json(name = "owner_id") val ownerId: Int, + @Json(name = "owner_id") val ownerId: Long, @Json(name = "access_key") val accessKey: String, - @Json(name = "thumb") val thumb: Thumb + @Json(name = "thumb") val thumb: Thumb? ) { @JsonClass(generateAdapter = true) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioMessageData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioMessageData.kt index f4645134..793af3d9 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioMessageData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioMessageData.kt @@ -6,8 +6,8 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkAudioMessageData( - @Json(name = "id") val id: Int, - @Json(name = "owner_id") val ownerId: Int, + @Json(name = "id") val id: Long, + @Json(name = "owner_id") val ownerId: Long, @Json(name = "duration") val duration: Int, @Json(name = "waveform") val waveform: List, @Json(name = "link_ogg") val linkOgg: String, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioPlaylistData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioPlaylistData.kt index 1f42373d..e21122a6 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioPlaylistData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkAudioPlaylistData.kt @@ -6,8 +6,8 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkAudioPlaylistData( - @Json(name = "id") val id: Int, - @Json(name = "owner_id") val ownerId: Int, + @Json(name = "id") val id: Long, + @Json(name = "owner_id") val ownerId: Long, @Json(name = "type") val type: Int, @Json(name = "title") val title: String, @Json(name = "description") val description: String, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkCallData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkCallData.kt index 6725a7cd..067067b6 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkCallData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkCallData.kt @@ -6,8 +6,8 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkCallData( - @Json(name = "initiator_id") val initiatorId: Int, - @Json(name = "receiver_id") val receiverId: Int, + @Json(name = "initiator_id") val initiatorId: Long, + @Json(name = "receiver_id") val receiverId: Long, @Json(name = "state") val state: String, @Json(name = "time") val time: Int, @Json(name = "duration") val duration: Int, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkChatData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkChatData.kt index 46f81ea8..aa2d6020 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkChatData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkChatData.kt @@ -8,9 +8,9 @@ import com.squareup.moshi.JsonClass data class VkChatData( @Json(name = "type") val type: String, @Json(name = "val title") val title: String, - @Json(name = "admin_id") val adminId: Int, + @Json(name = "admin_id") val adminId: Long, @Json(name = "members_count") val membersCount: Int, - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "photo_50") val photo50: String, @Json(name = "photo_100") val photo100: String, @Json(name = "photo_200") val photo200: String, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkChatMemberData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkChatMemberData.kt index 8768f809..3323038c 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkChatMemberData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkChatMemberData.kt @@ -6,7 +6,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkChatMemberData( - @Json(name = "member_id") val memberId: Int, + @Json(name = "member_id") val memberId: Long, @Json(name = "invited_by") val invitedBy: Int, @Json(name = "join_date") val joinDate: Int, @Json(name = "is_admin") val isAdmin: Boolean?, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkContactData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkContactData.kt index c20835b0..08d38c7a 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkContactData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkContactData.kt @@ -6,10 +6,10 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkContactData( - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "name") val name: String, @Json(name = "can_write") val canWrite: Boolean, - @Json(name = "user_id") val userId: Int, + @Json(name = "user_id") val userId: Long, @Json(name = "last_seen_status") val lastSeenStatus: String?, @Json(name = "photo_50") val photo50: String?, @Json(name = "calls_id") val callsId: String diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkConversationData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkConversationData.kt index ee6044c7..07eb6b23 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkConversationData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkConversationData.kt @@ -1,21 +1,21 @@ package dev.meloda.fast.model.api.data +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import dev.meloda.fast.model.api.PeerType import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkMessage -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkConversationData( @Json(name = "peer") val peer: Peer, - @Json(name = "last_message_id") val lastMessageId: Int?, - @Json(name = "in_read") val inRead: Int, - @Json(name = "out_read") val outRead: Int, - @Json(name = "in_read_cmid") val inReadConversationMessageId: Int, - @Json(name = "out_read_cmid") val outReadConversationMessageId: Int, + @Json(name = "last_message_id") val lastMessageId: Long?, + @Json(name = "in_read") val inRead: Long, + @Json(name = "out_read") val outRead: Long, + @Json(name = "in_read_cmid") val inReadConversationMessageId: Long, + @Json(name = "out_read_cmid") val outReadConversationMessageId: Long, @Json(name = "sort_id") val sortId: SortId, - @Json(name = "last_conversation_message_id") val lastConversationMessageId: Int, + @Json(name = "last_conversation_message_id") val lastConversationMessageId: Long, @Json(name = "is_marked_unread") val isMarkedUnread: Boolean, @Json(name = "important") val important: Boolean, @Json(name = "push_settings") val pushSettings: PushSettings?, @@ -25,13 +25,14 @@ data class VkConversationData( @Json(name = "chat_settings") val chatSettings: ChatSettings?, @Json(name = "call_in_progress") val callInProgress: CallInProgress?, @Json(name = "unread_count") val unreadCount: Int?, + @Json(name = "is_archived") val isArchived: Boolean? ) { @JsonClass(generateAdapter = true) data class Peer( - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "type") val type: String, - @Json(name = "local_id") val localId: Int, + @Json(name = "local_id") val localId: Long, ) @JsonClass(generateAdapter = true) @@ -55,7 +56,7 @@ data class VkConversationData( @JsonClass(generateAdapter = true) data class ChatSettings( - @Json(name = "owner_id") val ownerId: Int, + @Json(name = "owner_id") val ownerId: Long, @Json(name = "title") val title: String, @Json(name = "state") val state: String, @Json(name = "acl") val acl: Acl, @@ -119,7 +120,7 @@ data class VkConversationData( photo200 = chatSettings?.photo?.photo200, isCallInProgress = callInProgress != null, isPhantom = chatSettings?.isDisappearing == true, - lastConversationMessageId = lastConversationMessageId, + lastCmId = lastConversationMessageId, inRead = inRead, outRead = outRead, lastMessageId = lastMessageId, @@ -140,5 +141,6 @@ data class VkConversationData( pinnedMessage = chatSettings?.pinnedMessage?.mapToDomain(), user = null, group = null, + isArchived = isArchived == true ) } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkCuratorData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkCuratorData.kt index 397e9cc4..aa5c4298 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkCuratorData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkCuratorData.kt @@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkCuratorData( - val id: Int, + val id: Long, val name: String, val description: String, val url: String, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkEventData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkEventData.kt index 3b5c8eca..3a66ce5f 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkEventData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkEventData.kt @@ -7,7 +7,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkEventData( @Json(name = "button_text") val buttonText: String, - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "is_favorite") val isFavorite: Boolean, @Json(name = "text") val text: String, @Json(name = "address") val address: String, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkFileData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkFileData.kt index b956c67a..cbbb1ebc 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkFileData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkFileData.kt @@ -1,13 +1,13 @@ package dev.meloda.fast.model.api.data -import dev.meloda.fast.model.api.domain.VkFileDomain import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkFileDomain @JsonClass(generateAdapter = true) data class VkFileData( - @Json(name = "id") val id: Int, - @Json(name = "owner_id") val ownerId: Int, + @Json(name = "id") val id: Long, + @Json(name = "owner_id") val ownerId: Long, @Json(name = "title") val title: String, @Json(name = "size") val size: Int, @Json(name = "ext") val extension: String, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGiftData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGiftData.kt index 2ac9e6be..36ecabf9 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGiftData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGiftData.kt @@ -6,7 +6,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkGiftData( - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "thumb_256") val thumb256: String?, @Json(name = "thumb_96") val thumb96: String?, @Json(name = "thumb_48") val thumb48: String diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGraffitiData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGraffitiData.kt index c9750e43..5a8ba254 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGraffitiData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGraffitiData.kt @@ -1,13 +1,13 @@ package dev.meloda.fast.model.api.data -import dev.meloda.fast.model.api.domain.VkGraffitiDomain import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkGraffitiDomain @JsonClass(generateAdapter = true) data class VkGraffitiData( - @Json(name = "id") val id: Int, - @Json(name = "owner_id") val ownerId: Int, + @Json(name = "id") val id: Long, + @Json(name = "owner_id") val ownerId: Long, @Json(name = "url") val url: String, @Json(name = "width") val width: Int, @Json(name = "height") val height: Int, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGroupCallData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGroupCallData.kt index 7f7ae60e..0e3b7a36 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGroupCallData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGroupCallData.kt @@ -1,12 +1,12 @@ package dev.meloda.fast.model.api.data -import dev.meloda.fast.model.api.domain.VkGroupCallDomain import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkGroupCallDomain @JsonClass(generateAdapter = true) data class VkGroupCallData( - @Json(name = "initiator_id") val initiatorId: Int, + @Json(name = "initiator_id") val initiatorId: Long, @Json(name = "join_link") val joinLink: String, @Json(name = "participants") val participants: Participants ) { diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGroupData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGroupData.kt index f1e8824f..7aa4af31 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGroupData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGroupData.kt @@ -7,7 +7,7 @@ import kotlin.math.abs @JsonClass(generateAdapter = true) data class VkGroupData( - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "name") val name: String, @Json(name = "screen_name") val screenName: String, @Json(name = "is_closed") val isClosed: Int?, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGroupStickerData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGroupStickerData.kt new file mode 100644 index 00000000..4345dc1a --- /dev/null +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkGroupStickerData.kt @@ -0,0 +1,27 @@ +package dev.meloda.fast.model.api.data + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkGroupStickerDomain + +@JsonClass(generateAdapter = true) +data class VkGroupStickerData( + val id: Long, + val owner_id: Long, + val pack_id: Long?, + val status: String?, + val is_deleted: Boolean?, + val images: List? +): VkAttachmentData { + + @JsonClass(generateAdapter = true) + data class Image( + @Json(name = "width") val width: Int, + @Json(name = "height") val height: Int, + @Json(name = "url") val url: String + ) + + fun toDomain(): VkGroupStickerDomain = VkGroupStickerDomain( + id = id + ) +} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkMessageData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkMessageData.kt index c4ee04ab..fa393cdc 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkMessageData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkMessageData.kt @@ -1,29 +1,33 @@ package dev.meloda.fast.model.api.data -import dev.meloda.fast.model.api.domain.VkMessage import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.FormatDataType +import dev.meloda.fast.model.api.domain.VkMessage @JsonClass(generateAdapter = true) data class VkMessageData( - @Json(name = "id") val id: Int?, - @Json(name = "peer_id") val peerId: Int?, + @Json(name = "id") val id: Long?, + @Json(name = "peer_id") val peerId: Long?, @Json(name = "date") val date: Int, - @Json(name = "from_id") val fromId: Int, + @Json(name = "from_id") val fromId: Long, @Json(name = "out") val out: Int?, @Json(name = "text") val text: String, - @Json(name = "conversation_message_id") val conversationMessageId: Int, + @Json(name = "conversation_message_id") val cmId: Long, @Json(name = "fwd_messages") val fwdMessages: List? = emptyList(), - @Json(name = "important") val important: Boolean = false, - @Json(name = "random_id") val randomId: Int = 0, + @Json(name = "important") val important: Boolean?, + @Json(name = "random_id") val randomId: Long?, @Json(name = "attachments") val attachments: List = emptyList(), - @Json(name = "is_hidden") val isHidden: Boolean = false, + @Json(name = "is_hidden") val isHidden: Boolean?, @Json(name = "payload") val payload: String?, @Json(name = "geo") val geo: Geo?, @Json(name = "action") val action: Action?, @Json(name = "ttl") val ttl: Int?, @Json(name = "reply_message") val replyMessage: VkMessageData?, - @Json(name = "update_time") val updateTime: Int? + @Json(name = "update_time") val updateTime: Int?, + @Json(name = "is_pinned") val isPinned: Boolean?, + @Json(name = "pinned_at") val pinnedAt: Int?, + @Json(name = "format_data") val formatData: FormatData? ) { @JsonClass(generateAdapter = true) @@ -50,29 +54,58 @@ data class VkMessageData( @JsonClass(generateAdapter = true) data class Action( @Json(name = "type") val type: String, - @Json(name = "member_id") val memberId: Int?, + @Json(name = "member_id") val memberId: Long?, @Json(name = "text") val text: String?, - @Json(name = "conversation_message_id") val conversationMessageId: Int?, + @Json(name = "conversation_message_id") val conversationMessageId: Long?, @Json(name = "message") val message: String? ) + + @JsonClass(generateAdapter = true) + data class FormatData( + @Json(name = "version") val version: String, + @Json(name = "items") val items: List + ) { + + @JsonClass(generateAdapter = true) + data class Item( + @Json(name = "offset") val offset: Int, + @Json(name = "length") val length: Int, + @Json(name = "type") val type: String, + @Json(name = "url") val url: String? + ) + + fun asDomain(): VkMessage.FormatData = VkMessage.FormatData( + version = version, + items = items.mapNotNull { item -> + FormatDataType.parse(item.type)?.let { type -> + VkMessage.FormatData.Item( + offset = item.offset, + length = item.length, + type = type, + url = item.url + ) + } + } + ) + } } fun VkMessageData.asDomain(): VkMessage = VkMessage( id = id ?: -1, - conversationMessageId = conversationMessageId, + cmId = cmId, text = text.ifBlank { null }, isOut = out == 1, peerId = peerId ?: -1, fromId = fromId, date = date, - randomId = randomId, + randomId = randomId ?: 0, action = VkMessage.Action.parse(action?.type), actionMemberId = action?.memberId, actionText = action?.text, actionConversationMessageId = action?.conversationMessageId, actionMessage = action?.message, geoType = geo?.type, - important = important, + isImportant = important ?: false, updateTime = updateTime, forwards = fwdMessages.orEmpty().map(VkMessageData::asDomain), attachments = attachments.map(VkAttachmentItemData::toDomain), @@ -81,4 +114,8 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage( group = null, actionUser = null, actionGroup = null, + pinnedAt = pinnedAt, + isPinned = isPinned == true, + formatData = formatData?.asDomain(), + isSpam = false ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkMiniAppData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkMiniAppData.kt index 0d9fd9d4..b4153c93 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkMiniAppData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkMiniAppData.kt @@ -16,9 +16,9 @@ data class VkMiniAppData( @JsonClass(generateAdapter = true) data class App( @Json(name = "type") val type: String, - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "title") val title: String, - @Json(name = "author_owner_id") val authorOwnerId: Int, + @Json(name = "author_owner_id") val authorOwnerid: Long, @Json(name = "is_favorite") val isFavorite: Boolean, @Json(name = "share_url") val shareUrl: String, @Json(name = "webview_url") val webViewUrl: String, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkNarrativeData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkNarrativeData.kt index 1ca7d937..6b3897e5 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkNarrativeData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkNarrativeData.kt @@ -6,7 +6,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkNarrativeData( - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "title") val title: String? ) : VkAttachmentData { diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPhotoData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPhotoData.kt index 9e9c965e..719fc8b7 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPhotoData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPhotoData.kt @@ -1,23 +1,23 @@ package dev.meloda.fast.model.api.data -import dev.meloda.fast.model.api.domain.VkPhotoDomain import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkPhotoDomain @JsonClass(generateAdapter = true) data class VkPhotoData( - @Json(name = "album_id") val albumId: Int, - val date: Int, - val id: Int, - @Json(name = "owner_id") val ownerId: Int, - @Json(name = "has_tags") val hasTags: Boolean, + @Json(name = "album_id") val albumId: Long, + @Json(name = "date") val date: Int?, + @Json(name = "id") val id: Long, + @Json(name = "owner_id") val ownerId: Long, + @Json(name = "has_tags") val hasTags: Boolean?, @Json(name = "access_key") val accessKey: String?, - val sizes: List, - val text: String?, - @Json(name = "user_id") val userId: Int?, - val lat: Double?, - val long: Double?, - @Json(name = "post_id") val postId: Int? + @Json(name = "sizes") val sizes: List, + @Json(name = "text") val text: String?, + @Json(name = "user_id") val userId: Long?, + @Json(name = "lat") val lat: Double?, + @Json(name = "long") val long: Double?, + @Json(name = "post_id") val postId: Long? ) : VkAttachmentData { @JsonClass(generateAdapter = true) @@ -33,7 +33,7 @@ data class VkPhotoData( date = date, id = id, ownerId = ownerId, - hasTags = hasTags, + hasTags = hasTags == true, accessKey = accessKey, sizes = sizes, text = text, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPinnedMessageData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPinnedMessageData.kt index 2b4b523a..82886752 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPinnedMessageData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPinnedMessageData.kt @@ -1,21 +1,21 @@ package dev.meloda.fast.model.api.data -import dev.meloda.fast.model.api.domain.VkMessage import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkMessage @JsonClass(generateAdapter = true) data class VkPinnedMessageData( - @Json(name = "id") val id: Int?, - @Json(name = "peer_id") val peerId: Int?, + @Json(name = "id") val id: Long?, + @Json(name = "peer_id") val peerId: Long?, @Json(name = "date") val date: Int, - @Json(name = "from_id") val fromId: Int, + @Json(name = "from_id") val fromId: Long, @Json(name = "out") val out: Boolean?, @Json(name = "text") val text: String, - @Json(name = "conversation_message_id") val conversationMessageId: Int, + @Json(name = "conversation_message_id") val conversationMessageId: Long, @Json(name = "fwd_messages") val forwards: List?, @Json(name = "important") val important: Boolean = false, - @Json(name = "random_id") val randomId: Int = 0, + @Json(name = "random_id") val randomId: Long = 0, @Json(name = "attachments") val attachments: List?, @Json(name = "is_hidden") val isHidden: Boolean = false, @Json(name = "payload") val payload: String?, @@ -28,7 +28,7 @@ data class VkPinnedMessageData( fun mapToDomain(): VkMessage = VkMessage( id = id ?: -1, - conversationMessageId = conversationMessageId, + cmId = conversationMessageId, text = text.ifBlank { null }, isOut = out == true, peerId = peerId ?: -1, @@ -41,7 +41,7 @@ data class VkPinnedMessageData( actionConversationMessageId = action?.conversationMessageId, actionMessage = action?.message, geoType = geo?.type, - important = important, + isImportant = important, updateTime = updateTime, forwards = forwards.orEmpty().map(VkMessageData::asDomain), @@ -52,6 +52,9 @@ data class VkPinnedMessageData( group = null, actionUser = null, actionGroup = null, + pinnedAt = null, + isPinned = true, + isSpam = false, + formatData = null, ) } - diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPodcastData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPodcastData.kt index c3410f56..2d18c7bc 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPodcastData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPodcastData.kt @@ -6,7 +6,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkPodcastData( - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "title") val title: String, @Json(name = "artist") val artist: String, // ... other fields diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPollData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPollData.kt index f2542a5c..d2958827 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPollData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPollData.kt @@ -1,13 +1,13 @@ package dev.meloda.fast.model.api.data -import dev.meloda.fast.model.api.domain.VkPollDomain import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkPollDomain @JsonClass(generateAdapter = true) data class VkPollData( val multiple: Boolean, - val id: Int, + val id: Long, val votes: Int, val anonymous: Boolean?, val closed: Boolean, @@ -18,24 +18,24 @@ data class VkPollData( @Json(name = "can_report") val canReport: Boolean, @Json(name = "can_share") val canShare: Boolean, val created: Int, - @Json(name = "owner_id") val ownerId: Int, + @Json(name = "owner_id") val ownerId: Long, val question: String, @Json(name = "disable_unvote") val disableUnvote: Boolean, val friends: List?, @Json(name = "embed_hash") val embedHash: String, val answers: List, - @Json(name = "author_id") val authorId: Int?, + @Json(name = "author_id") val authorId: Long?, val background: Background? ) { @JsonClass(generateAdapter = true) data class Friend( - val id: Int + val id: Long ) @JsonClass(generateAdapter = true) data class Answer( - val id: Int, + val id: Long, val rate: Double, val text: String, val votes: Int @@ -45,7 +45,7 @@ data class VkPollData( data class Background( val angle: Int, val color: String, - val id: Int, + val id: Long, val name: String, val type: String, val points: List diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkStickerData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkStickerData.kt index 5282f16a..4f0ff2f2 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkStickerData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkStickerData.kt @@ -1,15 +1,15 @@ package dev.meloda.fast.model.api.data -import dev.meloda.fast.model.api.domain.VkStickerDomain import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkStickerDomain @JsonClass(generateAdapter = true) data class VkStickerData( - @Json(name = "product_id") val productId: Int, - @Json(name = "sticker_id") val stickerId: Int, - @Json(name = "images") val images: List, - @Json(name = "images_with_background") val imagesWithBackground: List, + @Json(name = "product_id") val productId: Long, + @Json(name = "sticker_id") val stickerId: Long, + @Json(name = "images") val images: List?, + @Json(name = "images_with_background") val imagesWithBackground: List?, @Json(name = "animation_url") val animationUrl: String?, @Json(name = "animations") val animations: List? ) { diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkStickerPackPreviewData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkStickerPackPreviewData.kt new file mode 100644 index 00000000..649e77d7 --- /dev/null +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkStickerPackPreviewData.kt @@ -0,0 +1,33 @@ +package dev.meloda.fast.model.api.data + +import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkStickerPackPreviewDomain + +@JsonClass(generateAdapter = true) +data class VkStickerPackPreviewData( + val id: Long, + val title: String, + val description: String?, + val author: String?, + val icon: Icon?, + val price: Price?, + val can_purchase: Boolean, + val url: String +) : VkAttachmentData { + + @JsonClass(generateAdapter = true) + data class Icon( + val base_url: String + ) + + @JsonClass(generateAdapter = true) + data class Price( + val current: Long, + val regular: Long + ) + + fun toDomain(): VkStickerPackPreviewDomain = VkStickerPackPreviewDomain( + id = id + ) +} + diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkStoryData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkStoryData.kt index 2341317a..6389bb06 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkStoryData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkStoryData.kt @@ -5,8 +5,8 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkStoryData( - val id: Int, - val owner_id: Int, + val id: Long, + val owner_id: Long, val access_key: String?, val can_comment: Int?, val can_reply: Int?, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkUserData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkUserData.kt index ef5a890e..d59b4439 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkUserData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkUserData.kt @@ -7,7 +7,7 @@ import dev.meloda.fast.model.api.domain.VkUser @JsonClass(generateAdapter = true) data class VkUserData( - @Json(name = "id") val id: Int, + @Json(name = "id") val id: Long, @Json(name = "first_name") val firstName: String, @Json(name = "last_name") val lastName: String, @Json(name = "can_access_closed") val canAccessClosed: Boolean, @@ -18,8 +18,8 @@ data class VkUserData( @Json(name = "photo_100") val photo100: String?, @Json(name = "photo_200") val photo200: String?, @Json(name = "photo_400_orig") val photo400Orig: String?, - @Json(name = "online") val online: Int?, @Json(name = "online_info") val onlineInfo: OnlineInfo?, + @Json(name = "last_seen") val lastSeen: LastSeen?, @Json(name = "screen_name") val screenName: String, @Json(name = "bdate") val birthday: String? //...other fields @@ -31,25 +31,26 @@ data class VkUserData( @Json(name = "status") val status: String?, @Json(name = "last_seen") val lastSeen: Int?, @Json(name = "is_online") val isOnline: Boolean?, - @Json(name = "online_mobile") val onlineMobile: Boolean?, - @Json(name = "app_id") val appId: Int? + @Json(name = "online_mobile") val isOnlineMobile: Boolean?, + @Json(name = "app_id") val appId: Long? + ) + + @JsonClass(generateAdapter = true) + data class LastSeen( + @Json(name = "platform") val platform: Int, + @Json(name = "time") val time: Int ) fun mapToDomain() = VkUser( id = id, firstName = firstName, lastName = lastName, - // TODO: 05/05/2024, Danil Nikolaev: improve - onlineStatus = when { - online != 1 -> OnlineStatus.Offline - onlineInfo?.onlineMobile == true -> { - OnlineStatus.OnlineMobile(appId = onlineInfo.appId) - } - - else -> { - OnlineStatus.Online(appId = onlineInfo?.appId) - } - }, + onlineStatus = parseUserOnlineState( + isOnline = onlineInfo?.isOnline, + isOnlineMobile = onlineInfo?.isOnlineMobile, + status = onlineInfo?.status, + appId = onlineInfo?.appId + ), photo50 = photo50, photo100 = photo100, photo200 = photo200, @@ -59,3 +60,26 @@ data class VkUserData( birthday = birthday ) } + +fun parseUserOnlineState( + isOnline: Boolean?, + isOnlineMobile: Boolean?, + status: String?, + appId: Long? +): OnlineStatus { + return when { + isOnlineMobile == true -> OnlineStatus.OnlineMobile(appId) + isOnline == true -> OnlineStatus.Online(appId) + + status != null -> { + when (status) { + "last_week" -> OnlineStatus.LastWeek + "last_month" -> OnlineStatus.LastMonth + + else -> OnlineStatus.Recently + } + } + + else -> OnlineStatus.Offline + } +} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt index 01bbfa3b..3774ff1b 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt @@ -1,75 +1,78 @@ package dev.meloda.fast.model.api.data +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import dev.meloda.fast.model.api.domain.VkVideoDomain @JsonClass(generateAdapter = true) data class VkVideoData( - val id: Int, - val title: String, - val width: Int?, - val height: Int?, - val duration: Int, - val date: Int, - val comments: Int?, - val description: String?, - val player: String?, - val added: Int?, - val type: String, - val views: Int, - val access_key: String?, - val owner_id: Int, - val is_favorite: Boolean?, - val image: List?, - val first_frame: List?, - val files: File? + @Json(name = "id") val id: Long, + @Json(name = "title") val title: String, + @Json(name = "width") val width: Int?, + @Json(name = "height") val height: Int?, + @Json(name = "duration") val duration: Int, + @Json(name = "date") val date: Int, + @Json(name = "comments") val comments: Int?, + @Json(name = "description") val description: String?, + @Json(name = "player") val player: String?, + @Json(name = "added") val added: Int?, + @Json(name = "type") val type: String, + @Json(name = "views") val views: Int, + @Json(name = "access_key") val accessKey: String?, + @Json(name = "owner_id") val ownerId: Long, + @Json(name = "is_favorite") val isFavorite: Boolean?, + @Json(name = "image") val image: List?, + @Json(name = "first_frame") val firstFrame: List?, + @Json(name = "files") val files: File? ) : VkAttachmentData { @JsonClass(generateAdapter = true) data class Image( - val width: Int, - val height: Int, - val url: String, - val with_padding: Int? + @Json(name = "width") val width: Int, + @Json(name = "height") val height: Int, + @Json(name = "url") val url: String, + @Json(name = "with_padding") val withPadding: Int? ) { fun asVideoImage() = VkVideoDomain.VideoImage( width = width, height = height, url = url, - withPadding = with_padding == 1 + withPadding = withPadding == 1 ) } @JsonClass(generateAdapter = true) data class FirstFrame( - val height: Int, - val width: Int, - val url: String + @Json(name = "height") val height: Int, + @Json(name = "width") val width: Int, + @Json(name = "url") val url: String ) @JsonClass(generateAdapter = true) data class File( - val mp4_240: String?, - val mp4_360: String?, - val mp4_480: String?, - val mp4_720: String?, - val mp4_1080: String?, - val mp4_1440: String?, - val hls: String?, - val dash_uni: String?, - val dash_sep: String?, - val hls_ondemand: String?, - val dash_ondemand: String?, - val failover_host: String? + @Json(name = "mp4_240") val mp4240: String?, + @Json(name = "mp4_360") val mp4360: String?, + @Json(name = "mp4_480") val mp4480: String?, + @Json(name = "mp4_720") val mp4720: String?, + @Json(name = "mp4_1080") val mp41080: String?, + @Json(name = "mp4_1440") val mp41440: String?, + @Json(name = "hls") val hls: String?, + @Json(name = "dash_uni") val dashUni: String?, + @Json(name = "dash_sep") val dashSep: String?, + @Json(name = "hls_ondemand") val hlsOnDemand: String?, + @Json(name = "dash_ondemand") val dashOnDemand: String?, + @Json(name = "failover_host") val failOverHost: String? ) fun toDomain() = VkVideoDomain( id = id, - ownerId = owner_id, + ownerId = ownerId, images = image.orEmpty().map { it.asVideoImage() }, - firstFrames = first_frame, - accessKey = access_key, - title = title + firstFrames = firstFrame, + accessKey = accessKey, + title = title, + views = views, + duration = duration ) } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoMessageData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoMessageData.kt new file mode 100644 index 00000000..3432eb23 --- /dev/null +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoMessageData.kt @@ -0,0 +1,78 @@ +package dev.meloda.fast.model.api.data + +import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.domain.VkVideoMessageDomain + +@JsonClass(generateAdapter = true) +data class VkVideoMessageData( + val id: Long, + val access_key: String?, + val can_add: Int?, + val can_dislike: Int?, + val can_download: Int?, + val can_play_in_background: Int?, + val date: Int?, + val description: String?, + val duration: Int?, + val files: Files?, + val first_frame: List?, + val height: Int?, + val image: List?, + val is_author: Boolean?, + val is_favorite: Boolean?, + val is_from_message: Int?, + val need_mediascope_stat: Boolean?, + val ov_id: String?, + val owner_id: Long?, + val player: String?, + val processing: Int?, + val repeat: Int?, + val response_type: String?, + val shape_id: Long?, + val timeline_thumbs: TimelineThumbs?, + val title: String?, + val track_code: String?, + val transcript_state: String?, + val type: String?, + val views: Int?, + val width: Int?, +) : VkAttachmentData { + + @JsonClass(generateAdapter = true) + data class Files( + val failover_host: String?, + val mp4_240: String?, + val mp4_480: String?, + ) + + @JsonClass(generateAdapter = true) + data class FirstFrame( + val height: Int?, + val url: String?, + val width: Int?, + ) + + @JsonClass(generateAdapter = true) + data class Image( + val height: Int?, + val url: String?, + val width: Int?, + val with_padding: Int?, + ) + + @JsonClass(generateAdapter = true) + data class TimelineThumbs( + val count_per_image: Int?, + val count_per_row: Int?, + val count_total: Int?, + val frame_height: Int?, + val frame_width: Double?, + val frequency: Int?, + val is_uv: Boolean?, + val links: List?, + ) + + fun toDomain(): VkVideoMessageDomain = VkVideoMessageDomain( + id = id + ) +} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallData.kt index d3c77fae..caab17e3 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallData.kt @@ -6,9 +6,9 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkWallData( - @Json(name = "id") val id: Int, - @Json(name = "from_id") val from_id: Int, - @Json(name = "to_id") val to_id: Int, + @Json(name = "id") val id: Long, + @Json(name = "from_id") val from_id: Long, + @Json(name = "to_id") val to_id: Long, @Json(name = "date") val date: Int, @Json(name = "text") val text: String, @Json(name = "attachments") val attachments: List?, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallReplyData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallReplyData.kt index 8906e618..bb7ecee4 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallReplyData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallReplyData.kt @@ -5,12 +5,12 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkWallReplyData( - val id: Int, - val from_id: Int, + val id: Long, + val from_id: Long, val date: Int, val text: String, - val post_id: Int, - val owner_id: Int, + val post_id: Long, + val owner_id: Long, val parents_stack: List, val likes: Likes, val reply_to_user: Int?, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWidgetData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWidgetData.kt index 07dfe16a..f4cb5ab6 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWidgetData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWidgetData.kt @@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkWidgetData( - val id: Int + val id: Long ) : VkAttachmentData { fun toDomain() = VkWidgetDomain(id) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/FormatDataType.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/FormatDataType.kt new file mode 100644 index 00000000..553614d6 --- /dev/null +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/FormatDataType.kt @@ -0,0 +1,10 @@ +package dev.meloda.fast.model.api.domain + +enum class FormatDataType { + BOLD, ITALIC, UNDERLINE, URL; + + companion object { + fun parse(value: String): FormatDataType? = + entries.firstOrNull { it.name.lowercase() == value } + } +} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkArticleDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkArticleDomain.kt index 55301fa4..f6163a89 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkArticleDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkArticleDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkArticleDomain( - val id: Int + val id: Long ) : VkAttachment { override val type: AttachmentType = AttachmentType.ARTICLE diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAttachmentHistoryMessage.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAttachmentHistoryMessage.kt index 15c62990..a4617240 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAttachmentHistoryMessage.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAttachmentHistoryMessage.kt @@ -1,10 +1,10 @@ package dev.meloda.fast.model.api.domain data class VkAttachmentHistoryMessage( - val messageId: Int, - val conversationMessageId: Int, + val messageId: Long, + val conversationMessageId: Long, val date: Int, - val fromId: Int, + val fromId: Long, val position: Int, val attachment: VkAttachment ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioDomain.kt index 9e11d8ef..e34a3b7b 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioDomain.kt @@ -3,8 +3,8 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkAudioDomain( - val id: Int, - val ownerId: Int, + val id: Long, + val ownerId: Long, val title: String, val artist: String, val url: String, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioMessageDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioMessageDomain.kt index 3b3fa0f8..49cbf530 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioMessageDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioMessageDomain.kt @@ -3,8 +3,8 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkAudioMessageDomain( - val id: Int, - val ownerId: Int, + val id: Long, + val ownerId: Long, val duration: Int, val waveform: List, val linkOgg: String, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioPlaylistDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioPlaylistDomain.kt index 39a6b039..06c53952 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioPlaylistDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAudioPlaylistDomain.kt @@ -3,8 +3,8 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkAudioPlaylistDomain( - val id: Int, - val ownerId: Int, + val id: Long, + val ownerId: Long, val title: String, val description: String, ) : VkAttachment { diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkCallDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkCallDomain.kt index efced050..9c44216f 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkCallDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkCallDomain.kt @@ -3,8 +3,8 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkCallDomain( - val initiatorId: Int, - val receiverId: Int, + val initiatorId: Long, + val receiverId: Long, val state: String, val time: Int, val duration: Int, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkChatDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkChatDomain.kt index a12860a2..aa062b2c 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkChatDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkChatDomain.kt @@ -3,9 +3,9 @@ package dev.meloda.fast.model.api.domain data class VkChatDomain( val type: String, val title: String, - val adminId: Int, + val adminId: Long, val membersCount: Int, - val id: Int, + val id: Long, val members: List = emptyList(), val photo50: String, val photo100: String, @@ -13,7 +13,7 @@ data class VkChatDomain( val isDefaultPhoto: Boolean ) { data class ChatMember( - val id: Int, + val id: Long, val type: ChatMemberType, val isOnline: Boolean?, val lastSeen: Int?, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkChatMemberDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkChatMemberDomain.kt index 7c045442..fbe71005 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkChatMemberDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkChatMemberDomain.kt @@ -1,7 +1,7 @@ package dev.meloda.fast.model.api.domain data class VkChatMemberDomain( - val memberId: Int, + val memberId: Long, val invitedBy: Int, val joinDate: Int, val isAdmin: Boolean, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkContactDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkContactDomain.kt index 37cea639..4fb26f00 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkContactDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkContactDomain.kt @@ -2,5 +2,5 @@ package dev.meloda.fast.model.api.domain data class VkContactDomain( val name: String, - val userId: Int + val userId: Long ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkConversation.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkConversation.kt index c54a6ad7..9b7fa495 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkConversation.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkConversation.kt @@ -4,31 +4,33 @@ import dev.meloda.fast.model.api.PeerType import dev.meloda.fast.model.database.VkConversationEntity data class VkConversation( - val id: Int, - val localId: Int, - val ownerId: Int?, + val id: Long, + val localId: Long, + val ownerId: Long?, val title: String?, val photo50: String?, val photo100: String?, val photo200: String?, val isCallInProgress: Boolean, val isPhantom: Boolean, - val lastConversationMessageId: Int, - val inReadCmId: Int, - val outReadCmId: Int, - val inRead: Int, - val outRead: Int, - val lastMessageId: Int?, + val lastCmId: Long, + val inReadCmId: Long, + val outReadCmId: Long, + val inRead: Long, + val outRead: Long, + val lastMessageId: Long?, val unreadCount: Int, val membersCount: Int?, val canChangePin: Boolean, val canChangeInfo: Boolean, val majorId: Int, val minorId: Int, - val pinnedMessageId: Int?, + val pinnedMessageId: Long?, val interactionType: Int, - val interactionIds: List, + val interactionIds: List, val peerType: PeerType, + val isArchived: Boolean, + val lastMessage: VkMessage?, val pinnedMessage: VkMessage?, val user: VkUser?, @@ -36,8 +38,20 @@ data class VkConversation( ) { fun isPinned(): Boolean = majorId > 0 - fun isInUnread() = inRead - (lastMessageId ?: 0) < 0 - fun isOutUnread() = outRead - (lastMessageId ?: 0) < 0 + + fun isInRead(cmId: Long? = null) = inReadCmId - (cmId ?: lastCmId) >= 0 + + fun isOutRead(cmId: Long? = null) = outReadCmId - (cmId ?: lastCmId) >= 0 + + fun isRead(lastMessage: VkMessage? = null): Boolean { + val message = lastMessage ?: this.lastMessage + + return when { + message == null -> true + message.isOut -> isOutRead(message.cmId) + else -> isInRead(message.cmId) + } + } companion object { val EMPTY: VkConversation = VkConversation( @@ -50,7 +64,7 @@ data class VkConversation( photo200 = null, isCallInProgress = false, isPhantom = false, - lastConversationMessageId = -1, + lastCmId = -1, inReadCmId = -1, outReadCmId = -1, inRead = -1, @@ -66,11 +80,12 @@ data class VkConversation( interactionType = -1, interactionIds = emptyList(), peerType = PeerType.USER, + isArchived = false, + lastMessage = null, pinnedMessage = null, user = null, group = null - ) } } @@ -84,7 +99,7 @@ fun VkConversation.asEntity(): VkConversationEntity = VkConversationEntity( photo100 = photo100, photo200 = photo200, isPhantom = isPhantom, - lastConversationMessageId = lastConversationMessageId, + lastConversationMessageId = lastCmId, inReadCmId = inReadCmId, outReadCmId = outReadCmId, inRead = inRead, @@ -98,4 +113,5 @@ fun VkConversation.asEntity(): VkConversationEntity = VkConversationEntity( minorId = minorId, pinnedMessageId = pinnedMessageId, peerType = peerType.value, + isArchived = isArchived ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkCuratorDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkCuratorDomain.kt index f94af5b0..82944f8f 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkCuratorDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkCuratorDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkCuratorDomain( - val id: Int, + val id: Long, ) : VkAttachment { override val type: AttachmentType = AttachmentType.CURATOR diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkEventDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkEventDomain.kt index 24c8cefc..c2b678a0 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkEventDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkEventDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkEventDomain( - val id: Int + val id: Long ) : VkAttachment { override val type: AttachmentType = AttachmentType.EVENT diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkFileDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkFileDomain.kt index e9e17829..479e06c9 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkFileDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkFileDomain.kt @@ -1,13 +1,13 @@ package dev.meloda.fast.model.api.domain +import com.squareup.moshi.JsonClass import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.VkFileData -import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkFileDomain( - val id: Int, - val ownerId: Int, + val id: Long, + val ownerId: Long, val title: String, val ext: String, val size: Int, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGiftDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGiftDomain.kt index cca67bb6..fa2c8680 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGiftDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGiftDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkGiftDomain( - val id: Int, + val id: Long, val thumb256: String?, val thumb96: String?, val thumb48: String diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGraffitiDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGraffitiDomain.kt index 95a60baf..821292e3 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGraffitiDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGraffitiDomain.kt @@ -3,8 +3,8 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkGraffitiDomain( - val id: Int, - val ownerId: Int, + val id: Long, + val ownerId: Long, val url: String, val width: Int, val height: Int, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGroupCallDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGroupCallDomain.kt index cb7b097b..300d00ac 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGroupCallDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGroupCallDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkGroupCallDomain( - val initiatorId: Int + val initiatorId: Long ) : VkAttachment { override val type: AttachmentType = AttachmentType.GROUP_CALL_IN_PROGRESS diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGroupDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGroupDomain.kt index f40e264f..397c28af 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGroupDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGroupDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.database.VkGroupEntity data class VkGroupDomain( - val id: Int, + val id: Long, val name: String, val screenName: String, val photo50: String?, @@ -13,14 +13,14 @@ data class VkGroupDomain( ) { override fun toString() = name.trim() - - fun mapToDB(): VkGroupEntity = VkGroupEntity( - id = id, - name = name, - screenName = screenName, - photo50 = photo50, - photo100 = photo100, - photo200 = photo200, - membersCount = membersCount - ) } + +fun VkGroupDomain.asEntity(): VkGroupEntity = VkGroupEntity( + id = id, + name = name, + screenName = screenName, + photo50 = photo50, + photo100 = photo100, + photo200 = photo200, + membersCount = membersCount +) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGroupStickerDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGroupStickerDomain.kt new file mode 100644 index 00000000..4b0f98c9 --- /dev/null +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkGroupStickerDomain.kt @@ -0,0 +1,10 @@ +package dev.meloda.fast.model.api.domain + +import dev.meloda.fast.model.api.data.AttachmentType + +data class VkGroupStickerDomain( + val id: Long +) : VkAttachment { + + override val type: AttachmentType = AttachmentType.GROUP_CHAT_STICKER +} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt index 6829b0ce..c86f0f46 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt @@ -1,30 +1,36 @@ package dev.meloda.fast.model.api.domain +import androidx.compose.runtime.Immutable import dev.meloda.fast.model.database.VkMessageEntity +@Immutable data class VkMessage( - val id: Int, - val conversationMessageId: Int, + val id: Long, + val cmId: Long, val text: String?, val isOut: Boolean, - val peerId: Int, - val fromId: Int, + val peerId: Long, + val fromId: Long, val date: Int, - val randomId: Int, + val randomId: Long, val action: Action?, - val actionMemberId: Int?, + val actionMemberId: Long?, val actionText: String?, - val actionConversationMessageId: Int?, + val actionConversationMessageId: Long?, val actionMessage: String?, val updateTime: Int?, - - val important: Boolean = false, + val pinnedAt: Int?, + val isPinned: Boolean, + val isImportant: Boolean, + val isSpam: Boolean, val forwards: List?, val attachments: List?, val replyMessage: VkMessage?, + val formatData: FormatData?, + val geoType: String?, val user: VkUser?, val group: VkGroupDomain?, @@ -40,8 +46,7 @@ data class VkMessage( fun isRead(conversation: VkConversation): Boolean = when { id <= 0 -> false - isOut -> conversation.outRead - id >= 0 - else -> conversation.inRead - id >= 0 + else -> conversation.isRead(this) } fun hasAttachments(): Boolean = attachments.orEmpty().isNotEmpty() @@ -54,6 +59,8 @@ data class VkMessage( fun isUpdated(): Boolean = updateTime != null && updateTime > 0 + fun isFailed(): Boolean = id <= -500_000 + enum class Action(val value: String) { CHAT_CREATE("chat_create"), CHAT_PHOTO_UPDATE("chat_photo_update"), @@ -74,11 +81,24 @@ data class VkMessage( fun parse(value: String?): Action? = entries.firstOrNull { it.value == value } } } + + data class FormatData( + val version: String, + val items: List + ) { + + data class Item( + val offset: Int, + val length: Int, + val type: FormatDataType, + val url: String? + ) + } } fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity( id = id, - conversationMessageId = conversationMessageId, + conversationMessageId = cmId, text = text, isOut = isOut, peerId = peerId, @@ -91,10 +111,12 @@ fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity( actionConversationMessageId = actionConversationMessageId, actionMessage = actionMessage, updateTime = updateTime, - important = important, + important = isImportant, forwardIds = forwards.orEmpty().map(VkMessage::id), // TODO: 05/05/2024, Danil Nikolaev: save attachments attachments = emptyList(), replyMessageId = replyMessage?.id, - geoType = geoType + geoType = geoType, + pinnedAt = pinnedAt, + isPinned = isPinned, ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkNarrativeDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkNarrativeDomain.kt index 26aac65b..895d7fb9 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkNarrativeDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkNarrativeDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkNarrativeDomain( - val id: Int, + val id: Long, val title: String? ) : VkAttachment { diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPhotoDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPhotoDomain.kt index 22cdfaa2..72f1da13 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPhotoDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPhotoDomain.kt @@ -7,15 +7,15 @@ import java.util.Stack // TODO: 11/04/2024, Danil Nikolaev: review data class VkPhotoDomain( - val albumId: Int, - val date: Int, - val id: Int, - val ownerId: Int, + val albumId: Long, + val date: Int?, + val id: Long, + val ownerId: Long, val hasTags: Boolean, val accessKey: String?, val sizes: List, val text: String?, - val userId: Int? + val userId: Long? ) : VkAttachment { override val type: AttachmentType = AttachmentType.PHOTO diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPodcastDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPodcastDomain.kt index fdb323eb..c2ac2202 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPodcastDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPodcastDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkPodcastDomain( - val id: Int, + val id: Long, val title: String, val artist: String ) : VkAttachment { diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPollDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPollDomain.kt index dbdb5cc0..bbc3ad77 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPollDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPollDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkPollDomain( - val id: Int + val id: Long ) : VkAttachment { override val type: AttachmentType = AttachmentType.POLL diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkStickerDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkStickerDomain.kt index 57ff0d78..afccb0ee 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkStickerDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkStickerDomain.kt @@ -4,10 +4,10 @@ import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.VkStickerData data class VkStickerDomain( - val id: Int, - val productId: Int, - val images: List, - val backgroundImages: List + val id: Long, + val productId: Long, + val images: List?, + val backgroundImages: List? ) : VkAttachment { override val type: AttachmentType = AttachmentType.STICKER @@ -15,7 +15,7 @@ data class VkStickerDomain( val className: String = this::class.java.name fun urlForSize(size: Int): String? { - for (image in images) { + for (image in images.orEmpty()) { if (image.width == size) return image.url } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkStickerPackPreviewDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkStickerPackPreviewDomain.kt new file mode 100644 index 00000000..8c747e69 --- /dev/null +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkStickerPackPreviewDomain.kt @@ -0,0 +1,10 @@ +package dev.meloda.fast.model.api.domain + +import dev.meloda.fast.model.api.data.AttachmentType + +data class VkStickerPackPreviewDomain( + val id: Long +): VkAttachment { + + override val type: AttachmentType = AttachmentType.STICKER_PACK_PREVIEW +} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkStoryDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkStoryDomain.kt index 7b1ce7c0..1160d181 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkStoryDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkStoryDomain.kt @@ -3,8 +3,8 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkStoryDomain( - val id: Int, - val ownerId: Int, + val id: Long, + val ownerId: Long, val date: Int, val photo: VkPhotoDomain? ) : VkAttachment { diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkUser.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkUser.kt index b7d8a876..6e4a9af3 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkUser.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkUser.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.database.VkUserEntity data class VkUser( - val id: Int, + val id: Long, val firstName: String, val lastName: String, val onlineStatus: OnlineStatus, @@ -20,9 +20,12 @@ data class VkUser( val fullName get() = "$firstName $lastName".trim() } -sealed class OnlineStatus(open val appId: Int?) { - data class Online(override val appId: Int?) : OnlineStatus(appId) - data class OnlineMobile(override val appId: Int?) : OnlineStatus(appId) +sealed class OnlineStatus(open val appId: Long?) { + data class Online(override val appId: Long? = null) : OnlineStatus(appId) + data class OnlineMobile(override val appId: Long? = null) : OnlineStatus(appId) + data object Recently : OnlineStatus(null) + data object LastWeek : OnlineStatus(null) + data object LastMonth : OnlineStatus(null) data object Offline : OnlineStatus(null) fun isOnline(): Boolean = this is Online || this is OnlineMobile diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt index 10f2c1f0..30a44756 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt @@ -1,17 +1,19 @@ package dev.meloda.fast.model.api.domain +import com.squareup.moshi.JsonClass import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.VkVideoData -import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkVideoDomain( - val id: Int, - val ownerId: Int, + val id: Long, + val ownerId: Long, val images: List, val firstFrames: List?, val accessKey: String?, val title: String, + val views: Int, + val duration: Int ) : VkAttachment { override val type: AttachmentType = AttachmentType.VIDEO diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoMessageDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoMessageDomain.kt new file mode 100644 index 00000000..a9a8dcba --- /dev/null +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoMessageDomain.kt @@ -0,0 +1,10 @@ +package dev.meloda.fast.model.api.domain + +import dev.meloda.fast.model.api.data.AttachmentType + +data class VkVideoMessageDomain( + val id: Long +) : VkAttachment { + + override val type: AttachmentType = AttachmentType.VIDEO_MESSAGE +} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWallDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWallDomain.kt index c2fdb0fd..a156e744 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWallDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWallDomain.kt @@ -4,9 +4,9 @@ import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.VkAttachmentItemData data class VkWallDomain( - val id: Int, - val fromId: Int, - val toId: Int, + val id: Long, + val fromId: Long, + val toId: Long, val date: Int, val text: String, val attachments: List?, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWallReplyDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWallReplyDomain.kt index 8ca0b72c..26238656 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWallReplyDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWallReplyDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkWallReplyDomain( - val id: Int + val id: Long ) : VkAttachment { override val type: AttachmentType = AttachmentType.WALL_REPLY diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWidgetDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWidgetDomain.kt index 8e3c459a..8543c4f9 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWidgetDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWidgetDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkWidgetDomain( - val id: Int + val id: Long ) : VkAttachment { override val type: AttachmentType = AttachmentType.WIDGET diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/ConversationsRequest.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/ConversationsRequest.kt index 62a91322..adeee04c 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/ConversationsRequest.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/ConversationsRequest.kt @@ -1,18 +1,20 @@ package dev.meloda.fast.model.api.requests +import dev.meloda.fast.model.ConversationsFilter + data class ConversationsGetRequest( val count: Int? = null, val offset: Int? = null, val fields: String = "", - val filter: String = "all", + val filter: ConversationsFilter = ConversationsFilter.ALL, val extended: Boolean? = true, - val startMessageId: Int? = null + val startMessageId: Long? = null ) { val map get() = mutableMapOf( "fields" to fields, - "filter" to filter + "filter" to filter.toString().lowercase() ).apply { count?.let { this["count"] = it.toString() } offset?.let { this["offset"] = it.toString() } @@ -20,15 +22,3 @@ data class ConversationsGetRequest( startMessageId?.let { this["start_message_id"] = it.toString() } } } - -data class ConversationsDeleteRequest(val peerId: Int) { - val map get() = mapOf("peer_id" to peerId.toString()) -} - -data class ConversationsPinRequest(val peerId: Int) { - val map get() = mapOf("peer_id" to peerId.toString()) -} - -data class ConversationsUnpinRequest(val peerId: Int) { - val map get() = mapOf("peer_id" to peerId.toString()) -} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt index 6bc6f569..cb29fa8d 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt @@ -6,9 +6,9 @@ import dev.meloda.fast.model.api.domain.VkAttachment data class MessagesGetHistoryRequest( val count: Int? = null, val offset: Int? = null, - val peerId: Int, + val peerId: Long, val extended: Boolean? = null, - val startMessageId: Int? = null, + val startMessageId: Long? = null, val rev: Boolean? = null, val fields: String? = null, ) { @@ -28,13 +28,13 @@ data class MessagesGetHistoryRequest( } data class MessagesSendRequest( - val peerId: Int, - val randomId: Int = 0, + val peerId: Long, + val randomId: Long = 0, val message: String?, val lat: Int? = null, val lon: Int? = null, - val replyTo: Int? = null, - val stickerId: Int? = null, + val replyTo: Long? = null, + val stickerId: Long? = null, val disableMentions: Boolean? = null, val doNotParseLinks: Boolean? = null, val silent: Boolean? = null, @@ -65,8 +65,8 @@ data class MessagesSendRequest( } data class MessagesMarkAsReadRequest( - val peerId: Int, - val startMessageId: Int? + val peerId: Long, + val startMessageId: Long? ) { val map: Map @@ -78,7 +78,7 @@ data class MessagesMarkAsReadRequest( } data class MessagesMarkAsImportantRequest( - val messagesIds: List, + val messagesIds: List, val important: Boolean ) { @@ -104,9 +104,9 @@ data class MessagesGetLongPollServerRequest( data class MessagesPinMessageRequest( - val peerId: Int, - val messageId: Int? = null, - val conversationMessageId: Int? = null + val peerId: Long, + val messageId: Long? = null, + val conversationMessageId: Long? = null ) { val map: Map @@ -119,15 +119,15 @@ data class MessagesPinMessageRequest( } -data class MessagesUnPinMessageRequest(val peerId: Int) { +data class MessagesUnpinMessageRequest(val peerId: Long) { val map: Map get() = mapOf("peer_id" to peerId.toString()) } data class MessagesDeleteRequest( - val peerId: Int, - val messagesIds: List? = null, - val conversationsMessagesIds: List? = null, + val peerId: Long, + val messagesIds: List? = null, + val conversationsMessagesIds: List? = null, val isSpam: Boolean? = null, val deleteForAll: Boolean? = null ) { @@ -147,25 +147,27 @@ data class MessagesDeleteRequest( } data class MessagesEditRequest( - val peerId: Int, - val messageId: Int, - val message: String? = null, - val lat: Float? = null, - val long: Float? = null, - val attachments: List? = null, - val notParseLinks: Boolean = false, - val keepSnippets: Boolean = true, - val keepForwardedMessages: Boolean = true + val peerId: Long, + val cmId: Long?, + val messageId: Long?, + val message: String?, + val lat: Float?, + val long: Float?, + val attachments: List?, + val notParseLinks: Boolean, + val keepSnippets: Boolean, + val keepForwardedMessages: Boolean ) { val map: Map get() = mutableMapOf( "peer_id" to peerId.toString(), - "message_id" to messageId.toString(), "dont_parse_links" to notParseLinks.asInt().toString(), "keep_snippets" to keepSnippets.asInt().toString(), "keep_forward_messages" to keepForwardedMessages.asInt().toString() ).apply { + messageId?.let { this["message_id"] = it.toString() } + cmId?.let { this["cmid"] = it.toString() } message?.let { this["message"] = it } lat?.let { this["lat"] = it.toString() } long?.let { this["long"] = it.toString() } @@ -183,15 +185,20 @@ data class MessagesEditRequest( data class MessagesGetByIdRequest( - val messagesIds: List, + val peerCmIds: List?, + val peerId: Long?, + val messagesIds: List?, + val cmIds: List?, val extended: Boolean? = null, val fields: String? = null ) { val map: Map - get() = mutableMapOf( - "message_ids" to messagesIds.joinToString(), - ).apply { + get() = mutableMapOf().apply { + peerCmIds?.let { this["peer_cmids"] = it.joinToString() } + peerId?.let { this["peer_id"] = it.toString() } + messagesIds?.let { this["message_ids"] = it.joinToString() } + cmIds?.let { this["cmids"] = it.joinToString() } extended?.let { this["extended"] = it.asInt().toString() } fields?.let { this["fields"] = it } } @@ -199,7 +206,7 @@ data class MessagesGetByIdRequest( data class MessagesGetChatRequest( - val chatId: Int, + val chatId: Long, val fields: String? = null ) { @@ -213,7 +220,7 @@ data class MessagesGetChatRequest( data class MessagesGetConversationMembersRequest( - val peerId: Int, + val peerId: Long, val offset: Int? = null, val count: Int? = null, val extended: Boolean? = null, @@ -234,8 +241,8 @@ data class MessagesGetConversationMembersRequest( data class MessagesRemoveChatUserRequest( - val chatId: Int, - val memberId: Int + val chatId: Long, + val memberId: Long ) { val map: Map get() = mapOf( @@ -245,13 +252,13 @@ data class MessagesRemoveChatUserRequest( } data class MessagesGetHistoryAttachmentsRequest( - val peerId: Int, + val peerId: Long, val extended: Boolean?, val count: Int?, val offset: Int?, val preserveOrder: Boolean?, val attachmentTypes: List, - val conversationMessageId: Int, + val conversationMessageId: Long, val fields: String? ) { @@ -269,7 +276,7 @@ data class MessagesGetHistoryAttachmentsRequest( } data class MessagesCreateChatRequest( - val userIds: List?, + val userIds: List?, val title: String? ) { diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/OAuthRequest.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/OAuthRequest.kt index c90bacee..e388db4e 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/OAuthRequest.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/OAuthRequest.kt @@ -59,3 +59,33 @@ data class AuthWithAppRequest( "sdk_fingerprint" to sdkFingerprint ) } + +data class GetAnonymTokenRequest( + val clientId: String, + val clientSecret: String +) { + + val map + get() = mapOf( + "client_id" to clientId, + "client_secret" to clientSecret + ) +} + +data class ExchangeSilentTokenRequest( + val anonymToken: String, + val silentToken: String, + val silentUuid: String +) { + + val map + get() = mapOf( + "access_token" to anonymToken, + "token" to silentToken, + "uuid" to silentUuid + ) +} + +data class GetExchangeTokenRequest(val accessToken: String) { + val map get() = mapOf("access_token" to accessToken) +} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/UsersRequest.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/UsersRequest.kt index 1ae5ccad..b738fd9e 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/UsersRequest.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/UsersRequest.kt @@ -1,7 +1,7 @@ package dev.meloda.fast.model.api.requests data class UsersGetRequest( - val userIds: List? = null, + val userIds: List? = null, val fields: String? = null, val nomCase: String? = null ) { diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/ConversationsResponse.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/ConversationsResponse.kt index 927101a5..70c0fb93 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/ConversationsResponse.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/ConversationsResponse.kt @@ -35,5 +35,5 @@ data class ConversationsResponseItem( @JsonClass(generateAdapter = true) data class ConversationsDeleteResponse( - @Json(name = "last_deleted_id") val lastDeletedId: Int + @Json(name = "last_deleted_id") val lastDeletedId: Long ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt index 672295f5..4af64159 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt @@ -25,7 +25,8 @@ data class MessagesGetByIdResponse( val count: Int, val items: List = emptyList(), val profiles: List?, - val groups: List? + val groups: List?, + val contacts: List? ) @JsonClass(generateAdapter = true) @@ -33,7 +34,8 @@ data class MessagesGetConversationMembersResponse( val count: Int, val items: List?, val profiles: List?, - val groups: List? + val groups: List?, + val contacts: List? ) @JsonClass(generateAdapter = true) @@ -47,6 +49,12 @@ data class MessagesGetHistoryAttachmentsResponse( @JsonClass(generateAdapter = true) data class MessagesCreateChatResponse( - @Json(name = "chat_id") val chatId: Int, + @Json(name = "chat_id") val chatId: Long, @Json(name = "peer_ids") val peerIds: List ) + +@JsonClass(generateAdapter = true) +data class MessagesSendResponse( + @Json(name = "message_id") val messageId: Long, + @Json(name = "cmid") val cmId: Long +) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/OAuthResponse.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/OAuthResponse.kt index 99d893d9..3be65f94 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/OAuthResponse.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/OAuthResponse.kt @@ -2,18 +2,29 @@ package dev.meloda.fast.model.api.responses import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import dev.meloda.fast.model.api.responses.AuthDirectResponse.BanInfo @JsonClass(generateAdapter = true) data class AuthDirectErrorOnlyResponse( - @Json(name = "error") val error: String, + @Json(name = "cant_get_code_open_restore") val restoreIfCannotGetCode: Boolean?, + @Json(name = "error") val error: String?, @Json(name = "error_description") val errorDescription: String?, - @Json(name = "error_type") val errorType: String? + @Json(name = "error_type") val errorType: String?, + @Json(name = "ban_info") val banInfo: BanInfo?, + @Json(name = "captcha_sid") val captchaSid: String?, + @Json(name = "captcha_img") val captchaImage: String?, + @Json(name = "captcha_ts") val captchaTs: Double?, + @Json(name = "validation_sid") val validationSid: String?, + @Json(name = "validation_type") val validationType: String?, + @Json(name = "phone_mask") val phoneMask: String?, + @Json(name = "redirect_uri") val redirectUri: String?, + @Json(name = "validation_resend") val validationResend: String?, ) @JsonClass(generateAdapter = true) data class AuthDirectResponse( @Json(name = "access_token") val accessToken: String?, - @Json(name = "user_id") val userId: Int?, + @Json(name = "user_id") val userId: Long?, @Json(name = "trusted_hash") val validationHash: String?, @Json(name = "validation_sid") val validationSid: String?, @Json(name = "validation_type") val validationType: String?, @@ -44,7 +55,45 @@ data class AuthDirectResponse( } @JsonClass(generateAdapter = true) -data class GetAnonymousTokenResponse( - @Json(name = "token") val token: String, - @Json(name = "expired_at") val expiredAt: Int +data class GetSilentTokenResponse( + @Json(name = "silent_token") val silentToken: String, + @Json(name = "silent_token_uuid") val silentTokenUuid: String, + @Json(name = "silent_token_ttl") val silentTokenTtl: Int, + @Json(name = "trusted_hash") val trustedHash: String?, // Приходит при наличии 2fa, + @Json(name = "error") val error: Error? ) + +@JsonClass(generateAdapter = true) +data class Error( + @Json(name = "error_code") val errorCode: Int, + @Json(name = "error_msg") val errorMessage: Int, + @Json(name = "redirect_uri") val redirectUri: String? +) + +@JsonClass(generateAdapter = true) +data class GetAnonymTokenResponse( + @Json(name = "token") val token: String +) + +@JsonClass(generateAdapter = true) +data class ExchangeSilentTokenResponse( + @Json(name = "access_token") val accessToken: String, + @Json(name = "is_partial") val isPartial: Boolean, + @Json(name = "is_service") val isService: Boolean, + @Json(name = "additional_signup_required") val additionalSignupRequired: Boolean, + @Json(name = "user_id") val userId: Long, + @Json(name = "expires_in") val expiresIn: Long +) + +@JsonClass(generateAdapter = true) +data class GetExchangeTokenResponse( + @Json(name = "users_exchange_tokens") val usersTokens: List +) { + + @JsonClass(generateAdapter = true) + data class UserTokenInfo( + @Json(name = "user_id") val userId: Long, + @Json(name = "profile_type") val profileType: Int, + @Json(name = "common_token") val commonToken: String + ) +} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/PhotosResponses.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/PhotosResponses.kt index 3138cc6b..9db1420e 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/PhotosResponses.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/PhotosResponses.kt @@ -4,7 +4,7 @@ import com.squareup.moshi.Json data class PhotosGetMessagesUploadServerResponse( @Json(name = "album_id") - val albumId: Int, + val albumid: Long, @Json(name = "upload_url") val uploadUrl: String ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/VideosResponses.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/VideosResponses.kt index 49888a3b..b5f18379 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/VideosResponses.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/VideosResponses.kt @@ -5,17 +5,17 @@ import com.squareup.moshi.Json data class VideosSaveResponse( @Json(name = "access_key") val accessKey: String, val description: String, - @Json(name = "owner_id") val ownerId: Int, + @Json(name = "owner_id") val ownerid: Long, val title: String, @Json(name = "upload_url") val uploadUrl: String, - @Json(name = "video_id") val videoId: Int + @Json(name = "video_id") val videoid: Long ) data class VideosUploadResponse( @Json(name = "video_hash") val hash: String?, val size: Int, @Json(name = "direct_link") val directLink: String, - @Json(name = "owner_id") val ownerId: Int, - @Json(name = "video_id") val videoId: Int, + @Json(name = "owner_id") val ownerid: Long, + @Json(name = "video_id") val videoid: Long, val error: String? ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/database/AccountEntity.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/database/AccountEntity.kt index 65b9d2e5..38c4a519 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/database/AccountEntity.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/database/AccountEntity.kt @@ -6,8 +6,9 @@ import androidx.room.PrimaryKey @Entity(tableName = "accounts") data class AccountEntity( @PrimaryKey(autoGenerate = false) - val userId: Int, + val userId: Long, val accessToken: String, val fastToken: String?, - val trustedHash: String? + val trustedHash: String?, + val exchangeToken: String? ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkConversationEntity.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkConversationEntity.kt index 9f5f67dc..094e8e9e 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkConversationEntity.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkConversationEntity.kt @@ -7,28 +7,29 @@ import dev.meloda.fast.model.api.domain.VkConversation @Entity(tableName = "conversations") data class VkConversationEntity( - @PrimaryKey val id: Int, - val localId: Int, - val ownerId: Int?, + @PrimaryKey val id: Long, + val localId: Long, + val ownerId: Long?, val title: String?, val photo50: String?, val photo100: String?, val photo200: String?, val isPhantom: Boolean, - val lastConversationMessageId: Int, - val inReadCmId: Int, - val outReadCmId: Int, - val inRead: Int, - val outRead: Int, - val lastMessageId: Int?, + val lastConversationMessageId: Long, + val inReadCmId: Long, + val outReadCmId: Long, + val inRead: Long, + val outRead: Long, + val lastMessageId: Long?, val unreadCount: Int, val membersCount: Int?, val canChangePin: Boolean, val canChangeInfo: Boolean, val majorId: Int, val minorId: Int, - val pinnedMessageId: Int?, + val pinnedMessageId: Long?, val peerType: String, + val isArchived: Boolean ) fun VkConversationEntity.asExternalModel(): VkConversation = VkConversation( @@ -41,7 +42,7 @@ fun VkConversationEntity.asExternalModel(): VkConversation = VkConversation( photo200 = photo200, isCallInProgress = false, isPhantom = isPhantom, - lastConversationMessageId = lastConversationMessageId, + lastCmId = lastConversationMessageId, inReadCmId = inReadCmId, outReadCmId = outReadCmId, inRead = inRead, @@ -57,6 +58,8 @@ fun VkConversationEntity.asExternalModel(): VkConversation = VkConversation( interactionType = -1, interactionIds = emptyList(), peerType = PeerType.parse(peerType), + isArchived = isArchived, + lastMessage = null,//lastMessage?.asExternalModel(), pinnedMessage = null,//pinnedMessage?.asExternalModel(), user = null, diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkGroupEntity.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkGroupEntity.kt index b93df715..ff4faf17 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkGroupEntity.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkGroupEntity.kt @@ -2,10 +2,11 @@ package dev.meloda.fast.model.database import androidx.room.Entity import androidx.room.PrimaryKey +import dev.meloda.fast.model.api.domain.VkGroupDomain @Entity(tableName = "groups") data class VkGroupEntity( - @PrimaryKey val id: Int, + @PrimaryKey val id: Long, val name: String, val screenName: String, val photo50: String?, @@ -13,3 +14,13 @@ data class VkGroupEntity( val photo200: String?, val membersCount: Int? ) + +fun VkGroupEntity.asDomain(): VkGroupDomain = VkGroupDomain( + id = id, + name = name, + screenName = screenName, + photo50 = photo50, + photo100 = photo100, + photo200 = photo200, + membersCount = membersCount +) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkMessageEntity.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkMessageEntity.kt index 1eb9e8b5..6ff5d676 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkMessageEntity.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkMessageEntity.kt @@ -7,30 +7,32 @@ import dev.meloda.fast.model.api.domain.VkUnknownAttachment @Entity(tableName = "messages") data class VkMessageEntity( - @PrimaryKey val id: Int, - val conversationMessageId: Int, + @PrimaryKey val id: Long, + val conversationMessageId: Long, val text: String?, val isOut: Boolean, - val peerId: Int, - val fromId: Int, + val peerId: Long, + val fromId: Long, val date: Int, - val randomId: Int, + val randomId: Long, val action: String?, - val actionMemberId: Int?, + val actionMemberId: Long?, val actionText: String?, - val actionConversationMessageId: Int?, + val actionConversationMessageId: Long?, val actionMessage: String?, val updateTime: Int?, val important: Boolean, - val forwardIds: List?, + val forwardIds: List?, val attachments: List?, // TODO: 01/05/2024, Danil Nikolaev: how to store??? - val replyMessageId: Int?, - val geoType: String? + val replyMessageId: Long?, + val geoType: String?, + val pinnedAt: Int?, + val isPinned: Boolean ) fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage( id = id, - conversationMessageId = conversationMessageId, + cmId = conversationMessageId, text = text, isOut = isOut, peerId = peerId, @@ -43,7 +45,7 @@ fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage( actionConversationMessageId = actionConversationMessageId, actionMessage = actionMessage, updateTime = updateTime, - important = important, + isImportant = important, forwards = emptyList(),//forwards.orEmpty().map(VkMessageEntity::asExternalModel), // TODO: 05/05/2024, Danil Nikolaev: restore attachments attachments = attachments.orEmpty().map { VkUnknownAttachment }, @@ -53,4 +55,8 @@ fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage( group = null, actionUser = null, actionGroup = null, + pinnedAt = pinnedAt, + isPinned = isPinned, + isSpam = false, + formatData = null, ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkUserEntity.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkUserEntity.kt index b5ea957a..d92e04d6 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkUserEntity.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/database/VkUserEntity.kt @@ -2,17 +2,17 @@ package dev.meloda.fast.model.database import androidx.room.Entity import androidx.room.PrimaryKey -import dev.meloda.fast.model.api.domain.OnlineStatus +import dev.meloda.fast.model.api.data.parseUserOnlineState import dev.meloda.fast.model.api.domain.VkUser @Entity(tableName = "users") data class VkUserEntity( - @PrimaryKey val id: Int, + @PrimaryKey val id: Long, val firstName: String, val lastName: String, val isOnline: Boolean, val isOnlineMobile: Boolean, - val onlineAppId: Int?, + val onlineAppId: Long?, val lastSeen: Int?, val lastSeenStatus: String?, val birthday: String?, @@ -26,11 +26,12 @@ fun VkUserEntity.asExternalModel(): VkUser = VkUser( id = id, firstName = firstName, lastName = lastName, - onlineStatus = when { - !isOnline -> OnlineStatus.Offline - !isOnlineMobile -> OnlineStatus.Online(onlineAppId) - else -> OnlineStatus.OnlineMobile(onlineAppId) - }, + onlineStatus = parseUserOnlineState( + isOnline = isOnline, + isOnlineMobile = isOnlineMobile, + status = lastSeenStatus, + appId = onlineAppId + ), photo50 = photo50, photo100 = photo100, photo200 = photo200, diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/di/NetworkModule.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/di/NetworkModule.kt index 936671dc..ec429e19 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/di/NetworkModule.kt @@ -35,6 +35,7 @@ import org.koin.dsl.bind import org.koin.dsl.module import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.create import java.util.concurrent.TimeUnit val networkModule = module { @@ -44,53 +45,45 @@ val networkModule = module { single { ChuckerInterceptor.Builder(get()).collector(get()).build() } singleOf(::VersionInterceptor) singleOf(::LanguageInterceptor) - single { - OkHttpClient.Builder() - .connectTimeout(20, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .addInterceptor(get(named("token_interceptor")) as Interceptor) - .addInterceptor(get()) - .addInterceptor(get()) - .addInterceptor(get()) - .followRedirects(true) - .followSslRedirects(true) - .addInterceptor( - HttpLoggingInterceptor().apply { - level = - HttpLoggingInterceptor.Level.entries[AppSettings.Debug.networkLogLevel.ordinal] - } - ) - .build() + + single(named("auth")) { + buildHttpClient(true) } - single { - Retrofit.Builder() - .baseUrl("${AppConstants.URL_API}/") - .addConverterFactory(ApiResultConverterFactory) - .addCallAdapterFactory(ApiResultCallAdapterFactory) - .addConverterFactory(ResponseConverterFactory(get())) - .addConverterFactory(MoshiConverterFactory.create(get())) - .client(get()) - .build() + single { + buildHttpClient(false) + } + + single(named("auth")) { + buildRetrofit(get(named("auth"))) + } + single { + buildRetrofit(get()) } - singleOf(::OAuthResultCallFactory) single(named("oauth")) { Retrofit.Builder() .baseUrl("${AppConstants.URL_OAUTH}/") .addCallAdapterFactory(get()) .addConverterFactory(MoshiConverterFactory.create(get())) - .client(get()) + .client(get(named("auth"))) .build() } + single { + get(named("auth")).create() + } + single { + get(named("auth")).create() + } + + singleOf(::OAuthResultCallFactory) + single { service(AccountService::class.java) } single { service(AudiosService::class.java) } - single { service(AuthService::class.java) } single { service(ConversationsService::class.java) } single { service(FilesService::class.java) } single { service(LongPollService::class.java) } single { service(MessagesService::class.java) } - single { service(OAuthService::class.java) } single { service(PhotosService::class.java) } single { service(UsersService::class.java) } single { service(VideosService::class.java) } @@ -98,3 +91,37 @@ val networkModule = module { } private fun Scope.service(className: Class): T = get().create(className) + +private fun Scope.buildHttpClient(forAuth: Boolean): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .apply { + if (!forAuth) { + addInterceptor(get(named("token_interceptor")) as Interceptor) + } + } + .addInterceptor(get()) + .addInterceptor(get()) + .addInterceptor(get()) + .followRedirects(true) + .followSslRedirects(true) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = + HttpLoggingInterceptor.Level.entries[AppSettings.Debug.networkLogLevel.ordinal] + } + ) + .build() +} + +private fun Scope.buildRetrofit(client: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl("${AppConstants.URL_API}/") + .addConverterFactory(ApiResultConverterFactory) + .addCallAdapterFactory(ApiResultCallAdapterFactory) + .addConverterFactory(ResponseConverterFactory(get())) + .addConverterFactory(MoshiConverterFactory.create(get())) + .client(client) + .build() +} diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/auth/AuthService.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/auth/AuthService.kt index 18606900..149c98df 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/auth/AuthService.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/auth/AuthService.kt @@ -1,16 +1,28 @@ package dev.meloda.fast.network.service.auth +import com.slack.eithernet.ApiResult +import dev.meloda.fast.model.api.responses.ExchangeSilentTokenResponse +import dev.meloda.fast.model.api.responses.GetAnonymTokenResponse +import dev.meloda.fast.model.api.responses.GetExchangeTokenResponse import dev.meloda.fast.model.api.responses.ValidateLoginResponse import dev.meloda.fast.model.api.responses.ValidatePhoneResponse import dev.meloda.fast.network.ApiResponse import dev.meloda.fast.network.RestApiError -import com.slack.eithernet.ApiResult +import retrofit2.http.FieldMap +import retrofit2.http.FormUrlEncoded import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.Query import retrofit2.http.QueryMap interface AuthService { + @GET(AuthUrls.LOGOUT) + suspend fun logout( + @Query("client_id") clientId: String, + @Query("client_secret") clientSecret: String + ): ApiResult, RestApiError> + @GET(AuthUrls.VALIDATE_PHONE) suspend fun validatePhone( @Query("sid") validationSid: String @@ -20,4 +32,22 @@ interface AuthService { suspend fun validateLogin( @QueryMap param: Map ): ApiResult, RestApiError> + + @FormUrlEncoded + @POST(AuthUrls.GET_ANONYM_TOKEN) + suspend fun getAnonymToken( + @FieldMap param: Map + ): ApiResult, RestApiError> + + @FormUrlEncoded + @POST(AuthUrls.EXCHANGE_SILENT_TOKEN) + suspend fun exchangeSilentToken( + @FieldMap param: Map + ): ApiResult, RestApiError> + + @FormUrlEncoded + @POST(AuthUrls.GET_EXCHANGE_TOKEN) + suspend fun getExchangeToken( + @FieldMap param: Map + ): ApiResult, RestApiError> } diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/auth/AuthUrls.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/auth/AuthUrls.kt index 7f03b1ed..d8f82376 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/auth/AuthUrls.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/auth/AuthUrls.kt @@ -5,6 +5,12 @@ import dev.meloda.fast.common.AppConstants object AuthUrls { private const val URL = AppConstants.URL_API + const val LOGOUT = "$URL/auth.logout" + const val VALIDATE_PHONE = "$URL/auth.validatePhone" const val VALIDATE_LOGIN = "$URL/auth.validateLogin" + + const val GET_ANONYM_TOKEN = "$URL/auth.getAnonymToken" + const val EXCHANGE_SILENT_TOKEN = "$URL/auth.exchangeSilentAuthToken" + const val GET_EXCHANGE_TOKEN = "$URL/auth.getExchangeToken" } diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsService.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsService.kt index e891472b..8fa267f5 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsService.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsService.kt @@ -32,13 +32,31 @@ interface ConversationsService { @FormUrlEncoded @POST(ConversationsUrls.PIN) - suspend fun pin(@FieldMap params: Map): ApiResult, RestApiError> + suspend fun pin( + @FieldMap params: Map + ): ApiResult, RestApiError> @FormUrlEncoded @POST(ConversationsUrls.UNPIN) - suspend fun unpin(@FieldMap params: Map): ApiResult, RestApiError> + suspend fun unpin( + @FieldMap params: Map + ): ApiResult, RestApiError> @FormUrlEncoded @POST(ConversationsUrls.REORDER_PINNED) - suspend fun reorderPinned(@FieldMap params: Map): ApiResult + suspend fun reorderPinned( + @FieldMap params: Map + ): ApiResult, RestApiError> + + @FormUrlEncoded + @POST(ConversationsUrls.ARCHIVE) + suspend fun archive( + @FieldMap params: Map + ): ApiResult, RestApiError> + + @FormUrlEncoded + @POST(ConversationsUrls.UNARCHIVE) + suspend fun unarchive( + @FieldMap params: Map + ): ApiResult, RestApiError> } diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsUrls.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsUrls.kt index ba130cd3..c1230abf 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsUrls.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsUrls.kt @@ -4,10 +4,14 @@ import dev.meloda.fast.common.AppConstants object ConversationsUrls { - const val GET = "${AppConstants.URL_API}/messages.getConversations" - const val GET_BY_ID = "${AppConstants.URL_API}/messages.getConversationsById" - const val DELETE = "${AppConstants.URL_API}/messages.deleteConversation" - const val PIN = "${AppConstants.URL_API}/messages.pinConversation" - const val UNPIN = "${AppConstants.URL_API}/messages.unpinConversation" - const val REORDER_PINNED = "${AppConstants.URL_API}/messages.reorderPinnedConversations" + private const val URL = AppConstants.URL_API + + const val GET = "$URL/messages.getConversations" + const val GET_BY_ID = "$URL/messages.getConversationsById" + const val DELETE = "$URL/messages.deleteConversation" + const val PIN = "$URL/messages.pinConversation" + const val UNPIN = "$URL/messages.unpinConversation" + const val REORDER_PINNED = "$URL/messages.reorderPinnedConversations" + const val ARCHIVE = "$URL/messages.archiveConversation" + const val UNARCHIVE = "$URL/messages.unarchiveConversation" } diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/friends/FriendsService.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/friends/FriendsService.kt index 35069f7d..7618f673 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/friends/FriendsService.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/friends/FriendsService.kt @@ -20,5 +20,5 @@ interface FriendsService { @POST(FriendsUrls.GET_ONLINE) suspend fun getOnlineFriends( @FieldMap params: Map - ): ApiResult>, RestApiError> + ): ApiResult>, RestApiError> } diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt index e5a16e09..d528af64 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt @@ -1,11 +1,15 @@ package dev.meloda.fast.network.service.messages import com.slack.eithernet.ApiResult +import dev.meloda.fast.model.api.data.VkChatData import dev.meloda.fast.model.api.data.VkLongPollData +import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse +import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse +import dev.meloda.fast.model.api.responses.MessagesSendResponse import dev.meloda.fast.network.ApiResponse import dev.meloda.fast.network.RestApiError import retrofit2.http.FieldMap @@ -30,7 +34,7 @@ interface MessagesService { @POST(MessagesUrls.SEND) suspend fun send( @FieldMap params: Map - ): ApiResult, RestApiError> + ): ApiResult, RestApiError> @FormUrlEncoded @POST(MessagesUrls.GET_LONG_POLL_SERVER) @@ -56,52 +60,51 @@ interface MessagesService { @FieldMap params: Map ): ApiResult, RestApiError> -// @FormUrlEncoded -// @POST(MessagesUrls.MarkAsImportant) -// suspend fun markAsImportant( -// @FieldMap params: Map -// ): ApiResult>, RestApiError> -// -// @FormUrlEncoded -// @POST(MessagesUrls.Pin) -// suspend fun pin( -// @FieldMap params: Map -// ): ApiResult, RestApiError> -// -// @FormUrlEncoded -// @POST(MessagesUrls.Unpin) -// suspend fun unpin( -// @FieldMap params: Map -// ): ApiResult, RestApiError> -// -// @FormUrlEncoded -// @POST(MessagesUrls.Delete) -// suspend fun delete( -// @FieldMap params: Map -// ): ApiResult, RestApiError> -// -// @FormUrlEncoded -// @POST(MessagesUrls.Edit) -// suspend fun edit( -// @FieldMap params: Map -// ): ApiResult, RestApiError> -// -// -// @FormUrlEncoded -// @POST(MessagesUrls.GetChat) -// suspend fun getChat( -// @FieldMap params: Map -// ): ApiResult, RestApiError> -// -// @FormUrlEncoded -// @POST(MessagesUrls.GetConversationMembers) -// suspend fun getConversationMembers( -// @FieldMap params: Map -// ): ApiResult, RestApiError> -// -// @FormUrlEncoded -// @POST(MessagesUrls.RemoveChatUser) -// suspend fun removeChatUser( -// @FieldMap params: Map -// ): ApiResult, RestApiError> + @FormUrlEncoded + @POST(MessagesUrls.PIN) + suspend fun pin( + @FieldMap params: Map + ): ApiResult, RestApiError> + + @FormUrlEncoded + @POST(MessagesUrls.UNPIN) + suspend fun unpin( + @FieldMap params: Map + ): ApiResult, RestApiError> + + @FormUrlEncoded + @POST(MessagesUrls.MARK_AS_IMPORTANT) + suspend fun markAsImportant( + @FieldMap params: Map + ): ApiResult>, RestApiError> + + @FormUrlEncoded + @POST(MessagesUrls.DELETE) + suspend fun delete( + @FieldMap params: Map + ): ApiResult>, RestApiError> + + @FormUrlEncoded + @POST(MessagesUrls.EDIT) + suspend fun edit( + @FieldMap params: Map + ): ApiResult, RestApiError> + + @FormUrlEncoded + @POST(MessagesUrls.GET_CHAT) + suspend fun getChat( + @FieldMap params: Map + ): ApiResult, RestApiError> + + @FormUrlEncoded + @POST(MessagesUrls.GET_CONVERSATIONS_MEMBERS) + suspend fun getConversationMembers( + @FieldMap params: Map + ): ApiResult, RestApiError> + + @FormUrlEncoded + @POST(MessagesUrls.REMOVE_CHAT_USER) + suspend fun removeChatUser( + @FieldMap params: Map + ): ApiResult, RestApiError> } diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesUrls.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesUrls.kt index 2de07a9f..b4f196d4 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesUrls.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesUrls.kt @@ -3,21 +3,23 @@ package dev.meloda.fast.network.service.messages import dev.meloda.fast.common.AppConstants object MessagesUrls { - - const val GET_HISTORY = "${AppConstants.URL_API}/messages.getHistory" - const val SEND = "${AppConstants.URL_API}/messages.send" - const val MARK_AS_IMPORTANT = "${AppConstants.URL_API}/messages.markAsImportant" - const val GET_LONG_POLL_SERVER = "${AppConstants.URL_API}/messages.getLongPollServer" - const val GET_LONG_POLL_HISTORY = "${AppConstants.URL_API}/messages.getLongPollHistory" - const val PIN = "${AppConstants.URL_API}/messages.pin" - const val UNPIN = "${AppConstants.URL_API}/messages.unpin" - const val DELETE = "${AppConstants.URL_API}/messages.delete" - const val EDIT = "${AppConstants.URL_API}/messages.edit" - const val GET_BY_ID = "${AppConstants.URL_API}/messages.getById" - const val MARK_AS_READ = "${AppConstants.URL_API}/messages.markAsRead" - const val GET_CHAT = "${AppConstants.URL_API}/messages.getChat" - const val GET_CONVERSATIONS_MEMBERS = "${AppConstants.URL_API}/messages.getConversationMembers" - const val REMOVE_CHAT_USER = "${AppConstants.URL_API}/messages.removeChatUser" - const val GET_HISTORY_ATTACHMENTS = "${AppConstants.URL_API}/messages.getHistoryAttachments" - const val CREATE_CHAT = "${AppConstants.URL_API}/messages.createChat" + + private const val URL = AppConstants.URL_API + + const val GET_HISTORY = "$URL/messages.getHistory" + const val SEND = "$URL/messages.send" + const val MARK_AS_IMPORTANT = "$URL/messages.markAsImportant" + const val GET_LONG_POLL_SERVER = "$URL/messages.getLongPollServer" + const val GET_LONG_POLL_HISTORY = "$URL/messages.getLongPollHistory" + const val PIN = "$URL/messages.pin" + const val UNPIN = "$URL/messages.unpin" + const val DELETE = "$URL/messages.delete" + const val EDIT = "$URL/messages.edit" + const val GET_BY_ID = "$URL/messages.getById" + const val MARK_AS_READ = "$URL/messages.markAsRead" + const val GET_CHAT = "$URL/messages.getChat" + const val GET_CONVERSATIONS_MEMBERS = "$URL/messages.getConversationMembers" + const val REMOVE_CHAT_USER = "$URL/messages.removeChatUser" + const val GET_HISTORY_ATTACHMENTS = "$URL/messages.getHistoryAttachments" + const val CREATE_CHAT = "$URL/messages.createChat" } diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/oauth/OAuthService.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/oauth/OAuthService.kt index 427258b8..cb717950 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/oauth/OAuthService.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/oauth/OAuthService.kt @@ -1,23 +1,24 @@ package dev.meloda.fast.network.service.oauth -import dev.meloda.fast.model.api.responses.AuthDirectResponse -import dev.meloda.fast.model.api.responses.GetAnonymousTokenResponse import com.slack.eithernet.ApiResult import com.slack.eithernet.DecodeErrorBody +import dev.meloda.fast.model.api.responses.AuthDirectErrorOnlyResponse +import dev.meloda.fast.model.api.responses.AuthDirectResponse +import dev.meloda.fast.model.api.responses.GetSilentTokenResponse import retrofit2.http.GET import retrofit2.http.QueryMap interface OAuthService { @DecodeErrorBody - @GET(OAuthUrls.DIRECT_AUTH) + @GET(OAuthUrls.GET_SILENT_TOKEN) suspend fun auth( @QueryMap param: Map - ): ApiResult + ): ApiResult @DecodeErrorBody - @GET(OAuthUrls.GET_ANONYMOUS_TOKEN) - suspend fun getAnonymousToken( + @GET(OAuthUrls.GET_SILENT_TOKEN) + suspend fun getSilentToken( @QueryMap param: Map - ): ApiResult + ): ApiResult } diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/oauth/OAuthUrls.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/oauth/OAuthUrls.kt index c9a6c593..557ed263 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/oauth/OAuthUrls.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/oauth/OAuthUrls.kt @@ -5,6 +5,5 @@ import dev.meloda.fast.common.AppConstants object OAuthUrls { private const val URL = AppConstants.URL_OAUTH - const val DIRECT_AUTH = "$URL/token" - const val GET_ANONYMOUS_TOKEN = "$URL/get_anonym_token" + const val GET_SILENT_TOKEN = "$URL/token" } diff --git a/core/presentation/src/main/AndroidManifest.xml b/core/presentation/src/main/AndroidManifest.xml deleted file mode 100644 index 8bdb7e14..00000000 --- a/core/presentation/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/basic/AutoFill.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/basic/AutoFill.kt deleted file mode 100644 index 41711e14..00000000 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/basic/AutoFill.kt +++ /dev/null @@ -1,135 +0,0 @@ -package dev.meloda.fast.ui.basic - -import android.os.Build -import android.view.autofill.AutofillManager -import androidx.annotation.RequiresApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.Autofill -import androidx.compose.ui.autofill.AutofillNode -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalAutofill -import androidx.compose.ui.platform.LocalAutofillTree -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import kotlin.math.roundToInt - -fun Modifier.connectNode(handler: AutoFillHandler): Modifier { - return with(handler) { fillBounds() } -} - -fun Modifier.defaultFocusChangeAutoFill(handler: AutoFillHandler): Modifier { - return this.then( - Modifier.onFocusChanged { - if (it.isFocused) { - handler.request() - } else { - handler.cancel() - } - } - ) -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun autoFillRequestHandler( - autofillTypes: List = listOf(), - onFill: (String) -> Unit, -): AutoFillHandler { - val view = LocalView.current - val context = LocalContext.current - var isFillRecently = remember { false } - val autoFillNode = remember { - AutofillNode( - autofillTypes = autofillTypes, - onFill = { - isFillRecently = true - onFill(it) - } - ) - } - val autofill = LocalAutofill.current - LocalAutofillTree.current += autoFillNode - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return EmptyAutoFillHandler - - return remember { - @RequiresApi(Build.VERSION_CODES.O) - object : AutoFillHandler { - val autofillManager = context.getSystemService(AutofillManager::class.java) - override fun requestManual() { - autofillManager.requestAutofill( - view, - autoFillNode.id, - autoFillNode.boundingBox?.toAndroidRect() ?: error("BoundingBox is not provided yet") - ) - } - - override fun requestVerifyManual() { - if (isFillRecently) { - isFillRecently = false - requestManual() - } - } - - override val autoFill: Autofill? - get() = autofill - - override val autoFillNode: AutofillNode - get() = autoFillNode - - override fun request() { - autofill?.requestAutofillForNode(autofillNode = autoFillNode) - } - - override fun cancel() { - autofill?.cancelAutofillForNode(autofillNode = autoFillNode) - } - - override fun Modifier.fillBounds(): Modifier { - return this.then( - Modifier.onGloballyPositioned { - autoFillNode.boundingBox = it.boundsInWindow() - }) - } - } - } -} - -fun Rect.toAndroidRect(): android.graphics.Rect { - return android.graphics.Rect( - left.roundToInt(), - top.roundToInt(), - right.roundToInt(), - bottom.roundToInt() - ) -} - -@OptIn(ExperimentalComposeUiApi::class) -interface AutoFillHandler { - - val autoFill: Autofill? - val autoFillNode: AutofillNode? - fun requestVerifyManual() - fun requestManual() - fun request() - fun cancel() - fun Modifier.fillBounds(): Modifier -} - -@ExperimentalComposeUiApi -data object EmptyAutoFillHandler : AutoFillHandler { - override val autoFill: Autofill? = null - override val autoFillNode: AutofillNode? = null - override fun requestVerifyManual() {} - override fun requestManual() {} - override fun request() {} - override fun cancel() {} - override fun Modifier.fillBounds(): Modifier = this.then(Modifier) -} diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/ErrorView.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/ErrorView.kt index a7140fec..8922460a 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/ErrorView.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/ErrorView.kt @@ -6,19 +6,26 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import dev.meloda.fast.ui.R as UiR + @Composable fun ErrorView( modifier: Modifier = Modifier, + iconResId: Int? = UiR.drawable.round_error_24, text: String, buttonText: String? = null, onButtonClick: (() -> Unit)? = null, @@ -30,6 +37,16 @@ fun ErrorView( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { + iconResId?.let { + Icon( + modifier = Modifier.size(120.dp), + painter = painterResource(iconResId), + contentDescription = null, + tint = MaterialTheme.colorScheme.primaryContainer + ) + Spacer(modifier = Modifier.height(12.dp)) + } + Text( text = text, style = MaterialTheme.typography.titleLarge, @@ -37,9 +54,10 @@ fun ErrorView( ) buttonText?.let { - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(24.dp)) Button( - onClick = { onButtonClick?.invoke() } + onClick = { onButtonClick?.invoke() }, + shape = RoundedCornerShape(6.dp) ) { Text(text = buttonText) } diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt index 5273e8e0..357cde96 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt @@ -110,13 +110,12 @@ fun MaterialDialog( .verticalScroll(scrollState) .onPlaced { isPlaced = true } ) { - Spacer(modifier = Modifier.height(8.dp)) - if (text != null && title == null) { Spacer(modifier = Modifier.height(20.dp)) } if (text != null) { + Spacer(modifier = Modifier.height(8.dp)) Row { Spacer(modifier = Modifier.width(24.dp)) Text( @@ -137,8 +136,6 @@ fun MaterialDialog( selectionType = selectionType, items = alertItems, onItemClick = { index -> - onItemClick?.invoke(index) - if (selectionType == SelectionType.None) { onDismissRequest.invoke() } else { @@ -149,6 +146,8 @@ fun MaterialDialog( alertItems = newItems } + + onItemClick?.invoke(index) }, onItemCheckedChanged = { index -> val newItems = alertItems.toMutableList() @@ -161,11 +160,7 @@ fun MaterialDialog( ) Spacer(modifier = Modifier.height(10.dp)) } else { - if (customContent != null) { - Spacer(modifier = Modifier.height(4.dp)) - customContent.invoke(this) - Spacer(modifier = Modifier.height(10.dp)) - } + customContent?.invoke(this) } } diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/VkErrorView.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/VkErrorView.kt new file mode 100644 index 00000000..288c688e --- /dev/null +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/VkErrorView.kt @@ -0,0 +1,70 @@ +package dev.meloda.fast.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import dev.meloda.fast.model.BaseError +import dev.meloda.fast.ui.R + +@Composable +fun VkErrorView( + modifier: Modifier = Modifier, + baseError: BaseError, + onButtonClick: () -> Unit = {} +) { + when (baseError) { + is BaseError.SessionExpired -> { + ErrorView( + modifier = modifier, + text = stringResource(R.string.session_expired), + buttonText = stringResource(R.string.action_log_out), + onButtonClick = onButtonClick + ) + } + + is BaseError.SimpleError -> { + ErrorView( + modifier = modifier, + text = baseError.message, + buttonText = stringResource(R.string.try_again), + onButtonClick = onButtonClick + ) + } + + BaseError.AccountBlocked -> { + ErrorView( + modifier = modifier, + text = "Account blocked", + buttonText = stringResource(R.string.action_log_out), + onButtonClick = onButtonClick + ) + } + + BaseError.ConnectionError -> { + ErrorView( + modifier = modifier, + text = "Connection error", + buttonText = stringResource(R.string.try_again), + onButtonClick = onButtonClick + ) + } + + BaseError.InternalError -> { + ErrorView( + modifier = modifier, + text = "Internal error", + buttonText = stringResource(R.string.try_again), + onButtonClick = onButtonClick + ) + } + + BaseError.UnknownError -> { + ErrorView( + modifier = modifier, + text = "Unknown error", + buttonText = stringResource(R.string.try_again), + onButtonClick = onButtonClick + ) + } + } +} diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/extensions/SharedViewModel.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/extensions/SharedViewModel.kt index 2bfb3f95..3b7c3143 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/extensions/SharedViewModel.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/extensions/SharedViewModel.kt @@ -6,13 +6,27 @@ import androidx.lifecycle.ViewModel import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController import org.koin.androidx.compose.koinViewModel -import org.koin.androidx.compose.navigation.koinNavViewModel +import org.koin.core.parameter.ParametersDefinition +import org.koin.core.qualifier.Qualifier @Composable -inline fun NavBackStackEntry.sharedViewModel(navController: NavController): T { - val navGraphRoute = destination.parent?.route ?: return koinViewModel() +inline fun NavBackStackEntry.sharedViewModel( + navController: NavController, + route: String? = null, + qualifier: Qualifier? = null, + noinline parameters: ParametersDefinition? = null, +): T { + val navGraphRoute = route ?: destination.parent?.route ?: return koinViewModel( + qualifier = qualifier, + parameters = parameters + ) val parentEntry = remember(this) { navController.getBackStackEntry(navGraphRoute) } - return koinNavViewModel(viewModelStoreOwner = parentEntry) + + return koinViewModel( + viewModelStoreOwner = parentEntry, + qualifier = qualifier, + parameters = parameters + ) } diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/ThemeConfig.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/ThemeConfig.kt index 387e4087..5e3b95e1 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/ThemeConfig.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/ThemeConfig.kt @@ -7,5 +7,6 @@ data class ThemeConfig( val amoledDark: Boolean, val enableBlur: Boolean, val enableMultiline: Boolean, - val useSystemFont: Boolean + val useSystemFont: Boolean, + val enableAnimations: Boolean ) diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationOption.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationOption.kt index 775a26a0..32689d70 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationOption.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationOption.kt @@ -28,4 +28,14 @@ sealed class ConversationOption( title = UiText.Resource(R.string.action_delete), icon = UiImage.Resource(R.drawable.round_delete_outline_24) ) + + data object Archive : ConversationOption( + title = UiText.Resource(R.string.conversation_context_action_archive), + icon = UiImage.Resource(R.drawable.outline_archive_24) + ) + + data object Unarchive : ConversationOption( + title = UiText.Resource(R.string.conversation_context_action_unarchive), + icon = UiImage.Resource(R.drawable.outline_unarchive_24) + ) } diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationsShowOptions.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationsShowOptions.kt deleted file mode 100644 index 66a77a8b..00000000 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationsShowOptions.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.meloda.fast.ui.model.api - -data class ConversationsShowOptions( - val showDeleteDialog: Int?, - val showPinDialog: UiConversation? -) { - - companion object { - val EMPTY: ConversationsShowOptions = ConversationsShowOptions( - showDeleteDialog = null, - showPinDialog = null - ) - } -} diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiConversation.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiConversation.kt index 9944284f..3a725d6b 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiConversation.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiConversation.kt @@ -9,8 +9,8 @@ import dev.meloda.fast.ui.util.ImmutableList @Immutable data class UiConversation( - val id: Int, - val lastMessageId: Int?, + val id: Long, + val lastMessageId: Long?, val avatar: UiImage?, val title: String, val unreadCount: String?, @@ -27,5 +27,6 @@ data class UiConversation( val peerType: PeerType, val interactionText: String?, val isExpanded: Boolean, + val isArchived: Boolean, val options: ImmutableList, ) diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiFriend.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiFriend.kt index 3e64e990..ed4aa94c 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiFriend.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiFriend.kt @@ -6,7 +6,7 @@ import dev.meloda.fast.model.api.domain.OnlineStatus @Immutable data class UiFriend( - val userId: Int, + val userId: Long, val avatar: UiImage?, val firstName: String, val lastName: String, diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/theme/AppTheme.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/theme/AppTheme.kt index a8a42537..17134119 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/theme/AppTheme.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/theme/AppTheme.kt @@ -2,6 +2,7 @@ package dev.meloda.fast.ui.theme import android.app.Activity import android.os.Build +import androidx.compose.animation.animateColorAsState import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme @@ -9,8 +10,10 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.SideEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView @@ -20,7 +23,9 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat +import androidx.navigation.NavController import dev.chrisbanes.haze.HazeState +import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.ui.R import dev.meloda.fast.ui.model.DeviceSize import dev.meloda.fast.ui.model.SizeConfig @@ -113,7 +118,8 @@ val LocalThemeConfig = compositionLocalOf { amoledDark = false, enableBlur = false, enableMultiline = false, - useSystemFont = false + useSystemFont = false, + enableAnimations = false ) } @@ -124,12 +130,16 @@ val LocalSizeConfig = compositionLocalOf { ) } -val LocalHazeState = compositionLocalOf { - HazeState() -} +val LocalHazeState = compositionLocalOf { HazeState() } +val LocalBottomPadding = compositionLocalOf { 0.dp } +val LocalUser = compositionLocalOf { null } +val LocalReselectedTab = compositionLocalOf { mapOf() } +val LocalNavRootController = compositionLocalOf { null } +val LocalNavController = compositionLocalOf { null } -val LocalBottomPadding = compositionLocalOf { - 0.dp +@Composable +fun ProvidableCompositionLocal.getOrThrow(): T { + return requireNotNull(current) } @Composable @@ -142,9 +152,10 @@ fun AppTheme( selectedColorScheme: Int = 0, content: @Composable () -> Unit ) { + val context = LocalContext.current + val colorScheme: ColorScheme = when { useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } @@ -167,6 +178,10 @@ fun AppTheme( } } + val colorPrimary by animateColorAsState(colorScheme.primary) + val colorSurface by animateColorAsState(colorScheme.surface) + val colorBackground by animateColorAsState(colorScheme.background) + val typography = if (useSystemFont) { MaterialTheme.typography } else { @@ -185,7 +200,7 @@ fun AppTheme( bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = robotoFonts), labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = robotoFonts), labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = robotoFonts), - labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = robotoFonts) + labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = robotoFonts), ) } @@ -199,7 +214,12 @@ fun AppTheme( } MaterialTheme( - colorScheme = predefinedColorScheme ?: colorScheme, + colorScheme = (predefinedColorScheme ?: colorScheme) + .copy( + primary = colorPrimary, + background = colorBackground, + surface = colorSurface + ), typography = typography, content = content ) diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt index 65128318..500882a1 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt @@ -65,3 +65,5 @@ class ImmutableList(val values: List) : Iterable { override fun iterator(): Iterator = values.listIterator() } + +fun emptyImmutableList(): ImmutableList = ImmutableList(emptyList()) diff --git a/core/ui/src/main/res/drawable/ic_multimedia.xml b/core/ui/src/main/res/drawable/ic_multimedia.xml new file mode 100644 index 00000000..01a283ce --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_multimedia.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/outline_archive_24.xml b/core/ui/src/main/res/drawable/outline_archive_24.xml new file mode 100644 index 00000000..61135264 --- /dev/null +++ b/core/ui/src/main/res/drawable/outline_archive_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/outline_unarchive_24.xml b/core/ui/src/main/res/drawable/outline_unarchive_24.xml new file mode 100644 index 00000000..8cf26a43 --- /dev/null +++ b/core/ui/src/main/res/drawable/outline_unarchive_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_content_copy_24.xml b/core/ui/src/main/res/drawable/round_content_copy_24.xml new file mode 100644 index 00000000..1ab5dfd5 --- /dev/null +++ b/core/ui/src/main/res/drawable/round_content_copy_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_create_24.xml b/core/ui/src/main/res/drawable/round_create_24.xml new file mode 100644 index 00000000..f143a80b --- /dev/null +++ b/core/ui/src/main/res/drawable/round_create_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_error_24.xml b/core/ui/src/main/res/drawable/round_error_24.xml new file mode 100644 index 00000000..73d61989 --- /dev/null +++ b/core/ui/src/main/res/drawable/round_error_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_filter_list_24.xml b/core/ui/src/main/res/drawable/round_filter_list_24.xml new file mode 100644 index 00000000..6d8c5ede --- /dev/null +++ b/core/ui/src/main/res/drawable/round_filter_list_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_forward_24.xml b/core/ui/src/main/res/drawable/round_forward_24.xml new file mode 100644 index 00000000..5b1d5707 --- /dev/null +++ b/core/ui/src/main/res/drawable/round_forward_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_mark_email_read_24.xml b/core/ui/src/main/res/drawable/round_mark_email_read_24.xml new file mode 100644 index 00000000..07c70bbd --- /dev/null +++ b/core/ui/src/main/res/drawable/round_mark_email_read_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_baseline_create_24.xml b/core/ui/src/main/res/drawable/round_play_arrow_24.xml similarity index 56% rename from core/ui/src/main/res/drawable/ic_baseline_create_24.xml rename to core/ui/src/main/res/drawable/round_play_arrow_24.xml index efc5ae47..75ff5f1d 100644 --- a/core/ui/src/main/res/drawable/ic_baseline_create_24.xml +++ b/core/ui/src/main/res/drawable/round_play_arrow_24.xml @@ -3,7 +3,9 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> + + android:pathData="M8,6.82v10.36c0,0.79 0.87,1.27 1.54,0.84l8.14,-5.18c0.62,-0.39 0.62,-1.29 0,-1.69L9.54,5.98C8.87,5.55 8,6.03 8,6.82z" /> + diff --git a/core/ui/src/main/res/drawable/round_reply_24.xml b/core/ui/src/main/res/drawable/round_reply_24.xml new file mode 100644 index 00000000..8f412d6f --- /dev/null +++ b/core/ui/src/main/res/drawable/round_reply_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_reply_all_24.xml b/core/ui/src/main/res/drawable/round_reply_all_24.xml new file mode 100644 index 00000000..921da1f8 --- /dev/null +++ b/core/ui/src/main/res/drawable/round_reply_all_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_report_gmailerrorred_24.xml b/core/ui/src/main/res/drawable/round_report_gmailerrorred_24.xml new file mode 100644 index 00000000..eed3ce7e --- /dev/null +++ b/core/ui/src/main/res/drawable/round_report_gmailerrorred_24.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/round_report_off_24.xml b/core/ui/src/main/res/drawable/round_report_off_24.xml new file mode 100644 index 00000000..885457a6 --- /dev/null +++ b/core/ui/src/main/res/drawable/round_report_off_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_star_24.xml b/core/ui/src/main/res/drawable/round_star_24.xml new file mode 100644 index 00000000..64f6840d --- /dev/null +++ b/core/ui/src/main/res/drawable/round_star_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_star_outline_24.xml b/core/ui/src/main/res/drawable/round_star_outline_24.xml new file mode 100644 index 00000000..81204117 --- /dev/null +++ b/core/ui/src/main/res/drawable/round_star_outline_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/values-ru/strings.xml b/core/ui/src/main/res/values-ru/strings.xml index c137d3b7..20927524 100644 --- a/core/ui/src/main/res/values-ru/strings.xml +++ b/core/ui/src/main/res/values-ru/strings.xml @@ -5,19 +5,26 @@ При выходе из учётной записи с устройства будут удалены все связанные с ней данные. Продолжить? Да Нет + Повторить Ответить + Переслать сюда + Переслать Пометить как важное - Помететить как не важное + Пометить как не важное Время: %1$s + Помеьиьб как не спам Закрепить Открепить Изменить Удалить + Прочитать + Скопировать Удалить сообщение? Для всех Пометить как спам Прочитать Удалить + Из архива Удалить Удалить чат? Выйти @@ -26,8 +33,12 @@ Закрепить Открепить чат? Закрепить чат? + Разархивировать чат? Закрепить Открепить + Пометить + Убрать пометку + Из архива Исходящий вызов Входящий вызов Закончился @@ -117,10 +128,13 @@ Подкаст Момент Статья + Видеосообщение + Стикер группы + Превью стикерпака Загрузка файла Загрузка фото Загрузка видео - Печатает + печатает Записывает %1$s печатают %1$s печатает @@ -167,6 +181,7 @@ Участники: %1$d Загрузка… Чаты + Архив Друзья Профиль Все @@ -218,4 +233,34 @@ Создать чат Создать Название + Вложения чата + Вложения + Приоритет + Имя + Случайно + Упорядочить по + Фото + Видео + Музыка + Файлы + Ссылки + Пометить как спам + Вы уверены, что хотите закрепить это сообщение? Это изменение увидят все участники чата. + Открепить сообщение + Вы уверены, что хотите открепить это сообщение? Все участники чата увидят это изменение. + Удалить сообщение? + Для всех + Пометить как важное + Вы уверены, что хотите пометить это сообщение как важное? + Вы уверены, что хотите убрать пометку избранного у этого сообщения? + Пометить как спам + Вы уверены, что хотите пометить это сообщение как спам? + Убрать пометку избранного + Убрать пометку спама + Вы уверены, что хотите убрать пометку спама у этого сообщения? + Закрепить сообщение + Скопировано в буфер обмена + В архив + Архивировать чат? + В архив diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index a9f1c0f4..96f11fe1 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -104,11 +104,14 @@ Podcast Narrative Article + Video message + Group sticker + Sticker pack preview Uploading file Uploading photo Uploading video - Typing + typing Recording %1$s are typing @@ -124,14 +127,22 @@ Signing out will delete all data related to this account from this device. Continue? Yes No + Time: %1$s + + Retry Reply + Forward here + Forward Mark as important Unmark as important - Time: %1$s + Mark as spam + Unmark as spam Pin Unpin Edit Delete + Read + Copy Delete the message? @@ -141,6 +152,8 @@ Read Delete + Archive + Unarchive Delete Delete the conversation? Sign out @@ -149,8 +162,14 @@ Pin Unpin the conversation? Pin the conversation? + Archive the conversation? + Unarchive the conversation? Pin Unpin + Mark + Unmark + Archive + Unarchive Outgoing call Incoming call Ended @@ -226,6 +245,7 @@ Members: %1$d Loading… Conversations + Archive Friends Profile All @@ -283,4 +303,39 @@ Create chat Create Title + Chat materials + Materials + Priority + Name + Random + Mobile + Smart + Order by + Photos + Videos + Music + Files + Links + + Pin message + Are you sure you want to pin this message? All chat members will see this change. + + Unpin message + Are you sure you want to unpin this message? All chat members will see this change. + + Delete the message? + For everyone + + Mark as important + Are you sure you want to mark this message as important? + + Unmark as important + Are you sure you want to unmark this message as important? + + Mark as spam + Are you sure you want to mark this message as spam? + + Unmark as spam + Are you sure you want to unmark this message as spam? + Copied to clipboard diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index a65db56e..930f0059 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -46,6 +46,13 @@ androidComponents { } } +// TODO: 29-Mar-25, Danil Nikolaev: remove when autofill changes will be in release +configurations.all { + resolutionStrategy { + force(libs.compose.ui) + } +} + android { namespace = "dev.meloda.fast.auth" diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt index d1aa7d82..74cb253b 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt @@ -6,9 +6,8 @@ import androidx.navigation.navigation import dev.meloda.fast.auth.captcha.navigation.captchaScreen import dev.meloda.fast.auth.captcha.navigation.navigateToCaptcha import dev.meloda.fast.auth.captcha.navigation.setCaptchaResult -import dev.meloda.fast.auth.login.navigation.Logo +import dev.meloda.fast.auth.login.navigation.Login import dev.meloda.fast.auth.login.navigation.loginScreen -import dev.meloda.fast.auth.login.navigation.navigateToLogin import dev.meloda.fast.auth.userbanned.model.UserBannedArguments import dev.meloda.fast.auth.userbanned.navigation.navigateToUserBanned import dev.meloda.fast.auth.userbanned.navigation.userBannedRoute @@ -26,9 +25,7 @@ fun NavGraphBuilder.authNavGraph( onNavigateToMain: () -> Unit, navController: NavController ) { - navigation( - startDestination = Logo - ) { + navigation(startDestination = Login) { loginScreen( onNavigateToCaptcha = { arguments -> navController.navigateToCaptcha( @@ -57,29 +54,28 @@ fun NavGraphBuilder.authNavGraph( ) ) }, - onNavigateToCredentials = navController::navigateToLogin, navController = navController ) validationScreen( onBack = { - navController.navigateUp() navController.setValidationResult(null) + navController.navigateUp() }, onResult = { code -> - navController.popBackStack() navController.setValidationResult(code) + navController.popBackStack() } ) captchaScreen( onBack = { - navController.navigateUp() navController.setCaptchaResult(null) + navController.navigateUp() }, onResult = { code -> - navController.popBackStack() navController.setCaptchaResult(code) + navController.popBackStack() } ) diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/navigation/CaptchaNavigation.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/navigation/CaptchaNavigation.kt index b415a2fa..abfd7d12 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/navigation/CaptchaNavigation.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/navigation/CaptchaNavigation.kt @@ -34,7 +34,7 @@ fun NavController.navigateToCaptcha(captchaImageUrl: String) { } fun NavController.setCaptchaResult(code: String?) { - this.currentBackStackEntry + this.previousBackStackEntry ?.savedStateHandle ?.set("captcha_code", code) } diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt index 3b92f65f..459a6bfa 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt @@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions @@ -24,6 +27,7 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable @@ -123,7 +127,9 @@ fun CaptchaScreen( val focusManager = LocalFocusManager.current - Scaffold { padding -> + Scaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime) + ) { padding -> Column( modifier = Modifier .fillMaxSize() diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/LoginViewModel.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/LoginViewModel.kt index 976ea9e0..e91b2a45 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/LoginViewModel.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/LoginViewModel.kt @@ -1,10 +1,11 @@ package dev.meloda.fast.auth.login +import android.os.Bundle import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.meloda.fast.auth.login.model.CaptchaArguments -import dev.meloda.fast.auth.login.model.LoginError +import dev.meloda.fast.auth.login.model.LoginDialog import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginValidationArguments @@ -14,18 +15,21 @@ import dev.meloda.fast.common.LongPollController import dev.meloda.fast.common.VkConstants 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.LongPollState import dev.meloda.fast.data.State import dev.meloda.fast.data.UserConfig +import dev.meloda.fast.data.api.auth.AuthRepository import dev.meloda.fast.data.db.AccountsRepository import dev.meloda.fast.data.processState +import dev.meloda.fast.data.success import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.OAuthUseCase import dev.meloda.fast.model.database.AccountEntity import dev.meloda.fast.network.OAuthErrorDomain +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -36,15 +40,20 @@ import kotlinx.coroutines.launch interface LoginViewModel { val screenState: StateFlow - val loginError: StateFlow + val loginDialog: StateFlow - val validationCode: StateFlow val validationArguments: StateFlow - val captchaCode: StateFlow val captchaArguments: StateFlow val userBannedArguments: StateFlow val isNeedToOpenMain: StateFlow - val isNeedToShowFastSignInAlert: StateFlow + + val isNeedToClearCaptchaCode: StateFlow + val isNeedToClearValidationCode: StateFlow + + fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) + fun onDialogDismissed(dialog: LoginDialog) + + fun onBackPressed() fun onPasswordVisibilityButtonClicked() @@ -53,24 +62,20 @@ interface LoginViewModel { fun onSignInButtonClicked() - fun onErrorDialogDismissed() - fun onNavigatedToMain() fun onNavigatedToUserBanned() fun onNavigatedToCaptcha() fun onNavigatedToValidation() - fun onValidationCodeReceived(code: String) - fun onCaptchaCodeReceived(code: String) - - fun onLogoLongClicked() - - fun onFastLogInAlertDismissed() - fun onFastLogInAlertConfirmClicked(token: String) + fun onValidationCodeReceived(code: String?) + fun onValidationCodeCleared() + fun onCaptchaCodeReceived(code: String?) + fun onCaptchaCodeCleared() } class LoginViewModelImpl( private val oAuthUseCase: OAuthUseCase, + private val authRepository: AuthRepository, private val loadUserByIdUseCase: LoadUserByIdUseCase, private val accountsRepository: AccountsRepository, private val loginValidator: LoginValidator, @@ -78,47 +83,85 @@ class LoginViewModelImpl( ) : ViewModel(), LoginViewModel { override val screenState = MutableStateFlow(LoginScreenState.EMPTY) - override val loginError = MutableStateFlow(null) + override val loginDialog = MutableStateFlow(null) - override val validationCode = MutableStateFlow(null) override val validationArguments = MutableStateFlow(null) - override val captchaCode = MutableStateFlow(null) override val captchaArguments = MutableStateFlow(null) override val userBannedArguments = MutableStateFlow(null) override val isNeedToOpenMain = MutableStateFlow(false) - override val isNeedToShowFastSignInAlert = MutableStateFlow(false) + + override val isNeedToClearCaptchaCode = MutableStateFlow(false) + override val isNeedToClearValidationCode = MutableStateFlow(false) private val validationState: StateFlow> = screenState.map(loginValidator::validate) .stateIn(viewModelScope, SharingStarted.Eagerly, listOf(LoginValidationResult.Empty)) + private val captchaSid = MutableStateFlow(null) + private val captchaCode = MutableStateFlow(null) + private val validationSid = MutableStateFlow(null) + private val validationCode = MutableStateFlow(null) + + init { + captchaCode.listenValue(viewModelScope) { + if (it != null) { + login() + } + } + validationCode.listenValue(viewModelScope) { + if (it != null) { + login() + } + } + } + + override fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) { + onDialogDismissed(dialog) + + when (dialog) { + is LoginDialog.Error -> Unit + } + } + + override fun onDialogDismissed(dialog: LoginDialog) { + loginDialog.setValue { null } + } + + override fun onBackPressed() { + screenState.setValue { old -> old.copy(showLogo = true) } + } + override fun onPasswordVisibilityButtonClicked() { screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) } } override fun onLoginInputChanged(newLogin: String) { - val newState = screenState.value.copy( - login = newLogin.trim(), - loginError = false - ) - screenState.setValue { newState } + screenState.setValue { old -> + old.copy( + login = newLogin.trim(), + loginError = false + ) + } } override fun onPasswordInputChanged(newPassword: String) { - val newState = screenState.value.copy( - password = newPassword.trim(), - passwordError = false - ) - screenState.setValue { newState } + screenState.setValue { old -> + old.copy( + password = newPassword.trim(), + passwordError = false + ) + } } override fun onSignInButtonClicked() { if (screenState.value.isLoading) return - login() - } - override fun onErrorDialogDismissed() { - loginError.update { null } + if (screenState.value.showLogo) { + screenState.setValue { old -> old.copy(showLogo = false) } + return + } + + login() } override fun onNavigatedToMain() { @@ -137,72 +180,20 @@ class LoginViewModelImpl( validationArguments.update { null } } - override fun onValidationCodeReceived(code: String) { + override fun onValidationCodeReceived(code: String?) { validationCode.update { code } - - login() } - override fun onCaptchaCodeReceived(code: String) { + override fun onValidationCodeCleared() { + isNeedToClearValidationCode.update { false } + } + + override fun onCaptchaCodeReceived(code: String?) { captchaCode.update { code } - - login() } - override fun onLogoLongClicked() { - isNeedToShowFastSignInAlert.update { true } - } - - override fun onFastLogInAlertDismissed() { - isNeedToShowFastSignInAlert.update { false } - } - - override fun onFastLogInAlertConfirmClicked(token: String) { - var currentAccount = AccountEntity( - userId = -1, - accessToken = token, - fastToken = null, - trustedHash = null - ).also { account -> - UserConfig.currentUserId = account.userId - UserConfig.userId = account.userId - UserConfig.accessToken = account.accessToken - UserConfig.fastToken = account.fastToken - UserConfig.trustedHash = account.trustedHash - } - - loadUserByIdUseCase( - userId = null, - fields = VkConstants.USER_FIELDS, - nomCase = null - ).listenValue(viewModelScope) { state -> - state.processState( - error = { error -> - UserConfig.currentUserId = -1 - UserConfig.userId = -1 - UserConfig.accessToken = "" - - // TODO: 19/07/2024, Danil Nikolaev: show error? - }, - success = { response -> - val actualUserId = requireNotNull(response).id - - currentAccount = currentAccount.copy(userId = actualUserId) - - UserConfig.userId = actualUserId - UserConfig.currentUserId = actualUserId - - startLongPoll() - - viewModelScope.launch(Dispatchers.IO) { - accountsRepository.storeAccounts(listOf(currentAccount)) - delay(350) - isNeedToOpenMain.update { true } - } - } - ) - screenState.setValue { old -> old.copy(isLoading = state.isLoading()) } - } + override fun onCaptchaCodeCleared() { + isNeedToClearCaptchaCode.update { false } } private fun login(forceSms: Boolean = false) { @@ -219,77 +210,120 @@ class LoginViewModelImpl( processValidation() if (!validationState.value.contains(LoginValidationResult.Valid)) return - oAuthUseCase.auth( + screenState.updateValue { copy(isLoading = false) } + + val currentValidationSid = validationSid.value + val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null } + val currentCaptchaSid = captchaSid.value + val currentCaptchaCode = captchaCode.value?.takeIf { currentCaptchaSid != null } + + oAuthUseCase.getSilentToken( login = currentState.login, password = currentState.password, forceSms = forceSms, - validationCode = validationCode.value, - captchaSid = captchaArguments.value?.captchaSid, - captchaKey = captchaCode.value + validationCode = currentValidationCode, + captchaSid = currentCaptchaSid, + captchaKey = currentCaptchaCode ).listenValue(viewModelScope) { state -> state.processState( error = { error -> Log.d("LoginViewModelImpl", "login: error: $error") - validationCode.update { null } - captchaCode.update { null } + screenState.updateValue { copy(isLoading = false) } + captchaSid.setValue { null } parseError(error) }, success = { response -> - val userId = response.userId - val accessToken = response.accessToken + val exceptionHandler = + CoroutineExceptionHandler { _, _ -> + screenState.updateValue { copy(isLoading = false) } + loginDialog.setValue { LoginDialog.Error() } + } - if (userId == null || accessToken == null) { - loginError.update { LoginError.Unknown } - return@processState + viewModelScope.launch(Dispatchers.IO + exceptionHandler) { + val (anonymToken) = authRepository.getAnonymToken( + VkConstants.MESSENGER_APP_ID.toString(), + VkConstants.MESSENGER_APP_SECRET + ).success() + + val exchangeSilentTokenResponse = authRepository.exchangeSilentToken( + anonymToken = anonymToken, + silentToken = response.silentToken, + silentUuid = response.silentTokenUuid + ).success() + + + val getExchangeTokenResponse = + authRepository.getExchangeToken(exchangeSilentTokenResponse.accessToken) + .success() + + val exchangeToken = + getExchangeTokenResponse.usersTokens.firstOrNull { + it.userId == exchangeSilentTokenResponse.userId + } + + if (exchangeToken == null) { + screenState.updateValue { copy(isLoading = false) } + loginDialog.setValue { LoginDialog.Error() } + return@launch + } + + val userId = exchangeSilentTokenResponse.userId + val accessToken = exchangeSilentTokenResponse.accessToken + + // TODO: 30-Mar-25, Danil Nikolaev: get fast's app token + + val currentAccount = AccountEntity( + userId = userId, + accessToken = accessToken, + fastToken = null, + trustedHash = response.trustedHash, + exchangeToken = exchangeToken.commonToken + ).also { account -> + UserConfig.currentUserId = account.userId + UserConfig.userId = account.userId + UserConfig.accessToken = account.accessToken + UserConfig.fastToken = account.fastToken + UserConfig.trustedHash = account.trustedHash + UserConfig.exchangeToken = account.exchangeToken + } + + accountsRepository.storeAccounts(listOf(currentAccount)) + + startLongPoll() + + captchaSid.update { null } + validationSid.update { null } + + loadUserByIdUseCase( + userId = userId, + fields = VkConstants.USER_FIELDS, + nomCase = null + ).listenValue(viewModelScope) { state -> + state.processState( + any = { + screenState.updateValue { copy(isLoading = false) } + }, + error = ::parseError, + success = { user -> + if (user == null) { + loginDialog.update { LoginDialog.Error() } + } else { + screenState.updateValue { copy(login = "", password = "") } + isNeedToOpenMain.update { true } + } + } + ) + } } - - loadUserByIdUseCase( - userId = userId, - fields = VkConstants.USER_FIELDS, - nomCase = null - ) - - val currentAccount = AccountEntity( - userId = userId, - accessToken = accessToken, - fastToken = null, - trustedHash = response.validationHash - ).also { account -> - UserConfig.currentUserId = account.userId - UserConfig.userId = account.userId - UserConfig.accessToken = account.accessToken - UserConfig.fastToken = account.fastToken - UserConfig.trustedHash = account.trustedHash - } - - startLongPoll() - - accountsRepository.storeAccounts(listOf(currentAccount)) - - captchaArguments.update { null } - captchaCode.update { null } - - validationArguments.update { null } - validationCode.update { null } - - screenState.setValue { old -> - old.copy( - login = "", - password = "", - ) - } - - isNeedToOpenMain.update { true } } ) - screenState.emit(screenState.value.copy(isLoading = state.isLoading())) } } - private fun parseError(stateError: State.Error): Boolean { - return when (stateError) { + private fun parseError(stateError: State.Error) { + when (stateError) { is State.Error.OAuthError -> { when (val error = stateError.error) { is OAuthErrorDomain.ValidationRequiredError -> { @@ -301,6 +335,7 @@ class LoginViewModelImpl( canResendSms = error.validationResend == "sms" ) validationArguments.update { arguments } + validationSid.update { error.validationSid } } is OAuthErrorDomain.CaptchaRequiredError -> { @@ -309,10 +344,13 @@ class LoginViewModelImpl( captchaImageUrl = error.captchaImageUrl ) captchaArguments.update { arguments } + captchaSid.update { error.captchaSid } } OAuthErrorDomain.InvalidCredentialsError -> { - loginError.update { LoginError.WrongCredentials } + loginDialog.setValue { + LoginDialog.Error(errorText = "Wrong login or password.") + } } is OAuthErrorDomain.UserBannedError -> { @@ -326,33 +364,34 @@ class LoginViewModelImpl( } OAuthErrorDomain.WrongValidationCode -> { - loginError.update { LoginError.WrongValidationCode } + isNeedToClearValidationCode.update { true } + validationCode.update { null } + loginDialog.setValue { + LoginDialog.Error(errorText = "Wrong validation code.") + } } OAuthErrorDomain.WrongValidationCodeFormat -> { - loginError.update { LoginError.WrongValidationCodeFormat } + isNeedToClearValidationCode.update { true } + validationCode.update { null } + loginDialog.setValue { + LoginDialog.Error(errorText = "Wrong validation code format.") + } } OAuthErrorDomain.TooManyTriesError -> { - loginError.update { LoginError.TooManyTries } + loginDialog.setValue { + LoginDialog.Error(errorText = "Too many tries. Try in another hour or later.") + } } OAuthErrorDomain.UnknownError -> { - loginError.update { LoginError.Unknown } + loginDialog.setValue { LoginDialog.Error() } } } - - true } - is State.Error.TestError -> { - val message = stateError.message - val error = LoginError.SimpleError(message = message) - loginError.update { error } - true - } - - else -> false + else -> Unit } } diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/LoginDialog.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/LoginDialog.kt new file mode 100644 index 00000000..4eab5c24 --- /dev/null +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/LoginDialog.kt @@ -0,0 +1,12 @@ +package dev.meloda.fast.auth.login.model + +import androidx.compose.runtime.Immutable + +@Immutable +sealed class LoginDialog { + + data class Error( + val errorText: String? = null, + val errorTextResId: Int? = null + ) : LoginDialog() +} diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/LoginError.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/LoginError.kt deleted file mode 100644 index c3a6c84b..00000000 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/LoginError.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.meloda.fast.auth.login.model - -import androidx.compose.runtime.Immutable - -@Immutable -sealed class LoginError { - data object Unknown : LoginError() - data object WrongCredentials : LoginError() - data object TooManyTries : LoginError() - data object WrongValidationCode : LoginError() - data object WrongValidationCodeFormat : LoginError() - data class SimpleError(val message: String): LoginError() -} diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/LoginScreenState.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/LoginScreenState.kt index cdad4e06..ecc7cbb3 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/LoginScreenState.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/LoginScreenState.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable @Immutable data class LoginScreenState( + val showLogo: Boolean, val login: String, val password: String, val isLoading: Boolean, @@ -14,6 +15,7 @@ data class LoginScreenState( companion object { val EMPTY = LoginScreenState( + showLogo = true, login = "", password = "", isLoading = false, diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt index 512aeeea..424f0216 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt @@ -1,5 +1,8 @@ package dev.meloda.fast.auth.login.navigation +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder @@ -10,28 +13,40 @@ import dev.meloda.fast.auth.login.model.CaptchaArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginValidationArguments import dev.meloda.fast.auth.login.presentation.LoginRoute -import dev.meloda.fast.auth.login.presentation.LogoRoute import dev.meloda.fast.ui.extensions.sharedViewModel import kotlinx.serialization.Serializable @Serializable object Login -@Serializable -object Logo - fun NavGraphBuilder.loginScreen( onNavigateToCaptcha: (CaptchaArguments) -> Unit, onNavigateToValidation: (LoginValidationArguments) -> Unit, onNavigateToMain: () -> Unit, onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit, - onNavigateToCredentials: () -> Unit, navController: NavController ) { composable { backStackEntry -> val viewModel: LoginViewModel = backStackEntry.sharedViewModel(navController = navController) + val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle() + val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle() + + LaunchedEffect(clearValidationCode) { + if (clearValidationCode) { + backStackEntry.savedStateHandle["validation_code"] = null + viewModel.onValidationCodeCleared() + } + } + + LaunchedEffect(clearCaptchaCode) { + if (clearCaptchaCode) { + backStackEntry.savedStateHandle["captcha_code"] = null + viewModel.onCaptchaCodeCleared() + } + } + val validationCode = backStackEntry.getValidationResult() val captchaCode = backStackEntry.getCaptchaResult() @@ -45,17 +60,6 @@ fun NavGraphBuilder.loginScreen( viewModel = viewModel ) } - - composable { - LogoRoute( - onNavigateToMain = onNavigateToMain, - onGoNextButtonClicked = onNavigateToCredentials - ) - } -} - -fun NavController.navigateToLogin() { - this.navigate(route = Login) } fun NavBackStackEntry.getValidationResult(): String? { diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt index b85683a7..3e11bd5f 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt @@ -1,6 +1,9 @@ package dev.meloda.fast.auth.login.presentation +import android.os.Bundle +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box @@ -28,13 +31,9 @@ import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -42,28 +41,30 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextRange +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.meloda.fast.auth.login.LoginViewModel +import dev.meloda.fast.auth.login.LoginViewModelImpl import dev.meloda.fast.auth.login.model.CaptchaArguments -import dev.meloda.fast.auth.login.model.LoginError +import dev.meloda.fast.auth.login.model.LoginDialog import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginValidationArguments -import dev.meloda.fast.ui.basic.autoFillRequestHandler -import dev.meloda.fast.ui.basic.connectNode -import dev.meloda.fast.ui.basic.defaultFocusChangeAutoFill +import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.theme.LocalSizeConfig import dev.meloda.fast.ui.util.handleEnterKey import dev.meloda.fast.ui.util.handleTabKey import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject import dev.meloda.fast.ui.R as UiR @Composable @@ -74,59 +75,54 @@ fun LoginRoute( onNavigateToValidation: (LoginValidationArguments) -> Unit, validationCode: String?, captchaCode: String?, - viewModel: dev.meloda.fast.auth.login.LoginViewModel = koinViewModel() + viewModel: LoginViewModel = koinViewModel() ) { val screenState by viewModel.screenState.collectAsStateWithLifecycle() val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle() val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle() val captchaArguments by viewModel.captchaArguments.collectAsStateWithLifecycle() val validationArguments by viewModel.validationArguments.collectAsStateWithLifecycle() - val loginError by viewModel.loginError.collectAsStateWithLifecycle() + val loginDialog by viewModel.loginDialog.collectAsStateWithLifecycle() + + BackHandler( + enabled = !screenState.showLogo, + onBack = viewModel::onBackPressed + ) LaunchedEffect(isNeedToOpenMain) { if (isNeedToOpenMain) { viewModel.onNavigatedToMain() onNavigateToMain() } - } + } LaunchedEffect(userBannedArguments) { userBannedArguments?.let { arguments -> viewModel.onNavigatedToUserBanned() onNavigateToUserBanned(arguments) } } - LaunchedEffect(captchaArguments) { captchaArguments?.let { arguments -> viewModel.onNavigatedToCaptcha() onNavigateToCaptcha(arguments) } } - LaunchedEffect(validationArguments) { validationArguments?.let { arguments -> viewModel.onNavigatedToValidation() onNavigateToValidation(arguments) } } - LaunchedEffect(validationCode) { - if (validationCode != null) { - viewModel.onValidationCodeReceived(validationCode) - } + viewModel.onValidationCodeReceived(validationCode) } - LaunchedEffect(captchaCode) { - if (captchaCode != null) { - viewModel.onCaptchaCodeReceived(captchaCode) - } + viewModel.onCaptchaCodeReceived(captchaCode) } LoginScreen( screenState = screenState, - onLoginAutoFilled = viewModel::onLoginInputChanged, - onPasswordAutoFilled = viewModel::onPasswordInputChanged, onLoginInputChanged = viewModel::onLoginInputChanged, onPasswordInputChanged = viewModel::onPasswordInputChanged, onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked, @@ -135,18 +131,16 @@ fun LoginRoute( onSignInButtonClicked = viewModel::onSignInButtonClicked ) - HandleError( - onDismiss = viewModel::onErrorDialogDismissed, - error = loginError + HandleDialogs( + loginDialog = loginDialog, + onConfirmed = viewModel::onDialogConfirmed, + onDismissed = viewModel::onDialogDismissed ) } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun LoginScreen( screenState: LoginScreenState = LoginScreenState.EMPTY, - onLoginAutoFilled: (String) -> Unit = {}, - onPasswordAutoFilled: (String) -> Unit = {}, onLoginInputChanged: (String) -> Unit = {}, onPasswordInputChanged: (String) -> Unit = {}, onPasswordFieldEnterKeyClicked: () -> Unit = {}, @@ -154,218 +148,193 @@ fun LoginScreen( onPasswordFieldGoAction: () -> Unit = {}, onSignInButtonClicked: () -> Unit = {} ) { - val currentSize = LocalSizeConfig.current + val size = LocalSizeConfig.current val focusManager = LocalFocusManager.current - val (loginFocusable, passwordFocusable) = FocusRequester.createRefs() - var loginText by remember { mutableStateOf(TextFieldValue(screenState.login)) } - val showLoginError = screenState.loginError + val titleSpacerSize by animateDpAsState(if (size.isHeightSmall) 24.dp else 58.dp) + val bottomPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp) - val autoFillEmailHandler = autoFillRequestHandler( - autofillTypes = listOf(AutofillType.EmailAddress), - onFill = { value -> - loginText = TextFieldValue(text = value, selection = TextRange(value.length)) - onLoginAutoFilled(value) - } - ) - - var passwordText by remember { mutableStateOf(TextFieldValue(screenState.password)) } - val showPasswordError = screenState.passwordError - - val autoFillPasswordHandler = autoFillRequestHandler( - autofillTypes = listOf(AutofillType.Password), - onFill = { value -> - passwordText = TextFieldValue(text = value, selection = TextRange(value.length)) - onPasswordAutoFilled(value) - } - ) - - val titleStyle = if (currentSize.isWidthSmall) { - MaterialTheme.typography.displayMedium - } else { - MaterialTheme.typography.displayMedium - } - - val titleSpacerSize = if (currentSize.isHeightSmall) { - 24.dp - } else { - 58.dp - } - - val bottomPadding = if (currentSize.isHeightSmall) { - 10.dp - } else { - 30.dp - } + val (loginFocusable, passwordFocusable) = + FocusRequester.createRefs() Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime) ) { padding -> Box( modifier = Modifier - .fillMaxSize() .padding(padding) .padding(top = 30.dp) .padding(horizontal = 30.dp) .padding(bottom = bottomPadding) + .fillMaxSize() ) { - Column( + AnimatedVisibility( + visible = screenState.showLogo, + enter = fadeIn(), + exit = fadeOut() + ) { + Logo() + } + + AnimatedVisibility( modifier = Modifier .fillMaxWidth() - .align(Alignment.Center) + .align(Alignment.Center), + visible = !screenState.showLogo, + enter = fadeIn(), + exit = fadeOut() ) { - Text( - text = stringResource(id = UiR.string.sign_in_to_vk), - color = MaterialTheme.colorScheme.onBackground, - style = titleStyle - ) - - Spacer(modifier = Modifier.height(titleSpacerSize)) - - TextField( + Column( modifier = Modifier - .height(58.dp) .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .handleEnterKey { - passwordFocusable.requestFocus() - true - } - .handleTabKey { - passwordFocusable.requestFocus() - true - } - .focusRequester(loginFocusable) - .connectNode(handler = autoFillEmailHandler) - .defaultFocusChangeAutoFill(handler = autoFillEmailHandler), - value = loginText, - onValueChange = { newText -> - val text = newText.text - if (text.isEmpty()) { - autoFillEmailHandler.requestVerifyManual() - } + .align(Alignment.Center) + ) { + Text( + text = stringResource(id = UiR.string.sign_in_to_vk), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.displayMedium + ) - loginText = newText - onLoginInputChanged(text) - }, - label = { Text(text = stringResource(id = UiR.string.login_hint)) }, - placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) }, - leadingIcon = { - Icon( - painter = painterResource(id = UiR.drawable.ic_round_person_24), - contentDescription = "Login icon", - tint = if (showLoginError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary + Spacer(modifier = Modifier.height(titleSpacerSize)) + + TextField( + modifier = Modifier + .height(58.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .handleEnterKey { + passwordFocusable.requestFocus() + true } - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Next, - keyboardType = KeyboardType.Email - ), - keyboardActions = KeyboardActions(onNext = { passwordFocusable.requestFocus() }), - isError = showLoginError, - singleLine = true - ) - AnimatedVisibility(visible = showLoginError) { - TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field)) - } - - Spacer(modifier = Modifier.height(16.dp)) - - TextField( - modifier = Modifier - .height(58.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .handleEnterKey { - focusManager.clearFocus() - onPasswordFieldEnterKeyClicked() - true - } - .focusRequester(passwordFocusable) - .connectNode(handler = autoFillPasswordHandler) - .defaultFocusChangeAutoFill(handler = autoFillPasswordHandler), - value = passwordText, - onValueChange = { newText -> - val text = newText.text - if (text.isEmpty()) { - autoFillPasswordHandler.requestVerifyManual() - } - - passwordText = newText - onPasswordInputChanged(text) - }, - label = { Text(text = stringResource(id = UiR.string.password_login_hint)) }, - placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) }, - leadingIcon = { - Icon( - painter = painterResource(id = UiR.drawable.round_vpn_key_24), - contentDescription = "Password icon", - tint = if (showPasswordError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary + .handleTabKey { + passwordFocusable.requestFocus() + true } - ) - }, - trailingIcon = { - val imagePainter = painterResource( - id = if (screenState.passwordVisible) UiR.drawable.round_visibility_off_24 - else UiR.drawable.round_visibility_24 - ) - - IconButton(onClick = onPasswordVisibilityButtonClicked) { + .focusRequester(loginFocusable) + .semantics { + contentType = ContentType.Username + ContentType.EmailAddress + }, + value = screenState.login, + onValueChange = onLoginInputChanged, + label = { Text(text = stringResource(id = UiR.string.login_hint)) }, + placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) }, + leadingIcon = { Icon( - painter = imagePainter, - contentDescription = if (screenState.passwordVisible) "Password visible icon" - else "Password invisible icon" + painter = painterResource(id = UiR.drawable.ic_round_person_24), + contentDescription = "Login icon", + tint = if (screenState.loginError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } ) - } - }, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Go, - keyboardType = KeyboardType.Password - ), - keyboardActions = KeyboardActions( - onGo = { - focusManager.clearFocus() - onPasswordFieldGoAction() - } - ), - isError = showPasswordError, - visualTransformation = if (screenState.passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - singleLine = true - ) - AnimatedVisibility(visible = showPasswordError) { - TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field)) + }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Email + ), + keyboardActions = KeyboardActions(onNext = { passwordFocusable.requestFocus() }), + isError = screenState.loginError, + singleLine = true + ) + AnimatedVisibility(visible = screenState.loginError) { + TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextField( + modifier = Modifier + .height(58.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .handleEnterKey { + focusManager.clearFocus() + onPasswordFieldEnterKeyClicked() + true + } + .focusRequester(passwordFocusable) + .semantics { contentType = ContentType.Password }, + value = screenState.password, + onValueChange = onPasswordInputChanged, + label = { Text(text = stringResource(id = UiR.string.password_login_hint)) }, + placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) }, + leadingIcon = { + Icon( + painter = painterResource(id = UiR.drawable.round_vpn_key_24), + contentDescription = "Password icon", + tint = if (screenState.passwordError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + }, + trailingIcon = { + val imagePainter = painterResource( + id = if (screenState.passwordVisible) UiR.drawable.round_visibility_off_24 + else UiR.drawable.round_visibility_24 + ) + + IconButton(onClick = onPasswordVisibilityButtonClicked) { + Icon( + painter = imagePainter, + contentDescription = if (screenState.passwordVisible) "Password visible icon" + else "Password invisible icon" + ) + } + }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Go, + keyboardType = KeyboardType.Password + ), + keyboardActions = KeyboardActions( + onGo = { + focusManager.clearFocus() + onPasswordFieldGoAction() + } + ), + isError = screenState.passwordError, + visualTransformation = if (screenState.passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + singleLine = true + ) + AnimatedVisibility(visible = screenState.passwordError) { + TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field)) + } } } + Box( modifier = Modifier.align(Alignment.BottomCenter), contentAlignment = Alignment.Center ) { - AnimatedVisibility( - visible = !screenState.isLoading, - enter = fadeIn(), - exit = fadeOut() + FloatingActionButton( + onClick = { + if (!screenState.isLoading) { + focusManager.clearFocus() + onSignInButtonClicked() + } + }, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.testTag("sing_in_fab") ) { - FloatingActionButton( - onClick = { - if (!screenState.isLoading) { - focusManager.clearFocus() - onSignInButtonClicked() - } - }, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.testTag("sing_in_fab") + AnimatedVisibility( + visible = screenState.isLoading, + enter = fadeIn(), + exit = fadeOut() + ) { + CircularProgressIndicator() + } + + AnimatedVisibility( + visible = !screenState.isLoading, + enter = fadeIn(), + exit = fadeOut() ) { Icon( painter = painterResource(id = UiR.drawable.ic_arrow_end), @@ -374,77 +343,28 @@ fun LoginScreen( ) } } - AnimatedVisibility( - visible = screenState.isLoading, - enter = fadeIn(), - exit = fadeOut() - ) { - CircularProgressIndicator() - } } } } } + @Composable -fun HandleError( - onDismiss: () -> Unit, - error: LoginError?, +fun HandleDialogs( + loginDialog: LoginDialog?, + onConfirmed: (LoginDialog, Bundle) -> Unit = { _, _ -> }, + onDismissed: (LoginDialog) -> Unit = {}, ) { - when (error) { + when (loginDialog) { null -> Unit - LoginError.Unknown -> { + is LoginDialog.Error -> { MaterialDialog( - onDismissRequest = onDismiss, - title = "Error", - text = "Unknown error", - confirmText = stringResource(id = UiR.string.ok) - ) - } - - LoginError.WrongCredentials -> { - MaterialDialog( - onDismissRequest = onDismiss, - title = "Error", - text = "Wrong login or password.", - confirmText = stringResource(id = UiR.string.ok) - ) - } - - LoginError.TooManyTries -> { - MaterialDialog( - onDismissRequest = onDismiss, - title = "Error", - text = "Too many tries. Try in another hour or later.", - confirmText = stringResource(id = UiR.string.ok) - ) - } - - - LoginError.WrongValidationCode -> { - MaterialDialog( - onDismissRequest = onDismiss, - title = "Error", - text = "Wrong validation code.", - confirmText = stringResource(id = UiR.string.ok) - ) - } - - LoginError.WrongValidationCodeFormat -> { - MaterialDialog( - onDismissRequest = onDismiss, - title = "Error", - text = "Wrong validation code format.", - confirmText = stringResource(id = UiR.string.ok) - ) - } - - is LoginError.SimpleError -> { - MaterialDialog( - onDismissRequest = onDismiss, - title = "Error", - text = error.message, + onDismissRequest = { onDismissed(loginDialog) }, + title = stringResource(UiR.string.title_error), + text = loginDialog.errorTextResId?.let { stringResource(it) } + ?: loginDialog.errorText + ?: stringResource(UiR.string.unknown_error_occurred), confirmText = stringResource(id = UiR.string.ok) ) } diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/Logo.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/Logo.kt new file mode 100644 index 00000000..7bceedf4 --- /dev/null +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/Logo.kt @@ -0,0 +1,91 @@ +package dev.meloda.fast.auth.login.presentation + +import android.os.Build +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.meloda.fast.datastore.UserSettings +import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.theme.LocalSizeConfig +import org.koin.compose.koinInject + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Logo(modifier: Modifier = Modifier) { + val size = LocalSizeConfig.current + + val iconWidth by animateDpAsState(if (size.isWidthSmall) 110.dp else 134.dp) + val appNameFontSize by animateIntAsState(if (size.isWidthSmall) 32 else 40) + val bottomAdditionalPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp) + + val userSettings: UserSettings = koinInject() + + Box( + modifier = modifier + .fillMaxSize() + .padding(top = 30.dp) + .padding(horizontal = 30.dp) + .padding(bottom = bottomAdditionalPadding) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.ic_logo_big), + contentDescription = "Application Logo", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .width(iconWidth) + .combinedClickable( + interactionSource = null, + indication = null, + onLongClick = null, + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + userSettings.onEnableDynamicColorsChanged( + !userSettings.enableDynamicColors.value + ) + } + } + ) + ) + + Spacer(modifier = Modifier.height(46.dp)) + Text( + text = stringResource(id = R.string.fast_messenger), + style = MaterialTheme.typography.displayMedium.copy(fontSize = appNameFontSize.sp), + color = MaterialTheme.colorScheme.onBackground + ) + } + } +} + +@Preview +@Composable +private fun LogoPreview() { + Logo() +} diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LogoScreen.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LogoScreen.kt deleted file mode 100644 index d777b0fc..00000000 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LogoScreen.kt +++ /dev/null @@ -1,229 +0,0 @@ -package dev.meloda.fast.auth.login.presentation - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.meloda.fast.auth.BuildConfig -import dev.meloda.fast.ui.components.ActionInvokeDismiss -import dev.meloda.fast.ui.components.MaterialDialog -import dev.meloda.fast.ui.theme.LocalSizeConfig -import org.koin.androidx.compose.koinViewModel -import dev.meloda.fast.ui.R as UiR - -@Composable -fun LogoRoute( - onNavigateToMain: () -> Unit, - onGoNextButtonClicked: () -> Unit, - viewModel: dev.meloda.fast.auth.login.LoginViewModel = koinViewModel() -) { - val screenState by viewModel.screenState.collectAsStateWithLifecycle() - val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle() - val isNeedToShowSignInAlert by viewModel.isNeedToShowFastSignInAlert.collectAsStateWithLifecycle() - - LaunchedEffect(isNeedToOpenMain) { - if (isNeedToOpenMain) { - viewModel.onNavigatedToMain() - onNavigateToMain() - } - } - - LogoScreen( - isLoading = screenState.isLoading, - onLogoLongClicked = viewModel::onLogoLongClicked, - onGoNextButtonClicked = onGoNextButtonClicked - ) - - if (isNeedToShowSignInAlert) { - SignInAlert( - onDismissRequest = viewModel::onFastLogInAlertDismissed, - onConfirmClick = viewModel::onFastLogInAlertConfirmClicked, - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun LogoScreen( - isLoading: Boolean = false, - onLogoLongClicked: () -> Unit = {}, - onGoNextButtonClicked: () -> Unit = {} -) { - val currentSize = LocalSizeConfig.current - - Scaffold { padding -> - val topPadding by animateDpAsState( - targetValue = padding.calculateTopPadding(), - label = "topPaddingAnimation" - ) - val bottomPadding by animateDpAsState( - targetValue = padding.calculateBottomPadding(), - label = "bottomPaddingAnimation" - ) - - val endPadding by animateDpAsState( - targetValue = padding.calculateEndPadding(LayoutDirection.Ltr), - label = "endPaddingAnimation" - ) - val startPadding by animateDpAsState( - targetValue = padding.calculateStartPadding(LayoutDirection.Ltr), - label = "startPaddingAnimation" - ) - - val iconWidth = if (currentSize.isWidthSmall) { - 110.dp - } else { - 134.dp - } - - val appNameTextStyle = if (currentSize.isWidthSmall) { - MaterialTheme.typography.displayMedium.copy(fontSize = 40.sp) - } else { - MaterialTheme.typography.displayMedium - } - - val bottomAdditionalPadding = if (currentSize.isHeightSmall) { - 10.dp - } else { - 30.dp - } - - Box( - modifier = Modifier - .fillMaxSize() - .padding( - start = startPadding, - top = topPadding, - end = endPadding, - bottom = bottomPadding - ) - .padding(top = 30.dp) - .padding(horizontal = 30.dp) - .padding(bottom = bottomAdditionalPadding) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - painter = painterResource(id = UiR.drawable.ic_logo_big), - contentDescription = "Application Logo", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .width(iconWidth) - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onLongClick = onLogoLongClicked, - onClick = {} - ) - ) - Spacer(modifier = Modifier.height(46.dp)) - Text( - text = stringResource(id = UiR.string.fast_messenger), - style = appNameTextStyle, - color = MaterialTheme.colorScheme.onBackground - ) - } - - AnimatedVisibility( - visible = !isLoading, - modifier = Modifier.align(Alignment.BottomCenter), - enter = fadeIn(), - exit = fadeOut() - ) { - FloatingActionButton( - onClick = { - if (!isLoading) { - onGoNextButtonClicked() - } - }, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.testTag("go_next_fab") - ) { - Icon( - painter = painterResource(id = UiR.drawable.ic_arrow_end), - contentDescription = "Go button", - tint = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - - AnimatedVisibility( - visible = isLoading, - modifier = Modifier.align(Alignment.BottomCenter) - ) { - CircularProgressIndicator() - } - } - } -} - -@Composable -fun SignInAlert( - onDismissRequest: () -> Unit, - onConfirmClick: (token: String) -> Unit -) { - var tokenText by rememberSaveable { - mutableStateOf(BuildConfig.debugToken) - } - - val maxWidthModifier = Modifier.fillMaxWidth() - - MaterialDialog( - onDismissRequest = onDismissRequest, - title = "Fast authorization", - confirmText = stringResource(id = UiR.string.action_authorize), - confirmAction = { onConfirmClick(tokenText) }, - cancelText = stringResource(id = UiR.string.cancel), - actionInvokeDismiss = ActionInvokeDismiss.Always - ) { - Column(modifier = maxWidthModifier) { - OutlinedTextField( - modifier = maxWidthModifier.padding(horizontal = 16.dp), - value = tokenText, - onValueChange = { tokenText = it }, - placeholder = { Text(text = "Access token") }, - label = { Text(text = "Access token") } - ) - } - } -} diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/navigation/ValidationNavigation.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/navigation/ValidationNavigation.kt index 891cbbb8..463e325f 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/navigation/ValidationNavigation.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/navigation/ValidationNavigation.kt @@ -38,7 +38,7 @@ fun NavController.navigateToValidation(arguments: ValidationArguments) { } fun NavController.setValidationResult(code: String?) { - this.currentBackStackEntry + this.previousBackStackEntry ?.savedStateHandle ?.set("validation_code", code) } diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt index ed30f9f1..0c2804c0 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt @@ -7,10 +7,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions @@ -23,6 +26,7 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable @@ -35,10 +39,13 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue @@ -146,7 +153,9 @@ fun ValidationScreen( var code by remember { mutableStateOf(TextFieldValue(screenState.code.orEmpty())) } - Scaffold { padding -> + Scaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime) + ) { padding -> Column( modifier = Modifier .fillMaxSize() @@ -210,7 +219,8 @@ fun ValidationScreen( placeholder = { Text(text = "Code") }, modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)), + .clip(RoundedCornerShape(10.dp)) + .semantics { contentType = ContentType.SmsOtpCode }, leadingIcon = { Icon( painter = painterResource(id = UiR.drawable.round_qr_code_24), diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/ChatMaterialsViewModel.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/ChatMaterialsViewModel.kt index 12638fff..4da7a2e2 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/ChatMaterialsViewModel.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/ChatMaterialsViewModel.kt @@ -4,17 +4,19 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState +import dev.meloda.fast.chatmaterials.model.MaterialType import dev.meloda.fast.chatmaterials.navigation.ChatMaterials import dev.meloda.fast.chatmaterials.util.asPresentation 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.processState import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage +import dev.meloda.fast.network.VkErrorCode import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update interface ChatMaterialsViewModel { val screenState: StateFlow @@ -23,7 +25,7 @@ interface ChatMaterialsViewModel { val currentOffset: StateFlow val canPaginate: StateFlow - fun onMetPaginationCondition() + fun onPaginationConditionsMet() fun onRefresh() @@ -33,6 +35,7 @@ interface ChatMaterialsViewModel { } class ChatMaterialsViewModelImpl( + private val materialType: MaterialType, private val messagesUseCase: MessagesUseCase, savedStateHandle: SavedStateHandle ) : ViewModel(), ChatMaterialsViewModel { @@ -50,15 +53,15 @@ class ChatMaterialsViewModelImpl( screenState.setValue { old -> old.copy( peerId = arguments.peerId, - conversationMessageId = arguments.conversationMessageId + cmId = arguments.conversationMessageId ) } loadChatMaterials() } - override fun onMetPaginationCondition() { - currentOffset.update { screenState.value.materials.size } + override fun onPaginationConditionsMet() { + currentOffset.setValue { old -> old + LOAD_COUNT } loadChatMaterials() } @@ -75,31 +78,33 @@ class ChatMaterialsViewModelImpl( loadChatMaterials(0) } - private fun loadChatMaterials( - offset: Int = currentOffset.value - ) { + private fun loadChatMaterials(offset: Int = currentOffset.value) { messagesUseCase.getHistoryAttachments( peerId = screenState.value.peerId, count = LOAD_COUNT, offset = offset, - attachmentTypes = listOf(screenState.value.attachmentType), - conversationMessageId = screenState.value.conversationMessageId + attachmentTypes = listOf(materialType.toString()), + cmId = screenState.value.cmId ).listenValue(viewModelScope) { state -> state.processState( - error = { error -> - - }, + error = ::handleError, success = { response -> val itemsCountSufficient = response.size == LOAD_COUNT canPaginate.setValue { itemsCountSufficient } - val paginationExhausted = !itemsCountSufficient && - screenState.value.materials.size >= LOAD_COUNT + val paginationExhausted = !itemsCountSufficient + && screenState.value.materials.isNotEmpty() - val loadedMaterials = response.map(VkAttachmentHistoryMessage::asPresentation) + val loadedMaterials = response.mapNotNull(VkAttachmentHistoryMessage::asPresentation) val newState = screenState.value.copy( - isPaginationExhausted = paginationExhausted + isPaginationExhausted = paginationExhausted, + cmId = if (loadedMaterials.size + offset > 200) { + currentOffset.setValue { 0 } + loadedMaterials.lastOrNull()?.conversationMessageId ?: -1 + } else { + screenState.value.cmId + } ) if (offset == 0) { @@ -125,6 +130,44 @@ class ChatMaterialsViewModelImpl( } } + private fun handleError(error: State.Error) { + when (error) { + is State.Error.ApiError -> { + when (error.errorCode) { + VkErrorCode.USER_AUTHORIZATION_FAILED -> { + baseError.setValue { BaseError.SessionExpired } + } + + else -> { + baseError.setValue { + BaseError.SimpleError(message = error.errorMessage) + } + } + } + } + + State.Error.ConnectionError -> { + baseError.setValue { + BaseError.SimpleError(message = "Connection error") + } + } + + State.Error.InternalError -> { + baseError.setValue { + BaseError.SimpleError(message = "Internal error") + } + } + + State.Error.UnknownError -> { + baseError.setValue { + BaseError.SimpleError(message = "Unknown error") + } + } + + else -> Unit + } + } + companion object { const val LOAD_COUNT = 100 } diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/di/ChatMaterialsModule.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/di/ChatMaterialsModule.kt index 95f2222b..6fcfcb13 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/di/ChatMaterialsModule.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/di/ChatMaterialsModule.kt @@ -1,9 +1,45 @@ package dev.meloda.fast.chatmaterials.di import dev.meloda.fast.chatmaterials.ChatMaterialsViewModelImpl -import org.koin.androidx.viewmodel.dsl.viewModelOf +import dev.meloda.fast.chatmaterials.model.MaterialType +import org.koin.core.module.dsl.viewModel +import org.koin.core.qualifier.named import org.koin.dsl.module val chatMaterialsModule = module { - viewModelOf(::ChatMaterialsViewModelImpl) + viewModel(named(MaterialType.PHOTO)) { + ChatMaterialsViewModelImpl( + materialType = MaterialType.PHOTO, + messagesUseCase = get(), + savedStateHandle = get() + ) + } + viewModel(named(MaterialType.AUDIO)) { + ChatMaterialsViewModelImpl( + materialType = MaterialType.AUDIO, + messagesUseCase = get(), + savedStateHandle = get() + ) + } + viewModel(named(MaterialType.VIDEO)) { + ChatMaterialsViewModelImpl( + materialType = MaterialType.VIDEO, + messagesUseCase = get(), + savedStateHandle = get() + ) + } + viewModel(named(MaterialType.FILE)) { + ChatMaterialsViewModelImpl( + materialType = MaterialType.FILE, + messagesUseCase = get(), + savedStateHandle = get() + ) + } + viewModel(named(MaterialType.LINK)) { + ChatMaterialsViewModelImpl( + materialType = MaterialType.LINK, + messagesUseCase = get(), + savedStateHandle = get() + ) + } } diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/ChatMaterialsScreenState.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/ChatMaterialsScreenState.kt index 3d8ee96d..7e18500a 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/ChatMaterialsScreenState.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/ChatMaterialsScreenState.kt @@ -9,8 +9,8 @@ data class ChatMaterialsScreenState( val attachmentType: String, val isPaginating: Boolean, val isPaginationExhausted: Boolean, - val peerId: Int, - val conversationMessageId: Int + val peerId: Long, + val cmId: Long ) { companion object { @@ -21,7 +21,7 @@ data class ChatMaterialsScreenState( isPaginating = false, isPaginationExhausted = false, peerId = -1, - conversationMessageId = -1 + cmId = -1 ) } } diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/MaterialType.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/MaterialType.kt new file mode 100644 index 00000000..9593c3a9 --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/MaterialType.kt @@ -0,0 +1,13 @@ +package dev.meloda.fast.chatmaterials.model + +enum class MaterialType { + PHOTO, VIDEO, AUDIO, FILE, LINK; + + override fun toString(): String = when (this) { + PHOTO -> "photo" + VIDEO -> "video" + AUDIO -> "audio" + FILE -> "doc" + LINK -> "link" + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/UiChatMaterial.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/UiChatMaterial.kt index 788d900d..f63b8b6d 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/UiChatMaterial.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/UiChatMaterial.kt @@ -1,28 +1,43 @@ package dev.meloda.fast.chatmaterials.model -sealed class UiChatMaterial { +sealed class UiChatMaterial( + open val conversationMessageId: Long +) { data class Photo( + override val conversationMessageId: Long, val previewUrl: String - ) : UiChatMaterial() + ) : UiChatMaterial(conversationMessageId) data class Video( - val previewUrl: String - ) : UiChatMaterial() + override val conversationMessageId: Long, + val previewUrl: String?, + val title: String, + val views: Int, + val duration: String + ) : UiChatMaterial(conversationMessageId) data class Audio( + override val conversationMessageId: Long, val previewUrl: String?, val title: String, val artist: String, val duration: String - ) : UiChatMaterial() + ) : UiChatMaterial(conversationMessageId) data class File( - val title: String - ) : UiChatMaterial() + override val conversationMessageId: Long, + val previewUrl: String?, + val title: String, + val size: String, + val extension: String + ) : UiChatMaterial(conversationMessageId) data class Link( - val title: String, - val previewUrl: String? - ) : UiChatMaterial() + override val conversationMessageId: Long, + val previewUrl: String?, + val title: String?, + val url: String, + val urlFirstChar: String + ) : UiChatMaterial(conversationMessageId) } diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/navigation/ChatMaterialsNavigation.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/navigation/ChatMaterialsNavigation.kt index d16700eb..1d6c5033 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/navigation/ChatMaterialsNavigation.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/navigation/ChatMaterialsNavigation.kt @@ -10,8 +10,8 @@ import kotlinx.serialization.Serializable @Serializable data class ChatMaterials( - val peerId: Int, - val conversationMessageId: Int + val peerId: Long, + val conversationMessageId: Long ) { companion object { fun from(savedStateHandle: SavedStateHandle) = @@ -31,7 +31,7 @@ fun NavGraphBuilder.chatMaterialsScreen( } } -fun NavController.navigateToChatMaterials(peerId: Int, conversationMessageId: Int) { +fun NavController.navigateToChatMaterials(peerId: Long, conversationMessageId: Long) { this.navigate( ChatMaterials( peerId = peerId, diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/ChatMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/ChatMaterialsScreen.kt index f7acddf4..0e830fa3 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/ChatMaterialsScreen.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/ChatMaterialsScreen.kt @@ -1,93 +1,73 @@ package dev.meloda.fast.chatmaterials.presentation import android.annotation.SuppressLint -import android.util.Log import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.chrisbanes.haze.HazeState 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.chatmaterials.ChatMaterialsViewModel import dev.meloda.fast.chatmaterials.ChatMaterialsViewModelImpl -import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState -import dev.meloda.fast.chatmaterials.model.UiChatMaterial -import dev.meloda.fast.ui.R +import dev.meloda.fast.chatmaterials.model.MaterialType +import dev.meloda.fast.chatmaterials.presentation.materials.AudioMaterialsScreen +import dev.meloda.fast.chatmaterials.presentation.materials.FileMaterialsScreen +import dev.meloda.fast.chatmaterials.presentation.materials.LinkMaterialsScreen +import dev.meloda.fast.chatmaterials.presentation.materials.PhotoMaterialsScreen +import dev.meloda.fast.chatmaterials.presentation.materials.VideoMaterialsScreen +import dev.meloda.fast.ui.model.TabItem +import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalThemeConfig +import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel +import org.koin.core.qualifier.named +import dev.meloda.fast.ui.R as UiR @Composable fun ChatMaterialsRoute( onBack: () -> Unit, onPhotoClicked: (url: String) -> Unit, - viewModel: ChatMaterialsViewModel = koinViewModel() ) { - val screenState by viewModel.screenState.collectAsStateWithLifecycle() ChatMaterialsScreen( - screenState = screenState, onBack = onBack, - onTypeChanged = viewModel::onTypeChanged, - onRefreshDropdownItemClicked = viewModel::onRefresh, - onRefresh = viewModel::onRefresh, onPhotoClicked = onPhotoClicked ) } @@ -99,55 +79,36 @@ fun ChatMaterialsRoute( ) @Composable fun ChatMaterialsScreen( - screenState: ChatMaterialsScreenState = ChatMaterialsScreenState.EMPTY, onBack: () -> Unit = {}, - onTypeChanged: (String) -> Unit = {}, - onRefreshDropdownItemClicked: () -> Unit = {}, - onRefresh: () -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {} ) { + val scope = rememberCoroutineScope() val currentTheme = LocalThemeConfig.current - - val attachments = screenState.materials - - var moreClearBlur by rememberSaveable { - mutableStateOf(false) - } - val hazeState = remember { HazeState() } - val hazeStyle = if (moreClearBlur) HazeMaterials.ultraThin() else HazeMaterials.regular() - var dropDownMenuExpanded by remember { - mutableStateOf(false) - } - var checkedTypeIndex by rememberSaveable { - mutableIntStateOf(0) - } - - LaunchedEffect(checkedTypeIndex) { - onTypeChanged( - when (checkedTypeIndex) { - 0 -> "photo" - 1 -> "video" - 2 -> "audio" - 3 -> "doc" - 4 -> "link" - else -> "" - } + val titles = remember { + listOf( + UiR.string.chat_attachment_photos, + UiR.string.chat_attachment_videos, + UiR.string.chat_attachment_music, + UiR.string.chat_attachment_files, + UiR.string.chat_attachment_links, ) } - val titles = listOf("Photos", "Videos", "Audios")//, "Files", "Links") - - val listState = rememberLazyListState() - val gridState = rememberLazyGridState() - - val canScrollBackward = when (checkedTypeIndex) { - in 0..1 -> gridState.canScrollBackward - else -> listState.canScrollBackward + val tabItems = remember { + titles.map { resId -> + TabItem( + titleResId = resId, + unselectedIconResId = null, + selectedIconResId = null + ) + } } - Log.d("ChatMaterialsScreen", "ChatMaterialsScreen: canScrollBackward: $canScrollBackward") + var canScrollBackward by remember { + mutableStateOf(false) + } val topBarContainerColorAlpha by animateFloatAsState( targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f, @@ -159,8 +120,7 @@ fun ChatMaterialsScreen( ) val topBarContainerColor by animateColorAsState( - targetValue = - if (currentTheme.enableBlur || !canScrollBackward) + targetValue = if (currentTheme.enableBlur || !canScrollBackward) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), @@ -171,7 +131,13 @@ fun ChatMaterialsScreen( ) ) - val pullToRefreshState = rememberPullToRefreshState() + val pagerState = rememberPagerState( + pageCount = tabItems::size + ) + + val selectedTabIndex by remember { + derivedStateOf { pagerState.currentPage } + } Scaffold( topBar = { @@ -181,11 +147,9 @@ fun ChatMaterialsScreen( if (currentTheme.enableBlur) { Modifier.hazeEffect( state = hazeState, - style = hazeStyle + style = HazeMaterials.thick() ) - } else { - Modifier - } + } else Modifier ) .background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha)) .fillMaxWidth() @@ -193,7 +157,7 @@ fun ChatMaterialsScreen( TopAppBar( title = { Text( - text = "Chat Materials", + text = stringResource(UiR.string.chat_materials_title), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.headlineSmall @@ -210,162 +174,147 @@ fun ChatMaterialsScreen( contentDescription = null ) } - }, - actions = { - IconButton( - onClick = { - dropDownMenuExpanded = true - } - ) { - Icon( - imageVector = Icons.Outlined.MoreVert, - contentDescription = "Options button" - ) - } - - DropdownMenu( - modifier = Modifier.defaultMinSize(minWidth = 140.dp), - expanded = dropDownMenuExpanded, - onDismissRequest = { - dropDownMenuExpanded = false - }, - offset = DpOffset(x = (-4).dp, y = (-60).dp) - ) { - DropdownMenuItem( - onClick = { - onRefreshDropdownItemClicked() - dropDownMenuExpanded = false - }, - text = { - Text(text = stringResource(id = R.string.action_refresh)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Refresh, - contentDescription = null - ) - } - ) - - if (currentTheme.enableBlur) { - DropdownMenuItem( - text = { - Text(text = if (moreClearBlur) "Default blur" else "Clearer blur") - }, - onClick = { - moreClearBlur = !moreClearBlur - dropDownMenuExpanded = false - } - ) - } - - HorizontalDivider() - - titles.forEachIndexed { index, title -> - DropdownMenuItem( - leadingIcon = { - RadioButton( - selected = checkedTypeIndex == index, - onClick = null - ) - }, - text = { - Text(text = title) - }, - onClick = { - checkedTypeIndex = index - dropDownMenuExpanded = false - } - ) - } - - } } ) + ScrollableTabRow( + modifier = Modifier.fillMaxWidth(), + selectedTabIndex = selectedTabIndex, + containerColor = Color.Transparent, + edgePadding = 0.dp, + indicator = { tabPositions -> + TabRowDefaults.PrimaryIndicator( + modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), + color = MaterialTheme.colorScheme.primary + ) + } + ) { + tabItems.forEachIndexed { index, item -> + Tab( + selected = index == selectedTabIndex, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { + item.titleResId?.let { resId -> + Text(text = stringResource(id = resId)) + } + } + ) + } + } } } ) { padding -> - PullToRefreshBox( - modifier = Modifier - .fillMaxSize() - .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) - .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)), - state = pullToRefreshState, - isRefreshing = screenState.isLoading, - onRefresh = onRefresh, - indicator = { - PullToRefreshDefaults.Indicator( - state = pullToRefreshState, - isRefreshing = screenState.isLoading, - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = padding.calculateTopPadding()), - ) - } - ) { - if (checkedTypeIndex in listOf(0, 1)) { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - state = gridState, - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier - .then( - if (currentTheme.enableBlur) { - Modifier.hazeSource(state = hazeState) - } else { - Modifier - } - ) - .fillMaxSize() + CompositionLocalProvider(LocalHazeState provides hazeState) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { index -> + when (index) { + 0 -> { + val viewModel: ChatMaterialsViewModel = + koinViewModel(named(MaterialType.PHOTO)) + val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val baseError by viewModel.baseError.collectAsStateWithLifecycle() + val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() - ) { - repeat(3) { - item { - Spacer(modifier = Modifier.height(padding.calculateTopPadding())) - } - } - items(attachments) { item -> - ChatMaterialItem( - item = item, - onClick = { - if (item is UiChatMaterial.Photo) { - onPhotoClicked(item.previewUrl) - } - } + PhotoMaterialsScreen( + modifier = Modifier, + screenState = screenState, + baseError = baseError, + padding = padding, + onRefresh = viewModel::onRefresh, + onSessionExpiredLogOutButtonClicked = { }, + setCanScrollBackward = { canScrollBackward = it }, + canPaginate = canPaginate, + onPaginationConditionsMet = viewModel::onPaginationConditionsMet, + onPhotoClicked = onPhotoClicked ) } - repeat(3) { - item { - Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) - } - } - } - } else { - LazyColumn( - state = listState, - modifier = Modifier - .then( - if (currentTheme.enableBlur) { - Modifier.hazeSource(state = hazeState) - } else { - Modifier - } - ) - .fillMaxSize() - ) { - item { - Spacer(modifier = Modifier.height(padding.calculateTopPadding())) - } - items(attachments) { item -> - ChatMaterialItem( - item = item, - onClick = {} + 1 -> { + val viewModel: ChatMaterialsViewModel = + koinViewModel(named(MaterialType.VIDEO)) + val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val baseError by viewModel.baseError.collectAsStateWithLifecycle() + val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() + + VideoMaterialsScreen( + modifier = Modifier, + screenState = screenState, + baseError = baseError, + padding = padding, + onRefresh = viewModel::onRefresh, + onSessionExpiredLogOutButtonClicked = { }, + setCanScrollBackward = { canScrollBackward = it }, + canPaginate = canPaginate, + onPaginationConditionsMet = viewModel::onPaginationConditionsMet ) } - item { - Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) + + 2 -> { + val viewModel: ChatMaterialsViewModel = + koinViewModel(named(MaterialType.AUDIO)) + val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val baseError by viewModel.baseError.collectAsStateWithLifecycle() + val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() + + AudioMaterialsScreen( + modifier = Modifier, + screenState = screenState, + baseError = baseError, + padding = padding, + onRefresh = viewModel::onRefresh, + onSessionExpiredLogOutButtonClicked = { }, + setCanScrollBackward = { canScrollBackward = it }, + canPaginate = canPaginate, + onPaginationConditionsMet = viewModel::onPaginationConditionsMet + ) } + + 3 -> { + val viewModel: ChatMaterialsViewModel = + koinViewModel(named(MaterialType.FILE)) + val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val baseError by viewModel.baseError.collectAsStateWithLifecycle() + val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() + + FileMaterialsScreen( + modifier = Modifier, + screenState = screenState, + baseError = baseError, + padding = padding, + onRefresh = viewModel::onRefresh, + onSessionExpiredLogOutButtonClicked = { }, + setCanScrollBackward = { canScrollBackward = it }, + canPaginate = canPaginate, + onPaginationConditionsMet = viewModel::onPaginationConditionsMet + ) + } + + 4 -> { + val viewModel: ChatMaterialsViewModel = + koinViewModel(named(MaterialType.LINK)) + val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val baseError by viewModel.baseError.collectAsStateWithLifecycle() + val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() + + LinkMaterialsScreen( + modifier = Modifier, + screenState = screenState, + baseError = baseError, + padding = padding, + onRefresh = viewModel::onRefresh, + onSessionExpiredLogOutButtonClicked = { }, + setCanScrollBackward = { canScrollBackward = it }, + canPaginate = canPaginate, + onPaginationConditionsMet = viewModel::onPaginationConditionsMet + ) + } + + else -> Unit } } } diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/AudioMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/AudioMaterialsScreen.kt new file mode 100644 index 00000000..74155846 --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/AudioMaterialsScreen.kt @@ -0,0 +1,232 @@ +package dev.meloda.fast.chatmaterials.presentation.materials + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.hazeSource +import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState +import dev.meloda.fast.chatmaterials.model.UiChatMaterial +import dev.meloda.fast.model.BaseError +import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.basic.ContentAlpha +import dev.meloda.fast.ui.basic.LocalContentAlpha +import dev.meloda.fast.ui.components.FullScreenLoader +import dev.meloda.fast.ui.components.NoItemsView +import dev.meloda.fast.ui.components.VkErrorView +import dev.meloda.fast.ui.theme.LocalHazeState +import dev.meloda.fast.ui.theme.LocalThemeConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import dev.meloda.fast.ui.R as UiR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AudioMaterialsScreen( + modifier: Modifier = Modifier, + canPaginate: Boolean, + screenState: ChatMaterialsScreenState, + baseError: BaseError?, + padding: PaddingValues, + onRefresh: () -> Unit, + onSessionExpiredLogOutButtonClicked: () -> Unit, + setCanScrollBackward: (Boolean) -> Unit, + onPaginationConditionsMet: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val hazeState = LocalHazeState.current + val currentTheme = LocalThemeConfig.current + val listState = rememberLazyListState() + val pullToRefreshState = rememberPullToRefreshState() + + LaunchedEffect(listState) { + snapshotFlow { listState.canScrollBackward } + .collect(setCanScrollBackward) + } + + val paginationConditionMet by remember(canPaginate, listState) { + derivedStateOf { + canPaginate && + (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + ?: -9) >= (listState.layoutInfo.totalItemsCount - 6) + } + } + + LaunchedEffect(paginationConditionMet) { + if (paginationConditionMet && !screenState.isPaginating) { + onPaginationConditionsMet() + } + } + + when { + baseError != null -> { + VkErrorView(baseError = baseError) + } + + screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() + + else -> { + PullToRefreshBox( + modifier = modifier + .fillMaxSize() + .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) + .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)), + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + onRefresh = onRefresh, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = padding.calculateTopPadding()), + ) + } + ) { + LazyColumn( + state = listState, + modifier = Modifier + .then( + if (currentTheme.enableBlur) { + Modifier.hazeSource(state = hazeState) + } else { + Modifier + } + ) + .fillMaxSize() + + ) { + item { + Spacer(modifier = Modifier.height(padding.calculateTopPadding())) + } + items(screenState.materials) { item -> + item as UiChatMaterial.Audio + + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .size(42.dp) + .padding(4.dp), + painter = painterResource(UiR.drawable.round_play_arrow_24), + contentDescription = null, + tint = contentColorFor(MaterialTheme.colorScheme.primary) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + LocalContentAlpha(alpha = ContentAlpha.medium) { + Text( + text = item.artist, + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Text(text = item.duration) + } + } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .animateItem(fadeInSpec = null, fadeOutSpec = null), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (screenState.isPaginating) { + CircularProgressIndicator() + } + + if (screenState.isPaginationExhausted) { + IconButton( + onClick = { + coroutineScope.launch(Dispatchers.Main) { + listState.scrollToItem(14) + listState.animateScrollToItem(0) + } + }, + colors = IconButtonDefaults.filledIconButtonColors() + ) { + Icon( + imageVector = Icons.Rounded.KeyboardArrowUp, + contentDescription = null + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } + item { + Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) + } + } + + if (screenState.materials.isEmpty()) { + NoItemsView( + buttonText = stringResource(R.string.action_refresh), + onButtonClick = onRefresh + ) + } + } + } + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/FileMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/FileMaterialsScreen.kt new file mode 100644 index 00000000..1d477c23 --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/FileMaterialsScreen.kt @@ -0,0 +1,264 @@ +package dev.meloda.fast.chatmaterials.presentation.materials + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import coil.imageLoader +import dev.chrisbanes.haze.hazeSource +import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState +import dev.meloda.fast.chatmaterials.model.UiChatMaterial +import dev.meloda.fast.model.BaseError +import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.basic.ContentAlpha +import dev.meloda.fast.ui.basic.LocalContentAlpha +import dev.meloda.fast.ui.components.FullScreenLoader +import dev.meloda.fast.ui.components.NoItemsView +import dev.meloda.fast.ui.components.VkErrorView +import dev.meloda.fast.ui.theme.LocalHazeState +import dev.meloda.fast.ui.theme.LocalThemeConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FileMaterialsScreen( + modifier: Modifier = Modifier, + canPaginate: Boolean, + screenState: ChatMaterialsScreenState, + baseError: BaseError?, + padding: PaddingValues, + onRefresh: () -> Unit, + onSessionExpiredLogOutButtonClicked: () -> Unit, + setCanScrollBackward: (Boolean) -> Unit, + onPaginationConditionsMet: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val hazeState = LocalHazeState.current + val currentTheme = LocalThemeConfig.current + val listState = rememberLazyListState() + val pullToRefreshState = rememberPullToRefreshState() + + LaunchedEffect(listState) { + snapshotFlow { listState.canScrollBackward } + .collect(setCanScrollBackward) + } + + val paginationConditionMet by remember(canPaginate, listState) { + derivedStateOf { + canPaginate && + (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + ?: -9) >= (listState.layoutInfo.totalItemsCount - 6) + } + } + + LaunchedEffect(paginationConditionMet) { + if (paginationConditionMet && !screenState.isPaginating) { + onPaginationConditionsMet() + } + } + + when { + baseError != null -> { + VkErrorView(baseError = baseError) + } + + screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() + + else -> { + PullToRefreshBox( + modifier = modifier + .fillMaxSize() + .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) + .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)), + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + onRefresh = onRefresh, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = padding.calculateTopPadding()), + ) + } + ) { + LazyColumn( + state = listState, + modifier = Modifier + .then( + if (currentTheme.enableBlur) { + Modifier.hazeSource(state = hazeState) + } else { + Modifier + } + ) + .fillMaxSize() + + ) { + item { + Spacer(modifier = Modifier.height(padding.calculateTopPadding())) + } + items(screenState.materials) { item -> + item as UiChatMaterial.File + + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + var errorLoading by remember { + mutableStateOf(false) + } + + if (item.previewUrl != null && !errorLoading) { + Image( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .size(width = 64.dp, height = 48.dp), + painter = rememberAsyncImagePainter( + model = item.previewUrl, + imageLoader = LocalContext.current.imageLoader, + onState = { + errorLoading = it is AsyncImagePainter.State.Error + } + ), + contentDescription = null, + contentScale = ContentScale.Crop + ) + } else { + Text( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background( + MaterialTheme.colorScheme + .surfaceColorAtElevation(3.dp) + ) + .size(width = 64.dp, height = 48.dp) + .padding(4.dp), + text = item.extension.uppercase(), + textAlign = TextAlign.Center, + lineHeight = 40.sp, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + LocalContentAlpha(alpha = ContentAlpha.medium) { + Text( + text = item.size, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .animateItem(fadeInSpec = null, fadeOutSpec = null), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (screenState.isPaginating) { + CircularProgressIndicator() + } + + if (screenState.isPaginationExhausted) { + IconButton( + onClick = { + coroutineScope.launch(Dispatchers.Main) { + listState.scrollToItem(14) + listState.animateScrollToItem(0) + } + }, + colors = IconButtonDefaults.filledIconButtonColors() + ) { + Icon( + imageVector = Icons.Rounded.KeyboardArrowUp, + contentDescription = null + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } + item { + Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) + } + } + + if (screenState.materials.isEmpty()) { + NoItemsView( + buttonText = stringResource(R.string.action_refresh), + onButtonClick = onRefresh + ) + } + } + } + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/LinkMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/LinkMaterialsScreen.kt new file mode 100644 index 00000000..05e193e1 --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/LinkMaterialsScreen.kt @@ -0,0 +1,283 @@ +package dev.meloda.fast.chatmaterials.presentation.materials + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import coil.imageLoader +import dev.chrisbanes.haze.hazeSource +import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState +import dev.meloda.fast.chatmaterials.model.UiChatMaterial +import dev.meloda.fast.model.BaseError +import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.basic.ContentAlpha +import dev.meloda.fast.ui.basic.LocalContentAlpha +import dev.meloda.fast.ui.components.FullScreenLoader +import dev.meloda.fast.ui.components.NoItemsView +import dev.meloda.fast.ui.components.VkErrorView +import dev.meloda.fast.ui.theme.LocalHazeState +import dev.meloda.fast.ui.theme.LocalThemeConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LinkMaterialsScreen( + modifier: Modifier = Modifier, + canPaginate: Boolean, + screenState: ChatMaterialsScreenState, + baseError: BaseError?, + padding: PaddingValues, + onRefresh: () -> Unit, + onSessionExpiredLogOutButtonClicked: () -> Unit, + setCanScrollBackward: (Boolean) -> Unit, + onPaginationConditionsMet: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val hazeState = LocalHazeState.current + val currentTheme = LocalThemeConfig.current + val listState = rememberLazyListState() + val pullToRefreshState = rememberPullToRefreshState() + + LaunchedEffect(listState) { + snapshotFlow { listState.canScrollBackward } + .collect(setCanScrollBackward) + } + + val paginationConditionMet by remember(canPaginate, listState) { + derivedStateOf { + canPaginate && + (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + ?: -9) >= (listState.layoutInfo.totalItemsCount - 6) + } + } + + LaunchedEffect(paginationConditionMet) { + if (paginationConditionMet && !screenState.isPaginating) { + onPaginationConditionsMet() + } + } + + when { + baseError != null -> { + VkErrorView(baseError = baseError) + } + + screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() + + else -> { + PullToRefreshBox( + modifier = modifier + .fillMaxSize() + .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) + .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)), + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + onRefresh = onRefresh, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = padding.calculateTopPadding()), + ) + } + ) { + LazyColumn( + state = listState, + modifier = Modifier + .then( + if (currentTheme.enableBlur) { + Modifier.hazeSource(state = hazeState) + } else { + Modifier + } + ) + .fillMaxSize() + + ) { + item { + Spacer(modifier = Modifier.height(padding.calculateTopPadding())) + } + items(screenState.materials) { item -> + item as UiChatMaterial.Link + + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 72.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + var errorLoading by remember { + mutableStateOf(false) + } + + if (item.previewUrl != null && !errorLoading) { + Image( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .size( + width = 86.dp, + height = 64.dp + ), + painter = rememberAsyncImagePainter( + model = item.previewUrl, + imageLoader = LocalContext.current.imageLoader, + onState = { + errorLoading = it is AsyncImagePainter.State.Error + } + ), + contentDescription = null, + contentScale = ContentScale.Crop + ) + } else { + Text( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background( + MaterialTheme.colorScheme + .surfaceColorAtElevation(3.dp) + ) + .size( + width = 86.dp, + height = 64.dp + ) + .padding(4.dp), + text = item.urlFirstChar, + textAlign = TextAlign.Center, + lineHeight = 56.sp, + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.titleLarge + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + if (item.title != null) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + } + + LocalContentAlpha( + alpha = if (item.title != null) ContentAlpha.medium + else ContentAlpha.high + ) { + Text( + text = item.url, + style = if (item.title != null) { + MaterialTheme.typography.bodyMedium + } else { + MaterialTheme.typography.bodyLarge + }, + maxLines = if (item.title != null) 1 else 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .animateItem(fadeInSpec = null, fadeOutSpec = null), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (screenState.isPaginating) { + CircularProgressIndicator() + } + + if (screenState.isPaginationExhausted) { + IconButton( + onClick = { + coroutineScope.launch(Dispatchers.Main) { + listState.scrollToItem(14) + listState.animateScrollToItem(0) + } + }, + colors = IconButtonDefaults.filledIconButtonColors() + ) { + Icon( + imageVector = Icons.Rounded.KeyboardArrowUp, + contentDescription = null + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } + item { + Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) + } + } + + if (screenState.materials.isEmpty()) { + NoItemsView( + buttonText = stringResource(R.string.action_refresh), + onButtonClick = onRefresh + ) + } + } + } + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/PhotoMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/PhotoMaterialsScreen.kt new file mode 100644 index 00000000..db04ffb5 --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/PhotoMaterialsScreen.kt @@ -0,0 +1,207 @@ +package dev.meloda.fast.chatmaterials.presentation.materials + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import dev.chrisbanes.haze.hazeSource +import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState +import dev.meloda.fast.chatmaterials.model.UiChatMaterial +import dev.meloda.fast.model.BaseError +import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.components.FullScreenLoader +import dev.meloda.fast.ui.components.NoItemsView +import dev.meloda.fast.ui.components.VkErrorView +import dev.meloda.fast.ui.theme.LocalHazeState +import dev.meloda.fast.ui.theme.LocalThemeConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PhotoMaterialsScreen( + modifier: Modifier = Modifier, + canPaginate: Boolean, + screenState: ChatMaterialsScreenState, + baseError: BaseError?, + padding: PaddingValues, + onRefresh: () -> Unit, + onSessionExpiredLogOutButtonClicked: () -> Unit, + setCanScrollBackward: (Boolean) -> Unit, + onPhotoClicked: (String) -> Unit, + onPaginationConditionsMet: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val hazeState = LocalHazeState.current + val currentTheme = LocalThemeConfig.current + val gridState = rememberLazyGridState() + val pullToRefreshState = rememberPullToRefreshState() + + LaunchedEffect(gridState) { + snapshotFlow { gridState.canScrollBackward } + .collect(setCanScrollBackward) + } + + val paginationConditionMet by remember(canPaginate, gridState) { + derivedStateOf { + canPaginate && + (gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + ?: -9) >= (gridState.layoutInfo.totalItemsCount - 6) + } + } + + LaunchedEffect(paginationConditionMet) { + if (paginationConditionMet && !screenState.isPaginating) { + onPaginationConditionsMet() + } + } + + when { + baseError != null -> { + VkErrorView(baseError = baseError) + } + + screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() + + else -> { + PullToRefreshBox( + modifier = modifier + .fillMaxSize() + .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) + .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)), + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + onRefresh = onRefresh, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = padding.calculateTopPadding()), + ) + } + ) { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + state = gridState, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .then( + if (currentTheme.enableBlur) { + Modifier.hazeSource(state = hazeState) + } else { + Modifier + } + ) + .fillMaxSize() + + ) { + item(span = { GridItemSpan(3) }) { + Spacer(modifier = Modifier.height(padding.calculateTopPadding())) + } + items(items = screenState.materials) { item -> + item as UiChatMaterial.Photo + AsyncImage( + model = item.previewUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable( + onClick = { + onPhotoClicked(item.previewUrl) + } + ) + ) + } + item(span = { GridItemSpan(3) }) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateItem(fadeInSpec = null, fadeOutSpec = null), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (screenState.isPaginating) { + CircularProgressIndicator() + } + + if (screenState.isPaginationExhausted) { + Spacer(modifier = Modifier.height(32.dp)) + + IconButton( + onClick = { + coroutineScope.launch(Dispatchers.Main) { + gridState.scrollToItem(14) + gridState.animateScrollToItem(0) + } + }, + colors = IconButtonDefaults.filledIconButtonColors() + ) { + Icon( + imageVector = Icons.Rounded.KeyboardArrowUp, + contentDescription = null + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } + item { + Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) + } + item(span = { GridItemSpan(3) }) { + Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) + } + } + + if (screenState.materials.isEmpty()) { + NoItemsView( + buttonText = stringResource(R.string.action_refresh), + onButtonClick = onRefresh + ) + } + } + } + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/VideoMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/VideoMaterialsScreen.kt new file mode 100644 index 00000000..acd85816 --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/VideoMaterialsScreen.kt @@ -0,0 +1,256 @@ +package dev.meloda.fast.chatmaterials.presentation.materials + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import coil.imageLoader +import dev.chrisbanes.haze.hazeSource +import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState +import dev.meloda.fast.chatmaterials.model.UiChatMaterial +import dev.meloda.fast.model.BaseError +import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.basic.ContentAlpha +import dev.meloda.fast.ui.basic.LocalContentAlpha +import dev.meloda.fast.ui.components.FullScreenLoader +import dev.meloda.fast.ui.components.NoItemsView +import dev.meloda.fast.ui.components.VkErrorView +import dev.meloda.fast.ui.theme.LocalHazeState +import dev.meloda.fast.ui.theme.LocalThemeConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VideoMaterialsScreen( + modifier: Modifier = Modifier, + canPaginate: Boolean, + screenState: ChatMaterialsScreenState, + baseError: BaseError?, + padding: PaddingValues, + onRefresh: () -> Unit, + onSessionExpiredLogOutButtonClicked: () -> Unit, + setCanScrollBackward: (Boolean) -> Unit, + onPaginationConditionsMet: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val hazeState = LocalHazeState.current + val currentTheme = LocalThemeConfig.current + val listState = rememberLazyListState() + val pullToRefreshState = rememberPullToRefreshState() + + LaunchedEffect(listState) { + snapshotFlow { listState.canScrollBackward } + .collect(setCanScrollBackward) + } + + val paginationConditionMet by remember(canPaginate, listState) { + derivedStateOf { + canPaginate && + (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + ?: -9) >= (listState.layoutInfo.totalItemsCount - 6) + } + } + + LaunchedEffect(paginationConditionMet) { + if (paginationConditionMet && !screenState.isPaginating) { + onPaginationConditionsMet() + } + } + + when { + baseError != null -> { + VkErrorView(baseError = baseError) + } + + screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() + + else -> { + PullToRefreshBox( + modifier = modifier + .fillMaxSize() + .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) + .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)), + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + onRefresh = onRefresh, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = padding.calculateTopPadding()), + ) + } + ) { + LazyColumn( + state = listState, + modifier = Modifier + .then( + if (currentTheme.enableBlur) { + Modifier.hazeSource(state = hazeState) + } else { + Modifier + } + ) + .fillMaxSize() + + ) { + item { + Spacer(modifier = Modifier.height(padding.calculateTopPadding())) + } + items(screenState.materials) { item -> + item as UiChatMaterial.Video + + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 72.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box { + Image( + modifier = Modifier + .fillMaxWidth(0.33f) + .height(64.dp) + .clip(RoundedCornerShape(4.dp)), + painter = rememberAsyncImagePainter( + model = item.previewUrl, + imageLoader = LocalContext.current.imageLoader + ), + contentDescription = null, + contentScale = ContentScale.Crop + ) + + Text( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding( + end = 4.dp, + bottom = 4.dp + ) + .clip(RoundedCornerShape(8.dp)) + .background( + MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + .padding( + vertical = 1.dp, + horizontal = 4.dp + ), + text = item.duration, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.background + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + LocalContentAlpha(alpha = ContentAlpha.medium) { + Text( + text = "${item.views} views", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .animateItem(fadeInSpec = null, fadeOutSpec = null), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (screenState.isPaginating) { + CircularProgressIndicator() + } + + if (screenState.isPaginationExhausted) { + IconButton( + onClick = { + coroutineScope.launch(Dispatchers.Main) { + listState.scrollToItem(14) + listState.animateScrollToItem(0) + } + }, + colors = IconButtonDefaults.filledIconButtonColors() + ) { + Icon( + imageVector = Icons.Rounded.KeyboardArrowUp, + contentDescription = null + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } + item { + Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) + } + } + + if (screenState.materials.isEmpty()) { + NoItemsView( + buttonText = stringResource(R.string.action_refresh), + onButtonClick = onRefresh + ) + } + } + } + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/util/ChatMaterialMapper.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/util/ChatMaterialMapper.kt index 484e6a6d..7a78b73d 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/util/ChatMaterialMapper.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/util/ChatMaterialMapper.kt @@ -1,6 +1,8 @@ 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 import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAudioDomain @@ -8,52 +10,133 @@ import dev.meloda.fast.model.api.domain.VkFileDomain import dev.meloda.fast.model.api.domain.VkLinkDomain import dev.meloda.fast.model.api.domain.VkPhotoDomain import dev.meloda.fast.model.api.domain.VkVideoDomain -import java.text.SimpleDateFormat import java.util.Locale -fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial = +fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? = when (val type = this.attachment.type) { AttachmentType.PHOTO -> { val attachment = this.attachment as VkPhotoDomain UiChatMaterial.Photo( + conversationMessageId = this.conversationMessageId, previewUrl = attachment.getSizeOrSmaller(VkPhotoDomain.SIZE_TYPE_1080_1024)?.url.orEmpty() ) } AttachmentType.VIDEO -> { val attachment = this.attachment as VkVideoDomain + + val duration = attachment.duration + + val days = duration / (24 * 3600) + val hours = (duration % (24 * 3600)) / 3600 + val minutes = (duration % 3600) / 60 + val seconds = duration % 60 + + val args = mutableListOf() + if (days > 0) args.add(days) + if (hours > 0) args.add(hours) + args.add(minutes) + args.add(seconds) + + val builder = StringBuilder() + if (days > 0) builder.append("%02d:") + if (hours > 0) builder.append("%02d:") + builder.append("%02d:%02d") + + val formattedDuration = + builder.toString().format(Locale.getDefault(), *args.toTypedArray()) + UiChatMaterial.Video( - previewUrl = attachment.images.firstOrNull()?.url.orEmpty() + conversationMessageId = this.conversationMessageId, + previewUrl = attachment.images.maxByOrNull(VkVideoDomain.VideoImage::width)?.url.orEmpty(), + title = attachment.title, + views = attachment.views, + duration = formattedDuration ) } AttachmentType.AUDIO -> { val attachment = this.attachment as VkAudioDomain + + val duration = attachment.duration + + val days = duration / (24 * 3600) + val hours = (duration % (24 * 3600)) / 3600 + val minutes = (duration % 3600) / 60 + val seconds = duration % 60 + + val args = mutableListOf() + if (days > 0) args.add(days) + if (hours > 0) args.add(hours) + args.add(minutes) + args.add(seconds) + + val builder = StringBuilder() + if (days > 0) builder.append("%02d:") + if (hours > 0) builder.append("%02d:") + builder.append("%d:%02d") + + val formattedDuration = + builder.toString().format(Locale.getDefault(), *args.toTypedArray()) + UiChatMaterial.Audio( + conversationMessageId = this.conversationMessageId, previewUrl = null, title = attachment.title, artist = attachment.artist, - duration = SimpleDateFormat( - "mm:ss", - Locale.getDefault() - ).format(attachment.duration) + duration = formattedDuration ) } AttachmentType.FILE -> { val attachment = this.attachment as VkFileDomain + + val previewUrl: String? = when (val preview = attachment.preview) { + null -> null + + else -> { + when { + preview.photo != null -> { + val size = preview.photo?.sizes?.maxByOrNull { it.width } + size?.src + } + + preview.video != null -> { + val size = preview.video?.src + size + } + + else -> null + } + } + } + UiChatMaterial.File( - title = attachment.title + conversationMessageId = this.conversationMessageId, + title = attachment.title, + previewUrl = previewUrl, + size = AndroidUtils.bytesToHumanReadableSize(attachment.size.toDouble()), + extension = attachment.ext.take(4) ) } AttachmentType.LINK -> { val attachment = this.attachment as VkLinkDomain + UiChatMaterial.Link( - title = attachment.title ?: attachment.url, - previewUrl = attachment.photo?.getMaxSize()?.url + conversationMessageId = this.conversationMessageId, + title = attachment.title, + previewUrl = attachment.photo?.getMaxSize()?.url, + url = attachment.url, + urlFirstChar = attachment.url.replaceFirst("http://", "") + .replaceFirst("https://", "") + .take(1) + .uppercase() ) } - else -> throw IllegalArgumentException("Unsupported type: $type") + else -> { + Log.w("ChatMaterialMapper", "Unsupported type: $type") + null + } } diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt index d6b6cf2f..c2527088 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt @@ -2,19 +2,26 @@ package dev.meloda.fast.conversations import android.content.Context import android.content.res.Resources +import android.os.Bundle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import coil.ImageLoader import coil.request.ImageRequest import com.conena.nanokt.collections.indexOfFirstOrNull +import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.extensions.createTimerFlow 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.conversations.model.ConversationDialog +import dev.meloda.fast.conversations.model.ConversationNavigation import dev.meloda.fast.conversations.model.ConversationsScreenState +import dev.meloda.fast.conversations.model.InteractionJob +import dev.meloda.fast.conversations.model.NewInteractionException import dev.meloda.fast.conversations.util.asPresentation import dev.meloda.fast.conversations.util.extractAvatar -import dev.meloda.fast.data.State +import dev.meloda.fast.data.VkUtils import dev.meloda.fast.data.processState import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.domain.ConversationsUseCase @@ -22,15 +29,13 @@ import dev.meloda.fast.domain.LoadConversationsByIdUseCase import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.model.BaseError +import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.api.domain.VkConversation -import dev.meloda.fast.network.VkErrorCode import dev.meloda.fast.ui.model.api.ConversationOption -import dev.meloda.fast.ui.model.api.ConversationsShowOptions import dev.meloda.fast.ui.model.api.UiConversation -import dev.meloda.fast.ui.util.ImmutableList -import kotlinx.coroutines.Job +import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -38,38 +43,49 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlin.coroutines.cancellation.CancellationException interface ConversationsViewModel { val screenState: StateFlow + val navigation: StateFlow + val dialog: StateFlow + + val conversations: StateFlow> + val uiConversations: StateFlow> + val baseError: StateFlow + val currentOffset: StateFlow val canPaginate: StateFlow + fun onNavigationConsumed() + + fun onDialogConfirmed(dialog: ConversationDialog, bundle: Bundle) + fun onDialogDismissed(dialog: ConversationDialog) + fun onDialogItemPicked(dialog: ConversationDialog, bundle: Bundle) + + fun onErrorButtonClicked() + fun onPaginationConditionsMet() - fun onDeleteDialogDismissed() - fun onDeleteDialogPositiveClick() + fun onOptionClicked(conversation: UiConversation, option: ConversationOption) fun onRefresh() - fun onConversationItemClick() + fun onConversationItemClick(conversation: UiConversation) fun onConversationItemLongClick(conversation: UiConversation) - fun onPinDialogDismissed() - fun onPinDialogPositiveClick() - - fun onOptionClicked(conversation: UiConversation, option: ConversationOption) - fun onErrorConsumed() fun setScrollIndex(index: Int) fun setScrollOffset(offset: Int) + + fun onCreateChatButtonClicked() } class ConversationsViewModelImpl( - updatesParser: LongPollUpdatesParser, + private val filter: ConversationsFilter, + private val updatesParser: LongPollUpdatesParser, private val conversationsUseCase: ConversationsUseCase, private val messagesUseCase: MessagesUseCase, private val resources: Resources, @@ -80,25 +96,31 @@ class ConversationsViewModelImpl( ) : ConversationsViewModel, ViewModel() { override val screenState = MutableStateFlow(ConversationsScreenState.EMPTY) - override val baseError = MutableStateFlow(null) - override val currentOffset = MutableStateFlow(0) - override val canPaginate = MutableStateFlow(false) + override val navigation = MutableStateFlow(null) + override val dialog = MutableStateFlow(null) - private val useContactNames: Boolean get() = userSettings.useContactNames.value - - override fun onPaginationConditionsMet() { - currentOffset.update { screenState.value.conversations.size } - loadConversations() - } - - private val conversations = MutableStateFlow>(emptyList()) + override val conversations = MutableStateFlow>(emptyList()) + override val uiConversations = MutableStateFlow>(emptyList()) private val pinnedConversationsCount = conversations.map { conversations -> conversations.count(VkConversation::isPinned) }.stateIn(viewModelScope, SharingStarted.Eagerly, 0) + override val baseError = MutableStateFlow(null) + + override val currentOffset = MutableStateFlow(0) + override val canPaginate = MutableStateFlow(false) + + private val expandedConversationId = MutableStateFlow(0L) + + private val useContactNames: Boolean get() = userSettings.useContactNames.value + + private val interactionsTimers = hashMapOf() + init { - userSettings.useContactNames.listenValue(viewModelScope, ::updateConversationsNames) + screenState.updateValue { copy(isArchive = filter == ConversationsFilter.ARCHIVE) } + + loadConversations() updatesParser.onNewMessage(::handleNewMessage) updatesParser.onMessageEdited(::handleEditedMessage) @@ -108,86 +130,94 @@ class ConversationsViewModelImpl( updatesParser.onChatMajorChanged(::handleChatMajorChanged) updatesParser.onChatMinorChanged(::handleChatMinorChanged) updatesParser.onChatCleared(::handleChatClearing) + updatesParser.onChatArchived(::handleChatArchived) + userSettings.useContactNames.listenValue(viewModelScope) { + syncUiConversation() + } + } + + override fun onNavigationConsumed() { + navigation.setValue { null } + } + + override fun onDialogConfirmed(dialog: ConversationDialog, bundle: Bundle) { + onDialogDismissed(dialog) + + when (dialog) { + is ConversationDialog.ConversationDelete -> { + deleteConversation(dialog.conversationId) + } + + is ConversationDialog.ConversationPin -> { + pinConversation(dialog.conversationId, true) + } + + is ConversationDialog.ConversationUnpin -> { + pinConversation(dialog.conversationId, false) + } + + is ConversationDialog.ConversationArchive -> { + archiveConversation(dialog.conversationId, true) + } + + is ConversationDialog.ConversationUnarchive -> { + archiveConversation(dialog.conversationId, false) + } + } + + expandedConversationId.setValue { 0 } + syncUiConversation() + } + + override fun onDialogDismissed(dialog: ConversationDialog) { + this.dialog.setValue { null } + } + + override fun onDialogItemPicked(dialog: ConversationDialog, bundle: Bundle) { + when (dialog) { + is ConversationDialog.ConversationDelete -> Unit + is ConversationDialog.ConversationPin -> Unit + is ConversationDialog.ConversationUnpin -> Unit + is ConversationDialog.ConversationArchive -> Unit + is ConversationDialog.ConversationUnarchive -> Unit + } + } + + override fun onErrorButtonClicked() { + when (baseError.value) { + null -> Unit + + is BaseError.ConnectionError, + is BaseError.InternalError, + is BaseError.SimpleError, + is BaseError.UnknownError -> onRefresh() + + else -> Unit + } + } + + override fun onPaginationConditionsMet() { + currentOffset.update { conversations.value.size } loadConversations() } - override fun onDeleteDialogDismissed() { - emitShowOptions { old -> old.copy(showDeleteDialog = null) } - } - - override fun onDeleteDialogPositiveClick() { - val conversationId = screenState.value.showOptions.showDeleteDialog ?: return - deleteConversation(conversationId) - hideOptions(conversationId) - onDeleteDialogDismissed() - } - override fun onRefresh() { onErrorConsumed() loadConversations(offset = 0) } - override fun onConversationItemClick() { - screenState.setValue { old -> - old.copy( - conversations = old.conversations.map { item -> - item.copy(isExpanded = false) - } - ) - } + override fun onConversationItemClick(conversation: UiConversation) { + collapseConversations() + navigation.setValue { ConversationNavigation.MessagesHistory(peerId = conversation.id) } } override fun onConversationItemLongClick(conversation: UiConversation) { - val options = mutableListOf() - if (!conversation.isExpanded) { - conversation.lastMessage?.run { - if (conversation.isUnread && !this.isOut) { - options += ConversationOption.MarkAsRead - } - } - - val conversationsSize = screenState.value.conversations.size - val pinnedCount = pinnedConversationsCount.value - - val canPinOneMoreDialog = - conversationsSize > 4 && pinnedCount < 5 && !conversation.isPinned - - if (conversation.isPinned) { - options += ConversationOption.Unpin - } else if (canPinOneMoreDialog) { - options += ConversationOption.Pin - } - - options += ConversationOption.Delete + expandedConversationId.setValue { + if (conversation.isExpanded) 0 + else conversation.id } - - screenState.setValue { old -> - old.copy( - conversations = old.conversations.map { item -> - item.copy( - isExpanded = - if (item.id == conversation.id) { - !item.isExpanded - } else { - false - }, - options = ImmutableList.copyOf(options) - ) - } - ) - } - } - - override fun onPinDialogDismissed() { - emitShowOptions { old -> old.copy(showPinDialog = null) } - } - - override fun onPinDialogPositiveClick() { - val conversation = screenState.value.showOptions.showPinDialog ?: return - pinConversation(conversation.id, !conversation.isPinned) - hideOptions(conversation.id) - onPinDialogDismissed() + syncUiConversation() } override fun onOptionClicked( @@ -196,9 +226,7 @@ class ConversationsViewModelImpl( ) { when (option) { ConversationOption.Delete -> { - emitShowOptions { old -> - old.copy(showDeleteDialog = conversation.id) - } + dialog.setValue { ConversationDialog.ConversationDelete(conversation.id) } } ConversationOption.MarkAsRead -> { @@ -207,13 +235,24 @@ class ConversationsViewModelImpl( peerId = conversation.id, startMessageId = lastMessageId ) - hideOptions(conversation.id) + collapseConversations() } } - ConversationOption.Pin, + ConversationOption.Pin -> { + dialog.setValue { ConversationDialog.ConversationPin(conversation.id) } + } + ConversationOption.Unpin -> { - emitShowOptions { old -> old.copy(showPinDialog = conversation) } + dialog.setValue { ConversationDialog.ConversationUnpin(conversation.id) } + } + + ConversationOption.Archive -> { + dialog.setValue { ConversationDialog.ConversationArchive(conversation.id) } + } + + ConversationOption.Unarchive -> { + dialog.setValue { ConversationDialog.ConversationUnarchive(conversation.id) } } } } @@ -230,124 +269,74 @@ class ConversationsViewModelImpl( screenState.setValue { old -> old.copy(scrollOffset = offset) } } - private fun hideOptions(conversationId: Int) { - screenState.setValue { old -> - old.copy( - conversations = old.conversations.map { item -> - if (item.id == conversationId) { - item.copy(isExpanded = false) - } else item - } - ) - } + override fun onCreateChatButtonClicked() { + navigation.setValue { ConversationNavigation.CreateChat } } - private fun emitShowOptions(function: (ConversationsShowOptions) -> ConversationsShowOptions) { - val newShowOptions = function.invoke(screenState.value.showOptions) - screenState.setValue { old -> old.copy(showOptions = newShowOptions) } + private fun collapseConversations() { + expandedConversationId.setValue { 0 } + syncUiConversation() } private fun loadConversations( offset: Int = currentOffset.value ) { - conversationsUseCase.getConversations(count = LOAD_COUNT, offset = offset) - .listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { response -> - val itemsCountSufficient = response.size == LOAD_COUNT - canPaginate.setValue { itemsCountSufficient } + conversationsUseCase.getConversations( + count = LOAD_COUNT, + offset = offset, + filter = filter + ).listenValue(viewModelScope) { state -> + state.processState( + error = { error -> + val newBaseError = VkUtils.parseError(error) + baseError.update { newBaseError } + }, + success = { response -> + val conversations = response + val fullConversations = if (offset == 0) { + conversations + } else { + this.conversations.value.plus(conversations) + } - val paginationExhausted = !itemsCountSufficient && - screenState.value.conversations.isNotEmpty() + val itemsCountSufficient = response.size == LOAD_COUNT - val imagesToPreload = - response.mapNotNull { it.extractAvatar().extractUrl() } + val paginationExhausted = !itemsCountSufficient && + this.conversations.value.isNotEmpty() - imagesToPreload.forEach { url -> - imageLoader.enqueue( - ImageRequest.Builder(applicationContext) - .data(url) - .build() - ) - } + screenState.updateValue { + copy(isPaginationExhausted = paginationExhausted) + } - conversationsUseCase.storeConversations(response) + val imagesToPreload = + response.mapNotNull { it.extractAvatar().extractUrl() } - val loadedConversations = response.map { - it.asPresentation( - resources, - userSettings.useContactNames.value - ) - } - - val newState = screenState.value.copy( - isPaginationExhausted = paginationExhausted + imagesToPreload.forEach { url -> + imageLoader.enqueue( + ImageRequest.Builder(applicationContext) + .data(url) + .build() ) - if (offset == 0) { - conversations.emit(response) - screenState.setValue { - newState.copy(conversations = loadedConversations) - } - } else { - conversations.emit(conversations.value.plus(response)) - screenState.setValue { - newState.copy( - conversations = newState.conversations.plus(loadedConversations) - ) - } - } } + + conversationsUseCase.storeConversations(response) + + this.conversations.emit(fullConversations) + syncUiConversation() + canPaginate.setValue { itemsCountSufficient } + } + ) + + screenState.setValue { old -> + old.copy( + isLoading = offset == 0 && state.isLoading(), + isPaginating = offset > 0 && state.isLoading() ) - - screenState.setValue { old -> - old.copy( - isLoading = offset == 0 && state.isLoading(), - isPaginating = offset > 0 && state.isLoading() - ) - } } - } - - private fun handleError(error: State.Error) { - when (error) { - is State.Error.ApiError -> { - when (error.errorCode) { - VkErrorCode.USER_AUTHORIZATION_FAILED -> { - baseError.setValue { BaseError.SessionExpired } - } - - else -> { - baseError.setValue { - BaseError.SimpleError(message = error.errorMessage) - } - } - } - } - - State.Error.ConnectionError -> { - baseError.setValue { - BaseError.SimpleError(message = "Connection error") - } - } - - State.Error.InternalError -> { - baseError.setValue { - BaseError.SimpleError(message = "Internal error") - } - } - - State.Error.UnknownError -> { - baseError.setValue { - BaseError.SimpleError(message = "Unknown error") - } - } - - else -> Unit } } - private fun deleteConversation(peerId: Int) { + private fun deleteConversation(peerId: Long) { conversationsUseCase.delete(peerId).listenValue(viewModelScope) { state -> state.processState( error = {}, @@ -358,15 +347,15 @@ class ConversationsViewModelImpl( ?: return@processState newConversations.removeAt(conversationIndex) - conversations.update { newConversations } - sortConversations() + conversations.update { newConversations.sorted() } + syncUiConversation() } ) screenState.emit(screenState.value.copy(isLoading = state.isLoading())) } } - private fun pinConversation(peerId: Int, pin: Boolean) { + private fun pinConversation(peerId: Long, pin: Boolean) { conversationsUseCase.changePinState(peerId, pin) .listenValue(viewModelScope) { state -> state.processState( @@ -389,6 +378,26 @@ class ConversationsViewModelImpl( } } + private fun archiveConversation(peerId: Long, archive: Boolean) { + conversationsUseCase.changeArchivedState(peerId, archive) + .listenValue(viewModelScope) { state -> + state.processState( + error = {}, + success = { + conversations.value.find { it.id == peerId }?.let { conversation -> + handleChatArchived( + LongPollParsedEvent.ChatArchived( + conversation = conversation, + archived = archive + ) + ) + } + } + ) + } + } + + // TODO: 03-Apr-25, Danil Nikolaev: handle business messages private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { val message = event.message @@ -397,26 +406,31 @@ class ConversationsViewModelImpl( newConversations.indexOfFirstOrNull { it.id == message.peerId } if (conversationIndex == null) { - loadConversationsByIdUseCase(peerIds = listOf(message.peerId)) - .listenValue(viewModelScope) { state -> - state.processState( - error = {}, - success = { response -> - val conversation = (response.firstOrNull() ?: return@listenValue) - .copy(lastMessage = message) + if (event.inArchive != (filter == ConversationsFilter.ARCHIVE)) return - newConversations.add(pinnedConversationsCount.value, conversation) - conversations.update { newConversations } - sortConversations() - } - ) - } + loadConversationsByIdUseCase( + peerIds = listOf(message.peerId), + extended = true, + fields = VkConstants.ALL_FIELDS + ).listenValue(viewModelScope) { state -> + state.processState( + error = {}, + success = { response -> + val conversation = (response.firstOrNull() ?: return@listenValue) + .copy(lastMessage = message) + + newConversations.add(pinnedConversationsCount.value, conversation) + conversations.update { newConversations.sorted() } + syncUiConversation() + } + ) + } } else { val conversation = newConversations[conversationIndex] var newConversation = conversation.copy( lastMessage = message, lastMessageId = message.id, - lastConversationMessageId = -1, + lastCmId = message.cmId, unreadCount = if (message.isOut) conversation.unreadCount else conversation.unreadCount + 1 ) @@ -447,18 +461,8 @@ class ConversationsViewModelImpl( newConversations.add(toPosition, newConversation) } - conversations.update { newConversations } - - screenState.setValue { old -> - old.copy( - conversations = newConversations.map { - it.asPresentation( - resources = resources, - useContactName = useContactNames - ) - } - ) - } + conversations.update { newConversations.sorted() } + syncUiConversation() } } @@ -474,20 +478,10 @@ class ConversationsViewModelImpl( newConversations[conversationIndex] = conversation.copy( lastMessage = message, lastMessageId = message.id, - lastConversationMessageId = -1 + lastCmId = message.cmId ) conversations.update { newConversations } - - screenState.setValue { old -> - old.copy( - conversations = newConversations.map { - it.asPresentation( - resources = resources, - useContactName = useContactNames - ) - } - ) - } + syncUiConversation() } } @@ -502,22 +496,12 @@ class ConversationsViewModelImpl( } else { newConversations[conversationIndex] = newConversations[conversationIndex].copy( - inRead = event.messageId, + inReadCmId = event.cmId, unreadCount = event.unreadCount ) conversations.update { newConversations } - - screenState.setValue { old -> - old.copy( - conversations = newConversations.map { - it.asPresentation( - resources = resources, - useContactName = useContactNames - ) - } - ) - } + syncUiConversation() } } @@ -532,144 +516,15 @@ class ConversationsViewModelImpl( } else { newConversations[conversationIndex] = newConversations[conversationIndex].copy( - outRead = event.messageId, + outReadCmId = event.cmId, unreadCount = event.unreadCount ) conversations.update { newConversations } - screenState.setValue { old -> - old.copy( - conversations = newConversations.map { - it.asPresentation( - resources = resources, - useContactName = useContactNames - ) - } - ) - } + syncUiConversation() } } - private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) { - val newConversations = conversations.value.toMutableList() - val conversationIndex = - newConversations.indexOfFirstOrNull { it.id == event.peerId } - - if (conversationIndex == null) { // диалога нет в списке - // pizdets - } else { - newConversations[conversationIndex] = - newConversations[conversationIndex].copy(majorId = event.majorId) - - conversations.setValue { newConversations } - screenState.setValue { old -> - old.copy( - conversations = newConversations.map { - it.asPresentation( - resources = resources, - useContactName = useContactNames - ) - } - ) - } - sortConversations() - } - } - - private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) { - val newConversations = conversations.value.toMutableList() - val conversationIndex = - newConversations.indexOfFirstOrNull { it.id == event.peerId } - - if (conversationIndex == null) { // диалога нет в списке - // pizdets - } else { - newConversations[conversationIndex] = - newConversations[conversationIndex].copy(minorId = event.minorId) - - conversations.setValue { newConversations } - screenState.setValue { old -> - old.copy( - conversations = newConversations.map { - it.asPresentation( - resources = resources, - useContactName = useContactNames - ) - } - ) - } - sortConversations() - } - } - - private fun sortConversations() { - val newConversations = conversations.value.toMutableList() - val pinnedConversations = newConversations - .filter(VkConversation::isPinned) - .sortedWith { c1, c2 -> - val diff = c2.majorId - c1.majorId - - if (diff == 0) { - c2.minorId - c1.minorId - } else { - diff - } - } - - newConversations.removeAll(pinnedConversations) - newConversations.sortWith { c1, c2 -> - (c2.lastMessage?.date ?: 0) - (c1.lastMessage?.date ?: 0) - } - - newConversations.addAll(0, pinnedConversations) - - conversations.update { newConversations } - screenState.setValue { old -> - old.copy( - conversations = newConversations.map { - it.asPresentation( - resources = resources, - useContactName = useContactNames - ) - } - ) - } - } - - private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) { - val newConversations = conversations.value.toMutableList() - - val conversationIndex = newConversations.indexOfFirstOrNull { it.id == event.peerId } - - if (conversationIndex == null) { // диалога нет в списке - // pizdets - } else { - newConversations.removeAt(conversationIndex) - - conversations.setValue { newConversations } - - screenState.setValue { old -> - old.copy( - conversations = newConversations.map { - it.asPresentation( - resources = resources, - useContactName = useContactNames - ) - } - ) - } - } - } - - private val interactionsTimers = hashMapOf() - - private data class InteractionJob( - val interactionType: InteractionType, - val timerJob: Job - ) - - private class NewInteractionException : CancellationException() - private fun handleInteraction(event: LongPollParsedEvent.Interaction) { val interactionType = event.interactionType val peerId = event.peerId @@ -687,17 +542,7 @@ class ConversationsViewModelImpl( ) conversations.update { newConversations } - - screenState.setValue { old -> - old.copy( - conversations = newConversations.map { - it.asPresentation( - resources = resources, - useContactName = useContactNames - ) - } - ) - } + syncUiConversation() interactionsTimers[peerId]?.let { interactionJob -> if (interactionJob.interactionType == interactionType) { @@ -708,7 +553,7 @@ class ConversationsViewModelImpl( var timeoutAction: (() -> Unit)? = null val timerJob = createTimerFlow( - time = 5, + time = 6, onTimeoutAction = { timeoutAction?.invoke() } ).launchIn(viewModelScope) @@ -725,7 +570,7 @@ class ConversationsViewModelImpl( } } - private fun stopInteraction(peerId: Int, interactionJob: InteractionJob) { + private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) { interactionsTimers[peerId] ?: return val newConversations = conversations.value.toMutableList() @@ -739,22 +584,98 @@ class ConversationsViewModelImpl( ) conversations.update { newConversations } - screenState.setValue { old -> - old.copy( - conversations = newConversations.map { - it.asPresentation( - resources = resources, - useContactName = useContactNames - ) - } - ) - } + syncUiConversation() interactionJob.timerJob.cancel() interactionsTimers[peerId] = null } - private fun readConversation(peerId: Int, startMessageId: Int) { + private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) { + val newConversations = conversations.value.toMutableList() + val conversationIndex = + newConversations.indexOfFirstOrNull { it.id == event.peerId } + + if (conversationIndex == null) { // диалога нет в списке + // pizdets + } else { + newConversations[conversationIndex] = + newConversations[conversationIndex].copy(majorId = event.majorId) + + conversations.setValue { newConversations.sorted() } + syncUiConversation() + } + } + + private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) { + val newConversations = conversations.value.toMutableList() + val conversationIndex = + newConversations.indexOfFirstOrNull { it.id == event.peerId } + + if (conversationIndex == null) { // диалога нет в списке + // pizdets + } else { + newConversations[conversationIndex] = + newConversations[conversationIndex].copy(minorId = event.minorId) + + conversations.setValue { newConversations.sorted() } + syncUiConversation() + } + } + + private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) { + val newConversations = conversations.value.toMutableList() + + val conversationIndex = newConversations.indexOfFirstOrNull { it.id == event.peerId } + + if (conversationIndex == null) { // диалога нет в списке + // pizdets + } else { + newConversations.removeAt(conversationIndex) + + conversations.setValue { newConversations.sorted() } + syncUiConversation() + } + } + + private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) { + val conversation = event.conversation + + val newConversations = conversations.value.toMutableList() + + when (filter) { + ConversationsFilter.BUSINESS_NOTIFY -> Unit + + ConversationsFilter.ARCHIVE -> { + if (event.archived) { + newConversations.add(0, conversation) + } else { + val index = newConversations.indexOfFirstOrNull { it.id == conversation.id } + if (index == null) return + + newConversations.removeAt(index) + } + + conversations.update { newConversations } + syncUiConversation() + } + + else -> { + if (event.archived) { + val index = newConversations.indexOfFirstOrNull { it.id == conversation.id } + if (index == null) return + + newConversations.removeAt(index) + } else { + newConversations.add(pinnedConversationsCount.value, conversation) + } + + conversations.update { newConversations.sorted() } + syncUiConversation() + } + } + } + + private fun readConversation(peerId: Long, startMessageId: Long) { messagesUseCase.markAsRead( peerId = peerId, startMessageId = startMessageId @@ -771,36 +692,83 @@ class ConversationsViewModelImpl( newConversations[conversationIndex].copy(inRead = startMessageId) conversations.update { newConversations } - screenState.setValue { old -> - old.copy( - conversations = newConversations.map { - it.asPresentation( - resources = resources, - useContactName = useContactNames - ) - } - ) - } + syncUiConversation() } ) } } - private fun updateConversationsNames(useContactNames: Boolean) { + private fun List.sorted(): List { + val newConversations = toMutableList() + + val pinnedConversations = newConversations + .filter(VkConversation::isPinned) + .sortedWith { c1, c2 -> + val diff = c2.majorId - c1.majorId + + if (diff == 0) { + c2.minorId - c1.minorId + } else { + diff + } + } + + newConversations.removeAll(pinnedConversations) + newConversations.sortWith { c1, c2 -> + (c2.lastMessage?.date ?: 0) - (c1.lastMessage?.date ?: 0) + } + + newConversations.addAll(0, pinnedConversations) + return newConversations + } + + private fun syncUiConversation(): List { val conversations = conversations.value - if (conversations.isEmpty()) return - val uiConversations = conversations.map { conversation -> - conversation.asPresentation(resources, useContactNames) - } + val newUiConversations = conversations.map { conversation -> + val options = mutableListOf() + conversation.lastMessage?.run { + if (!conversation.isRead() && !this.isOut) { + options += ConversationOption.MarkAsRead + } + } - screenState.setValue { old -> - old.copy(conversations = uiConversations) + val conversationsSize = this.conversations.value.size + val pinnedCount = pinnedConversationsCount.value + + val canPinOneMoreDialog = + conversationsSize > 4 && pinnedCount < 5 && !conversation.isPinned() + + if (conversation.isPinned()) { + options += ConversationOption.Unpin + } else if (canPinOneMoreDialog) { + options += ConversationOption.Pin + } + + when (filter) { + ConversationsFilter.ARCHIVE -> ConversationOption.Unarchive + + ConversationsFilter.UNREAD, + ConversationsFilter.ALL -> ConversationOption.Archive + + ConversationsFilter.BUSINESS_NOTIFY -> null + }?.let(options::add) + + options += ConversationOption.Delete + + conversation.asPresentation( + resources = resources, + useContactName = useContactNames, + isExpanded = expandedConversationId.value == conversation.id, + options = options.toImmutableList() + ) } + uiConversations.setValue { newUiConversations } + + return newUiConversations } companion object { const val LOAD_COUNT = 30 } } - diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/di/ConversationsModule.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/di/ConversationsModule.kt index 15b4edf4..cb3fe4dc 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/di/ConversationsModule.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/di/ConversationsModule.kt @@ -3,12 +3,35 @@ package dev.meloda.fast.conversations.di import dev.meloda.fast.conversations.ConversationsViewModelImpl import dev.meloda.fast.domain.ConversationsUseCase import dev.meloda.fast.domain.ConversationsUseCaseImpl +import dev.meloda.fast.model.ConversationsFilter import org.koin.core.module.dsl.singleOf -import org.koin.core.module.dsl.viewModelOf +import org.koin.core.module.dsl.viewModel +import org.koin.core.qualifier.named +import org.koin.core.scope.Scope import org.koin.dsl.bind import org.koin.dsl.module val conversationsModule = module { + viewModel(named(ConversationsFilter.ALL)) { + createConversationsViewModel(ConversationsFilter.ALL) + } + viewModel(named(ConversationsFilter.ARCHIVE)) { + createConversationsViewModel(ConversationsFilter.ARCHIVE) + } + singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class - viewModelOf(::ConversationsViewModelImpl) +} + +private fun Scope.createConversationsViewModel(filter: ConversationsFilter): ConversationsViewModelImpl { + return ConversationsViewModelImpl( + filter = filter, + updatesParser = get(), + conversationsUseCase = get(), + messagesUseCase = get(), + resources = get(), + userSettings = get(), + imageLoader = get(), + applicationContext = get(), + loadConversationsByIdUseCase = get() + ) } diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationDialog.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationDialog.kt new file mode 100644 index 00000000..779a71cc --- /dev/null +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationDialog.kt @@ -0,0 +1,12 @@ +package dev.meloda.fast.conversations.model + +import androidx.compose.runtime.Immutable + +@Immutable +sealed class ConversationDialog { + data class ConversationPin(val conversationId: Long) : ConversationDialog() + data class ConversationUnpin(val conversationId: Long) : ConversationDialog() + data class ConversationDelete(val conversationId: Long) : ConversationDialog() + data class ConversationArchive(val conversationId: Long) : ConversationDialog() + data class ConversationUnarchive(val conversationId: Long) : ConversationDialog() +} diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationNavigation.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationNavigation.kt new file mode 100644 index 00000000..d409284e --- /dev/null +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationNavigation.kt @@ -0,0 +1,11 @@ +package dev.meloda.fast.conversations.model + +import androidx.compose.runtime.Immutable + +@Immutable +sealed class ConversationNavigation { + + data class MessagesHistory(val peerId: Long) : ConversationNavigation() + + data object CreateChat : ConversationNavigation() +} diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt index c8026d52..a0ab9319 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt @@ -1,31 +1,28 @@ package dev.meloda.fast.conversations.model import androidx.compose.runtime.Immutable -import dev.meloda.fast.ui.model.api.ConversationsShowOptions import dev.meloda.fast.ui.model.api.UiConversation @Immutable data class ConversationsScreenState( - val showOptions: ConversationsShowOptions, - val conversations: List, val isLoading: Boolean, val isPaginating: Boolean, val isPaginationExhausted: Boolean, val profileImageUrl: String?, val scrollIndex: Int, - val scrollOffset: Int + val scrollOffset: Int, + val isArchive: Boolean ) { companion object { val EMPTY: ConversationsScreenState = ConversationsScreenState( - showOptions = ConversationsShowOptions.EMPTY, - conversations = emptyList(), isLoading = true, isPaginating = false, isPaginationExhausted = false, profileImageUrl = null, scrollIndex = 0, scrollOffset = 0, + isArchive = false ) } } diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/InteractionJob.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/InteractionJob.kt new file mode 100644 index 00000000..b0b6f1ac --- /dev/null +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/InteractionJob.kt @@ -0,0 +1,9 @@ +package dev.meloda.fast.conversations.model + +import dev.meloda.fast.model.InteractionType +import kotlinx.coroutines.Job + +data class InteractionJob( + val interactionType: InteractionType, + val timerJob: Job +) diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/NewInteractionException.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/NewInteractionException.kt new file mode 100644 index 00000000..ca01ea53 --- /dev/null +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/NewInteractionException.kt @@ -0,0 +1,5 @@ +package dev.meloda.fast.conversations.model + +import kotlinx.coroutines.CancellationException + +class NewInteractionException : CancellationException() diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt index 0cf05c68..435eccc7 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt @@ -1,35 +1,73 @@ package dev.meloda.fast.conversations.navigation -import androidx.navigation.NavController +import androidx.activity.compose.LocalActivity +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import dev.meloda.fast.conversations.ConversationsViewModel +import androidx.navigation.navigation import dev.meloda.fast.conversations.ConversationsViewModelImpl import dev.meloda.fast.conversations.presentation.ConversationsRoute import dev.meloda.fast.model.BaseError -import dev.meloda.fast.ui.extensions.sharedViewModel +import dev.meloda.fast.model.ConversationsFilter +import dev.meloda.fast.ui.theme.LocalNavController +import dev.meloda.fast.ui.theme.getOrThrow import kotlinx.serialization.Serializable +import org.koin.androidx.compose.koinViewModel +import org.koin.core.qualifier.named + +@Serializable +object ConversationsGraph @Serializable object Conversations -fun NavGraphBuilder.conversationsScreen( - onError: (BaseError) -> Unit, - onConversationItemClicked: (id: Int) -> Unit, - onPhotoClicked: (url: String) -> Unit, - onCreateChatClicked: () -> Unit, - navController: NavController, -) { - composable { - val viewModel: ConversationsViewModel = - it.sharedViewModel(navController = navController) +@Serializable +object Archive - ConversationsRoute( - onError = onError, - onConversationItemClicked = onConversationItemClicked, - onConversationPhotoClicked = onPhotoClicked, - onCreateChatButtonClicked = onCreateChatClicked, - viewModel = viewModel - ) +fun NavGraphBuilder.conversationsGraph( + onError: (BaseError) -> Unit, + onNavigateToMessagesHistory: (id: Long) -> Unit, + onNavigateToCreateChat: () -> Unit, + onScrolledToTop: () -> Unit +) { + navigation( + startDestination = Conversations + ) { + composable { + val context = LocalContext.current + val navController = LocalNavController.getOrThrow() + + val viewModel: ConversationsViewModelImpl = koinViewModel( + qualifier = named(ConversationsFilter.ALL), + viewModelStoreOwner = context as AppCompatActivity + ) + + ConversationsRoute( + viewModel = viewModel, + onError = onError, + onNavigateToMessagesHistory = onNavigateToMessagesHistory, + onNavigateToCreateChat = onNavigateToCreateChat, + onNavigateToArchive = { navController.navigate(Archive) }, + onScrolledToTop = onScrolledToTop + ) + } + composable { + val context = LocalContext.current + val navController = LocalNavController.getOrThrow() + + val viewModel: ConversationsViewModelImpl = koinViewModel( + qualifier = named(ConversationsFilter.ARCHIVE), + viewModelStoreOwner = context as AppCompatActivity + ) + + ConversationsRoute( + viewModel = viewModel, + onBack = navController::navigateUp, + onError = onError, + onNavigateToMessagesHistory = onNavigateToMessagesHistory, + onScrolledToTop = onScrolledToTop + ) + } } } diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationDialogs.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationDialogs.kt new file mode 100644 index 00000000..882fff06 --- /dev/null +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationDialogs.kt @@ -0,0 +1,74 @@ +package dev.meloda.fast.conversations.presentation + +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.core.os.bundleOf +import dev.meloda.fast.conversations.model.ConversationDialog +import dev.meloda.fast.conversations.model.ConversationsScreenState +import dev.meloda.fast.ui.components.MaterialDialog + +import dev.meloda.fast.ui.R as UiR + +@Composable +fun HandleDialogs( + screenState: ConversationsScreenState, + dialog: ConversationDialog?, + onConfirmed: (ConversationDialog, Bundle) -> Unit = { _, _ -> }, + onDismissed: (ConversationDialog) -> Unit = {}, + onItemPicked: (ConversationDialog, Bundle) -> Unit = { _, _ -> } +) { + when (dialog) { + null -> Unit + + is ConversationDialog.ConversationArchive -> { + MaterialDialog( + onDismissRequest = { onDismissed(dialog) }, + title = stringResource(id = UiR.string.confirm_archive_conversation), + confirmAction = { onConfirmed(dialog, bundleOf()) }, + confirmText = stringResource(id = UiR.string.action_archive), + cancelText = stringResource(id = UiR.string.cancel) + ) + } + + is ConversationDialog.ConversationUnarchive -> { + MaterialDialog( + onDismissRequest = { onDismissed(dialog) }, + title = stringResource(id = UiR.string.confirm_unarchive_conversation), + confirmAction = { onConfirmed(dialog, bundleOf()) }, + confirmText = stringResource(id = UiR.string.action_unarchive), + cancelText = stringResource(id = UiR.string.cancel) + ) + } + + is ConversationDialog.ConversationDelete -> { + MaterialDialog( + onDismissRequest = { onDismissed(dialog) }, + title = stringResource(id = UiR.string.confirm_delete_conversation), + confirmAction = { onConfirmed(dialog, bundleOf()) }, + confirmText = stringResource(id = UiR.string.action_delete), + cancelText = stringResource(id = UiR.string.cancel) + ) + } + + is ConversationDialog.ConversationPin -> { + MaterialDialog( + onDismissRequest = { onDismissed(dialog) }, + title = stringResource(id = UiR.string.confirm_pin_conversation), + confirmAction = { onConfirmed(dialog, bundleOf()) }, + confirmText = stringResource(id = UiR.string.action_pin), + cancelText = stringResource(id = UiR.string.cancel) + ) + } + + is ConversationDialog.ConversationUnpin -> { + MaterialDialog( + onDismissRequest = { onDismissed(dialog) }, + title = stringResource(id = UiR.string.confirm_unpin_conversation), + confirmAction = { onConfirmed(dialog, bundleOf()) }, + confirmText = stringResource(id = UiR.string.action_unpin), + cancelText = stringResource(id = UiR.string.cancel) + ) + } + } +} diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt index ed0751f0..d8bd35bc 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt @@ -6,10 +6,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -21,7 +18,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ElevatedAssistChip @@ -40,7 +38,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString @@ -63,16 +60,14 @@ val BirthdayColor = Color(0xffb00b69) @OptIn(ExperimentalFoundationApi::class) @Composable fun ConversationItem( - onItemClick: (Int) -> Unit, + onItemClick: (UiConversation) -> Unit, onItemLongClick: (conversation: UiConversation) -> Unit, onOptionClicked: (UiConversation, ConversationOption) -> Unit, maxLines: Int, isUserAccount: Boolean, conversation: UiConversation, - modifier: Modifier = Modifier, - onPhotoClicked: (url: String) -> Unit + modifier: Modifier = Modifier ) { - val context = LocalContext.current val hapticFeedback = LocalHapticFeedback.current val bottomStartCornerRadius by animateDpAsState( @@ -84,7 +79,7 @@ fun ConversationItem( modifier = modifier .fillMaxWidth() .combinedClickable( - onClick = { onItemClick(conversation.id) }, + onClick = { onItemClick(conversation) }, onLongClick = { onItemLongClick(conversation) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) @@ -154,12 +149,7 @@ fun ConversationItem( contentDescription = "Avatar", modifier = Modifier .fillMaxSize() - .clip(CircleShape) - .clickable { - if (avatarImage is String) { - onPhotoClicked(avatarImage) - } - }, + .clip(CircleShape), placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut) ) } @@ -250,7 +240,7 @@ fun ConversationItem( text = conversation.title, minLines = 1, maxLines = maxLines, - style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp) + style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp), ) Row { @@ -338,9 +328,13 @@ fun ConversationItem( Box( modifier = Modifier .clip(CircleShape) - .defaultMinSize(minWidth = 20.dp, minHeight = 20.dp) + .defaultMinSize( + minWidth = 20.dp, + minHeight = 20.dp + ) .background(MaterialTheme.colorScheme.primary) .align(Alignment.CenterHorizontally) + .padding(horizontal = if (count.length > 1) 2.dp else 0.dp) ) { Text( modifier = Modifier @@ -361,18 +355,19 @@ fun ConversationItem( Column( modifier = Modifier .fillMaxWidth() + .height(60.dp) .padding(start = 8.dp) ) { Spacer(modifier = Modifier.height(12.dp)) HorizontalDivider() + Spacer(modifier = Modifier.height(4.dp)) - Row( + LazyRow( modifier = Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp) + .fillMaxWidth() + .padding(horizontal = 10.dp) ) { - conversation.options.forEach { option -> + items(conversation.options.toList()) { option -> ElevatedAssistChip( onClick = { onOptionClicked(conversation, option) }, leadingIcon = { @@ -388,6 +383,7 @@ fun ConversationItem( Text(text = option.title.getString().orEmpty()) } ) + Spacer(Modifier.width(8.dp)) } } } @@ -402,5 +398,3 @@ fun ConversationItem( } } } - - diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt index 666827c6..3e15067e 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt @@ -27,25 +27,26 @@ import dev.meloda.fast.data.UserConfig import dev.meloda.fast.ui.model.api.ConversationOption import dev.meloda.fast.ui.model.api.UiConversation import dev.meloda.fast.ui.theme.LocalBottomPadding +import dev.meloda.fast.ui.theme.LocalThemeConfig +import dev.meloda.fast.ui.util.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable fun ConversationsList( - onConversationsClick: (Int) -> Unit, + modifier: Modifier = Modifier, + conversations: ImmutableList, + onConversationsClick: (UiConversation) -> Unit, onConversationsLongClick: (UiConversation) -> Unit, screenState: ConversationsScreenState, state: LazyListState, maxLines: Int, - modifier: Modifier, onOptionClicked: (UiConversation, ConversationOption) -> Unit, - padding: PaddingValues, - onPhotoClicked: (url: String) -> Unit + padding: PaddingValues ) { + val theme = LocalThemeConfig.current val coroutineScope = rememberCoroutineScope() - val bottomPadding = LocalBottomPadding.current - LazyColumn( modifier = modifier, state = state @@ -55,7 +56,7 @@ fun ConversationsList( Spacer(modifier = Modifier.height(8.dp)) } items( - items = screenState.conversations, + items = conversations.values, key = UiConversation::id, ) { conversation -> val isUserAccount by remember(conversation) { @@ -71,8 +72,12 @@ fun ConversationsList( maxLines = maxLines, isUserAccount = isUserAccount, conversation = conversation, - modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), - onPhotoClicked = onPhotoClicked + modifier = + if (theme.enableAnimations) Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null + ) + else Modifier ) Spacer(modifier = Modifier.height(8.dp)) @@ -82,7 +87,14 @@ fun ConversationsList( Column( modifier = Modifier .fillMaxWidth() - .animateItem(fadeInSpec = null, fadeOutSpec = null), + .then( + if (theme.enableAnimations) + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null + ) + else Modifier + ), horizontalAlignment = Alignment.CenterHorizontally ) { if (screenState.isPaginating) { @@ -107,6 +119,7 @@ fun ConversationsList( } Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(LocalBottomPadding.current)) } } } diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsRoute.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsRoute.kt new file mode 100644 index 00000000..076a0e9b --- /dev/null +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsRoute.kt @@ -0,0 +1,80 @@ +package dev.meloda.fast.conversations.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.meloda.fast.conversations.ConversationsViewModel +import dev.meloda.fast.conversations.model.ConversationNavigation +import dev.meloda.fast.model.BaseError +import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList + +@Composable +fun ConversationsRoute( + viewModel: ConversationsViewModel, + onBack: (() -> Unit)? = null, + onError: (BaseError) -> Unit, + onNavigateToMessagesHistory: (conversationId: Long) -> Unit, + onNavigateToCreateChat: (() -> Unit)? = null, + onNavigateToArchive: (() -> Unit)? = null, + onScrolledToTop: () -> Unit, +) { + val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle() + val conversations by viewModel.uiConversations.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 ConversationNavigation.CreateChat -> { + onNavigateToCreateChat?.invoke() + true + } + + is ConversationNavigation.MessagesHistory -> { + onNavigateToMessagesHistory(navigation.peerId) + true + } + } + + if (shouldBeConsumed) viewModel.onNavigationConsumed() + } + + ConversationsScreen( + onBack = { onBack?.invoke() }, + screenState = screenState, + conversations = conversations.toImmutableList(), + baseError = baseError, + canPaginate = canPaginate, + onConversationItemClicked = viewModel::onConversationItemClick, + onConversationItemLongClicked = viewModel::onConversationItemLongClick, + onOptionClicked = viewModel::onOptionClicked, + onPaginationConditionsMet = viewModel::onPaginationConditionsMet, + onRefreshDropdownItemClicked = viewModel::onRefresh, + 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( + screenState = screenState, + dialog = dialog, + onConfirmed = viewModel::onDialogConfirmed, + onDismissed = viewModel::onDialogDismissed, + onItemPicked = viewModel::onDialogItemPicked + ) +} diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt index 863fafef..22a11715 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt @@ -3,11 +3,8 @@ package dev.meloda.fast.conversations.presentation import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideIn -import androidx.compose.animation.slideOut import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -17,11 +14,13 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -51,7 +50,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment 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 @@ -59,66 +57,30 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle 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.conversations.ConversationsViewModel import dev.meloda.fast.conversations.model.ConversationsScreenState +import dev.meloda.fast.conversations.navigation.Conversations import dev.meloda.fast.model.BaseError -import dev.meloda.fast.ui.components.ErrorView import dev.meloda.fast.ui.components.FullScreenLoader -import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.NoItemsView +import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.model.api.ConversationOption import dev.meloda.fast.ui.model.api.UiConversation 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.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 dev.meloda.fast.ui.R as UiR -@Composable -fun ConversationsRoute( - onError: (BaseError) -> Unit, - onConversationItemClicked: (conversationId: Int) -> Unit, - onConversationPhotoClicked: (url: String) -> Unit, - onCreateChatButtonClicked: () -> Unit, - viewModel: ConversationsViewModel -) { - val screenState by viewModel.screenState.collectAsStateWithLifecycle() - val baseError by viewModel.baseError.collectAsStateWithLifecycle() - val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() - - ConversationsScreen( - screenState = screenState, - baseError = baseError, - canPaginate = canPaginate, - onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) }, - onConversationItemClicked = { id -> - onConversationItemClicked(id) - viewModel.onConversationItemClick() - }, - onConversationItemLongClicked = viewModel::onConversationItemLongClick, - onOptionClicked = viewModel::onOptionClicked, - onPaginationConditionsMet = viewModel::onPaginationConditionsMet, - onRefreshDropdownItemClicked = viewModel::onRefresh, - onRefresh = viewModel::onRefresh, - onConversationPhotoClicked = onConversationPhotoClicked, - onCreateChatButtonClicked = onCreateChatButtonClicked, - setScrollIndex = viewModel::setScrollIndex, - setScrollOffset = viewModel::setScrollOffset - ) - - HandleDialogs( - screenState = screenState, - viewModel = viewModel - ) -} - @OptIn( ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, @@ -126,21 +88,23 @@ fun ConversationsRoute( @Composable fun ConversationsScreen( screenState: ConversationsScreenState = ConversationsScreenState.EMPTY, + conversations: ImmutableList = emptyImmutableList(), baseError: BaseError? = null, canPaginate: Boolean = false, - onSessionExpiredLogOutButtonClicked: () -> Unit = {}, - onConversationItemClicked: (conversationId: Int) -> Unit = {}, + onBack: () -> Unit = {}, + onConversationItemClicked: (conversation: UiConversation) -> Unit = {}, onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {}, onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> }, onPaginationConditionsMet: () -> Unit = {}, onRefreshDropdownItemClicked: () -> Unit = {}, onRefresh: () -> Unit = {}, - onConversationPhotoClicked: (url: String) -> Unit = {}, onCreateChatButtonClicked: () -> Unit = {}, + onArchiveActionClicked: () -> Unit = {}, setScrollIndex: (Int) -> Unit = {}, - setScrollOffset: (Int) -> Unit = {} + setScrollOffset: (Int) -> Unit = {}, + onConsumeReselection: () -> Unit = {}, + onErrorViewButtonClicked: () -> Unit = {} ) { - val view = LocalView.current val currentTheme = LocalThemeConfig.current val maxLines by remember(currentTheme) { @@ -152,6 +116,21 @@ fun ConversationsScreen( initialFirstVisibleItemScrollOffset = screenState.scrollOffset ) + val currentTabReselected = LocalReselectedTab.current[Conversations] ?: false + LaunchedEffect(currentTabReselected) { + if (currentTabReselected) { + if (screenState.isArchive) { + onBack.invoke() + } else { + if (listState.firstVisibleItemIndex > 14) { + listState.scrollToItem(14) + } + listState.animateScrollToItem(0) + onConsumeReselection() + } + } + } + LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex } .debounce(500L) @@ -209,22 +188,40 @@ fun ConversationsScreen( title = { Text( text = stringResource( - id = if (screenState.isLoading) UiR.string.title_loading - else UiR.string.title_conversations + id = when { + screenState.isLoading -> UiR.string.title_loading + screenState.isArchive -> UiR.string.title_archive + else -> UiR.string.title_conversations + } ), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.headlineSmall ) }, - actions = { - IconButton( - onClick = { - dropDownMenuExpanded = true + navigationIcon = { + if (screenState.isArchive) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) } - ) { + } + }, + actions = { + if (!screenState.isArchive) { + IconButton(onClick = onArchiveActionClicked) { + Icon( + painter = painterResource(UiR.drawable.outline_archive_24), + contentDescription = null + ) + } + } + + IconButton(onClick = { dropDownMenuExpanded = true }) { Icon( - imageVector = Icons.Default.MoreVert, + imageVector = Icons.Rounded.MoreVert, contentDescription = null ) } @@ -270,7 +267,7 @@ fun ConversationsScreen( ) val showHorizontalProgressBar by remember(screenState) { - derivedStateOf { screenState.isLoading && screenState.conversations.isNotEmpty() } + derivedStateOf { screenState.isLoading && conversations.isNotEmpty() } } AnimatedVisibility(showHorizontalProgressBar) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) @@ -281,46 +278,38 @@ fun ConversationsScreen( } }, floatingActionButton = { - Column { - AnimatedVisibility( - visible = listState.isScrollingUp(), - enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)), - exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200)) - ) { - FloatingActionButton(onClick = onCreateChatButtonClicked) { + if (!screenState.isArchive) { + val offsetY by animateIntAsState( + targetValue = if (listState.isScrollingUp()) 0 else 600 + ) + + Column { + FloatingActionButton( + onClick = onCreateChatButtonClicked, + modifier = Modifier.offset { + IntOffset(0, offsetY) + } + ) { Icon( - painter = painterResource(id = UiR.drawable.ic_baseline_create_24), + painter = painterResource(id = UiR.drawable.round_create_24), contentDescription = "Add chat button" ) } - } - Spacer(modifier = Modifier.height(LocalBottomPadding.current)) + Spacer(modifier = Modifier.height(LocalBottomPadding.current)) + } } } ) { padding -> when { baseError != null -> { - when (baseError) { - is BaseError.SessionExpired -> { - ErrorView( - text = stringResource(UiR.string.session_expired), - buttonText = stringResource(UiR.string.action_log_out), - onButtonClick = onSessionExpiredLogOutButtonClicked - ) - } - - is BaseError.SimpleError -> { - ErrorView( - text = baseError.message, - buttonText = stringResource(UiR.string.try_again), - onButtonClick = onRefresh - ) - } - } + VkErrorView( + baseError = baseError, + onButtonClick = onErrorViewButtonClicked + ) } - screenState.isLoading && screenState.conversations.isEmpty() -> FullScreenLoader() + screenState.isLoading && conversations.isEmpty() -> FullScreenLoader() else -> { val pullToRefreshState = rememberPullToRefreshState() @@ -345,6 +334,7 @@ fun ConversationsScreen( } ) { ConversationsList( + conversations = conversations, onConversationsClick = onConversationItemClicked, onConversationsLongClick = onConversationItemLongClicked, screenState = screenState, @@ -356,11 +346,10 @@ fun ConversationsScreen( Modifier }.fillMaxSize(), onOptionClicked = onOptionClicked, - padding = padding, - onPhotoClicked = onConversationPhotoClicked + padding = padding ) - if (screenState.conversations.isEmpty()) { + if (conversations.isEmpty()) { NoItemsView( buttonText = stringResource(UiR.string.action_refresh), onButtonClick = onRefresh @@ -371,38 +360,3 @@ fun ConversationsScreen( } } } - -// TODO: 26.08.2023, Danil Nikolaev: remove usage of viewModel -@Composable -fun HandleDialogs( - screenState: ConversationsScreenState, - viewModel: ConversationsViewModel -) { - val showOptions = screenState.showOptions - - if (showOptions.showDeleteDialog != null) { - MaterialDialog( - onDismissRequest = viewModel::onDeleteDialogDismissed, - title = stringResource(id = UiR.string.confirm_delete_conversation), - confirmAction = viewModel::onDeleteDialogPositiveClick, - confirmText = stringResource(id = UiR.string.action_delete), - cancelText = stringResource(id = UiR.string.cancel) - ) - } - - showOptions.showPinDialog?.let { conversation -> - MaterialDialog( - onDismissRequest = viewModel::onPinDialogDismissed, - title = stringResource( - id = if (conversation.isPinned) UiR.string.confirm_unpin_conversation - else UiR.string.confirm_pin_conversation - ), - confirmAction = viewModel::onPinDialogPositiveClick, - confirmText = stringResource( - id = if (conversation.isPinned) UiR.string.action_unpin - else UiR.string.action_pin - ), - cancelText = stringResource(id = UiR.string.cancel) - ) - } -} diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt index 56634b9d..baca6a66 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt @@ -23,8 +23,10 @@ import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.ui.model.api.ActionState +import dev.meloda.fast.ui.model.api.ConversationOption import dev.meloda.fast.ui.model.api.UiConversation import dev.meloda.fast.ui.util.ImmutableList +import dev.meloda.fast.ui.util.emptyImmutableList import java.util.Calendar import java.util.Locale import kotlin.math.ln @@ -33,7 +35,9 @@ import dev.meloda.fast.ui.R as UiR fun VkConversation.asPresentation( resources: Resources, - useContactName: Boolean + useContactName: Boolean, + isExpanded: Boolean = false, + options: ImmutableList = emptyImmutableList() ): UiConversation = UiConversation( id = id, lastMessageId = lastMessageId, @@ -47,14 +51,15 @@ fun VkConversation.asPresentation( isPinned = majorId > 0, actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(), isBirthday = extractBirthday(this), - isUnread = extractReadCondition(this, lastMessage), + isUnread = !isRead(), isAccount = isAccount(id), isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true, lastMessage = lastMessage, peerType = peerType, interactionText = extractInteractionText(resources, this), - isExpanded = false, - options = ImmutableList.empty() + isExpanded = isExpanded, + isArchived = isArchived, + options = options ) fun VkConversation.extractAvatar() = when (peerType) { @@ -101,7 +106,7 @@ private fun extractUnreadCount( lastMessage: VkMessage?, conversation: VkConversation ): String? = when { - lastMessage?.isOut == false && !conversation.isInUnread() -> null + lastMessage?.isOut == false && conversation.isInRead() -> null conversation.unreadCount == 0 -> null conversation.unreadCount < 1000 -> conversation.unreadCount.toString() else -> { @@ -121,7 +126,7 @@ private fun extractUnreadCount( private fun extractMessage( resources: Resources, lastMessage: VkMessage?, - peerId: Int, + peerId: Long, peerType: PeerType ): AnnotatedString { val youPrefix = UiText.Resource(UiR.string.you_message_prefix) @@ -210,7 +215,12 @@ private fun extractMessage( .replace("
", " ") .replace("–", "-") .trim() - .let { text -> getTextWithVisualizedMentions(text, Color.Red) } + .let { text -> + extractTextWithVisualizedMentions( + isOut = lastMessage?.isOut == true, + originalText = text + ) + } .let { text -> prefix + text } } @@ -612,6 +622,9 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? { AttachmentType.PODCAST -> null AttachmentType.NARRATIVE -> null AttachmentType.ARTICLE -> null + AttachmentType.VIDEO_MESSAGE -> null + AttachmentType.GROUP_CHAT_STICKER -> UiR.drawable.ic_attachment_sticker + AttachmentType.STICKER_PACK_PREVIEW -> null }?.let(UiImage::Resource) } @@ -649,10 +662,9 @@ private fun extractForwardsText( else -> null } - -private fun getTextWithVisualizedMentions( - originalText: String, - mentionColor: Color, +fun extractTextWithVisualizedMentions( + isOut: Boolean, + originalText: String ): AnnotatedString = buildAnnotatedString { val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex() @@ -676,7 +688,7 @@ private fun getTextWithVisualizedMentions( replacements.add(indexRange to replaced) mentions += MentionIndex( - id = id.toIntOrNull() ?: -1, + id = id.toLongOrNull() ?: -1, idPrefix = idPrefix, indexRange = indexRange ) @@ -693,7 +705,7 @@ private fun getTextWithVisualizedMentions( val endIndex = mention.indexRange.last addStyle( - style = SpanStyle(color = mentionColor), + style = SpanStyle(color = Color.Red), start = startIndex, end = endIndex ) @@ -707,7 +719,7 @@ private fun getTextWithVisualizedMentions( } data class MentionIndex( - val id: Int, + val id: Long, val idPrefix: String, val indexRange: IntRange ) @@ -755,6 +767,9 @@ private fun getAttachmentUiText( AttachmentType.PODCAST -> UiR.string.message_attachments_podcast AttachmentType.NARRATIVE -> UiR.string.message_attachments_narrative AttachmentType.ARTICLE -> UiR.string.message_attachments_article + AttachmentType.VIDEO_MESSAGE -> UiR.string.message_attachments_video_message + AttachmentType.GROUP_CHAT_STICKER -> UiR.string.message_attachments_group_sticker + AttachmentType.STICKER_PACK_PREVIEW -> UiR.string.message_attachments_sticker_pack_preview }.let(UiText::Resource) } @@ -796,10 +811,9 @@ private fun extractBirthday(conversation: VkConversation): Boolean { private fun extractReadCondition( conversation: VkConversation, lastMessage: VkMessage? -): Boolean = (lastMessage?.isOut == true && conversation.isOutUnread()) || - (lastMessage?.isOut == false && conversation.isInUnread()) +): Boolean = !conversation.isRead(lastMessage) -private fun isAccount(peerId: Int) = peerId == UserConfig.userId +private fun isAccount(peerId: Long) = peerId == UserConfig.userId private fun extractInteractionText( resources: Resources, diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/CreateChatViewModel.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/CreateChatViewModel.kt index c9847dbe..b4a43fde 100644 --- a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/CreateChatViewModel.kt +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/CreateChatViewModel.kt @@ -33,13 +33,13 @@ interface CreateChatViewModel { val currentOffset: StateFlow val canPaginate: StateFlow - val isChatCreated: StateFlow + val isChatCreated: StateFlow fun onPaginationConditionsMet() fun onRefresh() fun onErrorConsumed() - fun toggleFriendSelection(userId: Int) + fun toggleFriendSelection(userId: Long) fun onTitleTextInputChanged(newTitle: String) @@ -62,7 +62,7 @@ class CreateChatViewModelImpl( override val currentOffset = MutableStateFlow(0) override val canPaginate = MutableStateFlow(false) - override val isChatCreated = MutableStateFlow(null) + override val isChatCreated = MutableStateFlow(null) private val useContactNames: Boolean = userSettings.useContactNames.value @@ -84,7 +84,7 @@ class CreateChatViewModelImpl( baseError.setValue { null } } - override fun toggleFriendSelection(userId: Int) { + override fun toggleFriendSelection(userId: Long) { val newSelectionList = screenState.value.selectedFriendsIds.toMutableList() if (newSelectionList.contains(userId)) { diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/model/CreateChatScreenState.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/model/CreateChatScreenState.kt index 03abef2c..10a6591c 100644 --- a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/model/CreateChatScreenState.kt +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/model/CreateChatScreenState.kt @@ -9,7 +9,7 @@ data class CreateChatScreenState( val isPaginating: Boolean, val isPaginationExhausted: Boolean, val friends: List, - val selectedFriendsIds: List, + val selectedFriendsIds: List, val chatTitle: String ) { companion object { diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/navigation/CreateChatNavigation.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/navigation/CreateChatNavigation.kt index 3a863420..81ef6ef0 100644 --- a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/navigation/CreateChatNavigation.kt +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/navigation/CreateChatNavigation.kt @@ -1,24 +1,28 @@ package dev.meloda.fast.conversations.navigation +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import dev.meloda.fast.conversations.CreateChatViewModel import dev.meloda.fast.conversations.CreateChatViewModelImpl import dev.meloda.fast.conversations.presentation.CreateChatRoute -import dev.meloda.fast.ui.extensions.sharedViewModel import kotlinx.serialization.Serializable +import org.koin.compose.viewmodel.koinViewModel @Serializable object CreateChat fun NavGraphBuilder.createChatScreen( - onChatCreated: (Int) -> Unit, + onChatCreated: (Long) -> Unit, navController: NavController, ) { composable { - val viewModel: CreateChatViewModel = - it.sharedViewModel(navController = navController) + val context = LocalContext.current + val viewModel: CreateChatViewModel = koinViewModel( + viewModelStoreOwner = context as AppCompatActivity + ) CreateChatRoute( onError = { diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatItem.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatItem.kt index e5ff6457..226ffc87 100644 --- a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatItem.kt +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatItem.kt @@ -34,7 +34,7 @@ fun CreateChatItem( friend: UiFriend, maxLines: Int, isSelected: Boolean, - onItemClicked: (Int) -> Unit + onItemClicked: (Long) -> Unit ) { Row( modifier = modifier diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatList.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatList.kt index ff2c00f2..425ffc30 100644 --- a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatList.kt +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatList.kt @@ -39,7 +39,7 @@ fun CreateChatList( maxLines: Int, modifier: Modifier, padding: PaddingValues, - onItemClicked: (Int) -> Unit, + onItemClicked: (Long) -> Unit, onTitleTextInputChanged: (String) -> Unit ) { val coroutineScope = rememberCoroutineScope() diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatScreen.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatScreen.kt index b2d331e4..7b1b9cc7 100644 --- a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatScreen.kt +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatScreen.kt @@ -1,6 +1,7 @@ package dev.meloda.fast.conversations.presentation import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -67,6 +68,7 @@ import dev.meloda.fast.ui.components.ErrorView import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.IconButton import dev.meloda.fast.ui.components.NoItemsView +import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.isScrollingUp @@ -76,11 +78,9 @@ import dev.meloda.fast.ui.R as UiR fun CreateChatRoute( onError: (BaseError) -> Unit, onBack: () -> Unit, - onChatCreated: (Int) -> Unit, + onChatCreated: (Long) -> Unit, viewModel: CreateChatViewModel ) { - val context = LocalContext.current - val screenState by viewModel.screenState.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() @@ -88,7 +88,7 @@ fun CreateChatRoute( LaunchedEffect(isChatCreated) { if (isChatCreated != null) { - onChatCreated(isChatCreated ?: -1) + onChatCreated(isChatCreated ?: -1L) viewModel.onNavigatedBack() } } @@ -121,7 +121,7 @@ fun CreateChatScreen( onBack: () -> Unit = {}, onRefresh: () -> Unit = {}, onCreateChatButtonClicked: () -> Unit = {}, - onItemClicked: (Int) -> Unit = {}, + onItemClicked: (Long) -> Unit = {}, onTitleTextInputChanged: (String) -> Unit = {} ) { val currentTheme = LocalThemeConfig.current @@ -148,20 +148,24 @@ fun CreateChatScreen( val hazeState = LocalHazeState.current - val toolbarColorAlpha by animateFloatAsState( - targetValue = if (!listState.canScrollBackward) 1f else 0f, + val topBarContainerColorAlpha by animateFloatAsState( + targetValue = if (!currentTheme.enableBlur || !listState.canScrollBackward) 1f else 0f, label = "toolbarColorAlpha", - animationSpec = tween(durationMillis = 50) + animationSpec = tween( + durationMillis = 200, + easing = FastOutLinearInEasing + ) ) - val toolbarContainerColor by animateColorAsState( + val topBarContainerColor by animateColorAsState( targetValue = - if (currentTheme.enableBlur || !listState.canScrollBackward) - MaterialTheme.colorScheme.surface - else - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + if (currentTheme.enableBlur || !listState.canScrollBackward) MaterialTheme.colorScheme.surface + else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), label = "toolbarColorAlpha", - animationSpec = tween(durationMillis = 50) + animationSpec = tween( + durationMillis = 200, + easing = FastOutLinearInEasing + ) ) Scaffold( @@ -171,11 +175,7 @@ fun CreateChatScreen( Column( modifier = Modifier .fillMaxWidth() - .background( - toolbarContainerColor.copy( - alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f - ) - ) + .background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha)) .then( if (currentTheme.enableBlur) { Modifier.hazeEffect( @@ -205,11 +205,7 @@ fun CreateChatScreen( style = MaterialTheme.typography.headlineSmall ) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = toolbarContainerColor.copy( - alpha = 0f - ) - ), + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), modifier = Modifier.fillMaxWidth(), ) @@ -272,23 +268,7 @@ fun CreateChatScreen( ) { padding -> when { baseError != null -> { - when (baseError) { - is BaseError.SessionExpired -> { - ErrorView( - text = stringResource(UiR.string.session_expired), - buttonText = stringResource(UiR.string.action_log_out), - onButtonClick = onSessionExpiredLogOutButtonClicked - ) - } - - is BaseError.SimpleError -> { - ErrorView( - text = baseError.message, - buttonText = stringResource(UiR.string.try_again), - onButtonClick = onRefresh - ) - } - } + VkErrorView(baseError = baseError) } screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader() diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt index bf953845..044c9935 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -// TODO: 13/07/2024, Danil Nikolaev: separate two lists and their pagination interface FriendsViewModel { val screenState: StateFlow @@ -33,19 +32,13 @@ interface FriendsViewModel { fun onErrorConsumed() - fun onTabSelected(tabIndex: Int) - fun setScrollIndex(index: Int) fun setScrollOffset(offset: Int) - fun setScrollIndexOnline(index: Int) - fun setScrollOffsetOnline(offset: Int) + + fun onOrderTypeChanged(newOrderType: String) } -class FriendsViewModelImpl( - private val friendsUseCase: FriendsUseCase, - private val userSettings: UserSettings, - private val loadUsersByIdsUseCase: LoadUsersByIdsUseCase -) : ViewModel(), FriendsViewModel { +abstract class BaseFriendsViewModelImpl : ViewModel(), FriendsViewModel { override val screenState = MutableStateFlow(FriendsScreenState.EMPTY) @@ -54,13 +47,7 @@ class FriendsViewModelImpl( override val currentOffset = MutableStateFlow(0) override val canPaginate = MutableStateFlow(false) - private val friends = MutableStateFlow>(emptyList()) - - init { - userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames) - - loadFriends() - } + protected val friends = MutableStateFlow>(emptyList()) override fun onPaginationConditionsMet() { currentOffset.update { screenState.value.friends.size } @@ -76,10 +63,6 @@ class FriendsViewModelImpl( baseError.setValue { null } } - override fun onTabSelected(tabIndex: Int) { - screenState.setValue { old -> old.copy(selectedTabIndex = tabIndex) } - } - override fun setScrollIndex(index: Int) { screenState.setValue { old -> old.copy(scrollIndex = index) } } @@ -88,87 +71,15 @@ class FriendsViewModelImpl( screenState.setValue { old -> old.copy(scrollOffset = offset) } } - override fun setScrollIndexOnline(index: Int) { - screenState.setValue { old -> old.copy(scrollIndexOnline = index) } + override fun onOrderTypeChanged(newOrderType: String) { + if (screenState.value.orderType == newOrderType) return + screenState.setValue { old -> old.copy(orderType = newOrderType) } + loadFriends(offset = 0) } - override fun setScrollOffsetOnline(offset: Int) { - screenState.setValue { old -> old.copy(scrollOffsetOnline = offset) } - } + abstract fun loadFriends(offset: Int = currentOffset.value) - private fun loadFriends(offset: Int = currentOffset.value) { - friendsUseCase.getOnlineFriends(null, null) - .listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { userIds -> - loadUsersByIdsUseCase(userIds = userIds) - .listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { onlineFriends -> - screenState.setValue { old -> - old.copy( - onlineFriends = onlineFriends.map { - it.asPresentation(userSettings.useContactNames.value) - } - ) - } - } - ) - } - } - ) - } - friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset) - .listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { response -> - val itemsCountSufficient = response.size == LOAD_COUNT - canPaginate.setValue { itemsCountSufficient } - - val paginationExhausted = !itemsCountSufficient && - screenState.value.friends.size >= LOAD_COUNT - - imagesToPreload.setValue { - response.mapNotNull(VkUser::photo100) - } - - friendsUseCase.storeUsers(response) - - val loadedFriends = response.map { - it.asPresentation(userSettings.useContactNames.value) - } - - val newState = screenState.value.copy( - isPaginationExhausted = paginationExhausted - ) - - if (offset == 0) { - friends.emit(response) - screenState.setValue { - newState.copy(friends = loadedFriends) - } - } else { - friends.emit(friends.value.plus(response)) - screenState.setValue { - newState.copy(friends = newState.friends.plus(loadedFriends)) - } - } - } - ) - - screenState.setValue { old -> - old.copy( - isLoading = offset == 0 && state.isLoading(), - isPaginating = offset > 0 && state.isLoading() - ) - } - } - } - - private fun handleError(error: State.Error) { + protected fun handleError(error: State.Error) { when (error) { is State.Error.ApiError -> { when (error.errorCode) { @@ -183,26 +94,30 @@ class FriendsViewModelImpl( } } } + State.Error.ConnectionError -> { baseError.setValue { BaseError.SimpleError(message = "Connection error") } } + State.Error.InternalError -> { baseError.setValue { BaseError.SimpleError(message = "Internal error") } } + State.Error.UnknownError -> { baseError.setValue { BaseError.SimpleError(message = "Unknown error") } } + else -> Unit } } - private fun updateFriendsNames(useContactNames: Boolean) { + protected fun updateFriendsNames(useContactNames: Boolean) { val friends = friends.value if (friends.isEmpty()) return @@ -210,19 +125,119 @@ class FriendsViewModelImpl( conversation.asPresentation(useContactNames) } - val onlineUiFriends = screenState.value.onlineFriends.mapNotNull { friend -> - uiFriends.find { it.userId == friend.userId } - } - screenState.setValue { old -> - old.copy( - friends = uiFriends, - onlineFriends = onlineUiFriends - ) + old.copy(friends = uiFriends) } } companion object { - const val LOAD_COUNT = 15 + const val LOAD_COUNT = 30 + } +} + +class FriendsViewModelImpl( + private val friendsUseCase: FriendsUseCase, + private val userSettings: UserSettings +) : BaseFriendsViewModelImpl() { + + init { + userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames) + loadFriends() + } + + override fun loadFriends(offset: Int) { + friendsUseCase.getFriends( + order = screenState.value.orderType, + count = LOAD_COUNT, + offset = offset + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { response -> + val itemsCountSufficient = response.size == LOAD_COUNT + canPaginate.setValue { itemsCountSufficient } + + val paginationExhausted = !itemsCountSufficient + && screenState.value.friends.isNotEmpty() + + imagesToPreload.setValue { + response.mapNotNull(VkUser::photo100) + } + + friendsUseCase.storeUsers(response) + + val loadedFriends = response.map { + it.asPresentation(userSettings.useContactNames.value) + } + + val newState = screenState.value.copy( + isPaginationExhausted = paginationExhausted + ) + + if (offset == 0) { + friends.emit(response) + screenState.setValue { + newState.copy(friends = loadedFriends) + } + } else { + friends.emit(friends.value.plus(response)) + screenState.setValue { + newState.copy(friends = newState.friends.plus(loadedFriends)) + } + } + } + ) + + screenState.setValue { old -> + old.copy( + isLoading = offset == 0 && state.isLoading(), + isPaginating = offset > 0 && state.isLoading() + ) + } + } + } +} + +class OnlineFriendsViewModelImpl( + private val friendsUseCase: FriendsUseCase, + private val userSettings: UserSettings, + private val loadUsersByIdsUseCase: LoadUsersByIdsUseCase +) : BaseFriendsViewModelImpl() { + + init { + userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames) + loadFriends() + } + + override fun loadFriends(offset: Int) { + friendsUseCase.getOnlineFriends(null, null) + .listenValue(viewModelScope) { onlineState -> + onlineState.processState( + error = ::handleError, + success = { userIds -> + loadUsersByIdsUseCase(userIds = userIds).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { onlineFriends -> + screenState.setValue { old -> + old.copy( + friends = onlineFriends.map { + it.asPresentation(userSettings.useContactNames.value) + } + ) + } + } + ) + + screenState.setValue { old -> + old.copy( + isLoading = offset == 0 && (onlineState.isLoading() || state.isLoading()), + isPaginating = offset > 0 && (onlineState.isLoading() || state.isLoading()) + ) + } + } + } + ) + } } } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/di/FriendsModule.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/di/FriendsModule.kt index c19b8cd4..8014e373 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/di/FriendsModule.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/di/FriendsModule.kt @@ -1,8 +1,9 @@ package dev.meloda.fast.friends.di import dev.meloda.fast.domain.FriendsUseCase -import dev.meloda.fast.friends.FriendsViewModelImpl import dev.meloda.fast.domain.FriendsUseCaseImpl +import dev.meloda.fast.friends.FriendsViewModelImpl +import dev.meloda.fast.friends.OnlineFriendsViewModelImpl import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.bind @@ -11,4 +12,5 @@ import org.koin.dsl.module val friendsModule = module { singleOf(::FriendsUseCaseImpl) bind FriendsUseCase::class viewModelOf(::FriendsViewModelImpl) + viewModelOf(::OnlineFriendsViewModelImpl) } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt index f2583c3a..967466e4 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt @@ -7,28 +7,22 @@ import dev.meloda.fast.ui.model.api.UiFriend data class FriendsScreenState( val isLoading: Boolean, val friends: List, - val onlineFriends: List, val isPaginating: Boolean, val isPaginationExhausted: Boolean, - val selectedTabIndex: Int, val scrollIndex: Int, val scrollOffset: Int, - val scrollIndexOnline: Int, - val scrollOffsetOnline: Int + val orderType: String, ) { companion object { val EMPTY: FriendsScreenState = FriendsScreenState( isLoading = true, friends = emptyList(), - onlineFriends = emptyList(), isPaginating = false, isPaginationExhausted = false, - selectedTabIndex = 0, scrollIndex = 0, scrollOffset = 0, - scrollIndexOnline = 0, - scrollOffsetOnline = 0, + orderType = "hints" ) } } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt index 9fdaa30e..17158dfd 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt @@ -1,13 +1,9 @@ package dev.meloda.fast.friends.navigation -import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import dev.meloda.fast.friends.FriendsViewModel -import dev.meloda.fast.friends.FriendsViewModelImpl import dev.meloda.fast.friends.presentation.FriendsRoute import dev.meloda.fast.model.BaseError -import dev.meloda.fast.ui.extensions.sharedViewModel import kotlinx.serialization.Serializable @Serializable @@ -15,19 +11,16 @@ object Friends fun NavGraphBuilder.friendsScreen( onError: (BaseError) -> Unit, - navController: NavController, onPhotoClicked: (url: String) -> Unit, - onMessageClicked: (userId: Int) -> Unit + onMessageClicked: (userid: Long) -> Unit, + onScrolledToTop: () -> Unit ) { composable { - val viewModel: FriendsViewModel = - it.sharedViewModel(navController = navController) - FriendsRoute( onError = onError, - viewModel = viewModel, onPhotoClicked = onPhotoClicked, - onMessageClicked = onMessageClicked + onMessageClicked = onMessageClicked, + onScrolledToTop = onScrolledToTop ) } } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt index 78e40aff..debe5891 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt @@ -36,7 +36,7 @@ fun FriendItem( friend: UiFriend, maxLines: Int, onPhotoClicked: (url: String) -> Unit, - onMessageClicked: (userId: Int) -> Unit + onMessageClicked: (userid: Long) -> Unit ) { Row( modifier = modifier.fillMaxWidth(), diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsList.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsList.kt index c63075ee..35762f62 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsList.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsList.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.meloda.fast.friends.model.FriendsScreenState import dev.meloda.fast.ui.model.api.UiFriend +import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.util.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -36,7 +37,7 @@ fun FriendsList( maxLines: Int, padding: PaddingValues, onPhotoClicked: (url: String) -> Unit, - onMessageClicked: (userId: Int) -> Unit, + onMessageClicked: (userid: Long) -> Unit, setCanScrollBackward: (Boolean) -> Unit ) { LaunchedEffect(listState) { @@ -46,8 +47,6 @@ fun FriendsList( val coroutineScope = rememberCoroutineScope() - val friends = uiFriends.toList() - LazyColumn( modifier = modifier, state = listState @@ -58,7 +57,7 @@ fun FriendsList( } items( - items = friends, + items = uiFriends.toList(), key = UiFriend::userId, ) { friend -> FriendItem( @@ -100,6 +99,7 @@ fun FriendsList( } Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(LocalBottomPadding.current)) } } } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt index d3560226..44da2f00 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt @@ -1,81 +1,72 @@ package dev.meloda.fast.friends.presentation -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding 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.lazy.rememberLazyListState -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.imageLoader import coil.request.ImageRequest -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.friends.FriendsViewModel import dev.meloda.fast.friends.FriendsViewModelImpl -import dev.meloda.fast.friends.model.FriendsScreenState -import dev.meloda.fast.model.BaseError -import dev.meloda.fast.ui.components.ErrorView +import dev.meloda.fast.friends.OnlineFriendsViewModelImpl +import dev.meloda.fast.friends.navigation.Friends +import dev.meloda.fast.ui.R import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.NoItemsView -import dev.meloda.fast.ui.model.TabItem +import dev.meloda.fast.ui.components.VkErrorView 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.ImmutableList import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import org.koin.androidx.compose.koinViewModel -import dev.meloda.fast.ui.R as UiR +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun FriendsRoute( - onError: (BaseError) -> Unit, - onPhotoClicked: (url: String) -> Unit, - onMessageClicked: (userId: Int) -> Unit, - viewModel: FriendsViewModel = koinViewModel() +fun FriendsScreen( + modifier: Modifier = Modifier, + orderType: String, + padding: PaddingValues, + tabIndex: Int, + onSessionExpiredLogOutButtonClicked: () -> Unit = {}, + onPhotoClicked: (url: String) -> Unit = {}, + onMessageClicked: (userid: Long) -> Unit = {}, + setCanScrollBackward: (Boolean) -> Unit = {}, + onScrolledToTop: () -> Unit = {} ) { - val context = LocalContext.current + val context: Context = LocalContext.current + val viewModel: FriendsViewModel = + if (tabIndex == 0) { + koinViewModel(viewModelStoreOwner = context as AppCompatActivity) + } else { + koinViewModel(viewModelStoreOwner = context as AppCompatActivity) + } + + LaunchedEffect(orderType) { + viewModel.onOrderTypeChanged(orderType) + } val screenState by viewModel.screenState.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle() @@ -92,43 +83,6 @@ fun FriendsRoute( } } - FriendsScreen( - screenState = screenState, - baseError = baseError, - canPaginate = canPaginate, - onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) }, - onPaginationConditionsMet = viewModel::onPaginationConditionsMet, - onRefresh = viewModel::onRefresh, - onPhotoClicked = onPhotoClicked, - onMessageClicked = onMessageClicked, - setSelectedTabIndex = viewModel::onTabSelected, - setScrollIndex = viewModel::setScrollIndex, - setScrollOffset = viewModel::setScrollOffset, - setScrollIndexOnline = viewModel::setScrollIndexOnline, - setScrollOffsetOnline = viewModel::setScrollOffsetOnline - ) -} - -@OptIn( - ExperimentalMaterial3Api::class, - ExperimentalHazeMaterialsApi::class -) -@Composable -fun FriendsScreen( - screenState: FriendsScreenState = FriendsScreenState.EMPTY, - baseError: BaseError? = null, - canPaginate: Boolean = false, - onSessionExpiredLogOutButtonClicked: () -> Unit = {}, - onPaginationConditionsMet: () -> Unit = {}, - onRefresh: () -> Unit = {}, - onPhotoClicked: (url: String) -> Unit = {}, - onMessageClicked: (userId: Int) -> Unit = {}, - setSelectedTabIndex: (Int) -> Unit = {}, - setScrollIndex: (Int) -> Unit = {}, - setScrollOffset: (Int) -> Unit = {}, - setScrollIndexOnline: (Int) -> Unit = {}, - setScrollOffsetOnline: (Int) -> Unit = {} -) { val currentTheme = LocalThemeConfig.current val maxLines by remember { @@ -141,33 +95,28 @@ fun FriendsScreen( initialFirstVisibleItemIndex = screenState.scrollIndex, initialFirstVisibleItemScrollOffset = screenState.scrollOffset ) - val listStateOnline = rememberLazyListState( - initialFirstVisibleItemIndex = screenState.scrollIndexOnline, - initialFirstVisibleItemScrollOffset = screenState.scrollOffsetOnline - ) + + val scrollToTop = LocalReselectedTab.current[Friends] ?: false + LaunchedEffect(scrollToTop) { + if (scrollToTop) { + if (listState.firstVisibleItemIndex > 14) { + listState.scrollToItem(14) + } + listState.animateScrollToItem(0) + onScrolledToTop() + } + } LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex } - .debounce(500L) - .collectLatest(setScrollIndex) + .debounce(250L) + .collectLatest(viewModel::setScrollIndex) } LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemScrollOffset } - .debounce(500L) - .collectLatest(setScrollOffset) - } - - LaunchedEffect(listStateOnline) { - snapshotFlow { listStateOnline.firstVisibleItemIndex } - .debounce(500L) - .collectLatest(setScrollIndexOnline) - } - - LaunchedEffect(listStateOnline) { - snapshotFlow { listStateOnline.firstVisibleItemScrollOffset } - .debounce(500L) - .collectLatest(setScrollOffsetOnline) + .debounce(250L) + .collectLatest(viewModel::setScrollOffset) } val paginationConditionMet by remember(canPaginate, listState) { @@ -180,209 +129,64 @@ fun FriendsScreen( LaunchedEffect(paginationConditionMet) { if (paginationConditionMet && !screenState.isPaginating) { - onPaginationConditionsMet() + viewModel.onPaginationConditionsMet() } } val hazeState = LocalHazeState.current - var canScrollBackward by remember { - mutableStateOf(false) + baseError?.let { error -> + VkErrorView(baseError = error) + return } - val topBarContainerColorAlpha by animateFloatAsState( - targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f, - label = "toolbarColorAlpha", - animationSpec = tween( - durationMillis = 200, - easing = FastOutLinearInEasing - ) - ) + when { + screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader() - val topBarContainerColor by animateColorAsState( - targetValue = if (currentTheme.enableBlur || !canScrollBackward) - MaterialTheme.colorScheme.surface - else - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), - label = "toolbarColorAlpha", - animationSpec = tween( - durationMillis = 200, - easing = FastOutLinearInEasing - ) - ) + else -> { + val pullToRefreshState = rememberPullToRefreshState() - val tabItems = remember { - listOf( - TabItem( - titleResId = UiR.string.title_friends_all, - unselectedIconResId = null, - selectedIconResId = null - ), - TabItem( - titleResId = UiR.string.title_friends_online, - unselectedIconResId = null, - selectedIconResId = null - ) - ) - } - - Scaffold( - modifier = Modifier.fillMaxSize(), - contentWindowInsets = WindowInsets.statusBars, - topBar = { - Column( - modifier = Modifier - .then( - if (currentTheme.enableBlur) { - Modifier.hazeEffect( - state = hazeState, - style = HazeMaterials.thick() - ) - } else { - Modifier - } + PullToRefreshBox( + modifier = modifier + .fillMaxSize() + .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) + .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)) + .padding(bottom = padding.calculateBottomPadding()), + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + onRefresh = viewModel::onRefresh, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = padding.calculateTopPadding()), ) - .background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha)) - .fillMaxWidth() + } ) { - TopAppBar( - title = { - Text( - text = stringResource(id = UiR.string.title_friends), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.headlineSmall - ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ), - modifier = Modifier.fillMaxWidth() + FriendsList( + modifier = if (currentTheme.enableBlur) { + Modifier.hazeSource(state = hazeState) + } else { + Modifier + }.fillMaxSize(), + screenState = screenState, + uiFriends = ImmutableList.copyOf(screenState.friends), + listState = listState, + maxLines = maxLines, + padding = padding, + onPhotoClicked = onPhotoClicked, + onMessageClicked = onMessageClicked, + setCanScrollBackward = setCanScrollBackward ) - PrimaryTabRow( - selectedTabIndex = screenState.selectedTabIndex, - modifier = Modifier, - containerColor = Color.Transparent - ) { - tabItems.forEachIndexed { index, item -> - Tab( - selected = index == screenState.selectedTabIndex, - onClick = { - if (screenState.selectedTabIndex != index) { - setSelectedTabIndex(index) - } - }, - text = { - item.titleResId?.let { resId -> - Text(text = stringResource(id = resId)) - } - } - ) - } - } - } - } - ) { padding -> - when { - baseError != null -> { - when (baseError) { - is BaseError.SessionExpired -> { - ErrorView( - text = stringResource(UiR.string.session_expired), - buttonText = stringResource(UiR.string.action_log_out), - onButtonClick = onSessionExpiredLogOutButtonClicked - ) - } - is BaseError.SimpleError -> { - ErrorView( - text = baseError.message, - buttonText = stringResource(UiR.string.try_again), - onButtonClick = onRefresh - ) - } - } - } - - screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader() - - else -> { - val pagerState = rememberPagerState( - initialPage = screenState.selectedTabIndex - ) { - tabItems.size - } - - LaunchedEffect(screenState.selectedTabIndex) { - pagerState.animateScrollToPage(screenState.selectedTabIndex) - } - - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.currentPage } - .collect(setSelectedTabIndex) - } - - val pullToRefreshState = rememberPullToRefreshState() - - Box(modifier = Modifier.fillMaxSize()) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { index -> - PullToRefreshBox( - modifier = Modifier - .fillMaxSize() - .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) - .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)) - .padding(bottom = padding.calculateBottomPadding()), - state = pullToRefreshState, - isRefreshing = screenState.isLoading, - onRefresh = onRefresh, - indicator = { - PullToRefreshDefaults.Indicator( - state = pullToRefreshState, - isRefreshing = screenState.isLoading, - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = padding.calculateTopPadding()), - ) - } - ) { - val friendsToDisplay = remember(index) { - if (index == 0) { - screenState.friends - } else { - screenState.onlineFriends - } - } - - FriendsList( - modifier = if (currentTheme.enableBlur) { - Modifier.hazeSource(state = hazeState) - } else { - Modifier - }.fillMaxSize(), - screenState = screenState, - uiFriends = ImmutableList.copyOf(friendsToDisplay), - listState = if (index == 0) listState else listStateOnline, - maxLines = maxLines, - padding = padding, - onPhotoClicked = onPhotoClicked, - onMessageClicked = onMessageClicked, - setCanScrollBackward = { can -> - canScrollBackward = can - } - ) - - if (friendsToDisplay.isEmpty()) { - NoItemsView( - customText = if (index == 1) stringResource(UiR.string.no_online_friends) else null, - buttonText = stringResource(UiR.string.action_refresh), - onButtonClick = onRefresh - ) - } - } - } + if (screenState.friends.isEmpty()) { + NoItemsView( + customText = if (tabIndex == 1) stringResource(R.string.no_online_friends) else null, + buttonText = stringResource(R.string.action_refresh), + onButtonClick = viewModel::onRefresh + ) } } } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/RootFriendsScreen.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/RootFriendsScreen.kt new file mode 100644 index 00000000..7ba8e209 --- /dev/null +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/RootFriendsScreen.kt @@ -0,0 +1,245 @@ +package dev.meloda.fast.friends.presentation + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.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 +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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 +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +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.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 kotlinx.coroutines.launch +import dev.meloda.fast.ui.R as UiR + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) +@Composable +fun FriendsRoute( + onError: (BaseError) -> Unit, + onPhotoClicked: (url: String) -> Unit, + onMessageClicked: (userid: Long) -> Unit, + onScrolledToTop: () -> Unit +) { + val scope = rememberCoroutineScope() + val currentTheme = LocalThemeConfig.current + val hazeState = LocalHazeState.current + + var canScrollBackward by remember { + mutableStateOf(false) + } + + val topBarContainerColorAlpha by animateFloatAsState( + targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f, + label = "toolbarColorAlpha", + animationSpec = tween( + durationMillis = 200, + easing = FastOutLinearInEasing + ) + ) + + val topBarContainerColor by animateColorAsState( + targetValue = if (currentTheme.enableBlur || !canScrollBackward) + MaterialTheme.colorScheme.surface + else + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + label = "toolbarColorAlpha", + animationSpec = tween( + durationMillis = 200, + easing = FastOutLinearInEasing + ) + ) + + val tabItems = remember { + listOf( + TabItem( + titleResId = R.string.title_friends_all, + unselectedIconResId = null, + selectedIconResId = null + ), + TabItem( + titleResId = R.string.title_friends_online, + unselectedIconResId = null, + selectedIconResId = null + ) + ) + } + + val pagerState = rememberPagerState(pageCount = tabItems::size) + + val selectedTabIndex by remember { + derivedStateOf { pagerState.currentPage } + } + + var orderType: String by remember { mutableStateOf("hints") } + + var showOrderDialog by remember { mutableStateOf(false) } + + val orderPriority = stringResource(UiR.string.friends_order_priority) + val orderName = stringResource(UiR.string.friends_order_name) + val orderRandom = stringResource(UiR.string.friends_order_random) + val orderMobile = stringResource(UiR.string.friends_order_mobile) + val orderSmart = stringResource(UiR.string.friends_order_smart) + + val orderTitleItems = remember { + ImmutableList.of( + orderPriority, + orderName, + orderRandom, + orderMobile, + orderSmart + ) + } + + val orderItems = remember { + listOf("hints", "name", "random", "mobile", "smart") + } + + var selectedIndex by remember { + mutableIntStateOf(0) + } + + if (showOrderDialog) { + MaterialDialog( + onDismissRequest = { showOrderDialog = false }, + confirmText = stringResource(R.string.ok), + confirmAction = { + orderType = orderItems[selectedIndex] + }, + cancelText = stringResource(R.string.cancel), + selectionType = SelectionType.Single, + items = orderTitleItems, + preSelectedItems = ImmutableList.of(selectedIndex), + onItemClick = { + selectedIndex = it + }, + title = stringResource(UiR.string.friends_order_by_title), + actionInvokeDismiss = ActionInvokeDismiss.Always + ) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + contentWindowInsets = WindowInsets.statusBars, + topBar = { + Column( + modifier = Modifier + .then( + if (currentTheme.enableBlur) { + Modifier.hazeEffect( + state = hazeState, + style = HazeMaterials.thick() + ) + } else { + Modifier + } + ) + .background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha)) + .fillMaxWidth() + ) { + TopAppBar( + title = { + Text( + text = stringResource(id = R.string.title_friends), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.headlineSmall + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ), + modifier = Modifier.fillMaxWidth(), + actions = { + IconButton( + onClick = { + showOrderDialog = true + } + ) { + Icon( + painter = painterResource(UiR.drawable.round_filter_list_24), + contentDescription = null + ) + } + } + ) + PrimaryTabRow( + selectedTabIndex = selectedTabIndex, + modifier = Modifier, + containerColor = Color.Transparent + ) { + tabItems.forEachIndexed { index, item -> + Tab( + selected = index == selectedTabIndex, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { + item.titleResId?.let { resId -> + Text(text = stringResource(id = resId)) + } + } + ) + } + } + } + } + ) { padding -> + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { index -> + FriendsScreen( + orderType = orderType, + padding = padding, + tabIndex = index, + onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) }, + onPhotoClicked = onPhotoClicked, + onMessageClicked = onMessageClicked, + setCanScrollBackward = { canScrollBackward = it }, + onScrolledToTop = onScrolledToTop + ) + } + } +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt index 345830e5..56759c9b 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt @@ -1,18 +1,27 @@ package dev.meloda.fast.messageshistory +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.Bundle import android.util.Log +import android.widget.Toast import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.conena.nanokt.collections.indexOfFirstOrNull -import com.conena.nanokt.collections.indexOfOrNull import com.conena.nanokt.text.isEmptyOrBlank import com.conena.nanokt.text.isNotEmptyOrBlank +import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.extensions.listenValue +import dev.meloda.fast.common.extensions.orDots import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.provider.ResourceProvider +import dev.meloda.fast.data.State import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.processState @@ -23,36 +32,57 @@ import dev.meloda.fast.domain.LoadConversationsByIdUseCase import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.messageshistory.model.ActionMode +import dev.meloda.fast.messageshistory.model.MessageDialog +import dev.meloda.fast.messageshistory.model.MessageNavigation +import dev.meloda.fast.messageshistory.model.MessageOption import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState -import dev.meloda.fast.messageshistory.model.SendingStatus import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.navigation.MessagesHistory import dev.meloda.fast.messageshistory.util.asPresentation import dev.meloda.fast.messageshistory.util.extractAvatar import dev.meloda.fast.messageshistory.util.extractTitle -import dev.meloda.fast.messageshistory.util.findMessageById import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkMessage +import dev.meloda.fast.network.VkErrorCode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlin.math.abs import kotlin.random.Random +import dev.meloda.fast.ui.R as UiR interface MessagesHistoryViewModel { val screenState: StateFlow + val navigation: StateFlow + val messages: StateFlow> + val uiMessages: StateFlow> + val dialog: StateFlow + val selectedMessages: StateFlow> + + val isNeedToScrollToIndex: StateFlow val baseError: StateFlow val imagesToPreload: StateFlow> val currentOffset: StateFlow - val canPaginate: StateFlow + fun onNavigationConsumed() + + fun onTopBarClicked() + + fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle) + fun onDialogDismissed(dialog: MessageDialog) + fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) + + fun onScrolledToIndex() + + fun onCloseButtonClicked() fun onRefresh() fun onAttachmentButtonClicked() fun onMessageInputChanged(newText: TextFieldValue) @@ -60,9 +90,18 @@ interface MessagesHistoryViewModel { fun onActionButtonClicked() fun onPaginationConditionsMet() + + fun onMessageClicked(messageId: Long) + fun onMessageLongClicked(messageId: Long) + + fun onPinnedMessageClicked(messageId: Long) + fun onUnpinMessageClicked() + + fun onDeleteSelectedMessagesClicked() } class MessagesHistoryViewModelImpl( + private val applicationContext: Context, private val messagesUseCase: MessagesUseCase, private val conversationsUseCase: ConversationsUseCase, private val resourceProvider: ResourceProvider, @@ -73,6 +112,11 @@ class MessagesHistoryViewModelImpl( ) : MessagesHistoryViewModel, ViewModel() { override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY) + override val navigation = MutableStateFlow(null) + override val dialog = MutableStateFlow(null) + override val selectedMessages = MutableStateFlow>(emptyList()) + + override val isNeedToScrollToIndex = MutableStateFlow(null) override val baseError = MutableStateFlow(null) override val imagesToPreload = MutableStateFlow>(emptyList()) @@ -81,27 +125,209 @@ class MessagesHistoryViewModelImpl( override val canPaginate = MutableStateFlow(false) - private val messages = MutableStateFlow>(emptyList()) + override val messages = MutableStateFlow>(emptyList()) + override val uiMessages = MutableStateFlow>(emptyList()) private var lastMessageText: String? = null private val sendingMessages: MutableList = mutableListOf() + private val failedMessages: MutableList = mutableListOf() init { val arguments = MessagesHistory.from(savedStateHandle).arguments screenState.setValue { old -> old.copy(conversationId = arguments.conversationId) } + + loadConversation() loadMessagesHistory() 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) - userSettings.showTimeInActionMessages.listenValue( - viewModelScope, - ::toggleShowTimeInActionMessages - ) + userSettings.showTimeInActionMessages.listenValue(viewModelScope) { + syncUiMessages() + } + } + + override fun onNavigationConsumed() { + navigation.setValue { null } + } + + override fun onTopBarClicked() { + val cmId = messages.value.firstOrNull()?.cmId ?: return + + navigation.setValue { + MessageNavigation.ChatMaterials( + peerId = screenState.value.conversationId, + cmId = cmId + ) + } + } + + override fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle) { + onDialogDismissed(dialog) + + when (dialog) { + is MessageDialog.MessageOptions -> Unit + + is MessageDialog.MessageDelete -> { + val deleteForEveryone = bundle.getBoolean("everyone") + + if (dialog.message.id <= 0) { + val newMessages = messages.value.toMutableList() + newMessages.remove(dialog.message) + messages.setValue { newMessages } + syncUiMessages() + return + } + + deleteMessage( + messageIds = listOf(dialog.message.id), + deleteForAll = deleteForEveryone + ) + } + + is MessageDialog.MessagesDelete -> { + val deleteForEveryone = bundle.getBoolean("everyone") + + val failedMessages = dialog.messages.filter { it.id <= 0 } + val messageIdsToDelete = + dialog.messages + .filter { it.id > 0 } + .map(VkMessage::id) + + deleteMessage( + messageIds = messageIdsToDelete, + deleteForAll = deleteForEveryone, + onSuccess = { + val newMessages = messages.value.toMutableList() + newMessages.removeAll(failedMessages) + messages.setValue { newMessages } + selectedMessages.setValue { emptyList() } + syncUiMessages() + } + ) + } + + is MessageDialog.MessagePin -> { + pinMessage(dialog.messageId) + } + + is MessageDialog.MessageUnpin -> { + unpinMessage(dialog.messageId) + } + + is MessageDialog.MessageMarkImportance -> { + markAsImportant( + messageIds = listOf(dialog.message.id), + important = dialog.isImportant + ) + } + + is MessageDialog.MessageSpam -> { + if (dialog.isSpam) { + deleteMessage( + messageIds = listOf(dialog.message.id), + spam = true + ) + } else { + // TODO: 29-Mar-25, Danil Nikolaev: report as not spam + } + } + } + } + + override fun onDialogDismissed(dialog: MessageDialog) { + this.dialog.setValue { null } + } + + override fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) { + when (dialog) { + is MessageDialog.MessageOptions -> { + when (val option = bundle.getParcelable("option")) { + null -> Unit + + MessageOption.Retry -> { + // TODO: 28-Mar-25, Danil Nikolaev: retry sending + } + + MessageOption.Reply -> {} + MessageOption.ForwardHere -> {} + MessageOption.Forward -> {} + + MessageOption.Pin -> { + this.dialog.setValue { + MessageDialog.MessagePin(dialog.message.id) + } + } + + MessageOption.Unpin -> { + this.dialog.setValue { + MessageDialog.MessageUnpin(dialog.message.id) + } + } + + MessageOption.Read -> { + readMessage(dialog.message) + } + + MessageOption.Copy -> { + copyMessage(dialog.message) + } + + MessageOption.MarkAsImportant, + MessageOption.UnmarkAsImportant -> { + this.dialog.setValue { + MessageDialog.MessageMarkImportance( + message = dialog.message, + isImportant = option is MessageOption.MarkAsImportant + ) + } + } + + MessageOption.MarkAsSpam, + MessageOption.UnmarkAsSpam -> { + this.dialog.setValue { + MessageDialog.MessageSpam( + message = dialog.message, + isSpam = option is MessageOption.MarkAsSpam + ) + } + } + + MessageOption.Edit -> {} + + MessageOption.Delete -> { + this.dialog.setValue { + MessageDialog.MessageDelete(dialog.message) + } + } + } + } + + is MessageDialog.MessageDelete -> Unit + is MessageDialog.MessageUnpin -> Unit + is MessageDialog.MessageMarkImportance -> Unit + is MessageDialog.MessageSpam -> Unit + is MessageDialog.MessagePin -> Unit + is MessageDialog.MessagesDelete -> Unit + } + } + + override fun onScrolledToIndex() { + isNeedToScrollToIndex.setValue { null } + } + + override fun onCloseButtonClicked() { + selectedMessages.setValue { emptyList() } + syncUiMessages() } override fun onRefresh() { @@ -154,106 +380,125 @@ class MessagesHistoryViewModelImpl( } override fun onPaginationConditionsMet() { - currentOffset.update { screenState.value.messages.size } + currentOffset.update { messages.value.size } loadMessagesHistory() } + override fun onMessageClicked(messageId: Long) { + val currentMessage = messages.value.firstOrNull { it.id == messageId } ?: return + + if (selectedMessages.value.isNotEmpty()) { + val isSelected = selectedMessages.value.contains(currentMessage) + + selectedMessages.setValue { old -> + old.toMutableList().also { + if (isSelected) { + it.remove(currentMessage) + } else { + it.add(currentMessage) + } + } + } + syncUiMessages() + } else { + dialog.setValue { + MessageDialog.MessageOptions(currentMessage) + } + } + } + + override fun onMessageLongClicked(messageId: Long) { + val currentMessage = messages.value.firstOrNull { it.id == messageId } ?: return + + val isSelected = selectedMessages.value.contains(currentMessage) + if (isSelected) return + + selectedMessages.setValue { old -> + old.toMutableList().also { + it.add(currentMessage) + } + } + syncUiMessages() + } + + override fun onPinnedMessageClicked(messageId: Long) { + val uiMessages = uiMessages.value + val messageIndex = uiMessages.indexOfFirstOrNull { + it is UiItem.Message && it.id == messageId + } + + if (messageIndex == null) { // сообщения нет в списке + // pizdets + } else { + isNeedToScrollToIndex.setValue { messageIndex } + } + } + + override fun onUnpinMessageClicked() { + val pinnedMessageId = screenState.value.pinnedMessage?.id ?: return + dialog.setValue { + MessageDialog.MessageUnpin(pinnedMessageId) + } + } + + override fun onDeleteSelectedMessagesClicked() { + dialog.setValue { + MessageDialog.MessagesDelete(selectedMessages.value) + } + } + private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { val message = event.message Log.d("MessagesHistoryViewModel", "handleNewMessage: $message") if (message.peerId != screenState.value.conversationId) return - if (screenState.value.messages.findMessageById(message.id) != null) return + if (messages.value.indexOfFirstOrNull { it.id == message.id } != null) return val randomIds = messages.value.map(VkMessage::randomId) - if (message.randomId != 0 && message.randomId in randomIds) return + if (message.randomId != 0L && message.randomId in randomIds) return - val newMessages = screenState.value.messages.toMutableList() - val prevMessage = messages.value.firstOrNull() + val newMessages = messages.value.toMutableList() + newMessages.add(0, message) - messages.setValue { old -> - old.toMutableList().also { it.add(0, message) } - } + messages.setValue { newMessages } - val newMessage = message.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = prevMessage, - nextMessage = null, - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = screenState.value.conversation, - ) - newMessages.add(0, newMessage) - - prevMessage?.let { prev -> - newMessages[1] = prev.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = prevMessage, - nextMessage = messages.value.first(), - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = screenState.value.conversation - ) - } - - screenState.setValue { old -> old.copy(messages = newMessages) } + syncUiMessages() } private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) { val message = event.message if (message.peerId != screenState.value.conversationId) return - screenState.value.messages - .indexOfFirstOrNull { it.id == message.id } - ?.let { index -> - val newMessage = message.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = messages.value.getOrNull(index + 1), - nextMessage = messages.value.getOrNull(index - 1), - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = screenState.value.conversation - ) - - val newMessages = screenState.value.messages.toMutableList() - newMessages[index] = newMessage - - screenState.setValue { old -> old.copy(messages = newMessages) } - } + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.id == message.id } + if (index == null) { // сообщения нет в списке + // pizdets + } else { + newMessages[index] = message + messages.setValue { newMessages } + syncUiMessages() + } } private fun handleReadIncomingEvent(event: LongPollParsedEvent.IncomingMessageRead) { if (event.peerId != screenState.value.conversationId) return val messages = messages.value - val messageIndex = - messages.indexOfFirstOrNull { it.id == event.messageId } + val index = messages.indexOfFirstOrNull { it.cmId == event.cmId } - if (messageIndex == null) { // диалога нет в списке + if (index == null) { // диалога нет в списке // pizdets } else { val newConversation = screenState.value.conversation.copy( - inRead = event.messageId + inReadCmId = event.cmId ) - val uiMessages = messages.mapIndexed { index, item -> - item.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = messages.getOrNull(index + 1), - nextMessage = messages.getOrNull(index - 1), - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = newConversation - ) + screenState.setValue { old -> + old.copy(conversation = newConversation) } - screenState.setValue { old -> - old.copy( - conversation = newConversation, - messages = uiMessages, - ) - } + syncUiMessages() } } @@ -261,33 +506,165 @@ class MessagesHistoryViewModelImpl( if (event.peerId != screenState.value.conversationId) return val messages = messages.value - val messageIndex = - messages.indexOfFirstOrNull { it.id == event.messageId } + val index = messages.indexOfFirstOrNull { it.cmId == event.cmId } - if (messageIndex == null) { // диалога нет в списке + if (index == null) { // сообщения нет в списке // pizdets } else { val newConversation = screenState.value.conversation.copy( - outRead = event.messageId + outReadCmId = event.cmId ) - val uiMessages = messages.mapIndexed { index, item -> - item.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = messages.getOrNull(index + 1), - nextMessage = messages.getOrNull(index - 1), - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = newConversation - ) + screenState.setValue { old -> + old.copy(conversation = newConversation) } + syncUiMessages() + } + } + + private fun handleMessageDeleted(event: LongPollParsedEvent.MessageDeleted) { + if (event.peerId != screenState.value.conversationId) return + + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId } + + if (index == null) { // сообщения нет в списке + // pizdets + } else { + newMessages.removeAt(index) + messages.setValue { newMessages } + syncUiMessages() + } + } + + private fun handleMessageRestored(event: LongPollParsedEvent.MessageRestored) { + if (event.message.peerId != screenState.value.conversationId) return + + val newMessages = messages.value.toMutableList() + val minDate = newMessages.minOf(VkMessage::date) + + if (event.message.date < minDate) { // сообщения не должно быть в списке + // pizdets + return + } + + newMessages.add(event.message) + messages.setValue { newMessages.sorted() } + syncUiMessages() + } + + private fun handleMessageMarkedAsImportant(event: LongPollParsedEvent.MessageMarkedAsImportant) { + if (event.peerId != screenState.value.conversationId) return + + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId } + + if (index == null) { // сообщения нет в списке + // pizdets + } else { + val newMessage = newMessages[index].copy(isImportant = event.marked) + newMessages[index] = newMessage + messages.setValue { newMessages } + syncUiMessages() + } + } + + private fun handleMessageMarkedAsSpam(event: LongPollParsedEvent.MessageMarkedAsSpam) { + if (event.peerId != screenState.value.conversationId) return + + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId } + + if (index == null) { // сообщения нет в списке + // pizdets + } else { + newMessages.removeAt(index) + messages.setValue { newMessages } + syncUiMessages() + } + } + + private fun handleMessageMarkedAsNotSpam(event: LongPollParsedEvent.MessageMarkedAsNotSpam) { + if (event.message.peerId != screenState.value.conversationId) return + + val newMessages = messages.value.toMutableList() + val maxDate = newMessages.maxOf(VkMessage::date) + val minDate = newMessages.minOf(VkMessage::date) + + if (event.message.date !in minDate..maxDate) return + + newMessages.add(event.message) + messages.setValue { newMessages.sorted() } + syncUiMessages() + } + + private fun loadConversation() { + Log.d("MessagesHistoryViewModelImpl", "loadConversation()") + + loadConversationsByIdUseCase( + peerIds = listOf(screenState.value.conversationId), + extended = true, + fields = VkConstants.ALL_FIELDS + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { response -> + val conversation = response.firstOrNull() ?: return@listenValue + val title = conversation.extractTitle( + useContactName = AppSettings.General.useContactNames, + resources = resourceProvider.resources + ) + val avatar = conversation.extractAvatar() + + screenState.setValue { old -> + old.copy( + conversation = conversation, + title = title, + avatar = avatar + ) + } + + conversation.pinnedMessage?.let(::handlePinnedMessage) + } + ) + } + } + + private fun handlePinnedMessage(pinnedMessage: VkMessage?) { + if (pinnedMessage == null) { screenState.setValue { old -> old.copy( - conversation = newConversation, - messages = uiMessages, + pinnedMessage = null, + conversation = old.conversation.copy( + pinnedMessage = null, + pinnedMessageId = null + ), + pinnedSummary = null, + pinnedTitle = null ) } + return + } + + val pinnedUser = VkMemoryCache.getUser(pinnedMessage.fromId) + val pinnedGroup = VkMemoryCache.getGroup(abs(pinnedMessage.fromId)) + val pinnedTitle = pinnedUser?.fullName ?: pinnedGroup?.name + + val pinnedSummary = buildAnnotatedString { + pinnedMessage.text?.let(::append) ?: append("...") + } + + screenState.setValue { old -> + old.copy( + pinnedMessage = pinnedMessage, + conversation = old.conversation.copy( + pinnedMessage = pinnedMessage, + pinnedMessageId = pinnedMessage.id + ), + pinnedSummary = pinnedSummary, + pinnedTitle = pinnedTitle.orDots() + ) } } @@ -300,7 +677,7 @@ class MessagesHistoryViewModelImpl( offset = offset, ).listenValue(viewModelScope) { state -> state.processState( - error = { error -> }, + error = ::handleError, success = { response -> val messages = response.messages val fullMessages = if (offset == 0) { @@ -318,42 +695,16 @@ class MessagesHistoryViewModelImpl( messagesUseCase.storeMessages(messages) conversationsUseCase.storeConversations(conversations) - val itemsCountSufficient = messages.size == MESSAGES_LOAD_COUNT val paginationExhausted = !itemsCountSufficient && - screenState.value.messages.isNotEmpty() - var newState = screenState.value.copy( - isPaginationExhausted = paginationExhausted, - ) - - conversations - .firstOrNull { it.id == screenState.value.conversationId } - ?.let { conversation -> - screenState.setValue { old -> old.copy(conversation = conversation) } - newState = newState.copy( - title = conversation.extractTitle( - useContactName = AppSettings.General.useContactNames, - resources = resourceProvider.resources - ), - avatar = conversation.extractAvatar(), - conversation = conversation - ) - } - - val loadedMessages = fullMessages.mapIndexed { index, message -> - message.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = messages.getOrNull(index + 1), - nextMessage = messages.getOrNull(index - 1), - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = screenState.value.conversation - ) + this.messages.value.isNotEmpty() + screenState.setValue { old -> + old.copy(isPaginationExhausted = paginationExhausted) } this.messages.emit(fullMessages) - screenState.setValue { newState.copy(messages = loadedMessages) } + syncUiMessages() canPaginate.setValue { itemsCountSufficient } } ) @@ -367,6 +718,44 @@ class MessagesHistoryViewModelImpl( } } + private fun handleError(error: State.Error) { + when (error) { + is State.Error.ApiError -> { + when (error.errorCode) { + VkErrorCode.USER_AUTHORIZATION_FAILED -> { + baseError.setValue { BaseError.SessionExpired } + } + + else -> { + baseError.setValue { + BaseError.SimpleError(message = error.errorMessage) + } + } + } + } + + State.Error.ConnectionError -> { + baseError.setValue { + BaseError.SimpleError(message = "Connection error") + } + } + + State.Error.InternalError -> { + baseError.setValue { + BaseError.SimpleError(message = "Internal error") + } + } + + State.Error.UnknownError -> { + baseError.setValue { + BaseError.SimpleError(message = "Unknown error") + } + } + + else -> Unit + } + } + private fun List.sorted(): List { return sortedWith { m1, m2 -> val dateDiff = m2.date - m1.date @@ -374,7 +763,7 @@ class MessagesHistoryViewModelImpl( dateDiff } else { val idDiff = m2.id - m1.id - idDiff + idDiff.toInt() } } } @@ -383,21 +772,21 @@ class MessagesHistoryViewModelImpl( lastMessageText = screenState.value.message.text val newMessage = VkMessage( - id = -1 - sendingMessages.size, - conversationMessageId = -1, + id = -1L - sendingMessages.size, + cmId = -1L - sendingMessages.size, text = lastMessageText, isOut = true, peerId = screenState.value.conversationId, fromId = UserConfig.userId, date = (System.currentTimeMillis() / 1000).toInt(), - randomId = Random.nextInt(), + randomId = Random.nextInt().toLong(), action = null, actionMemberId = null, actionText = null, actionConversationMessageId = null, actionMessage = null, updateTime = null, - important = false, + isImportant = false, forwards = null, attachments = null, replyMessage = null, @@ -405,26 +794,22 @@ class MessagesHistoryViewModelImpl( user = VkMemoryCache.getUser(UserConfig.userId), group = null, actionUser = null, - actionGroup = null + actionGroup = null, + isPinned = false, + isSpam = false, + pinnedAt = null, + + // TODO: 04-Apr-25, Danil Nikolaev: implement + formatData = null, ) sendingMessages += newMessage - - val newMessages = screenState.value.messages.toMutableList() - val newUiMessage = newMessage.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = messages.value.firstOrNull(), - nextMessage = null, - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = screenState.value.conversation - ) - newMessages.add(0, newUiMessage) + messages.setValue { old -> listOf(newMessage).plus(old) } + syncUiMessages() screenState.setValue { old -> old.copy( message = TextFieldValue(), - actionMode = ActionMode.Record, - messages = listOf(newUiMessage).plus(old.messages) + actionMode = ActionMode.Record ) } @@ -436,123 +821,137 @@ class MessagesHistoryViewModelImpl( attachments = null ).listenValue(viewModelScope) { state -> state.processState( + any = { sendingMessages.remove(newMessage) }, error = { error -> - sendingMessages -= newMessage + val failedId = -500_000L - failedMessages.size + val newFailedMessage = newMessage.copy(id = failedId) + failedMessages += newFailedMessage - val uiMessages = screenState.value.messages.toMutableList() - - uiMessages.indexOfOrNull(newUiMessage)?.let { index -> - (uiMessages[index] as? UiItem.Message)?.let { message -> - uiMessages[index] = message.copy(sendingStatus = SendingStatus.FAILED) - } - } - - screenState.setValue { old -> old.copy(messages = uiMessages) } + val newMessages = messages.value.toMutableList() + newMessages[newMessages.indexOf(newMessage)] = newFailedMessage + messages.setValue { newMessages } + syncUiMessages() }, - success = { messageId -> - sendingMessages -= newMessage - - val uiMessages = screenState.value.messages.toMutableList() - messages.setValue { old -> - listOf(newMessage.copy(id = messageId)).plus(old) - } - - uiMessages.indexOfOrNull(newUiMessage)?.let { index -> - (uiMessages[index] as? UiItem.Message)?.let { message -> - uiMessages[index] = message - .copy( - id = messageId, - sendingStatus = SendingStatus.SENT - ) - .copy(isRead = newMessage.isRead(screenState.value.conversation)) - } - } - - screenState.setValue { old -> old.copy(messages = uiMessages) } + success = { response -> + val newMessages = messages.value.toMutableList() + newMessages[newMessages.indexOf(newMessage)] = newMessage.copy( + id = response.messageId, + cmId = response.cmId + ) + messages.setValue { newMessages } + syncUiMessages() } ) } } - fun markAsImportant( - messagesIds: List, + private fun markAsImportant( + messageIds: List, important: Boolean, ) { - viewModelScope.launch(Dispatchers.IO) { -// sendRequest( -// request = { -// messagesRepository.markAsImportant( -// MessagesMarkAsImportantRequest( -// messagesIds = messagesIds, -// important = important -// ) -// ) -// }, -// onResponse = { response -> -// val markedIds = response.response ?: emptyList() -// // TODO: 25.08.2023, Danil Nikolaev: update messages -// } -// ) + messagesUseCase.markAsImportant( + peerId = screenState.value.conversationId, + messageIds = messageIds, + important = important + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + val newMessages = messages.value + .toMutableList() + .map { message -> + if (message.id in messageIds) { + message.copy(isImportant = important) + } else { + message + } + } + messages.setValue { newMessages } + syncUiMessages() + } + ) } } - fun pinMessage( - peerId: Int, - messageId: Int? = null, - conversationMessageId: Int? = null, - pin: Boolean, + private fun deleteMessage( + messageIds: List, + spam: Boolean = false, + deleteForAll: Boolean = false, + onSuccess: () -> Unit = {} ) { - viewModelScope.launch(Dispatchers.IO) { -// if (pin) { -// val pinnedMessage = sendRequest { -// messagesRepository.pin( -// MessagesPinMessageRequest( -// peerId = peerId, -// messageId = messageId, -// conversationMessageId = conversationMessageId -// ) -// ) -// } ?: return@launch -// -// // TODO: 25.08.2023, Danil Nikolaev: update message -// } else { -// val unpinnedMessage = sendRequest { -// messagesRepository.unpin(MessagesUnPinMessageRequest(peerId = peerId)) -// } ?: return@launch -// -// // TODO: 25.08.2023, Danil Nikolaev: update message -// } + messagesUseCase.delete( + peerId = screenState.value.conversationId, + messageIds = messageIds, + spam = spam, + deleteForAll = deleteForAll + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + onSuccess() + val newMessages = messages.value.toMutableList() + val messagesToDelete = newMessages.filter { it.id in messageIds } + newMessages.removeAll(messagesToDelete) + messages.setValue { newMessages } + syncUiMessages() + } + ) } } - fun deleteMessage( - peerId: Int, - messagesIds: List? = null, - conversationsMessagesIds: List? = null, - isSpam: Boolean? = null, - deleteForAll: Boolean? = null, - ) { - viewModelScope.launch(Dispatchers.IO) { -// sendRequest { -// messagesRepository.delete( -// MessagesDeleteRequest( -// peerId = peerId, -// messagesIds = messagesIds, -// conversationsMessagesIds = conversationsMessagesIds, -// isSpam = isSpam, -// deleteForAll = deleteForAll -// ) -// ) -// } ?: return@launch + private fun pinMessage(messageId: Long) { + messagesUseCase.pin( + peerId = screenState.value.conversationId, + messageId = messageId, + cmId = null + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { pinnedMessage -> + handlePinnedMessage(pinnedMessage) - // TODO: 25.08.2023, Danil Nikolaev: handle deleting + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.id == messageId } + + if (index == null) {// сообщения нет в списке + // pizdets + } else { + newMessages[index] = pinnedMessage + messages.setValue { newMessages } + syncUiMessages() + } + } + ) } } + private fun unpinMessage(messageId: Long) { + messagesUseCase.unpin(screenState.value.conversationId) + .listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.id == messageId } + + if (index == null) { // сообщения нет в списке + // pizdets + } else { + newMessages[index] = newMessages[index].copy(isPinned = false) + messages.setValue { newMessages } + syncUiMessages() + } + + handlePinnedMessage(null) + } + ) + } + } + fun editMessage( originalMessage: VkMessage, - peerId: Int, - messageId: Int, + peerid: Long, + messageid: Long, newText: String? = null, attachments: List? = null, ) { @@ -572,32 +971,67 @@ class MessagesHistoryViewModelImpl( } } - fun readMessage(peerId: Int, messageId: Int) { - viewModelScope.launch(Dispatchers.IO) { -// sendRequest { -// messagesRepository.markAsRead(peerId, startMessageId = messageId) -// } ?: return@launch + private fun readMessage(message: VkMessage) { + messagesUseCase.markAsRead( + peerId = screenState.value.conversationId, + startMessageId = message.id + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + val oldConversation = screenState.value.conversation + val newConversation = oldConversation.copy( + inRead = + if (!message.isOut) message.id + else oldConversation.inRead, + outRead = + if (message.isOut) message.id + else oldConversation.outRead + ) - // TODO: 25.08.2023, Danil Nikolaev: update messages + screenState.setValue { old -> + old.copy(conversation = newConversation) + } + + syncUiMessages() + } + ) } } - private fun toggleShowTimeInActionMessages(show: Boolean) { + private fun copyMessage(message: VkMessage) { + val contentToCopy = message.text.orEmpty().trim() + if (contentToCopy.isEmpty()) return + + val clipboardManager = + applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + clipboardManager.setPrimaryClip(ClipData.newPlainText("Message", contentToCopy)) + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) { + Toast.makeText(applicationContext, UiR.string.copied_to_clipboard, Toast.LENGTH_SHORT) + .show() + } + } + + private fun syncUiMessages(): List { val messages = messages.value - val uiMessages = messages.mapIndexed { index, item -> - item.asPresentation( + val selectedMessages = selectedMessages.value + + val newUiMessages = messages.mapIndexed { index, message -> + message.asPresentation( resourceProvider = resourceProvider, showName = false, prevMessage = messages.getOrNull(index + 1), nextMessage = messages.getOrNull(index - 1), - showTimeInActionMessages = show, - conversation = screenState.value.conversation + showTimeInActionMessages = userSettings.showTimeInActionMessages.value, + conversation = screenState.value.conversation, + isSelected = selectedMessages.indexOfFirstOrNull { it.id == message.id } != null ) } + uiMessages.setValue { newUiMessages } - screenState.setValue { old -> - old.copy(messages = uiMessages) - } + return newUiMessages } companion object { @@ -607,8 +1041,9 @@ class MessagesHistoryViewModelImpl( // TODO: 25.08.2023, Danil Nikolaev: this and down below - rewrite + // suspend fun uploadPhoto( -// peerId: Int, +// peerid: Long, // photo: File, // name: String, // ) { @@ -628,7 +1063,7 @@ class MessagesHistoryViewModelImpl( // } // } -// private suspend fun getPhotoMessageUploadServer(peerId: Int) { +// private suspend fun getPhotoMessageUploadServer(peerid: Long) { // suspendCoroutine { continuation -> // viewModelScope.launch { // sendRequestNotNull( @@ -825,7 +1260,7 @@ class MessagesHistoryViewModelImpl( // } // suspend fun uploadFile( -// peerId: Int, +// peerid: Long, // file: File, // name: String, // type: FilesRepository.FileType, @@ -842,7 +1277,7 @@ class MessagesHistoryViewModelImpl( // } // private suspend fun getFileMessageUploadServer( -// peerId: Int, +// peerid: Long, // type: FilesRepository.FileType, // ) { // suspendCoroutine { continuation -> @@ -921,14 +1356,14 @@ class MessagesHistoryViewModelImpl( // //object MessagesUnpinEvent : VkEvent() // -//data class MessagesDeleteEvent(val peerId: Int, val messagesIds: List) : VkEvent() +//data class MessagesDeleteEvent(val peerid: Long, val messagesIds: List) : VkEvent() // //data class MessagesEditEvent(val message: VkMessageDomain) : VkEvent() // //data class MessagesReadEvent( // val isOut: Boolean, -// val peerId: Int, -// val messageId: Int, +// val peerid: Long, +// val messageid: Long, //) : VkEvent() // //data class MessagesNewEvent( diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageDialog.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageDialog.kt new file mode 100644 index 00000000..a932bca1 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageDialog.kt @@ -0,0 +1,23 @@ +package dev.meloda.fast.messageshistory.model + +import androidx.compose.runtime.Immutable +import dev.meloda.fast.model.api.domain.VkMessage + +@Immutable +sealed class MessageDialog { + data class MessageOptions(val message: VkMessage) : MessageDialog() + data class MessagePin(val messageId: Long) : MessageDialog() + data class MessageUnpin(val messageId: Long) : MessageDialog() + data class MessageDelete(val message: VkMessage) : MessageDialog() + data class MessagesDelete(val messages: List) : MessageDialog() + + data class MessageSpam( + val message: VkMessage, + val isSpam: Boolean + ) : MessageDialog() + + data class MessageMarkImportance( + val message: VkMessage, + val isImportant: Boolean + ) : MessageDialog() +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageNavigation.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageNavigation.kt new file mode 100644 index 00000000..8870306b --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageNavigation.kt @@ -0,0 +1,12 @@ +package dev.meloda.fast.messageshistory.model + +import androidx.compose.runtime.Immutable + +@Immutable +sealed class MessageNavigation { + + data class ChatMaterials( + val peerId: Long, + val cmId: Long + ) : MessageNavigation() +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageOption.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageOption.kt new file mode 100644 index 00000000..b8374345 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageOption.kt @@ -0,0 +1,84 @@ +package dev.meloda.fast.messageshistory.model + +import android.os.Parcelable +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import dev.meloda.fast.ui.R +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed class MessageOption( + @StringRes val titleResId: Int, + @DrawableRes val iconResId: Int +) : Parcelable { + + data object Retry : MessageOption( + titleResId = R.string.message_context_action_retry, + iconResId = R.drawable.round_restart_alt_24 + ) + + data object Reply : MessageOption( + titleResId = R.string.message_context_action_reply, + iconResId = R.drawable.round_reply_24 + ) + + data object ForwardHere : MessageOption( + titleResId = R.string.message_context_action_forward_here, + iconResId = R.drawable.round_reply_all_24 + ) + + data object Forward : MessageOption( + titleResId = R.string.message_context_action_forward, + iconResId = R.drawable.round_forward_24 + ) + + data object Pin : MessageOption( + titleResId = R.string.message_context_action_pin, + iconResId = R.drawable.pin_outline_24 + ) + + data object Unpin : MessageOption( + titleResId = R.string.message_context_action_unpin, + iconResId = R.drawable.pin_off_outline_24 + ) + + data object Read : MessageOption( + titleResId = R.string.message_context_action_read, + iconResId = R.drawable.round_mark_email_read_24 + ) + + data object Copy : MessageOption( + titleResId = R.string.message_context_action_copy, + iconResId = R.drawable.round_content_copy_24 + ) + + data object MarkAsImportant : MessageOption( + titleResId = R.string.message_context_action_mark_as_important, + iconResId = R.drawable.round_star_24 + ) + + data object UnmarkAsImportant : MessageOption( + titleResId = R.string.message_context_action_unmark_as_important, + iconResId = R.drawable.round_star_outline_24 + ) + + data object MarkAsSpam : MessageOption( + titleResId = R.string.message_context_action_mark_as_spam, + iconResId = R.drawable.round_report_gmailerrorred_24 + ) + + data object UnmarkAsSpam : MessageOption( + titleResId = R.string.message_context_action_unmark_as_spam, + iconResId = R.drawable.round_report_off_24 + ) + + data object Edit : MessageOption( + titleResId = R.string.message_context_action_edit, + iconResId = R.drawable.round_create_24 + ) + + data object Delete : MessageOption( + titleResId = R.string.message_context_action_delete, + iconResId = R.drawable.round_delete_outline_24 + ) +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryArguments.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryArguments.kt index 2ecd656d..8e68b618 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryArguments.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryArguments.kt @@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable @Parcelize @Serializable -data class MessagesHistoryArguments(val conversationId: Int) : Parcelable +data class MessagesHistoryArguments(val conversationId: Long) : Parcelable diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt index 815e2d56..f4d8caf0 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt @@ -1,18 +1,19 @@ package dev.meloda.fast.messageshistory.model import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.TextFieldValue import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkConversation +import dev.meloda.fast.model.api.domain.VkMessage @Immutable data class MessagesHistoryScreenState( - val conversationId: Int, + val conversationId: Long, val title: String, val status: String?, val avatar: UiImage, - val messages: List, val message: TextFieldValue, val attachments: List, val isLoading: Boolean, @@ -20,7 +21,10 @@ data class MessagesHistoryScreenState( val isPaginationExhausted: Boolean, val actionMode: ActionMode, val chatImageUrl: String?, - val conversation: VkConversation + val conversation: VkConversation, + val pinnedMessage: VkMessage?, + val pinnedTitle: String?, + val pinnedSummary: AnnotatedString? ) { companion object { @@ -29,7 +33,6 @@ data class MessagesHistoryScreenState( title = "", status = null, avatar = UiImage.Color(0), - messages = emptyList(), message = TextFieldValue(), attachments = emptyList(), isLoading = true, @@ -37,7 +40,10 @@ data class MessagesHistoryScreenState( isPaginationExhausted = false, actionMode = ActionMode.Record, chatImageUrl = null, - conversation = VkConversation.EMPTY + conversation = VkConversation.EMPTY, + pinnedMessage = null, + pinnedTitle = null, + pinnedSummary = null ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt index 1eadb2d0..f7fab77e 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt @@ -4,18 +4,18 @@ import androidx.compose.ui.text.AnnotatedString import dev.meloda.fast.common.model.UiImage sealed class UiItem( - open val id: Int, - val cmId: Int + open val id: Long, + val cmId: Long ) { data class Message( - override val id: Int, - val conversationMessageId: Int, - val text: String?, + override val id: Long, + val conversationMessageId: Long, + val text: AnnotatedString?, val isOut: Boolean, - val fromId: Int, + val fromId: Long, val date: String, - val randomId: Int, + val randomId: Long, val isInChat: Boolean, val name: String, val showDate: Boolean, @@ -24,13 +24,16 @@ sealed class UiItem( val avatar: UiImage, val isEdited: Boolean, val isRead: Boolean, - val sendingStatus: SendingStatus = SendingStatus.SENT + val sendingStatus: SendingStatus, + val isSelected: Boolean, + val isPinned: Boolean, + val isImportant: Boolean ) : UiItem(id, conversationMessageId) data class ActionMessage( - override val id: Int, - val conversationMessageId: Int, + override val id: Long, + val conversationMessageId: Long, val text: AnnotatedString, - val actionCmId: Int? + val actionCmId: Long? ) : UiItem(id, conversationMessageId) } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/navigation/MessagesHistoryNavigation.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/navigation/MessagesHistoryNavigation.kt index 4de8bb72..40c4c273 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/navigation/MessagesHistoryNavigation.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/navigation/MessagesHistoryNavigation.kt @@ -27,17 +27,17 @@ data class MessagesHistory(val arguments: MessagesHistoryArguments) { fun NavGraphBuilder.messagesHistoryScreen( onError: (BaseError) -> Unit, onBack: () -> Unit, - onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit + onNavigateToChatMaterials: (peerId: Long, cmId: Long) -> Unit ) { composable(typeMap = MessagesHistory.typeMap) { MessagesHistoryRoute( onError = onError, onBack = onBack, - onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked, + onNavigateToChatMaterials = onNavigateToChatMaterials ) } } -fun NavController.navigateToMessagesHistory(conversationId: Int) { +fun NavController.navigateToMessagesHistory(conversationId: Long) { this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId))) } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/ActionMessageItem.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/ActionMessageItem.kt index faa0608e..8a095842 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/ActionMessageItem.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/ActionMessageItem.kt @@ -27,13 +27,15 @@ fun ActionMessageItem( Text( text = item.text, modifier = modifier - .padding(horizontal = 32.dp) + .padding( + horizontal = 32.dp, + vertical = 4.dp + ) .clip(RoundedCornerShape(12.dp)) .then( if (item.actionCmId != null) { Modifier.clickable(onClick = onClick) - } - else Modifier + } else Modifier ) .background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)) .fillMaxWidth() diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt index d01bdef3..6f67315c 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt @@ -1,6 +1,7 @@ package dev.meloda.fast.messageshistory.presentation import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -26,65 +27,75 @@ import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter import coil.imageLoader import dev.meloda.fast.messageshistory.model.UiItem +import dev.meloda.fast.ui.theme.LocalThemeConfig @Composable fun IncomingMessageBubble( modifier: Modifier = Modifier, message: UiItem.Message, - animate: Boolean ) { - val context = LocalContext.current - Row( modifier = modifier - .fillMaxWidth(0.75f) - .padding(start = 16.dp), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.Start + .fillMaxWidth() + .then( + if (LocalThemeConfig.current.enableAnimations) Modifier.animateContentSize() + else Modifier + ), ) { - if (message.isInChat) { - Image( - painter = - message.avatar.extractUrl()?.let { url -> - rememberAsyncImagePainter( - model = url, - imageLoader = context.imageLoader - ) - } ?: painterResource(id = message.avatar.extractResId()), - contentDescription = null, - modifier = Modifier - .padding(bottom = 6.dp) - .size(28.dp) - .alpha(if (message.showAvatar) 1f else 0f) - .clip(CircleShape), - ) - Spacer(modifier = Modifier.width(8.dp)) - } - - Column { - AnimatedVisibility(visible = message.showName) { - Text( + Row( + modifier = Modifier + .fillMaxWidth(0.85f) + .padding(start = 16.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.Start + ) { + if (message.isInChat) { + Image( + painter = + message.avatar.extractUrl()?.let { url -> + rememberAsyncImagePainter( + model = url, + imageLoader = LocalContext.current.imageLoader + ) + } ?: painterResource(id = message.avatar.extractResId()), + contentDescription = null, modifier = Modifier - .padding(start = 12.dp) - .widthIn(max = 140.dp), - text = message.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + .padding(bottom = 6.dp) + .size(28.dp) + .alpha(if (message.showAvatar) 1f else 0f) + .clip(CircleShape), ) + Spacer(modifier = Modifier.width(8.dp)) } - MessageBubble( - modifier = Modifier, - text = message.text, - isOut = false, - date = message.date, - edited = message.isEdited, - animate = animate, - isRead = message.isRead, - sendingStatus = message.sendingStatus - ) + Column { + AnimatedVisibility(visible = message.showName) { + Text( + modifier = Modifier + .padding(start = 12.dp) + .widthIn(max = 140.dp), + text = message.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + MessageBubble( + modifier = Modifier, + text = message.text, + isOut = false, + date = message.date, + edited = message.isEdited, + isRead = message.isRead, + sendingStatus = message.sendingStatus, + pinned = message.isPinned, + important = message.isImportant, + isSelected = message.isSelected + ) + } } + Spacer(modifier = Modifier.fillMaxWidth(0.25f)) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt index f4c1459c..de257b74 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Create import androidx.compose.material3.Icon @@ -20,113 +21,192 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import dev.meloda.fast.messageshistory.model.SendingStatus +import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.R as UiR @Composable fun MessageBubble( modifier: Modifier = Modifier, - text: String?, + text: AnnotatedString?, isOut: Boolean, date: String?, edited: Boolean, - animate: Boolean, isRead: Boolean, - sendingStatus: SendingStatus + sendingStatus: SendingStatus, + pinned: Boolean, + important: Boolean, + isSelected: Boolean ) { + val theme = LocalThemeConfig.current val backgroundColor = if (!isOut) { MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) } else { MaterialTheme.colorScheme.primaryContainer } - val textColor = if (!isOut) { + val contentColor = if (!isOut) { MaterialTheme.colorScheme.onSurface } else { MaterialTheme.colorScheme.onPrimaryContainer } - Box( - modifier = modifier - .widthIn(min = 56.dp) - .clip(RoundedCornerShape(24.dp)) - .background(backgroundColor) - .padding( - horizontal = 8.dp, - vertical = 6.dp - ) - ) { - val minDateContainerWidth = remember(edited, isOut) { - val mainPart = if (edited) 50.dp else 30.dp - val readIndicatorPart = if (isOut) 14.dp else 0.dp - - mainPart + readIndicatorPart - } - - val dateContainerWidth by animateDpAsState( - targetValue = minDateContainerWidth, - label = "dateContainerWidth" - ) - - if (text != null) { - Text( - text = text, - modifier = Modifier - .padding(2.dp) - .align(Alignment.Center) - .padding(end = 4.dp) - .padding(end = dateContainerWidth) - .padding(end = 4.dp) - .then(if (animate) Modifier.animateContentSize() else Modifier), - color = textColor - ) - } - - Row( - modifier = Modifier - .align(Alignment.BottomEnd) - .defaultMinSize(minWidth = dateContainerWidth) + CompositionLocalProvider(LocalContentColor provides contentColor) { + Box( + modifier = modifier + .widthIn(min = 56.dp) + .clip(RoundedCornerShape(24.dp)) + .background(backgroundColor) + .padding( + horizontal = 8.dp, + vertical = 6.dp + ) + .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier), ) { - if (edited) { - Icon( - imageVector = Icons.Rounded.Create, - contentDescription = null, - modifier = Modifier.size(14.dp) + val minDateContainerWidth by remember(edited, isOut, pinned, important) { + derivedStateOf { + val mainPart = if (edited) 50.dp else 30.dp + val readIndicatorPart = if (isOut) 14.dp else 0.dp + val pinnedIndicatorPart = if (pinned) 14.dp else 0.dp + val importantIndicatorPart = if (important) 14.dp else 0.dp + + mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart + } + } + + val dateContainerWidth by animateDpAsState( + targetValue = minDateContainerWidth, + label = "dateContainerWidth" + ) + + if (text != null) { + val textLambda: @Composable () -> Unit = remember(text, theme, dateContainerWidth) { + { + Text( + text = kotlin.run { + val builder = AnnotatedString.Builder(text) + + text.spanStyles.map { spanStyleRange -> + val updatedSpanStyle = + if (spanStyleRange.item.color == Color.Red) { + spanStyleRange.item.copy(color = + if (isOut) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.primary + } + ) + } else { + spanStyleRange.item + } + + builder.addStyle( + style = updatedSpanStyle, + start = spanStyleRange.start, + end = spanStyleRange.end + ) + } + + text.paragraphStyles.forEach { style -> + builder.addStyle( + style = style.item, + start = style.start, + end = style.end + ) + } + + builder.toAnnotatedString() + }, + modifier = Modifier + .padding(2.dp) + .align(Alignment.Center) + .padding(end = 4.dp) + .padding(end = dateContainerWidth) + .padding(end = 4.dp) + .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier) + ) + } + } + + if (isSelected) { + SelectionContainer { + textLambda.invoke() + } + } else { + textLambda.invoke() + } + } + + Row( + modifier = Modifier + .align(Alignment.BottomEnd) + .defaultMinSize(minWidth = dateContainerWidth) + .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier), + ) { + if (important) { + Icon( + painter = painterResource(UiR.drawable.round_star_24), + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + if (pinned) { + Icon( + painter = painterResource(UiR.drawable.ic_round_push_pin_24), + contentDescription = null, + modifier = Modifier + .size(14.dp) + .rotate(45f) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + if (edited) { + Icon( + imageVector = Icons.Rounded.Create, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + + Text( + text = date.orEmpty(), + style = MaterialTheme.typography.labelSmall ) Spacer(modifier = Modifier.width(4.dp)) - } - Text( - text = date.orEmpty(), - style = MaterialTheme.typography.labelSmall, - ) - Spacer(modifier = Modifier.width(4.dp)) - if (isOut) { - Icon( - modifier = Modifier.size(14.dp), - painter = painterResource( - when (sendingStatus) { - SendingStatus.SENDING -> UiR.drawable.round_access_time_24 - SendingStatus.SENT -> { - if (isRead) UiR.drawable.round_done_all_24 - else UiR.drawable.ic_round_done_24 + if (isOut) { + Icon( + modifier = Modifier.size(14.dp), + painter = painterResource( + when (sendingStatus) { + SendingStatus.SENDING -> UiR.drawable.round_access_time_24 + SendingStatus.SENT -> { + if (isRead) UiR.drawable.round_done_all_24 + else UiR.drawable.ic_round_done_24 + } + + SendingStatus.FAILED -> UiR.drawable.round_error_outline_24 } - - SendingStatus.FAILED -> UiR.drawable.round_error_outline_24 - } - ), - tint = if (sendingStatus == SendingStatus.FAILED) Color.Red - else LocalContentColor.current, - contentDescription = null - ) + ), + tint = if (sendingStatus == SendingStatus.FAILED) MaterialTheme.colorScheme.error + else LocalContentColor.current, + contentDescription = null + ) + } } } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryDialogs.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryDialogs.kt new file mode 100644 index 00000000..00bb158e --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryDialogs.kt @@ -0,0 +1,323 @@ +package dev.meloda.fast.messageshistory.presentation + +import android.os.Bundle +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import dev.meloda.fast.data.UserConfig +import dev.meloda.fast.messageshistory.model.MessageDialog +import dev.meloda.fast.messageshistory.model.MessageOption +import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState +import dev.meloda.fast.model.api.domain.VkMessage +import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.basic.ContentAlpha +import dev.meloda.fast.ui.basic.LocalContentAlpha +import dev.meloda.fast.ui.components.MaterialDialog +import java.util.concurrent.TimeUnit + +@Composable +fun HandleDialogs( + screenState: MessagesHistoryScreenState, + dialog: MessageDialog?, + onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> }, + onDismissed: (MessageDialog) -> Unit = {}, + onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> } +) { + when (dialog) { + null -> Unit + + is MessageDialog.MessageOptions -> { + MessageOptionsDialog( + screenState = screenState, + message = dialog.message, + onDismissed = { onDismissed(dialog) }, + onItemPicked = { bundle -> onItemPicked(dialog, bundle) } + ) + } + + is MessageDialog.MessageDelete -> { + MessageDeleteDialog( + messages = listOf(dialog.message), + onConfirmed = { onConfirmed(dialog, it) }, + onDismissed = { onDismissed(dialog) } + ) + } + + is MessageDialog.MessagesDelete -> { + MessageDeleteDialog( + messages = dialog.messages, + onConfirmed = { onConfirmed(dialog, it) }, + onDismissed = { onDismissed(dialog) } + ) + } + + is MessageDialog.MessagePin, + is MessageDialog.MessageUnpin -> { + MessagePinStateDialog( + pin = dialog is MessageDialog.MessagePin, + onConfirmed = { onConfirmed(dialog, bundleOf()) }, + onDismissed = { onDismissed(dialog) } + ) + } + + is MessageDialog.MessageMarkImportance -> { + MessageImportanceDialog( + important = dialog.isImportant, + onConfirmed = { onConfirmed(dialog, bundleOf()) }, + onDismissed = { onDismissed(dialog) } + ) + } + + is MessageDialog.MessageSpam -> { + MessageSpamDialog( + spam = dialog.isSpam, + onConfirmed = { onConfirmed(dialog, bundleOf()) }, + onDismissed = { onDismissed(dialog) } + ) + } + } +} + + +@Composable +fun MessageOptionsDialog( + screenState: MessagesHistoryScreenState, + message: VkMessage, + onDismissed: () -> Unit = {}, + onItemPicked: (Bundle) -> Unit +) { + val options = mutableListOf() + if (message.isFailed()) { + options += MessageOption.Retry + } else { + options += MessageOption.Reply + options += MessageOption.ForwardHere + options += MessageOption.Forward + + if (message.isPeerChat() && screenState.conversation.canChangePin) { + options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin + } + + if (!message.isRead(screenState.conversation)) { + options += MessageOption.Read + } + + options += MessageOption.Copy + + if (message.isOut) { + val diff = System.currentTimeMillis() - message.date * 1000L + if (diff - TimeUnit.DAYS.toMillis(1) <= 0) { + options += MessageOption.Edit + } + } + + options += if (message.isImportant) MessageOption.UnmarkAsImportant + else MessageOption.MarkAsImportant + + + if (!message.isOut) { + options += if (message.isSpam) MessageOption.UnmarkAsSpam + else MessageOption.MarkAsSpam + } + } + + options += MessageOption.Delete + + val messageOptions = options.map { option -> + Triple( + stringResource(option.titleResId), + painterResource(option.iconResId), + when { + option in listOf( + MessageOption.Delete, + MessageOption.MarkAsSpam + ) -> MaterialTheme.colorScheme.error + + else -> MaterialTheme.colorScheme.primary + } + ) + } + + MaterialDialog(onDismissRequest = onDismissed) { + messageOptions + .forEachIndexed { index, (title, painter, tintColor) -> + DropdownMenuItem( + text = { + Row { + Text(text = title) + Spacer(modifier = Modifier.width(8.dp)) + } + }, + leadingIcon = { + Row { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painter, + contentDescription = null, + tint = tintColor + ) + } + }, + onClick = { + onDismissed() + val pickedOption = options[index] + onItemPicked(bundleOf("option" to pickedOption)) + } + ) + } + } +} + +@Composable +fun MessageDeleteDialog( + messages: List, + onConfirmed: (Bundle) -> Unit = {}, + onDismissed: () -> Unit = {}, +) { + var forEveryone by remember { + mutableStateOf( + !messages.any { it.peerId == UserConfig.userId } + && messages.all(VkMessage::isOut) + ) + } + + val shouldBeDisabled by remember(messages) { + mutableStateOf( + messages.any { it.peerId == UserConfig.userId } + || messages.all(VkMessage::isFailed) + || !messages.all(VkMessage::isOut) + ) + } + + MaterialDialog( + onDismissRequest = onDismissed, + title = stringResource(R.string.delete_message_title), + confirmText = stringResource(R.string.action_delete), + confirmAction = { + onConfirmed( + bundleOf("everyone" to if (messages.all(VkMessage::isOut)) forEveryone else false) + ) + }, + cancelText = stringResource(R.string.cancel), + ) { + Row( + modifier = Modifier + .then( + if (!shouldBeDisabled) { + Modifier.clickable { forEveryone = !forEveryone } + } else Modifier) + .fillMaxWidth() + .minimumInteractiveComponentSize() + .padding(start = 24.dp, end = 16.dp) + ) { + Checkbox( + checked = forEveryone, + onCheckedChange = null, + enabled = !shouldBeDisabled + ) + + Spacer(modifier = Modifier.width(8.dp)) + + LocalContentAlpha( + alpha = if (shouldBeDisabled) ContentAlpha.disabled + else ContentAlpha.high + ) { + Text(text = stringResource(R.string.delete_message_for_everyone)) + } + } + } +} + +@Composable +fun MessagePinStateDialog( + pin: Boolean, + onConfirmed: () -> Unit = {}, + onDismissed: () -> Unit = {}, +) { + MaterialDialog( + onDismissRequest = onDismissed, + title = stringResource( + if (pin) R.string.pin_message_title + else R.string.unpin_message_title + ), + text = stringResource( + if (pin) R.string.pin_message_text + else R.string.unpin_message_text + ), + confirmText = stringResource( + if (pin) R.string.action_pin + else R.string.action_unpin + ), + confirmAction = onConfirmed, + cancelText = stringResource(R.string.cancel) + ) +} + +@Composable +fun MessageImportanceDialog( + important: Boolean, + onConfirmed: () -> Unit = {}, + onDismissed: () -> Unit = {}, +) { + MaterialDialog( + onDismissRequest = onDismissed, + title = stringResource( + if (important) R.string.important_message_title + else R.string.unimportant_message_title + ), + text = stringResource( + if (important) R.string.important_message_text + else R.string.unimportant_message_text + ), + confirmText = stringResource( + if (important) R.string.action_mark + else R.string.action_unmark + ), + confirmAction = onConfirmed, + cancelText = stringResource(R.string.cancel) + ) +} + +@Composable +fun MessageSpamDialog( + spam: Boolean, + onConfirmed: () -> Unit = {}, + onDismissed: () -> Unit = {}, +) { + MaterialDialog( + onDismissRequest = onDismissed, + title = stringResource( + if (spam) R.string.spam_message_title + else R.string.unspam_message_title + ), + text = stringResource( + if (spam) R.string.spam_message_text + else R.string.unspam_message_text + ), + confirmText = stringResource( + if (spam) R.string.action_mark + else R.string.action_unmark + ), + confirmAction = onConfirmed, + cancelText = stringResource(R.string.cancel) + ) +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryRoute.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryRoute.kt new file mode 100644 index 00000000..776dfe65 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryRoute.kt @@ -0,0 +1,83 @@ +package dev.meloda.fast.messageshistory.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.meloda.fast.datastore.UserSettings +import dev.meloda.fast.messageshistory.MessagesHistoryViewModel +import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl +import dev.meloda.fast.messageshistory.model.MessageNavigation +import dev.meloda.fast.model.BaseError +import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject + +@Composable +fun MessagesHistoryRoute( + onError: (BaseError) -> Unit, + onBack: () -> Unit, + onNavigateToChatMaterials: (peerId: Long, conversationMessageId: Long) -> Unit, + viewModel: MessagesHistoryViewModel = koinViewModel() +) { + val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle() + val messages by viewModel.messages.collectAsStateWithLifecycle() + val uiMessages by viewModel.uiMessages.collectAsStateWithLifecycle() + val dialog by viewModel.dialog.collectAsStateWithLifecycle() + val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle() + val baseError by viewModel.baseError.collectAsStateWithLifecycle() + val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() + val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle() + + val userSettings: UserSettings = koinInject() + val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle() + + LaunchedEffect(navigationEvent) { + val needToConsume = when (val navigation = navigationEvent) { + null -> false + + is MessageNavigation.ChatMaterials -> { + val (peerId, cmId) = navigation + onNavigateToChatMaterials(peerId, cmId) + true + } + } + if (needToConsume) viewModel.onNavigationConsumed() + } + + MessagesHistoryScreen( + screenState = screenState, + messages = messages.toImmutableList(), + uiMessages = uiMessages.toImmutableList(), + scrollIndex = scrollIndex, + selectedMessages = selectedMessages.toImmutableList(), + baseError = baseError, + canPaginate = canPaginate, + showEmojiButton = showEmojiButton, + onBack = onBack, + onClose = viewModel::onCloseButtonClicked, + onScrolledToIndex = viewModel::onScrolledToIndex, + onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) }, + onTopBarClicked = viewModel::onTopBarClicked, + onRefresh = viewModel::onRefresh, + onPaginationConditionsMet = viewModel::onPaginationConditionsMet, + onMessageInputChanged = viewModel::onMessageInputChanged, + onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked, + onActionButtonClicked = viewModel::onActionButtonClicked, + onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked, + onMessageClicked = viewModel::onMessageClicked, + onMessageLongClicked = viewModel::onMessageLongClicked, + onPinnedMessageClicked = viewModel::onPinnedMessageClicked, + onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked, + onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked + ) + + HandleDialogs( + screenState = screenState, + dialog = dialog, + onConfirmed = viewModel::onDialogConfirmed, + onDismissed = viewModel::onDialogDismissed, + onItemPicked = viewModel::onDialogItemPicked + ) +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt index a52702c2..628abf2a 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt @@ -1,13 +1,17 @@ package dev.meloda.fast.messageshistory.presentation -import android.content.SharedPreferences +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,6 +38,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -75,60 +80,29 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.core.view.HapticFeedbackConstantsCompat -import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeChild +import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials +import dev.meloda.fast.common.extensions.orDots +import dev.meloda.fast.data.UserConfig import dev.meloda.fast.datastore.AppSettings -import dev.meloda.fast.datastore.UserSettings -import dev.meloda.fast.messageshistory.MessagesHistoryViewModel -import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl import dev.meloda.fast.messageshistory.model.ActionMode import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState -import dev.meloda.fast.messageshistory.util.firstMessage +import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId import dev.meloda.fast.model.BaseError +import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.ui.components.IconButton +import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList +import dev.meloda.fast.ui.util.emptyImmutableList import dev.meloda.fast.ui.util.getImage import kotlinx.coroutines.launch -import org.koin.androidx.compose.koinViewModel -import org.koin.compose.koinInject import dev.meloda.fast.ui.R as UiR -@Composable -fun MessagesHistoryRoute( - onError: (BaseError) -> Unit, - onBack: () -> Unit, - onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit, - viewModel: MessagesHistoryViewModel = koinViewModel() -) { - val screenState by viewModel.screenState.collectAsStateWithLifecycle() - val baseError by viewModel.baseError.collectAsStateWithLifecycle() - val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() - - val userSettings: UserSettings = koinInject() - val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle() - - MessagesHistoryScreen( - screenState = screenState, - baseError = baseError, - canPaginate = canPaginate, - showEmojiButton = showEmojiButton, - onBack = onBack, - onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked, - onRefreshDropdownItemClicked = viewModel::onRefresh, - onPaginationConditionsMet = viewModel::onPaginationConditionsMet, - onMessageInputChanged = viewModel::onMessageInputChanged, - onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked, - onActionButtonClicked = viewModel::onActionButtonClicked, - onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked - ) -} - @OptIn( ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, @@ -137,27 +111,51 @@ fun MessagesHistoryRoute( @Composable fun MessagesHistoryScreen( screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY, + messages: ImmutableList = emptyImmutableList(), + uiMessages: ImmutableList = emptyImmutableList(), + scrollIndex: Int? = null, + selectedMessages: ImmutableList = emptyImmutableList(), baseError: BaseError? = null, canPaginate: Boolean = false, showEmojiButton: Boolean = false, onBack: () -> Unit = {}, - onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> }, - onRefreshDropdownItemClicked: () -> Unit = {}, - onToggleAnimationsDropdownItemClicked: (Boolean) -> Unit = {}, + onClose: () -> Unit = {}, + onScrolledToIndex: () -> Unit = {}, + onSessionExpiredLogOutButtonClicked: () -> Unit = {}, + onTopBarClicked: () -> Unit = {}, + onRefresh: () -> Unit = {}, onPaginationConditionsMet: () -> Unit = {}, onMessageInputChanged: (TextFieldValue) -> Unit = {}, onAttachmentButtonClicked: () -> Unit = {}, onActionButtonClicked: () -> Unit = {}, - onEmojiButtonLongClicked: () -> Unit = {} + onEmojiButtonLongClicked: () -> Unit = {}, + onMessageClicked: (Long) -> Unit = {}, + onMessageLongClicked: (Long) -> Unit = {}, + onPinnedMessageClicked: (Long) -> Unit = {}, + onUnpinMessageButtonClicked: () -> Unit = {}, + onDeleteSelectedButtonClicked: () -> Unit = {} ) { val view = LocalView.current - val coroutineScope = rememberCoroutineScope() - - val preferences: SharedPreferences = koinInject() - val currentTheme = LocalThemeConfig.current - + val theme = LocalThemeConfig.current val listState = rememberLazyListState() + val hazeState = remember { HazeState() } + + LaunchedEffect(scrollIndex) { + if (scrollIndex != null) { + coroutineScope.launch { + listState.animateScrollToItem(scrollIndex) + onScrolledToIndex() + } + } + } + + BackHandler( + enabled = selectedMessages.isNotEmpty(), + onBack = onClose + ) + + val pinnedMessage = screenState.pinnedMessage val paginationConditionMet by remember(canPaginate, listState) { derivedStateOf { @@ -177,12 +175,24 @@ fun MessagesHistoryScreen( mutableStateOf(false) } - val hazeState = remember { HazeState() } - - val toolbarColorAlpha by animateFloatAsState( - targetValue = if (!listState.canScrollForward) 1f else 0f, + val topBarContainerColorAlpha by animateFloatAsState( + targetValue = if (!theme.enableBlur || !listState.canScrollBackward) 1f else 0f, label = "toolbarColorAlpha", - animationSpec = tween(durationMillis = 50) + animationSpec = tween( + durationMillis = 200, + easing = FastOutLinearInEasing + ) + ) + + val topBarContainerColor by animateColorAsState( + targetValue = + if (theme.enableBlur || !listState.canScrollBackward) MaterialTheme.colorScheme.surface + else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + label = "toolbarColorAlpha", + animationSpec = tween( + durationMillis = 200, + easing = FastOutLinearInEasing + ) ) var messageBarHeight by remember { @@ -191,54 +201,97 @@ fun MessagesHistoryScreen( val density = LocalDensity.current + val showReplyAction by remember(selectedMessages) { + derivedStateOf { selectedMessages.size == 1 } + } + Scaffold( modifier = Modifier.fillMaxSize(), contentWindowInsets = WindowInsets.statusBars, topBar = { - Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha)) + .then( + if (theme.enableBlur) { + Modifier.hazeEffect( + state = hazeState, + style = HazeMaterials.thick() + ) + } else Modifier + ) + ) { TopAppBar( modifier = Modifier .then( - if (currentTheme.enableBlur) { - Modifier.hazeChild( + if (theme.enableBlur) { + Modifier.hazeEffect( state = hazeState, style = HazeMaterials.thick() ) } else Modifier ) - .fillMaxWidth(), + .fillMaxWidth() + .then( + if (screenState.isLoading && messages.isEmpty()) Modifier + else Modifier.clickable { + onTopBarClicked() + } + ), title = { Row( - modifier = Modifier - .weight(1f), + modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { - val avatar = screenState.avatar.getImage() - if (avatar is Painter) { - Image( - painter = avatar, - contentDescription = null, - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - ) - } else { - AsyncImage( - model = screenState.avatar.getImage(), - contentDescription = "Profile Image", - modifier = Modifier - .size(36.dp) - .clip(CircleShape), - placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut), - ) + if (selectedMessages.isEmpty()) { + val avatar = screenState.avatar.getImage() + if (screenState.conversationId == UserConfig.userId) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + ) { + Icon( + modifier = Modifier + .align(Alignment.Center) + .size(24.dp), + painter = painterResource(id = UiR.drawable.ic_round_bookmark_border_24), + contentDescription = "Favorites icon", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } else { + if (avatar is Painter) { + Image( + painter = avatar, + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + ) + } else { + AsyncImage( + model = screenState.avatar.getImage(), + contentDescription = "Profile Image", + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut), + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) } - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = - if (screenState.isLoading) stringResource(id = UiR.string.title_loading) - else screenState.title, + text = when { + screenState.isLoading -> stringResource(id = UiR.string.title_loading) + selectedMessages.size > 0 -> "(${selectedMessages.size})" + else -> screenState.title + }, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.headlineSmall @@ -246,73 +299,109 @@ fun MessagesHistoryScreen( } }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton( + onClick = { + if (selectedMessages.isEmpty()) onBack() + else onClose() + } + ) { Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + imageVector = if (selectedMessages.isEmpty()) { + Icons.AutoMirrored.Rounded.ArrowBack + } else { + Icons.Rounded.Close + }, contentDescription = "Back button" ) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface.copy( - alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f - ) - ), + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), actions = { - IconButton( - onClick = { dropDownMenuExpanded = true } - ) { - Icon( - imageVector = Icons.Outlined.MoreVert, - contentDescription = "Options" - ) - } - - DropdownMenu( - modifier = Modifier.defaultMinSize(minWidth = 140.dp), - expanded = dropDownMenuExpanded, - onDismissRequest = { - dropDownMenuExpanded = false - }, - offset = DpOffset(x = (-4).dp, y = (-60).dp) - ) { - DropdownMenuItem( - onClick = { - dropDownMenuExpanded = false - - // TODO: 11/07/2024, Danil Nikolaev: to VM - - // TODO: 23-Mar-25, Danil Nikolaev: crash if not messages (ex. new chat) - onChatMaterialsDropdownItemClicked( - screenState.conversationId, - screenState.messages.firstMessage().conversationMessageId - ) - }, - text = { - Text(text = "Materials") - } - ) - DropdownMenuItem( - onClick = { - onRefreshDropdownItemClicked() - dropDownMenuExpanded = false - }, - text = { - Text(text = "Refresh") - }, - leadingIcon = { + if (selectedMessages.isNotEmpty()) { + AnimatedVisibility(showReplyAction) { + IconButton( + onClick = { + if (AppSettings.General.enableHaptic) { + view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT) + } + } + ) { Icon( - imageVector = Icons.Rounded.Refresh, + painter = painterResource(UiR.drawable.round_reply_24), contentDescription = null ) } - ) + } + IconButton( + onClick = { + if (AppSettings.General.enableHaptic) { + view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT) + } + } + ) { + Icon( + painter = painterResource(UiR.drawable.round_reply_all_24), + contentDescription = null + ) + } + IconButton( + onClick = { + if (AppSettings.General.enableHaptic) { + view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT) + } + } + ) { + Icon( + painter = painterResource(UiR.drawable.round_forward_24), + contentDescription = null + ) + } + IconButton(onClick = onDeleteSelectedButtonClicked) { + Icon( + painter = painterResource(UiR.drawable.round_delete_outline_24), + contentDescription = null + ) + } + } else { + IconButton( + onClick = { dropDownMenuExpanded = true } + ) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = "Options" + ) + } + + DropdownMenu( + modifier = Modifier.defaultMinSize(minWidth = 140.dp), + expanded = dropDownMenuExpanded, + onDismissRequest = { + dropDownMenuExpanded = false + }, + offset = DpOffset(x = (-4).dp, y = (-60).dp) + ) { + DropdownMenuItem( + onClick = { + onRefresh() + dropDownMenuExpanded = false + }, + text = { + Text(text = stringResource(UiR.string.action_refresh)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = null + ) + } + ) + } } } ) val showHorizontalProgressBar by remember(screenState) { - derivedStateOf { screenState.isLoading && screenState.messages.isNotEmpty() } + derivedStateOf { screenState.isLoading && messages.isNotEmpty() } } if (showHorizontalProgressBar) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) @@ -320,6 +409,19 @@ fun MessagesHistoryScreen( AnimatedVisibility(!showHorizontalProgressBar) { HorizontalDivider() } + + if (!screenState.isLoading && pinnedMessage != null) { + PinnedMessageContainer( + modifier = Modifier, + pinnedMessage = requireNotNull(pinnedMessage), + title = screenState.pinnedTitle.orDots(), + summary = screenState.pinnedSummary, + canChangePin = screenState.conversation.canChangePin, + onPinnedMessageClicked = onPinnedMessageClicked, + onUnpinMessageButtonClicked = onUnpinMessageButtonClicked + ) + HorizontalDivider() + } } } ) { padding -> @@ -331,18 +433,32 @@ fun MessagesHistoryScreen( .padding(bottom = padding.calculateBottomPadding()), ) { MessagesList( + modifier = Modifier.align(Alignment.BottomStart), hazeState = hazeState, listState = listState, - immutableMessages = ImmutableList.copyOf(screenState.messages), + hasPinnedMessage = pinnedMessage != null, + uiMessages = uiMessages, isPaginating = screenState.isPaginating, messageBarHeight = messageBarHeight, onRequestScrollToCmId = { cmId -> - coroutineScope.launch { - listState.animateScrollToItem( - index = screenState.messages.indexOfMessageByCmId(cmId) - ) + val index = uiMessages.values.indexOfMessageByCmId(cmId) + if (index == null) { // сообщения нет в списке + // pizdets + } else { + coroutineScope.launch { + listState.animateScrollToItem(index = index) + } } - } + }, + onMessageClicked = { id -> + if (selectedMessages.isNotEmpty()) { + if (AppSettings.General.enableHaptic) { + view.performHapticFeedback(HapticFeedbackConstantsCompat.CONTEXT_CLICK) + } + } + onMessageClicked(id) + }, + onMessageLongClicked = onMessageLongClicked ) Column( @@ -362,13 +478,28 @@ fun MessagesHistoryScreen( verticalAlignment = Alignment.CenterVertically ) { Spacer(modifier = Modifier.width(10.dp)) - Row( modifier = Modifier + .clip(RoundedCornerShape(36.dp)) + .then( + if (theme.enableBlur) { + Modifier + .hazeEffect( + state = hazeState, + style = HazeMaterials.ultraThin() + ) + .border( + 1.dp, MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(36.dp) + ) + } else Modifier + ) .animateContentSize() .weight(1f) - .clip(RoundedCornerShape(36.dp)) - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)) + .background( + if (theme.enableBlur) Color.Transparent + else MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp) + ) .onGloballyPositioned { messageBarHeight = with(density) { it.size.height.toDp() @@ -386,7 +517,9 @@ fun MessagesHistoryScreen( IconButton( onClick = { if (AppSettings.General.enableHaptic) { - view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT) + view.performHapticFeedback( + HapticFeedbackConstantsCompat.REJECT + ) } scope.launch { for (i in 20 downTo 0 step 4) { @@ -405,7 +538,9 @@ fun MessagesHistoryScreen( }, onLongClick = { if (AppSettings.General.enableHaptic) { - view.performHapticFeedback(HapticFeedbackConstantsCompat.LONG_PRESS) + view.performHapticFeedback( + HapticFeedbackConstantsCompat.LONG_PRESS + ) } onEmojiButtonLongClicked() }, @@ -447,8 +582,11 @@ fun MessagesHistoryScreen( Column(verticalArrangement = Arrangement.Bottom) { IconButton( onClick = { + onAttachmentButtonClicked() if (AppSettings.General.enableHaptic) { - view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT) + view.performHapticFeedback( + HapticFeedbackConstantsCompat.REJECT + ) } scope.launch { for (i in 20 downTo 0 step 4) { @@ -484,7 +622,9 @@ fun MessagesHistoryScreen( onClick = { if (screenState.actionMode == ActionMode.Record) { if (AppSettings.General.enableHaptic) { - view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT) + view.performHapticFeedback( + HapticFeedbackConstantsCompat.REJECT + ) } scope.launch { for (i in 20 downTo 0 step 4) { @@ -535,8 +675,14 @@ fun MessagesHistoryScreen( } } - if (screenState.isLoading && screenState.messages.isEmpty()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + when { + screenState.isLoading && messages.values.isEmpty() -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + baseError != null -> { + VkErrorView(baseError = baseError) + } } } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt index ad9b7c0e..c5fc000b 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt @@ -1,54 +1,63 @@ package dev.meloda.fast.messageshistory.presentation +import android.view.HapticFeedbackConstants import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.haze +import dev.chrisbanes.haze.hazeSource import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList +@OptIn(ExperimentalFoundationApi::class) @Composable fun MessagesList( modifier: Modifier = Modifier, + hasPinnedMessage: Boolean, hazeState: HazeState, listState: LazyListState, - immutableMessages: ImmutableList, + uiMessages: ImmutableList, isPaginating: Boolean, messageBarHeight: Dp, - onRequestScrollToCmId: (cmId: Int) -> Unit = {} + onRequestScrollToCmId: (cmId: Long) -> Unit = {}, + onMessageClicked: (Long) -> Unit = {}, + onMessageLongClicked: (Long) -> Unit = {} ) { - val enableAnimations = remember { - AppSettings.Experimental.moreAnimations - } - val messages = remember(immutableMessages) { - immutableMessages.toList() - } - val currentTheme = LocalThemeConfig.current + val theme = LocalThemeConfig.current + val view = LocalView.current LazyColumn( modifier = modifier .fillMaxWidth() .then( - if (currentTheme.enableBlur) { - Modifier.haze(state = hazeState) + if (theme.enableBlur) { + Modifier.hazeSource(state = hazeState) } else Modifier ), state = listState, @@ -65,7 +74,7 @@ fun MessagesList( } items( - items = messages, + items = uiMessages.values, key = UiItem::id, contentType = { item -> when (item) { @@ -77,6 +86,12 @@ fun MessagesList( when (item) { is UiItem.ActionMessage -> { ActionMessageItem( + modifier = Modifier.then( + if (theme.enableAnimations) Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null + ) else Modifier + ), item = item, onClick = { if (item.actionCmId != null) { @@ -87,37 +102,65 @@ fun MessagesList( } is UiItem.Message -> { - if (item.isOut) { - OutgoingMessageBubble( - modifier = - Modifier.then( - if (enableAnimations) Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null - ) - else Modifier - ), - message = item, - animate = enableAnimations - ) - } else { - IncomingMessageBubble( - modifier = - Modifier.then( - if (enableAnimations) Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null - ) - else Modifier - ), - message = item, - animate = enableAnimations - ) + val backgroundColor by animateColorAsState( + targetValue = if (item.isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.35f) + } else { + Color.Transparent + } + ) + + Surface( + modifier = Modifier + .then( + if (theme.enableAnimations) Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null + ) else Modifier + ) + .combinedClickable( + onLongClick = { + if (AppSettings.General.enableHaptic) { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + onMessageLongClicked(item.id) + }, + onClick = { onMessageClicked(item.id) } + ), + color = backgroundColor + ) { + if (item.isOut) { + OutgoingMessageBubble( + modifier = + Modifier + .padding(vertical = 4.dp) + .then( + if (theme.enableAnimations) Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null + ) + else Modifier + ), + message = item + ) + } else { + IncomingMessageBubble( + modifier = + Modifier + .padding(vertical = 4.dp) + .then( + if (theme.enableAnimations) Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null + ) + else Modifier + ), + message = item + ) + } } } } - - Spacer(modifier = Modifier.height(8.dp)) } item { @@ -130,6 +173,10 @@ fun MessagesList( } } + if (hasPinnedMessage) { + Spacer(modifier = Modifier.height(56.dp)) + } + Spacer(Modifier.height(8.dp)) Spacer( modifier = Modifier diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt index f25a1bdb..7f97b086 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt @@ -1,5 +1,6 @@ package dev.meloda.fast.messageshistory.presentation +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,34 +12,41 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.meloda.fast.common.extensions.orDots import dev.meloda.fast.messageshistory.model.UiItem +import dev.meloda.fast.ui.theme.LocalThemeConfig @Composable fun OutgoingMessageBubble( modifier: Modifier = Modifier, message: UiItem.Message, - animate: Boolean ) { Row( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .then( + if (LocalThemeConfig.current.enableAnimations) Modifier.animateContentSize() + else Modifier + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { Column( modifier = Modifier .padding(end = 16.dp) - .fillMaxWidth(0.75f), + .fillMaxWidth(0.85f), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.End, ) { MessageBubble( modifier = Modifier, - text = message.text.orDots(), + text = message.text, isOut = true, date = message.date, edited = message.isEdited, - animate = animate, isRead = message.isRead, - sendingStatus = message.sendingStatus + sendingStatus = message.sendingStatus, + pinned = message.isPinned, + important = message.isImportant, + isSelected = message.isSelected ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/PinnedMessageContainer.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/PinnedMessageContainer.kt new file mode 100644 index 00000000..6d83607c --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/PinnedMessageContainer.kt @@ -0,0 +1,88 @@ +package dev.meloda.fast.messageshistory.presentation + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.meloda.fast.model.api.domain.VkMessage +import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.basic.ContentAlpha +import dev.meloda.fast.ui.basic.LocalContentAlpha +import dev.meloda.fast.ui.components.IconButton + +@Composable +fun PinnedMessageContainer( + modifier: Modifier = Modifier, + pinnedMessage: VkMessage, + title: String, + summary: AnnotatedString?, + canChangePin: Boolean, + onPinnedMessageClicked: (Long) -> Unit = {}, + onUnpinMessageButtonClicked: () -> Unit = {} +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(56.dp) + .clickable { onPinnedMessageClicked(pinnedMessage.id) } + .padding(start = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .rotate(45f) + .alpha(0.5f), + painter = painterResource(R.drawable.ic_round_push_pin_24), + contentDescription = null + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + summary?.let { summary -> + LocalContentAlpha(alpha = ContentAlpha.medium) { + Text(text = summary) + } + } + } + + if (canChangePin) { + Spacer(modifier = Modifier.width(16.dp)) + + IconButton(onClick = onUnpinMessageButtonClicked) { + Icon( + modifier = Modifier.alpha(0.5f), + imageVector = Icons.Rounded.Close, + contentDescription = null + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + } + } +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/Ext.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/Ext.kt index 36de0c13..4ac0a280 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/Ext.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/Ext.kt @@ -1,19 +1,20 @@ package dev.meloda.fast.messageshistory.util +import com.conena.nanokt.collections.indexOfFirstOrNull import dev.meloda.fast.messageshistory.model.UiItem fun List.firstMessage(): UiItem.Message = filterIsInstance().first() fun List.firstMessageOrNull(): UiItem.Message? = filterIsInstance().firstOrNull() -fun List.indexOfMessageById(messageId: Int): Int = +fun List.indexOfMessageById(messageId: Long): Int = indexOfFirst { it.id == messageId } -fun List.findMessageById(messageId: Int): UiItem.Message? = +fun List.findMessageById(messageId: Long): UiItem.Message? = firstOrNull { it.id == messageId } as UiItem.Message? -fun List.indexOfMessageByCmId(cmId: Int): Int = - indexOfFirst { it.cmId == cmId } +fun List.indexOfMessageByCmId(cmId: Long): Int? = + indexOfFirstOrNull { it.cmId == cmId } -fun List.findMessageByCmId(cmId: Int): UiItem.Message = +fun List.findMessageByCmId(cmId: Long): UiItem.Message = first { it.cmId == cmId } as UiItem.Message diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt index 94c8ac6f..f795b053 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt @@ -1,10 +1,15 @@ package dev.meloda.fast.messageshistory.util import android.content.res.Resources +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.AnnotatedString.Annotation import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.StringAnnotation import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import dev.meloda.fast.common.extensions.orDots import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiText @@ -15,6 +20,7 @@ import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.messageshistory.model.SendingStatus import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.model.api.PeerType +import dev.meloda.fast.model.api.domain.FormatDataType import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.ui.R @@ -22,7 +28,7 @@ import java.text.SimpleDateFormat import java.util.Locale import dev.meloda.fast.ui.R as UiR -private fun isAccount(fromId: Int) = fromId == UserConfig.userId +private fun isAccount(fromId: Long) = fromId == UserConfig.userId fun VkMessage.extractAvatar() = when { isUser() -> { @@ -96,11 +102,12 @@ fun VkMessage.asPresentation( showName: Boolean, prevMessage: VkMessage?, nextMessage: VkMessage?, - showTimeInActionMessages: Boolean + showTimeInActionMessages: Boolean, + isSelected: Boolean ): UiItem = when { action != null -> UiItem.ActionMessage( id = id, - conversationMessageId = conversationMessageId, + conversationMessageId = cmId, text = extractActionText( resources = resourceProvider.resources, youPrefix = resourceProvider.getString(R.string.you_message_prefix), @@ -111,8 +118,12 @@ fun VkMessage.asPresentation( else -> UiItem.Message( id = id, - conversationMessageId = conversationMessageId, - text = text, + conversationMessageId = cmId, + text = extractTextWithVisualizedMentions( + isOut = isOut, + originalText = text, + formatData = formatData + ), isOut = isOut, fromId = fromId, date = extractDate(), @@ -126,9 +137,13 @@ fun VkMessage.asPresentation( isEdited = updateTime != null, isRead = isRead(conversation), sendingStatus = when { + isFailed() -> SendingStatus.FAILED id <= 0 -> SendingStatus.SENDING else -> SendingStatus.SENT - } + }, + isSelected = isSelected, + isPinned = isPinned, + isImportant = isImportant ) } @@ -537,3 +552,144 @@ fun VkMessage.extractActionText( } } } + +// TODO: 04-Apr-25, Danil Nikolaev: get rid of method duplication +fun extractTextWithVisualizedMentions( + isOut: Boolean, + originalText: String?, + formatData: VkMessage.FormatData? +): AnnotatedString? { + if (originalText == null) return null + + val annotations = + mutableListOf>() + + val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex() + + val mentions = mutableListOf() + + var currentIndex = 0 + val replacements = mutableListOf>() + + val newText = regex.replace(originalText) { matchResult -> + val idPrefix = matchResult.groups[1]?.value.orEmpty() + val startIndex = matchResult.range.first + val endIndex = matchResult.range.last + + val id = matchResult.groups[2]?.value ?: "" + + val replaced = matchResult.groups[3]?.value.orEmpty() + + val indexRange = + (startIndex + currentIndex)..startIndex + currentIndex + replaced.length + + replacements.add(indexRange to replaced) + + mentions += MentionIndex( + id = id.toLongOrNull() ?: -1, + idPrefix = idPrefix, + indexRange = indexRange + ) + + currentIndex += replaced.length - (endIndex - startIndex + 1) + + replaced + } + + mentions.forEach { mention -> + val startIndex = mention.indexRange.first + val endIndex = mention.indexRange.last + + annotations += AnnotatedString.Range( + item = SpanStyle(color = Color.Red), + start = startIndex, + end = endIndex + ) + annotations += AnnotatedString.Range( + item = StringAnnotation(mention.id.toString()), + tag = mention.idPrefix, + start = startIndex, + end = endIndex + ) + } + + if (formatData == null) return AnnotatedString(text = newText, annotations = annotations) + + var current = 0 + + val newOffsets = formatData.items.map { (offset, length) -> + val r = replacements.filter { (range, _) -> + (range - current) collidesWith (offset.. range.first + } + + current = r.sumOf { (range, _) -> range.last - range.first - 1 } + + offset + current + } + + formatData.items.forEachIndexed { index, item -> + val offset = newOffsets[index] + + val spanStyle = when (item.type) { + FormatDataType.BOLD -> { + SpanStyle(fontWeight = FontWeight.SemiBold) + } + + FormatDataType.ITALIC -> { + SpanStyle(fontStyle = FontStyle.Italic) + } + + FormatDataType.UNDERLINE -> { + SpanStyle(textDecoration = TextDecoration.Underline) + } + + FormatDataType.URL -> { + annotations += AnnotatedString.Range( + item = StringAnnotation(item.url.orEmpty()), + start = offset, + end = offset + item.length, + tag = newText.substring(offset, offset + item.length) + ) + + if (isOut) { + SpanStyle( + fontWeight = FontWeight.SemiBold, + textDecoration = TextDecoration.Underline + ) + + } else { + SpanStyle( + fontWeight = FontWeight.SemiBold, + color = Color.Red + ) + } + } + } + + annotations += AnnotatedString.Range( + item = spanStyle, + start = offset, + end = offset + item.length + ) + } + + return AnnotatedString(text = newText, annotations = annotations) +} + +data class MentionIndex( + val id: Long, + val idPrefix: String, + val indexRange: IntRange +) + +infix fun ClosedRange.collidesWith(other: ClosedRange): Boolean { + return this.start < other.endInclusive && other.start < this.endInclusive +} + +operator fun ClosedRange.minus(other: ClosedRange): ClosedRange { + return (this.start - other.start)..(this.endInclusive - other.endInclusive) +} + +operator fun ClosedRange.minus(other: Int): ClosedRange { + return (this.start - other)..(this.endInclusive - other) +} diff --git a/feature/profile/src/main/kotlin/dev/meloda/fast/profile/navigation/ProfileRoute.kt b/feature/profile/src/main/kotlin/dev/meloda/fast/profile/navigation/ProfileRoute.kt index ddb31e89..0227a08f 100644 --- a/feature/profile/src/main/kotlin/dev/meloda/fast/profile/navigation/ProfileRoute.kt +++ b/feature/profile/src/main/kotlin/dev/meloda/fast/profile/navigation/ProfileRoute.kt @@ -1,14 +1,15 @@ package dev.meloda.fast.profile.navigation -import androidx.navigation.NavController +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.platform.LocalContext 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 dev.meloda.fast.ui.extensions.sharedViewModel import kotlinx.serialization.Serializable +import org.koin.androidx.compose.koinViewModel @Serializable object Profile @@ -16,12 +17,13 @@ object Profile fun NavGraphBuilder.profileScreen( onError: (BaseError) -> Unit, onSettingsButtonClicked: () -> Unit, - onPhotoClicked: (url: String) -> Unit, - navController: NavController + onPhotoClicked: (url: String) -> Unit ) { composable { - val viewModel: ProfileViewModel = - it.sharedViewModel(navController = navController) + val context = LocalContext.current + val viewModel: ProfileViewModel = koinViewModel( + viewModelStoreOwner = context as AppCompatActivity + ) ProfileRoute( onError = onError, diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 1a76347c..7e4b3a98 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { implementation(projects.core.data) + implementation(projects.core.domain) implementation(projects.core.model) implementation(projects.core.ui) diff --git a/feature/settings/src/main/kotlin/dev/meloda/fast/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/dev/meloda/fast/settings/SettingsViewModel.kt index b94909bb..216737e7 100644 --- a/feature/settings/src/main/kotlin/dev/meloda/fast/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/dev/meloda/fast/settings/SettingsViewModel.kt @@ -5,7 +5,6 @@ import android.os.Build import androidx.appcompat.app.AppCompatDelegate import androidx.core.view.HapticFeedbackConstantsCompat import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dev.meloda.fast.common.LongPollController import dev.meloda.fast.common.extensions.findWithIndex import dev.meloda.fast.common.extensions.setValue @@ -19,16 +18,19 @@ import dev.meloda.fast.data.db.AccountsRepository import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.SettingsKeys import dev.meloda.fast.datastore.UserSettings +import dev.meloda.fast.domain.AuthUseCase import dev.meloda.fast.model.database.AccountEntity import dev.meloda.fast.settings.model.SettingsItem import dev.meloda.fast.settings.model.SettingsScreenState import dev.meloda.fast.settings.model.SettingsShowOptions import dev.meloda.fast.settings.model.TextProvider import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import dev.meloda.fast.ui.R as UiR interface SettingsViewModel { @@ -37,7 +39,7 @@ interface SettingsViewModel { val hapticType: StateFlow fun onLogOutAlertDismissed() - fun onLogOutAlertPositiveClick() + suspend fun onLogOutAlertPositiveClick() fun onPerformCrashAlertDismissed() fun onPerformCrashPositiveButtonClicked() @@ -50,6 +52,7 @@ interface SettingsViewModel { } class SettingsViewModelImpl( + private val authUseCase: AuthUseCase, private val accountsRepository: AccountsRepository, private val userSettings: UserSettings, private val resources: Resources, @@ -69,20 +72,37 @@ class SettingsViewModelImpl( emitShowOptions { old -> old.copy(showLogOut = false) } } - override fun onLogOutAlertPositiveClick() { - viewModelScope.launch(Dispatchers.IO) { - accountsRepository.storeAccounts( - listOf( - AccountEntity( - userId = UserConfig.userId, - accessToken = "", - fastToken = UserConfig.fastToken, - trustedHash = UserConfig.trustedHash + override suspend fun onLogOutAlertPositiveClick() { + withContext(Dispatchers.IO) { + val tasks = listOf( +// async { +// suspendCoroutine { continuation -> +// authUseCase.logout().listenValue(viewModelScope) { state -> +// state.processState( +// any = { continuation.resume(Unit) }, +// success = {}, +// error = {} +// ) +// } +// } +// }, + async { + accountsRepository.storeAccounts( + listOf( + AccountEntity( + userId = UserConfig.userId, + accessToken = "", + fastToken = UserConfig.fastToken, + trustedHash = UserConfig.trustedHash, + exchangeToken = null + ) + ) ) - ) + }, + async { UserConfig.clear() } ) - UserConfig.clear() + tasks.awaitAll() } } diff --git a/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/SettingsScreen.kt index c177ad53..e83a7af2 100644 --- a/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/SettingsScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -52,6 +53,7 @@ import dev.meloda.fast.settings.presentation.item.TitleTextItem import dev.meloda.fast.ui.components.ActionInvokeDismiss import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.theme.LocalThemeConfig +import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import dev.meloda.fast.ui.R as UiR @@ -83,12 +85,16 @@ fun SettingsRoute( onSettingsItemValueChanged = viewModel::onSettingsItemChanged ) + val scope = rememberCoroutineScope() + HandlePopups( performCrashPositiveClick = viewModel::onPerformCrashPositiveButtonClicked, performCrashDismissed = viewModel::onPerformCrashAlertDismissed, logoutPositiveClick = { - viewModel.onLogOutAlertPositiveClick() - onLogOutButtonClicked() + scope.launch { + viewModel.onLogOutAlertPositiveClick() + onLogOutButtonClicked() + } }, logoutDismissed = viewModel::onLogOutAlertDismissed, screenState = screenState diff --git a/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/item/TextFieldItem.kt b/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/item/TextFieldItem.kt index 698382a7..ef5dabeb 100644 --- a/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/item/TextFieldItem.kt +++ b/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/item/TextFieldItem.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -139,8 +140,11 @@ fun EditTextAlert( cancelText = stringResource(id = R.string.cancel), actionInvokeDismiss = ActionInvokeDismiss.Always ) { - Row(modifier = Modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.width(20.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { TextField( modifier = Modifier .fillMaxWidth() @@ -155,8 +159,8 @@ fun EditTextAlert( placeholder = { Text(text = "Value") }, shape = RoundedCornerShape(10.dp), ) - Spacer(modifier = Modifier.width(20.dp)) } + Spacer(modifier = Modifier.height(8.dp)) } LaunchedEffect(Unit) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 643477f7..4aa011b7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,25 +2,25 @@ minSdk = "23" targetSdk = "35" compileSdk = "35" -versionCode = "9" -versionName = "0.1.6" +versionCode = "10" +versionName = "0.2.0" -agp = "8.9.0" +agp = "8.9.1" converterMoshi = "2.11.0" eithernet = "2.0.0" -haze = "1.5.1" -kotlin = "2.1.10" -ksp = "2.1.10-1.0.31" +haze = "1.5.2" +kotlin = "2.1.20" +ksp = "2.1.20-1.0.32" -compose-bom = "2025.03.00" -koin = "4.0.2" +compose-bom = "2025.03.01" +koin = "4.0.4" accompanist = "0.37.2" coil = "2.7.0" coroutines = "1.10.1" junit = "4.13.2" chucker = "4.1.0" -guava = "33.4.5-jre" +guava = "33.4.6-jre" lifecycle = "2.8.7" core-ktx = "1.15.0" material = "1.12.0" @@ -30,12 +30,8 @@ retrofit = "2.11.0" room = "2.6.1" preference-ktx = "1.2.1" nanokt = "1.2.0" -junitVersion = "1.2.1" -espressoCore = "3.6.1" -appcompat = "1.7.0" androidx-navigation = "2.8.9" -serialization = "1.8.0" -rebugger = "1.0.0-rc03" +serialization = "1.8.1" moduleGraph = "2.8.0" [libraries] @@ -69,18 +65,13 @@ preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = nanokt = { module = "com.conena.nanokt:nanokt", version.ref = "nanokt" } nanokt-android = { module = "com.conena.nanokt:nanokt-android", version.ref = "nanokt" } nanokt-jvm = { module = "com.conena.nanokt:nanokt-jvm", version.ref = "nanokt" } -ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } -rebugger = { module = "io.github.theapache64:rebugger-android", version.ref = "rebugger" } - compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } compose-material3 = { module = "androidx.compose.material3:material3" } -compose-ui = { module = "androidx.compose.ui:ui" } +compose-ui = { module = "androidx.compose.ui:ui", version = "1.8.0-rc02" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-material3-windowsize = { module = "androidx.compose.material3:material3-window-size-class" } @@ -92,10 +83,7 @@ compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } -koin-core-coroutines = { module = "io.insert-koin:koin-core-coroutines", version.ref = "koin" } -koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } -koin-android-test = { module = "io.insert-koin:koin-android-test", version.ref = "koin" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-androidx-compose-navigation = { module = "io.insert-koin:koin-androidx-compose-navigation", version.ref = "koin" }