do profile UI/UX

This commit is contained in:
Emin 2024-06-21 10:53:58 +05:00
parent 15b3613363
commit 45a8da004b
10 changed files with 391 additions and 68 deletions

View file

@ -3,12 +3,10 @@ package app.tourism.data.dto.profile
data class User(
val id: Int,
val avatar: String?,
val country_id: Any?,
val created_at: String,
val country: String,
val full_name: String,
val language: Int,
val phone: String,
val theme: Int,
val updated_at: String,
val language: String,
val phone: String?,
val theme: String,
val username: String
)

View file

@ -54,7 +54,6 @@ fun CoilImg(
modifier = modifier.background(color = backgroundColor),
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.decoderFactory(SvgDecoder.Factory())
.crossfade(500)
.error(R.drawable.error_centered)
.build(),
@ -63,14 +62,3 @@ fun CoilImg(
contentScale = contentScale
)
}
@Composable
fun CoilImgAccompanist() {
// CoilImage(
// modifier = modifier,
// imageModel = url,
// contentScale = contentScale,
// placeHolder = painterResource(R.drawable.placeholder),
// error = painterResource(R.drawable.error_centered)
// )
}

View file

@ -0,0 +1,75 @@
package app.tourism.ui.common.buttons
import ButtonLoading
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.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.shape.RoundedCornerShape
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.clip
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.organicmaps.R
import app.tourism.ui.common.HorizontalSpace
import app.tourism.ui.theme.TextStyles
@Composable
fun SecondaryButton(
modifier: Modifier = Modifier,
label: String,
loading: Boolean = false,
icon: (@Composable () -> Unit)? = null,
onClick: () -> Unit
) {
val shape = RoundedCornerShape(16.dp)
Box(
modifier = Modifier
.height(height = 56.dp)
.background(color = colorResource(id = R.color.transparent), shape = shape)
.border(width = 1.dp, color = MaterialTheme.colorScheme.primary, shape = shape)
.clip(shape)
.clickable { onClick() }
.then(modifier),
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
if (!loading) {
icon?.apply {
invoke()
HorizontalSpace(width = 8.dp)
}
Text(
text = label,
style = TextStyles.h4,
textAlign = TextAlign.Center,
fontSize = 16.sp,
fontWeight = FontWeight.W600,
color = MaterialTheme.colorScheme.primary
)
} else {
ButtonLoading()
}
}
}
}

View file

@ -7,9 +7,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.organicmaps.R
import app.tourism.ui.theme.TextStyles
@Composable
@ -25,7 +27,7 @@ fun AppEditText(
value = value,
onValueChange = onValueChange,
hint = hint,
hintColor = Color.Gray,
hintColor = MaterialTheme.colorScheme.onBackground,
isError = isError,
textFieldHeight = 50.dp,
textFieldPadding = PaddingValues(vertical = 8.dp),
@ -39,7 +41,7 @@ fun AppEditText(
keyboardActions = keyboardActions,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
focusedColor = MaterialTheme.colorScheme.onBackground,
unfocusedColor = Color.Gray,
unfocusedColor = MaterialTheme.colorScheme.onBackground,
errorColor = MaterialTheme.colorScheme.onError
)
}

View file

