profile avatar in bottom bar;

ability to open app settings from system's about app screen
This commit is contained in:
2024-12-12 10:44:34 +03:00
parent cd42a7992a
commit 3b66eb8da0
8 changed files with 124 additions and 60 deletions
+9 -2
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -9,6 +10,7 @@
<application <application
android:name="dev.meloda.fast.common.AppGlobal" android:name="dev.meloda.fast.common.AppGlobal"
android:allowBackup="true" android:allowBackup="true"
android:appCategory="social"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:fullBackupOnly="true" android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -16,7 +18,8 @@
android:localeConfig="@xml/locales_config" android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme"
tools:targetApi="tiramisu">
<activity <activity
android:name=".presentation.MainActivity" android:name=".presentation.MainActivity"
@@ -27,6 +30,10 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.NOTIFICATION_PREFERENCES" />
<action android:name="android.intent.action.APPLICATION_PREFERENCES" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
</activity> </activity>
@@ -1,11 +1,14 @@
package dev.meloda.fast package dev.meloda.fast
import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.NotificationCompat
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.conena.nanokt.android.os.isMinSdk
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.PermissionStatus
import dev.meloda.fast.auth.AuthGraph 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.extensions.setValue
import dev.meloda.fast.common.model.LongPollState import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.data.UserConfig 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.AppSettings
import dev.meloda.fast.datastore.UserSettings 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.BaseError
import dev.meloda.fast.navigation.Main import dev.meloda.fast.navigation.Main
import dev.meloda.fast.settings.navigation.Settings
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -36,11 +42,13 @@ interface MainViewModel {
val isNeedToCheckNotificationsPermission: StateFlow<Boolean> val isNeedToCheckNotificationsPermission: StateFlow<Boolean>
val isNeedToRequestNotifications: StateFlow<Boolean> val isNeedToRequestNotifications: StateFlow<Boolean>
val profileImageUrl: StateFlow<String?>
fun onError(error: BaseError) fun onError(error: BaseError)
fun onNavigatedToAuth() fun onNavigatedToAuth()
fun onAppResumed() fun onAppResumed(intent: Intent)
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
fun onPermissionCheckStatus(status: PermissionStatus) fun onPermissionCheckStatus(status: PermissionStatus)
@@ -55,14 +63,11 @@ interface MainViewModel {
class MainViewModelImpl( class MainViewModelImpl(
private val getCurrentAccountUseCase: GetCurrentAccountUseCase, private val getCurrentAccountUseCase: GetCurrentAccountUseCase,
private val loadUserByIdUseCase: LoadUserByIdUseCase,
private val userSettings: UserSettings, private val userSettings: UserSettings,
private val longPollController: LongPollController private val longPollController: LongPollController
) : MainViewModel, ViewModel() { ) : MainViewModel, ViewModel() {
init {
loadAccounts()
}
override val startDestination = MutableStateFlow<Any?>(null) override val startDestination = MutableStateFlow<Any?>(null)
override val isNeedToReplaceWithAuth = MutableStateFlow(false) override val isNeedToReplaceWithAuth = MutableStateFlow(false)
@@ -71,6 +76,11 @@ class MainViewModelImpl(
override val isNeedToCheckNotificationsPermission = MutableStateFlow(false) override val isNeedToCheckNotificationsPermission = MutableStateFlow(false)
override val isNeedToRequestNotifications = MutableStateFlow(false) override val isNeedToRequestNotifications = MutableStateFlow(false)
override val profileImageUrl = MutableStateFlow<String?>(null)
private var openNotificationsSettings = false
private var openAppSettings = false
override fun onError(error: BaseError) { override fun onError(error: BaseError) {
when (error) { when (error) {
BaseError.SessionExpired -> { BaseError.SessionExpired -> {
@@ -83,7 +93,12 @@ class MainViewModelImpl(
isNeedToReplaceWithAuth.update { false } 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) { if (isNeedToShowNotificationsRationaleDialog.value) {
isNeedToShowNotificationsRationaleDialog.update { false } isNeedToShowNotificationsRationaleDialog.update { false }
isNeedToCheckNotificationsPermission.update { true } isNeedToCheckNotificationsPermission.update { true }
@@ -100,6 +115,8 @@ class MainViewModelImpl(
.take(5) .take(5)
userSettings.onAppLanguageChanged(newLanguage) userSettings.onAppLanguageChanged(newLanguage)
loadAccounts()
} }
@ExperimentalPermissionsApi @ExperimentalPermissionsApi
@@ -151,6 +168,22 @@ class MainViewModelImpl(
disableBackgroundLongPoll() 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() { private fun listenLongPollState() {
longPollController.stateToApply.listenValue(viewModelScope) { newState -> longPollController.stateToApply.listenValue(viewModelScope) { newState ->
if (newState == LongPollState.Background) { if (newState == LongPollState.Background) {
@@ -184,9 +217,17 @@ class MainViewModelImpl(
) )
} }
if (currentAccount != null) {
loadProfile()
}
startDestination.setValue { startDestination.setValue {
if (currentAccount == null) AuthGraph when {
else Main openAppSettings -> Settings
openNotificationsSettings -> Settings
currentAccount == null -> AuthGraph
else -> Main
}
} }
} }
} }
@@ -2,6 +2,7 @@ package dev.meloda.fast.navigation
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.conversations.navigation.Conversations import dev.meloda.fast.conversations.navigation.Conversations
import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
@@ -22,7 +23,8 @@ fun NavGraphBuilder.mainScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit, onSettingsButtonClicked: () -> Unit,
onConversationClicked: (conversationId: Int) -> Unit, onConversationClicked: (conversationId: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit onPhotoClicked: (url: String) -> Unit,
viewModel: MainViewModel
) { ) {
val navigationItems = ImmutableList.of( val navigationItems = ImmutableList.of(
BottomNavigationItem( BottomNavigationItem(
@@ -51,7 +53,8 @@ fun NavGraphBuilder.mainScreen(
onError = onError, onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked, onSettingsButtonClicked = onSettingsButtonClicked,
onConversationItemClicked = onConversationClicked, onConversationItemClicked = onConversationClicked,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked,
viewModel = viewModel
) )
} }
} }
@@ -99,7 +99,7 @@ class MainActivity : AppCompatActivity() {
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>() val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LifecycleResumeEffect(true) { LifecycleResumeEffect(true) {
viewModel.onAppResumed() viewModel.onAppResumed(intent)
onPauseOrDispose {} onPauseOrDispose {}
} }
@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarDefaults import androidx.compose.material3.NavigationBarDefaults
@@ -16,19 +18,25 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.navigation import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import coil.compose.SubcomposeAsyncImage
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.conversations.navigation.conversationsScreen import dev.meloda.fast.conversations.navigation.conversationsScreen
import dev.meloda.fast.friends.navigation.friendsScreen import dev.meloda.fast.friends.navigation.friendsScreen
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
@@ -47,12 +55,15 @@ fun MainScreen(
onError: (BaseError) -> Unit = {}, onError: (BaseError) -> Unit = {},
onSettingsButtonClicked: () -> Unit = {}, onSettingsButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {}, onConversationItemClicked: (conversationId: Int) -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {} onPhotoClicked: (url: String) -> Unit = {},
viewModel: MainViewModel
) { ) {
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val navController = rememberNavController() val navController = rememberNavController()
val profileImageUrl by viewModel.profileImageUrl.collectAsStateWithLifecycle()
var selectedItemIndex by rememberSaveable { var selectedItemIndex by rememberSaveable {
mutableIntStateOf(1) mutableIntStateOf(1)
} }
@@ -90,13 +101,39 @@ fun MainScreen(
} }
}, },
icon = { icon = {
Icon( if (index == navigationItems.size - 1) {
painter = painterResource( var isLoading by remember {
id = if (selectedItemIndex == index) item.selectedIconResId mutableStateOf(true)
else item.unselectedIconResId }
), if (isLoading) {
contentDescription = null 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 alwaysShowLabel = false
) )
@@ -123,7 +123,8 @@ fun RootScreen(
onError = viewModel::onError, onError = viewModel::onError,
onSettingsButtonClicked = navController::navigateToSettings, onSettingsButtonClicked = navController::navigateToSettings,
onConversationClicked = navController::navigateToMessagesHistory, onConversationClicked = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) } onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) },
viewModel = viewModel
) )
messagesHistoryScreen( messagesHistoryScreen(
@@ -18,7 +18,6 @@ import dev.meloda.fast.data.State
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConversationsUseCase import dev.meloda.fast.domain.ConversationsUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
@@ -66,7 +65,6 @@ interface ConversationsViewModel {
class ConversationsViewModelImpl( class ConversationsViewModelImpl(
updatesParser: LongPollUpdatesParser, updatesParser: LongPollUpdatesParser,
private val conversationsUseCase: ConversationsUseCase, private val conversationsUseCase: ConversationsUseCase,
private val loadUserByIdUseCase: LoadUserByIdUseCase,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val resources: Resources, private val resources: Resources,
private val userSettings: UserSettings private val userSettings: UserSettings
@@ -99,8 +97,6 @@ class ConversationsViewModelImpl(
updatesParser.onConversationPinStateChanged(::handlePinStateChanged) updatesParser.onConversationPinStateChanged(::handlePinStateChanged)
updatesParser.onInteractions(::handleInteraction) updatesParser.onInteractions(::handleInteraction)
loadProfile()
loadConversations() loadConversations()
} }
@@ -227,24 +223,6 @@ class ConversationsViewModelImpl(
screenState.setValue { old -> old.copy(showOptions = newShowOptions) } 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( private fun loadConversations(
offset: Int = currentOffset.value offset: Int = currentOffset.value
) { ) {
@@ -10,7 +10,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideIn import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut import androidx.compose.animation.slideOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -54,7 +53,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
@@ -68,7 +66,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import dev.chrisbanes.haze.haze import dev.chrisbanes.haze.haze
@@ -251,16 +248,16 @@ fun ConversationsScreen(
) )
}, },
actions = { actions = {
AsyncImage( IconButton(
model = screenState.profileImageUrl, onClick = {
contentDescription = "Profile Image", dropDownMenuExpanded = true
modifier = Modifier }
.padding(end = 12.dp) ) {
.size(32.dp) Icon(
.clip(CircleShape) imageVector = Icons.Default.MoreVert,
.clickable { dropDownMenuExpanded = true }, contentDescription = null
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut) )
) }
DropdownMenu( DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp), modifier = Modifier.defaultMinSize(minWidth = 140.dp),