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"?>
<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.POST_NOTIFICATIONS" />
@@ -9,6 +10,7 @@
<application
android:name="dev.meloda.fast.common.AppGlobal"
android:allowBackup="true"
android:appCategory="social"
android:enableOnBackInvokedCallback="true"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
@@ -16,7 +18,8 @@
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
tools:targetApi="tiramisu">
<activity
android:name=".presentation.MainActivity"
@@ -27,6 +30,10 @@
<action android:name="android.intent.action.MAIN" />
<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>
</activity>
@@ -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<Boolean>
val isNeedToRequestNotifications: StateFlow<Boolean>
val profileImageUrl: StateFlow<String?>
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<Any?>(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<String?>(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
}
}
}
}
@@ -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
)
}
}
@@ -99,7 +99,7 @@ class MainActivity : AppCompatActivity() {
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LifecycleResumeEffect(true) {
viewModel.onAppResumed()
viewModel.onAppResumed(intent)
onPauseOrDispose {}
}
@@ -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
)
@@ -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(
@@ -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
) {
@@ -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),