From 3b66eb8da022dfbdc34ac9112e3f54f5b92fdb6b Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Thu, 12 Dec 2024 10:44:34 +0300 Subject: [PATCH] profile avatar in bottom bar; ability to open app settings from system's about app screen --- app/src/main/AndroidManifest.xml | 11 +++- .../kotlin/dev/meloda/fast/MainViewModel.kt | 59 ++++++++++++++++--- .../dev/meloda/fast/navigation/MainGraph.kt | 7 ++- .../meloda/fast/presentation/MainActivity.kt | 2 +- .../meloda/fast/presentation/MainScreen.kt | 53 ++++++++++++++--- .../meloda/fast/presentation/RootScreen.kt | 3 +- .../conversations/ConversationsViewModel.kt | 22 ------- .../presentation/ConversationsScreen.kt | 27 ++++----- 8 files changed, 124 insertions(+), 60 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 579b3586..d57ff13c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -9,6 +10,7 @@ + android:theme="@style/AppTheme" + tools:targetApi="tiramisu"> + + + + diff --git a/app/src/main/kotlin/dev/meloda/fast/MainViewModel.kt b/app/src/main/kotlin/dev/meloda/fast/MainViewModel.kt index 4175b3ea..f302b0ee 100644 --- a/app/src/main/kotlin/dev/meloda/fast/MainViewModel.kt +++ b/app/src/main/kotlin/dev/meloda/fast/MainViewModel.kt @@ -1,11 +1,14 @@ package dev.meloda.fast +import android.content.Intent import android.os.Build import android.util.Log import androidx.appcompat.app.AppCompatDelegate +import androidx.core.app.NotificationCompat import androidx.core.os.LocaleListCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.conena.nanokt.android.os.isMinSdk import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import dev.meloda.fast.auth.AuthGraph @@ -15,11 +18,14 @@ import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.model.LongPollState import dev.meloda.fast.data.UserConfig -import dev.meloda.fast.domain.GetCurrentAccountUseCase +import dev.meloda.fast.data.processState import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.UserSettings +import dev.meloda.fast.domain.GetCurrentAccountUseCase +import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.model.BaseError import dev.meloda.fast.navigation.Main +import dev.meloda.fast.settings.navigation.Settings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -36,11 +42,13 @@ interface MainViewModel { val isNeedToCheckNotificationsPermission: StateFlow val isNeedToRequestNotifications: StateFlow + val profileImageUrl: StateFlow + fun onError(error: BaseError) fun onNavigatedToAuth() - fun onAppResumed() + fun onAppResumed(intent: Intent) @OptIn(ExperimentalPermissionsApi::class) fun onPermissionCheckStatus(status: PermissionStatus) @@ -55,14 +63,11 @@ interface MainViewModel { class MainViewModelImpl( private val getCurrentAccountUseCase: GetCurrentAccountUseCase, + private val loadUserByIdUseCase: LoadUserByIdUseCase, private val userSettings: UserSettings, private val longPollController: LongPollController ) : MainViewModel, ViewModel() { - init { - loadAccounts() - } - override val startDestination = MutableStateFlow(null) override val isNeedToReplaceWithAuth = MutableStateFlow(false) @@ -71,6 +76,11 @@ class MainViewModelImpl( 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 -> { @@ -83,7 +93,12 @@ class MainViewModelImpl( isNeedToReplaceWithAuth.update { false } } - override fun onAppResumed() { + override fun onAppResumed(intent: Intent) { + openNotificationsSettings = + intent.hasCategory(NotificationCompat.INTENT_CATEGORY_NOTIFICATION_PREFERENCES) + openAppSettings = + isMinSdk(Build.VERSION_CODES.N) && intent.action == Intent.ACTION_APPLICATION_PREFERENCES + if (isNeedToShowNotificationsRationaleDialog.value) { isNeedToShowNotificationsRationaleDialog.update { false } isNeedToCheckNotificationsPermission.update { true } @@ -100,6 +115,8 @@ class MainViewModelImpl( .take(5) userSettings.onAppLanguageChanged(newLanguage) + + loadAccounts() } @ExperimentalPermissionsApi @@ -151,6 +168,22 @@ class MainViewModelImpl( disableBackgroundLongPoll() } + private fun loadProfile() { + loadUserByIdUseCase(userId = null) + .listenValue(viewModelScope) { state -> + state.processState( + error = { error -> + profileImageUrl.emit(null) + }, + success = { response -> + val user = response ?: return@listenValue + + profileImageUrl.emit(user.photo100) + } + ) + } + } + private fun listenLongPollState() { longPollController.stateToApply.listenValue(viewModelScope) { newState -> if (newState == LongPollState.Background) { @@ -184,9 +217,17 @@ class MainViewModelImpl( ) } + if (currentAccount != null) { + loadProfile() + } + startDestination.setValue { - if (currentAccount == null) AuthGraph - else Main + when { + openAppSettings -> Settings + openNotificationsSettings -> Settings + currentAccount == null -> AuthGraph + else -> Main + } } } } 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 6c6e39db..4f20efcd 100644 --- a/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt +++ b/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt @@ -2,6 +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.friends.navigation.Friends import dev.meloda.fast.model.BaseError @@ -22,7 +23,8 @@ fun NavGraphBuilder.mainScreen( onError: (BaseError) -> Unit, onSettingsButtonClicked: () -> Unit, onConversationClicked: (conversationId: Int) -> Unit, - onPhotoClicked: (url: String) -> Unit + onPhotoClicked: (url: String) -> Unit, + viewModel: MainViewModel ) { val navigationItems = ImmutableList.of( BottomNavigationItem( @@ -51,7 +53,8 @@ fun NavGraphBuilder.mainScreen( onError = onError, onSettingsButtonClicked = onSettingsButtonClicked, onConversationItemClicked = onConversationClicked, - onPhotoClicked = onPhotoClicked + onPhotoClicked = onPhotoClicked, + viewModel = viewModel ) } } 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 986acc1a..78bbe1ec 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt @@ -99,7 +99,7 @@ class MainActivity : AppCompatActivity() { val viewModel: MainViewModel = koinViewModel() LifecycleResumeEffect(true) { - viewModel.onAppResumed() + viewModel.onAppResumed(intent) onPauseOrDispose {} } 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 5bd64261..b2f3fa59 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt @@ -7,6 +7,8 @@ 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 import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarDefaults @@ -16,19 +18,25 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip 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 +import coil.compose.SubcomposeAsyncImage import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeChild 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.friends.navigation.friendsScreen import dev.meloda.fast.model.BaseError @@ -47,12 +55,15 @@ fun MainScreen( onError: (BaseError) -> Unit = {}, onSettingsButtonClicked: () -> Unit = {}, onConversationItemClicked: (conversationId: Int) -> Unit = {}, - onPhotoClicked: (url: String) -> Unit = {} + onPhotoClicked: (url: String) -> Unit = {}, + viewModel: MainViewModel ) { val currentTheme = LocalThemeConfig.current val hazeState = remember { HazeState() } val navController = rememberNavController() + val profileImageUrl by viewModel.profileImageUrl.collectAsStateWithLifecycle() + var selectedItemIndex by rememberSaveable { mutableIntStateOf(1) } @@ -90,13 +101,39 @@ fun MainScreen( } }, icon = { - Icon( - painter = painterResource( - id = if (selectedItemIndex == index) item.selectedIconResId - else item.unselectedIconResId - ), - contentDescription = null - ) + if (index == navigationItems.size - 1) { + var isLoading by remember { + mutableStateOf(true) + } + if (isLoading) { + Icon( + painter = painterResource( + id = if (selectedItemIndex == index) item.selectedIconResId + else item.unselectedIconResId + ), + contentDescription = null + ) + } + SubcomposeAsyncImage( + model = profileImageUrl, + contentDescription = "Profile Image", + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .alpha(if (isLoading) 0f else 1f), + onSuccess = { + isLoading = false + } + ) + } else { + Icon( + painter = painterResource( + id = if (selectedItemIndex == index) item.selectedIconResId + else item.unselectedIconResId + ), + contentDescription = null + ) + } }, alwaysShowLabel = false ) 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 c64f7f3b..4678a203 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt @@ -123,7 +123,8 @@ fun RootScreen( onError = viewModel::onError, onSettingsButtonClicked = navController::navigateToSettings, onConversationClicked = navController::navigateToMessagesHistory, - onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) } + onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }, + viewModel = viewModel ) messagesHistoryScreen( 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 2967eb9d..78cc31d3 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 @@ -18,7 +18,6 @@ import dev.meloda.fast.data.State import dev.meloda.fast.data.processState import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.domain.ConversationsUseCase -import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.model.BaseError @@ -66,7 +65,6 @@ interface ConversationsViewModel { class ConversationsViewModelImpl( updatesParser: LongPollUpdatesParser, private val conversationsUseCase: ConversationsUseCase, - private val loadUserByIdUseCase: LoadUserByIdUseCase, private val messagesUseCase: MessagesUseCase, private val resources: Resources, private val userSettings: UserSettings @@ -99,8 +97,6 @@ class ConversationsViewModelImpl( updatesParser.onConversationPinStateChanged(::handlePinStateChanged) updatesParser.onInteractions(::handleInteraction) - loadProfile() - loadConversations() } @@ -227,24 +223,6 @@ class ConversationsViewModelImpl( screenState.setValue { old -> old.copy(showOptions = newShowOptions) } } - private fun loadProfile() { - loadUserByIdUseCase(userId = null) - .listenValue(viewModelScope) { state -> - state.processState( - error = { error -> - - }, - success = { response -> - val user = response ?: return@listenValue - - screenState.setValue { old -> - old.copy(profileImageUrl = user.photo100) - } - } - ) - } - } - private fun loadConversations( offset: Int = currentOffset.value ) { 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 a00b2034..284fd3b7 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 @@ -10,7 +10,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -21,17 +20,17 @@ 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.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.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.FloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -54,7 +53,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.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView @@ -68,7 +66,6 @@ import androidx.compose.ui.unit.dp import androidx.core.content.edit import androidx.core.view.HapticFeedbackConstantsCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.AsyncImage import coil.imageLoader import coil.request.ImageRequest import dev.chrisbanes.haze.haze @@ -251,16 +248,16 @@ fun ConversationsScreen( ) }, actions = { - AsyncImage( - model = screenState.profileImageUrl, - contentDescription = "Profile Image", - modifier = Modifier - .padding(end = 12.dp) - .size(32.dp) - .clip(CircleShape) - .clickable { dropDownMenuExpanded = true }, - placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut) - ) + IconButton( + onClick = { + dropDownMenuExpanded = true + } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null + ) + } DropdownMenu( modifier = Modifier.defaultMinSize(minWidth = 140.dp),