wip
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.profile
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import xyz.magicalbits.smsremote.data.colleagueProfile
|
||||
import xyz.magicalbits.smsremote.data.meProfile
|
||||
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||
|
||||
@Preview(widthDp = 340, name = "340 width - Me")
|
||||
@Composable
|
||||
fun ProfilePreview340() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 480, name = "480 width - Me")
|
||||
@Composable
|
||||
fun ProfilePreview480Me() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 480, name = "480 width - Other")
|
||||
@Composable
|
||||
fun ProfilePreview480Other() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(colleagueProfile)
|
||||
}
|
||||
}
|
||||
@Preview(widthDp = 340, name = "340 width - Me - Dark")
|
||||
@Composable
|
||||
fun ProfilePreview340MeDark() {
|
||||
JetchatTheme(isDarkTheme = true) {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 480, name = "480 width - Me - Dark")
|
||||
@Composable
|
||||
fun ProfilePreview480MeDark() {
|
||||
JetchatTheme(isDarkTheme = true) {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 480, name = "480 width - Other - Dark")
|
||||
@Composable
|
||||
fun ProfilePreview480OtherDark() {
|
||||
JetchatTheme(isDarkTheme = true) {
|
||||
ProfileScreen(colleagueProfile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.profile
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
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.dp
|
||||
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import xyz.magicalbits.smsremote.components.AnimatingFabContent
|
||||
import xyz.magicalbits.smsremote.components.baselineHeight
|
||||
import xyz.magicalbits.smsremote.data.colleagueProfile
|
||||
import xyz.magicalbits.smsremote.data.meProfile
|
||||
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||
|
||||
//@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
userData: ProfileScreenState,
|
||||
nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||
) {
|
||||
var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
|
||||
if (functionalityNotAvailablePopupShown) {
|
||||
FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(nestedScrollInteropConnection)
|
||||
.systemBarsPadding(),
|
||||
) {
|
||||
Surface {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
ProfileHeader(
|
||||
scrollState,
|
||||
userData,
|
||||
this@BoxWithConstraints.maxHeight,
|
||||
)
|
||||
UserInfoFields(userData, this@BoxWithConstraints.maxHeight)
|
||||
}
|
||||
}
|
||||
|
||||
val fabExtended by remember { derivedStateOf { scrollState.value == 0 } }
|
||||
ProfileFab(
|
||||
extended = fabExtended,
|
||||
userIsMe = userData.isMe(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
// Offsets the FAB to compensate for CoordinatorLayout collapsing behaviour
|
||||
.offset(y = ((-100).dp)),
|
||||
onFabClicked = { functionalityNotAvailablePopupShown = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserInfoFields(userData: ProfileScreenState, containerHeight: Dp) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
NameAndPosition(userData)
|
||||
|
||||
ProfileProperty(stringResource(R.string.display_name), userData.displayName)
|
||||
|
||||
ProfileProperty(stringResource(R.string.status), userData.status)
|
||||
|
||||
ProfileProperty(stringResource(R.string.twitter), userData.twitter, isLink = true)
|
||||
|
||||
userData.timeZone?.let {
|
||||
ProfileProperty(stringResource(R.string.timezone), userData.timeZone)
|
||||
}
|
||||
|
||||
// Add a spacer that always shows part (320.dp) of the fields list regardless of the device,
|
||||
// in order to always leave some content at the top.
|
||||
Spacer(Modifier.height((containerHeight - 320.dp).coerceAtLeast(0.dp)))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NameAndPosition(userData: ProfileScreenState) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Name(
|
||||
userData,
|
||||
modifier = Modifier.baselineHeight(32.dp),
|
||||
)
|
||||
Position(
|
||||
userData,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 20.dp)
|
||||
.baselineHeight(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Name(userData: ProfileScreenState, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = userData.name,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Position(userData: ProfileScreenState, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = userData.position,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileHeader(scrollState: ScrollState, data: ProfileScreenState, containerHeight: Dp) {
|
||||
val offset = (scrollState.value / 2)
|
||||
val offsetDp = with(LocalDensity.current) { offset.toDp() }
|
||||
|
||||
data.photo?.let {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.heightIn(max = containerHeight / 2)
|
||||
.fillMaxWidth()
|
||||
// TODO: Update to use offset to avoid recomposition
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
top = offsetDp,
|
||||
end = 16.dp,
|
||||
)
|
||||
.clip(CircleShape),
|
||||
painter = painterResource(id = it),
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileProperty(label: String, value: String, isLink: Boolean = false) {
|
||||
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) {
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.baselineHeight(24.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val style = if (isLink) {
|
||||
MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
MaterialTheme.typography.bodyLarge
|
||||
}
|
||||
Text(
|
||||
text = value,
|
||||
modifier = Modifier.baselineHeight(24.dp),
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileError() {
|
||||
Text(stringResource(R.string.profile_error))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileFab(extended: Boolean, userIsMe: Boolean, modifier: Modifier = Modifier, onFabClicked: () -> Unit = { }) {
|
||||
key(userIsMe) {
|
||||
// Prevent multiple invocations to execute during composition
|
||||
FloatingActionButton(
|
||||
onClick = onFabClicked,
|
||||
modifier = modifier
|
||||
.padding(16.dp)
|
||||
.navigationBarsPadding()
|
||||
.height(48.dp)
|
||||
.widthIn(min = 48.dp),
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
) {
|
||||
AnimatingFabContent(
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = if (userIsMe) R.drawable.ic_create else R.drawable.ic_chat),
|
||||
contentDescription = stringResource(
|
||||
if (userIsMe) R.string.edit_profile else R.string.message,
|
||||
),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = if (userIsMe) R.string.edit_profile else R.string.message,
|
||||
),
|
||||
)
|
||||
},
|
||||
extended = extended,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 640, heightDp = 360)
|
||||
@Composable
|
||||
fun ConvPreviewLandscapeMeDefault() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 360, heightDp = 480)
|
||||
@Composable
|
||||
fun ConvPreviewPortraitMeDefault() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 360, heightDp = 480)
|
||||
@Composable
|
||||
fun ConvPreviewPortraitOtherDefault() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(colleagueProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ProfileFabPreview() {
|
||||
JetchatTheme {
|
||||
ProfileFab(extended = true, userIsMe = false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.profile
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||
import xyz.magicalbits.smsremote.MainViewModel
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import xyz.magicalbits.smsremote.components.JetchatAppBar
|
||||
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||
|
||||
class ProfileFragment : Fragment() {
|
||||
private val viewModel: ProfileViewModel by viewModels()
|
||||
private val activityViewModel: MainViewModel by activityViewModels()
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
// Consider using safe args plugin
|
||||
val userId = arguments?.getString("userId")
|
||||
viewModel.setUserId(userId)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false)
|
||||
|
||||
rootView.findViewById<ComposeView>(R.id.toolbar_compose_view).apply {
|
||||
setContent {
|
||||
var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
|
||||
if (functionalityNotAvailablePopupShown) {
|
||||
FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
|
||||
}
|
||||
|
||||
JetchatTheme {
|
||||
JetchatAppBar(
|
||||
// Reset the minimum bounds that are passed to the root of a compose tree
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
onNavIconPressed = { activityViewModel.openDrawer() },
|
||||
title = { },
|
||||
actions = {
|
||||
// More icon
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_more_vert),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable(onClick = {
|
||||
functionalityNotAvailablePopupShown = true
|
||||
})
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
.height(24.dp),
|
||||
contentDescription = stringResource(id = R.string.more_options),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rootView.findViewById<ComposeView>(R.id.profile_compose_view).apply {
|
||||
setContent {
|
||||
val userData by viewModel.userData.observeAsState()
|
||||
val nestedScrollInteropConnection = rememberNestedScrollInteropConnection()
|
||||
|
||||
JetchatTheme {
|
||||
if (userData == null) {
|
||||
ProfileError()
|
||||
} else {
|
||||
ProfileScreen(
|
||||
userData = userData!!,
|
||||
nestedScrollInteropConnection = nestedScrollInteropConnection,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rootView
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.profile
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import xyz.magicalbits.smsremote.data.colleagueProfile
|
||||
import xyz.magicalbits.smsremote.data.meProfile
|
||||
|
||||
class ProfileViewModel : ViewModel() {
|
||||
private var userId: String = ""
|
||||
|
||||
fun setUserId(newUserId: String?) {
|
||||
if (newUserId != userId) {
|
||||
userId = newUserId ?: meProfile.userId
|
||||
}
|
||||
// Workaround for simplicity
|
||||
_userData.value =
|
||||
if (userId == meProfile.userId || userId == meProfile.displayName) {
|
||||
meProfile
|
||||
} else {
|
||||
colleagueProfile
|
||||
}
|
||||
}
|
||||
|
||||
private val _userData = MutableLiveData<ProfileScreenState>()
|
||||
val userData: LiveData<ProfileScreenState> = _userData
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ProfileScreenState(
|
||||
val userId: String,
|
||||
@param:DrawableRes val photo: Int?,
|
||||
val name: String,
|
||||
val status: String,
|
||||
val displayName: String,
|
||||
val position: String,
|
||||
val twitter: String = "",
|
||||
val timeZone: String?, // Null if me
|
||||
val commonChannels: String?, // Null if me
|
||||
) {
|
||||
fun isMe() = userId == meProfile.userId
|
||||
}
|
||||
Reference in New Issue
Block a user