@ -5,6 +5,7 @@ import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -18,24 +19,12 @@ import app.tourism.ui.screens.main.favorites.favorites.FavoritesScreen
import app.tourism.ui.screens.main.home.home.HomeScreen
import app.tourism.ui.screens.main.profile.personal_data.PersonalDataScreen
import app.tourism.ui.screens.main.profile.profile.ProfileScreen
import app.tourism.ui.screens.main.profile.profile.ProfileViewModel
import app.tourism.ui.screens.main.site_details.SiteDetailsScreen
import app.tourism.utils.navigateToMap
import app.tourism.utils.navigateToMapForRoute
import kotlinx.serialization.Serializable
// tabs
@Serializable
object HomeTab
@Serializable
object CategoriesTab
@Serializable
object FavoritesTab
@Serializable
object ProfileTab
// home
@Serializable
object Home
@ -71,17 +60,17 @@ fun MainNavigation(rootNavController: NavHostController, themeVM: ThemeViewModel
}
val onMapClick = { navigateToMap(context) }
NavHost(rootNavController, startDestination = HomeTab) {
composable<HomeTab> {
NavHost(rootNavController, startDestination = "home_tab") {
composable("home_tab") {
HomeNavHost(onSiteClick, onMapClick)
}
composable<CategoriesTab> {
composable("categories_tab") {
CategoriesNavHost(onSiteClick, onMapClick)
}
composable<FavoritesTab> {
composable("favorites_tab") {
FavoritesNavHost(onSiteClick)
}
composable<ProfileTab> {
composable("profile_tab") {
ProfileNavHost(themeVM = themeVM)
}
composable<SiteDetails> { backStackEntry ->
@ -139,7 +128,7 @@ fun FavoritesNavHost(onSiteClick: (id: Int) -> Unit) {
}
@Composable
fun ProfileNavHost(themeVM: ThemeViewModel) {
fun ProfileNavHost(themeVM: ThemeViewModel, profileVM: ProfileViewModel = hiltViewModel()) {
val context = LocalContext.current
val profileNavController = rememberNavController()
val onBackClick = { profileNavController.navigateUp() }
@ -156,11 +145,12 @@ fun ProfileNavHost(themeVM: ThemeViewModel) {
onSignOutComplete = {
navigateToAuth(context)
},
profileVM = profileVM,
themeVM = themeVM
)
}
composable<PersonalData> {
PersonalDataScreen(onBackClick)
PersonalDataScreen(onBackClick, profileVM)
}
composable<Language> {
LanguageScreen(onBackClick)

View file

@ -102,7 +102,7 @@ fun MainSection(themeVM: ThemeViewModel) {
}
data class BottomNavigationItem(
val route: Any,
val route: String,
val title: String,
@DrawableRes val unselectedIcon: Int,
@DrawableRes val selectedIcon: Int
@ -112,25 +112,25 @@ data class BottomNavigationItem(
fun getNavItems(): List<BottomNavigationItem> {
return listOf(
BottomNavigationItem(
route = HomeTab,
route = "home_tab",
title = stringResource(id = R.string.home),
selectedIcon = R.drawable.home_selected,
unselectedIcon = R.drawable.home,
),
BottomNavigationItem(
route = CategoriesTab,
route = "categories_tab",
title = stringResource(id = R.string.categories),
selectedIcon = R.drawable.categories_selected,
unselectedIcon = R.drawable.categories,
),
BottomNavigationItem(
route = FavoritesTab,
route = "favorites_tab",
title = stringResource(id = R.string.favorites),
selectedIcon = R.drawable.heart_selected,
unselectedIcon = R.drawable.heart,
),
BottomNavigationItem(
route = ProfileTab,
route = "profile_tab",
title = stringResource(id = R.string.profile_tourism),
selectedIcon = R.drawable.profile_selected,
unselectedIcon = R.drawable.profile,

View file

@ -1,26 +1,176 @@
package app.tourism.ui.screens.main.profile.personal_data
import android.view.LayoutInflater
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import app.organicmaps.R
import app.tourism.Constants
import app.tourism.domain.models.resource.Resource
import app.tourism.ui.ObserveAsEvents
import app.tourism.ui.common.HorizontalSpace
import app.tourism.ui.common.LoadImg
import app.tourism.ui.common.SpaceForNavBar
import app.tourism.ui.common.VerticalSpace
import app.tourism.ui.common.buttons.PrimaryButton
import app.tourism.ui.common.nav.AppTopBar
import app.tourism.ui.common.textfields.AppEditText
import app.tourism.ui.screens.main.profile.profile.ProfileViewModel
import app.tourism.ui.screens.main.profile.profile.UiEvent
import app.tourism.ui.theme.TextStyles
import app.tourism.ui.utils.showToast
import com.hbb20.CountryCodePicker
@Composable
fun PersonalDataScreen(onBackClick: () -> Boolean) {
fun PersonalDataScreen(onBackClick: () -> Boolean, profileVM: ProfileViewModel) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val personalData = profileVM.profileDataResource.collectAsState().value
val fullName = profileVM.fullName.collectAsState().value
val email = profileVM.email.collectAsState().value
val countryCodeName = profileVM.countryCodeName.collectAsState().value
ObserveAsEvents(flow = profileVM.uiEventsChannelFlow) { event ->
if (event is UiEvent.ShowToast) context.showToast(event.message)
}
Scaffold(
topBar = {
AppTopBar(
title = stringResource(id = R.string.personal_data),
onBackClick = onBackClick,
)
}
},
contentWindowInsets = Constants.USUAL_WINDOW_INSETS
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
// todo
if (personalData is Resource.Success && personalData.data != null) {
val data = personalData.data
Column(
Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
VerticalSpace(height = 32.dp)
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
LoadImg(
modifier = Modifier
.size(100.dp)
.clip(CircleShape),
url = data.pfpUrl
)
HorizontalSpace(width = 20.dp)
Row(
modifier = Modifier
.clickable {
}
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val uploadPhotoText = stringResource(id = R.string.upload_photo)
Icon(
painter = painterResource(id = R.drawable.image_down),
contentDescription = uploadPhotoText,
)
HorizontalSpace(width = 8.dp)
Text(text = uploadPhotoText, style = TextStyles.h4)
}
}
VerticalSpace(height = 24.dp)
AppEditText(
value = fullName, onValueChange = { profileVM.setFullName(it) },
hint = stringResource(id = R.string.full_name),
keyboardActions = KeyboardActions(
onNext = {
focusManager.moveFocus(FocusDirection.Next)
},
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
SpaceBetweenTextFields()
AppEditText(
value = email, onValueChange = { profileVM.setEmail(it) },
hint = stringResource(id = R.string.email),
keyboardActions = KeyboardActions(
onNext = {
focusManager.moveFocus(FocusDirection.Next)
},
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
SpaceBetweenTextFields()
Text(
text = stringResource(id = R.string.country),
fontSize = 12.sp
)
AndroidView(
factory = { context ->
val view = LayoutInflater.from(context)
.inflate(R.layout.ccp_profile, null, false)
val ccp = view.findViewById<CountryCodePicker>(R.id.ccp)
ccp.setCountryForNameCode(countryCodeName)
ccp.setOnCountryChangeListener {
profileVM.setCountryCodeName(ccp.selectedCountryNameCode)
}
view
}
)
VerticalSpace(height = 10.dp)
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.onBackground,
thickness = 1.dp
)
VerticalSpace(height = 48.dp)
PrimaryButton(
label = stringResource(id = R.string.save),
onClick = { profileVM.save() },
)
SpaceForNavBar()
}
}
}
}
@Composable
fun ColumnScope.SpaceBetweenTextFields() {
VerticalSpace(height = 24.dp)
}

View file

@ -10,22 +10,32 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
@ -40,6 +50,8 @@ import app.tourism.ui.common.HorizontalSpace
import app.tourism.ui.common.LoadImg
import app.tourism.ui.common.SpaceForNavBar
import app.tourism.ui.common.VerticalSpace
import app.tourism.ui.common.buttons.PrimaryButton
import app.tourism.ui.common.buttons.SecondaryButton
import app.tourism.ui.common.nav.AppTopBar
import app.tourism.ui.common.ui_state.Loading
import app.tourism.ui.screens.main.ThemeViewModel
@ -47,19 +59,20 @@ import app.tourism.ui.theme.TextStyles
import app.tourism.ui.utils.showToast
import com.hbb20.CountryCodePicker
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
onPersonalDataClick: () -> Unit,
onLanguageClick: () -> Unit,
onSignOutComplete: () -> Unit,
vm: ProfileViewModel = hiltViewModel(),
profileVM: ProfileViewModel,
themeVM: ThemeViewModel,
) {
val context = LocalContext.current
val personalData = vm.profileDataResource.collectAsState().value
val signOutResponse = vm.signOutResponse.collectAsState().value
val personalData = profileVM.profileDataResource.collectAsState().value
val signOutResponse = profileVM.signOutResponse.collectAsState().value
ObserveAsEvents(flow = vm.uiEventsChannelFlow) { event ->
ObserveAsEvents(flow = profileVM.uiEventsChannelFlow) { event ->
when (event) {
is UiEvent.NavigateToAuth -> onSignOutComplete()
is UiEvent.ShowToast -> context.showToast(event.message)
@ -79,53 +92,74 @@ fun ProfileScreen(
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
VerticalSpace(height = 32.dp)
if (personalData is Resource.Success) {
personalData.data?.let {
ProfileBar(it, onPersonalDataClick)
ProfileBar(it)
VerticalSpace(height = 32.dp)
}
}
VerticalSpace(height = 48.dp)
// todo currency rates. Couldn't find free api or library :(
CurrencyRates(currencyRates = CurrencyRates(10.88, 10.88, 10.88))
VerticalSpace(height = 24.dp)
VerticalSpace(height = 20.dp)
GenericProfileItem(
label = stringResource(R.string.personal_data),
icon = R.drawable.profile,
onClick = onPersonalDataClick
)
VerticalSpace(height = 24.dp)
VerticalSpace(height = 20.dp)
GenericProfileItem(
label = stringResource(R.string.language),
icon = R.drawable.globe,
onClick = onLanguageClick
)
VerticalSpace(height = 24.dp)
VerticalSpace(height = 20.dp)
ThemeSwitch(themeVM = themeVM)
VerticalSpace(height = 24.dp)
VerticalSpace(height = 20.dp)
val sheetState = rememberModalBottomSheetState()
var isSheetOpen by rememberSaveable { mutableStateOf(false) }
GenericProfileItem(
label = stringResource(R.string.sign_out),
icon = R.drawable.sign_out,
isLoading = signOutResponse is Resource.Loading,
onClick = { vm.signOut() }
onClick = { isSheetOpen = true }
)
if (isSheetOpen) {
ModalBottomSheet(
containerColor = MaterialTheme.colorScheme.background,
sheetState = sheetState,
onDismissRequest = {
isSheetOpen = false
},
) {
SignOutWarning(
onSignOutClick = { profileVM.signOut() },
onCancelClick = { isSheetOpen = false },
)
}
}
SpaceForNavBar()
}
}
}
@Composable
fun ProfileBar(personalData: PersonalData, onPersonalDataClick: () -> Unit) {
fun ProfileBar(personalData: PersonalData) {
Row(
Modifier
.fillMaxWidth()
.clickable { onPersonalDataClick() },
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
LoadImg(url = personalData.pfpUrl)
LoadImg(
modifier = Modifier
.size(100.dp)
.clip(CircleShape),
url = personalData.pfpUrl
)
HorizontalSpace(width = 16.dp)
Column {
Text(text = personalData.fullName, style = TextStyles.h2)
VerticalSpace(height = 16.dp)
Country(Modifier.fillMaxWidth(), personalData.country)
}
}
@ -179,7 +213,7 @@ fun CurrencyRatesItem(currency: String, value: String) {
Row {
Text(text = currency, style = TextStyles.b1)
HorizontalSpace(width = 4.dp)
Text(text = value, style = TextStyles.b1.copy(fontWeight = FontWeight.Medium))
Text(text = value, style = TextStyles.b1.copy(fontWeight = FontWeight.SemiBold))
}
}
@ -237,3 +271,42 @@ fun ThemeSwitch(modifier: Modifier = Modifier, themeVM: ThemeViewModel) {
)
}
}
@Composable
fun SignOutWarning(
modifier: Modifier = Modifier,
onSignOutClick: () -> Unit,
onCancelClick: () -> Unit,
) {
Column(
Modifier
.padding(top = 0.dp, bottom = 48.dp, start = 32.dp, end = 32.dp)
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(id = R.string.sign_out_title),
style = TextStyles.h3.copy(fontWeight = FontWeight.W700)
)
VerticalSpace(height = 24.dp)
Text(
text = stringResource(id = R.string.sign_out_warning),
style = TextStyles.h3.copy(fontWeight = FontWeight.W500),
textAlign = TextAlign.Center
)
VerticalSpace(height = 32.dp)
Row {
SecondaryButton(
modifier = Modifier.weight(1f),
label = stringResource(id = R.string.cancel),
onClick = onCancelClick,
)
HorizontalSpace(width = 16.dp)
PrimaryButton(
modifier = Modifier.weight(1f),
label = stringResource(id = R.string.sign_out),
onClick = onSignOutClick,
)
}
}
}

View file

@ -26,6 +26,32 @@ class ProfileViewModel @Inject constructor(
private val uiChannel = Channel<UiEvent>()
val uiEventsChannelFlow = uiChannel.receiveAsFlow()
// region fields to update
private val _fullName = MutableStateFlow("")
val fullName = _fullName.asStateFlow()
fun setFullName(value: String) {
_fullName.value = value
}
private val _email = MutableStateFlow("")
val email = _email.asStateFlow()
fun setEmail(value: String) {
_email.value = value
}
private val _countryCodeName = MutableStateFlow<String?>(null)
val countryCodeName = _countryCodeName.asStateFlow()
fun setCountryCodeName(value: String) {
_countryCodeName.value = value
}
// endregion fields to update
// region requests
private val _personalDataResource = MutableStateFlow<Resource<PersonalData>>(Resource.Idle())
val profileDataResource = _personalDataResource.asStateFlow()
@ -34,6 +60,9 @@ class ProfileViewModel @Inject constructor(
profileRepository.getPersonalData()
.collectLatest { resource ->
_personalDataResource.value = resource
if (resource is Resource.Success) {
resource.data?.let { updatePersonalDataInMemory(it) }
}
if (resource is Resource.Error) {
uiChannel.send(UiEvent.ShowToast(resource.message ?: ""))
}
@ -41,6 +70,23 @@ class ProfileViewModel @Inject constructor(
}
}
fun save() {
viewModelScope.launch {
// todo
}
}
private fun updatePersonalDataInMemory(personalData: PersonalData) {
personalData.let {
setFullName(it.fullName)
setEmail(it.email)
setCountryCodeName(it.country)
}
}
private val _signOutResponse = MutableStateFlow<Resource<SimpleResponse>>(Resource.Idle())
val signOutResponse = _signOutResponse.asStateFlow()
@ -60,6 +106,7 @@ class ProfileViewModel @Inject constructor(
}
}
}
// endregion requests
init {
getPersonalData()

View file

@ -4,7 +4,7 @@
android:id="@+id/ccp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="12dp"
android:paddingVertical="0dp"
app:ccp_autoDetectLanguage="true"
app:ccp_contentColor="@color/onBackground"
app:ccpDialog_backgroundColor="@color/transparent"