From 66aaaa37d79734a54a6c73aae399d1ad9674f4db Mon Sep 17 00:00:00 2001 From: AllenTom Date: Wed, 2 Oct 2024 20:55:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=99=BB=E5=BD=95UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/composables/toolbar/Annotations.kt | 36 ++ .../ui/composables/toolbar/AppBarContainer.kt | 191 ++++++ .../composables/toolbar/CollapsingToolbar.kt | 386 ++++++++++++ .../toolbar/CollapsingToolbarScaffold.kt | 200 ++++++ .../ui/composables/toolbar/FabPlacement.kt | 32 + .../ui/composables/toolbar/FabPosition.kt | 6 + .../ui/composables/toolbar/ScrollStrategy.kt | 239 +++++++ .../toolbar/ToolbarWithFabScaffold.kt | 104 ++++ .../riderpro/ui/index/tabs/profile/Profile.kt | 581 +----------------- .../ui/index/tabs/profile/ProfileV3.kt | 452 ++++++++++++++ .../ui/index/tabs/profile/ProfileWrap.kt | 41 ++ .../ui/index/tabs/profile/Profilev2.kt | 481 +++++++++++++++ .../tabs/profile/composable/GalleryItem.kt | 51 ++ .../tabs/profile/composable/MomentCard.kt | 292 +++++++++ .../profile/composable/OtherProfileAction.kt | 97 +++ .../profile/composable/SelfProfileAction.kt | 58 ++ .../composable/UserContentPageIndicator.kt | 85 +++ .../index/tabs/profile/composable/UserItem.kt | 131 ++++ .../com/aiosman/riderpro/ui/login/login.kt | 5 - .../riderpro/ui/profile/AccountProfileV2.kt | 4 +- 20 files changed, 2898 insertions(+), 574 deletions(-) create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/Annotations.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/AppBarContainer.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/CollapsingToolbar.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/CollapsingToolbarScaffold.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/FabPlacement.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/FabPosition.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/ScrollStrategy.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/ToolbarWithFabScaffold.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/ProfileV3.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/ProfileWrap.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profilev2.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/GalleryItem.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/MomentCard.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/OtherProfileAction.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/SelfProfileAction.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/UserContentPageIndicator.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/UserItem.kt diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/Annotations.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/Annotations.kt new file mode 100644 index 0000000..9dd20e2 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/Annotations.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.riderpro.ui.composables.toolbar + +@RequiresOptIn( + message = "This is an experimental API of compose-collapsing-toolbar. Any declarations with " + + "the annotation might be removed or changed in some way without any notice.", + level = RequiresOptIn.Level.WARNING +) +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CLASS +) +@Retention(AnnotationRetention.BINARY) +annotation class ExperimentalToolbarApi diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/AppBarContainer.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/AppBarContainer.kt new file mode 100644 index 0000000..bedfe11 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/AppBarContainer.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.riderpro.ui.composables.toolbar + +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import kotlin.math.max + +@Deprecated( + "Use AppBarContainer for naming consistency", + replaceWith = ReplaceWith( + "AppBarContainer(modifier, scrollStrategy, collapsingToolbarState, content)", + "me.onebone.toolbar" + ) +) +@Composable +fun AppbarContainer( + modifier: Modifier = Modifier, + scrollStrategy: ScrollStrategy, + collapsingToolbarState: CollapsingToolbarState, + content: @Composable AppbarContainerScope.() -> Unit +) { + AppBarContainer( + modifier = modifier, + scrollStrategy = scrollStrategy, + collapsingToolbarState = collapsingToolbarState, + content = content + ) +} + +@Deprecated( + "AppBarContainer is replaced with CollapsingToolbarScaffold", + replaceWith = ReplaceWith( + "CollapsingToolbarScaffold", + "me.onebone.toolbar" + ) +) +@Composable +fun AppBarContainer( + modifier: Modifier = Modifier, + scrollStrategy: ScrollStrategy, + /** The state of a connected collapsing toolbar */ + collapsingToolbarState: CollapsingToolbarState, + content: @Composable AppbarContainerScope.() -> Unit +) { + val offsetY = remember { mutableStateOf(0) } + val flingBehavior = ScrollableDefaults.flingBehavior() + + val (scope, measurePolicy) = remember(scrollStrategy, collapsingToolbarState) { + AppbarContainerScopeImpl(scrollStrategy.create(offsetY, collapsingToolbarState, flingBehavior)) to + AppbarMeasurePolicy(scrollStrategy, collapsingToolbarState, offsetY) + } + + Layout( + content = { scope.content() }, + measurePolicy = measurePolicy, + modifier = modifier + ) +} + +interface AppbarContainerScope { + fun Modifier.appBarBody(): Modifier +} + +internal class AppbarContainerScopeImpl( + private val nestedScrollConnection: NestedScrollConnection +): AppbarContainerScope { + override fun Modifier.appBarBody(): Modifier { + return this + .then(AppBarBodyMarkerModifier) + .nestedScroll(nestedScrollConnection) + } +} + +private object AppBarBodyMarkerModifier: ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return AppBarBodyMarker + } +} + +private object AppBarBodyMarker + +private class AppbarMeasurePolicy( + private val scrollStrategy: ScrollStrategy, + private val toolbarState: CollapsingToolbarState, + private val offsetY: State +): MeasurePolicy { + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints + ): MeasureResult { + var width = 0 + var height = 0 + + var toolbarPlaceable: Placeable? = null + + val nonToolbars = measurables.filter { + val data = it.parentData + if(data != AppBarBodyMarker) { + if(toolbarPlaceable != null) + throw IllegalStateException("There cannot exist multiple toolbars under single parent") + + val placeable = it.measure(constraints.copy( + minWidth = 0, + minHeight = 0 + )) + width = max(width, placeable.width) + height = max(height, placeable.height) + + toolbarPlaceable = placeable + + false + }else{ + true + } + } + + val placeables = nonToolbars.map { measurable -> + val childConstraints = if(scrollStrategy == ScrollStrategy.ExitUntilCollapsed) { + constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = max(0, constraints.maxHeight - toolbarState.minHeight) + ) + }else{ + constraints.copy( + minWidth = 0, + minHeight = 0 + ) + } + + val placeable = measurable.measure(childConstraints) + + width = max(width, placeable.width) + height = max(height, placeable.height) + + placeable + } + + height += (toolbarPlaceable?.height ?: 0) + + return layout( + width.coerceIn(constraints.minWidth, constraints.maxWidth), + height.coerceIn(constraints.minHeight, constraints.maxHeight) + ) { + toolbarPlaceable?.place(x = 0, y = offsetY.value) + + placeables.forEach { placeable -> + placeable.place( + x = 0, + y = offsetY.value + (toolbarPlaceable?.height ?: 0) + ) + } + } + } +} diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/CollapsingToolbar.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/CollapsingToolbar.kt new file mode 100644 index 0000000..e769b30 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/CollapsingToolbar.kt @@ -0,0 +1,386 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.riderpro.ui.composables.toolbar + +import androidx.annotation.FloatRange +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.tween +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import kotlin.math.absoluteValue +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +@Stable +class CollapsingToolbarState( + initial: Int = Int.MAX_VALUE +): ScrollableState { + /** + * [height] indicates current height of the toolbar. + */ + var height: Int by mutableStateOf(initial) + private set + + /** + * [minHeight] indicates the minimum height of the collapsing toolbar. The toolbar + * may collapse its height to [minHeight] but not smaller. This size is determined by + * the smallest child. + */ + var minHeight: Int + get() = minHeightState + internal set(value) { + minHeightState = value + + if(height < value) { + height = value + } + } + + /** + * [maxHeight] indicates the maximum height of the collapsing toolbar. The toolbar + * may expand its height to [maxHeight] but not larger. This size is determined by + * the largest child. + */ + var maxHeight: Int + get() = maxHeightState + internal set(value) { + maxHeightState = value + + if(value < height) { + height = value + } + } + + private var maxHeightState by mutableStateOf(Int.MAX_VALUE) + private var minHeightState by mutableStateOf(0) + + val progress: Float + @FloatRange(from = 0.0, to = 1.0) + get() = + if(minHeight == maxHeight) { + 0f + }else{ + ((height - minHeight).toFloat() / (maxHeight - minHeight)).coerceIn(0f, 1f) + } + + private val scrollableState = ScrollableState { value -> + val consume = if(value < 0) { + max(minHeight.toFloat() - height, value) + }else{ + min(maxHeight.toFloat() - height, value) + } + + val current = consume + deferredConsumption + val currentInt = current.toInt() + + if(current.absoluteValue > 0) { + height += currentInt + deferredConsumption = current - currentInt + } + + consume + } + + private var deferredConsumption: Float = 0f + + /** + * @return consumed scroll value is returned + */ + @Deprecated( + message = "feedScroll() is deprecated, use dispatchRawDelta() instead.", + replaceWith = ReplaceWith("dispatchRawDelta(value)") + ) + fun feedScroll(value: Float): Float = dispatchRawDelta(value) + + @ExperimentalToolbarApi + suspend fun expand(duration: Int = 200) { + val anim = AnimationState(height.toFloat()) + + scroll { + var prev = anim.value + anim.animateTo(maxHeight.toFloat(), tween(duration)) { + scrollBy(value - prev) + prev = value + } + } + } + + @ExperimentalToolbarApi + suspend fun collapse(duration: Int = 200) { + val anim = AnimationState(height.toFloat()) + + scroll { + var prev = anim.value + anim.animateTo(minHeight.toFloat(), tween(duration)) { + scrollBy(value - prev) + prev = value + } + } + } + + /** + * @return Remaining velocity after fling + */ + suspend fun fling(flingBehavior: FlingBehavior, velocity: Float): Float { + var left = velocity + scroll { + with(flingBehavior) { + left = performFling(left) + } + } + + return left + } + + override val isScrollInProgress: Boolean + get() = scrollableState.isScrollInProgress + + override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta) + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit + ) = scrollableState.scroll(scrollPriority, block) +} + +@Composable +fun rememberCollapsingToolbarState( + initial: Int = Int.MAX_VALUE +): CollapsingToolbarState { + return remember { + CollapsingToolbarState( + initial = initial + ) + } +} + +@Composable +fun CollapsingToolbar( + modifier: Modifier = Modifier, + clipToBounds: Boolean = true, + collapsingToolbarState: CollapsingToolbarState, + content: @Composable CollapsingToolbarScope.() -> Unit +) { + val measurePolicy = remember(collapsingToolbarState) { + CollapsingToolbarMeasurePolicy(collapsingToolbarState) + } + + Layout( + content = { CollapsingToolbarScopeInstance.content() }, + measurePolicy = measurePolicy, + modifier = modifier.then( + if (clipToBounds) { + Modifier.clipToBounds() + } else { + Modifier + } + ) + ) +} + +private class CollapsingToolbarMeasurePolicy( + private val collapsingToolbarState: CollapsingToolbarState +): MeasurePolicy { + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints + ): MeasureResult { + val placeables = measurables.map { + it.measure( + constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = Constraints.Infinity + ) + ) + } + + val placeStrategy = measurables.map { it.parentData } + + val minHeight = placeables.minOfOrNull { it.height } + ?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0 + + val maxHeight = placeables.maxOfOrNull { it.height } + ?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0 + + val maxWidth = placeables.maxOfOrNull{ it.width } + ?.coerceIn(constraints.minWidth, constraints.maxWidth) ?: 0 + + collapsingToolbarState.also { + it.minHeight = minHeight + it.maxHeight = maxHeight + } + + val height = collapsingToolbarState.height + return layout(maxWidth, height) { + val progress = collapsingToolbarState.progress + + placeables.forEachIndexed { i, placeable -> + val strategy = placeStrategy[i] + if(strategy is CollapsingToolbarData) { + strategy.progressListener?.onProgressUpdate(progress) + } + + when(strategy) { + is CollapsingToolbarRoadData -> { + val collapsed = strategy.whenCollapsed + val expanded = strategy.whenExpanded + + val collapsedOffset = collapsed.align( + size = IntSize(placeable.width, placeable.height), + space = IntSize(maxWidth, height), + layoutDirection = layoutDirection + ) + + val expandedOffset = expanded.align( + size = IntSize(placeable.width, placeable.height), + space = IntSize(maxWidth, height), + layoutDirection = layoutDirection + ) + + val offset = collapsedOffset + (expandedOffset - collapsedOffset) * progress + + placeable.place(offset.x, offset.y) + } + is CollapsingToolbarParallaxData -> + placeable.placeRelative( + x = 0, + y = -((maxHeight - minHeight) * (1 - progress) * strategy.ratio).roundToInt() + ) + else -> placeable.placeRelative(0, 0) + } + } + } + } +} + +interface CollapsingToolbarScope { + fun Modifier.progress(listener: ProgressListener): Modifier + + fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier + + fun Modifier.parallax(ratio: Float = 0.2f): Modifier + + fun Modifier.pin(): Modifier +} + +internal object CollapsingToolbarScopeInstance: CollapsingToolbarScope { + override fun Modifier.progress(listener: ProgressListener): Modifier { + return this.then(ProgressUpdateListenerModifier(listener)) + } + + override fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier { + return this.then(RoadModifier(whenCollapsed, whenExpanded)) + } + + override fun Modifier.parallax(ratio: Float): Modifier { + return this.then(ParallaxModifier(ratio)) + } + + override fun Modifier.pin(): Modifier { + return this.then(PinModifier()) + } +} + +internal class RoadModifier( + private val whenCollapsed: Alignment, + private val whenExpanded: Alignment +): ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarRoadData( + this@RoadModifier.whenCollapsed, this@RoadModifier.whenExpanded, + (parentData as? CollapsingToolbarData)?.progressListener + ) + } +} + +internal class ParallaxModifier( + private val ratio: Float +): ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarParallaxData(ratio, (parentData as? CollapsingToolbarData)?.progressListener) + } +} + +internal class PinModifier: ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarPinData((parentData as? CollapsingToolbarData)?.progressListener) + } +} + +internal class ProgressUpdateListenerModifier( + private val listener: ProgressListener +): ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarProgressData(listener) + } +} + +fun interface ProgressListener { + fun onProgressUpdate(value: Float) +} + +internal sealed class CollapsingToolbarData( + var progressListener: ProgressListener? +) + +internal class CollapsingToolbarProgressData( + progressListener: ProgressListener? +): CollapsingToolbarData(progressListener) + +internal class CollapsingToolbarRoadData( + var whenCollapsed: Alignment, + var whenExpanded: Alignment, + progressListener: ProgressListener? = null +): CollapsingToolbarData(progressListener) + +internal class CollapsingToolbarPinData( + progressListener: ProgressListener? = null +): CollapsingToolbarData(progressListener) + +internal class CollapsingToolbarParallaxData( + var ratio: Float, + progressListener: ProgressListener? = null +): CollapsingToolbarData(progressListener) diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/CollapsingToolbarScaffold.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/CollapsingToolbarScaffold.kt new file mode 100644 index 0000000..1805abc --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/CollapsingToolbarScaffold.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.riderpro.ui.composables.toolbar + +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import kotlin.math.max + +@Stable +class CollapsingToolbarScaffoldState( + val toolbarState: CollapsingToolbarState, + initialOffsetY: Int = 0 +) { + val offsetY: Int + get() = offsetYState.value + + internal val offsetYState = mutableStateOf(initialOffsetY) +} + +private class CollapsingToolbarScaffoldStateSaver: Saver> { + override fun restore(value: List): CollapsingToolbarScaffoldState = + CollapsingToolbarScaffoldState( + CollapsingToolbarState(value[0] as Int), + value[1] as Int + ) + + override fun SaverScope.save(value: CollapsingToolbarScaffoldState): List = + listOf( + value.toolbarState.height, + value.offsetY + ) +} + +@Composable +fun rememberCollapsingToolbarScaffoldState( + toolbarState: CollapsingToolbarState = rememberCollapsingToolbarState() +): CollapsingToolbarScaffoldState { + return rememberSaveable(toolbarState, saver = CollapsingToolbarScaffoldStateSaver()) { + CollapsingToolbarScaffoldState(toolbarState) + } +} + +interface CollapsingToolbarScaffoldScope { + @ExperimentalToolbarApi + fun Modifier.align(alignment: Alignment): Modifier +} + +@Composable +fun CollapsingToolbarScaffold( + modifier: Modifier, + state: CollapsingToolbarScaffoldState, + scrollStrategy: ScrollStrategy, + enabled: Boolean = true, + toolbarModifier: Modifier = Modifier, + toolbarClipToBounds: Boolean = true, + toolbar: @Composable CollapsingToolbarScope.() -> Unit, + body: @Composable CollapsingToolbarScaffoldScope.() -> Unit +) { + val flingBehavior = ScrollableDefaults.flingBehavior() + val layoutDirection = LocalLayoutDirection.current + + val nestedScrollConnection = remember(scrollStrategy, state) { + scrollStrategy.create(state.offsetYState, state.toolbarState, flingBehavior) + } + + val toolbarState = state.toolbarState + + Layout( + content = { + CollapsingToolbar( + modifier = toolbarModifier, + clipToBounds = toolbarClipToBounds, + collapsingToolbarState = toolbarState, + ) { + toolbar() + } + + CollapsingToolbarScaffoldScopeInstance.body() + }, + modifier = modifier + .then( + if (enabled) { + Modifier.nestedScroll(nestedScrollConnection) + } else { + Modifier + } + ) + ) { measurables, constraints -> + check(measurables.size >= 2) { + "the number of children should be at least 2: toolbar, (at least one) body" + } + + val toolbarConstraints = constraints.copy( + minWidth = 0, + minHeight = 0 + ) + val bodyConstraints = constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = when (scrollStrategy) { + ScrollStrategy.ExitUntilCollapsed -> + (constraints.maxHeight - toolbarState.minHeight).coerceAtLeast(0) + + ScrollStrategy.EnterAlways, ScrollStrategy.EnterAlwaysCollapsed -> + constraints.maxHeight + } + ) + + val toolbarPlaceable = measurables[0].measure(toolbarConstraints) + + val bodyMeasurables = measurables.subList(1, measurables.size) + val childrenAlignments = bodyMeasurables.map { + (it.parentData as? ScaffoldParentData)?.alignment + } + val bodyPlaceables = bodyMeasurables.map { + it.measure(bodyConstraints) + } + + val toolbarHeight = toolbarPlaceable.height + + val width = max( + toolbarPlaceable.width, + bodyPlaceables.maxOfOrNull { it.width } ?: 0 + ).coerceIn(constraints.minWidth, constraints.maxWidth) + val height = max( + toolbarHeight, + bodyPlaceables.maxOfOrNull { it.height } ?: 0 + ).coerceIn(constraints.minHeight, constraints.maxHeight) + + layout(width, height) { + bodyPlaceables.forEachIndexed { index, placeable -> + val alignment = childrenAlignments[index] + + if (alignment == null) { + placeable.placeRelative(0, toolbarHeight + state.offsetY) + } else { + val offset = alignment.align( + size = IntSize(placeable.width, placeable.height), + space = IntSize(width, height), + layoutDirection = layoutDirection + ) + placeable.place(offset) + } + } + toolbarPlaceable.placeRelative(0, state.offsetY) + } + } +} + +internal object CollapsingToolbarScaffoldScopeInstance: CollapsingToolbarScaffoldScope { + @ExperimentalToolbarApi + override fun Modifier.align(alignment: Alignment): Modifier = + this.then(ScaffoldChildAlignmentModifier(alignment)) +} + +private class ScaffoldChildAlignmentModifier( + private val alignment: Alignment +) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return (parentData as? ScaffoldParentData) ?: ScaffoldParentData(alignment) + } +} + +private data class ScaffoldParentData( + var alignment: Alignment? = null +) diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/FabPlacement.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/FabPlacement.kt new file mode 100644 index 0000000..50555ed --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/FabPlacement.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.riderpro.ui.composables.toolbar + +import androidx.compose.runtime.Immutable + +@Immutable +class FabPlacement( + val left: Int, + val width: Int, + val height: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/FabPosition.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/FabPosition.kt new file mode 100644 index 0000000..f487424 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/FabPosition.kt @@ -0,0 +1,6 @@ +package com.aiosman.riderpro.ui.composables.toolbar + +enum class FabPosition { + Center, + End +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/ScrollStrategy.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/ScrollStrategy.kt new file mode 100644 index 0000000..2c81274 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/ScrollStrategy.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.riderpro.ui.composables.toolbar + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.runtime.MutableState +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity + +enum class ScrollStrategy { + EnterAlways { + override fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection = + EnterAlwaysNestedScrollConnection(offsetY, toolbarState, flingBehavior) + }, + EnterAlwaysCollapsed { + override fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection = + EnterAlwaysCollapsedNestedScrollConnection(offsetY, toolbarState, flingBehavior) + }, + ExitUntilCollapsed { + override fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection = + ExitUntilCollapsedNestedScrollConnection(toolbarState, flingBehavior) + }; + + internal abstract fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection +} + +private class ScrollDelegate( + private val offsetY: MutableState +) { + private var scrollToBeConsumed: Float = 0f + + fun doScroll(delta: Float) { + val scroll = scrollToBeConsumed + delta + val scrollInt = scroll.toInt() + + scrollToBeConsumed = scroll - scrollInt + + offsetY.value += scrollInt + } +} + +internal class EnterAlwaysNestedScrollConnection( + private val offsetY: MutableState, + private val toolbarState: CollapsingToolbarState, + private val flingBehavior: FlingBehavior +): NestedScrollConnection { + private val scrollDelegate = ScrollDelegate(offsetY) + //private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl()) + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val dy = available.y + + val toolbar = toolbarState.height.toFloat() + val offset = offsetY.value.toFloat() + + // -toolbarHeight <= offsetY + dy <= 0 + val consume = if(dy < 0) { + val toolbarConsumption = toolbarState.dispatchRawDelta(dy) + val remaining = dy - toolbarConsumption + val offsetConsumption = remaining.coerceAtLeast(-toolbar - offset) + scrollDelegate.doScroll(offsetConsumption) + + toolbarConsumption + offsetConsumption + }else{ + val offsetConsumption = dy.coerceAtMost(-offset) + scrollDelegate.doScroll(offsetConsumption) + + val toolbarConsumption = toolbarState.dispatchRawDelta(dy - offsetConsumption) + + offsetConsumption + toolbarConsumption + } + + return Offset(0f, consume) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val left = if(available.y > 0) { + toolbarState.fling(flingBehavior, available.y) + }else{ + // If velocity < 0, the main content should have a remaining scroll space + // so the scroll resumes to the onPreScroll(..., Fling) phase. Hence we do + // not need to process it at onPostFling() manually. + available.y + } + + return Velocity(x = 0f, y = available.y - left) + } +} + +internal class EnterAlwaysCollapsedNestedScrollConnection( + private val offsetY: MutableState, + private val toolbarState: CollapsingToolbarState, + private val flingBehavior: FlingBehavior +): NestedScrollConnection { + private val scrollDelegate = ScrollDelegate(offsetY) + //private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl()) + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val dy = available.y + + val consumed = if(dy > 0) { // expanding: offset -> body -> toolbar + val offsetConsumption = dy.coerceAtMost(-offsetY.value.toFloat()) + scrollDelegate.doScroll(offsetConsumption) + + offsetConsumption + }else{ // collapsing: toolbar -> offset -> body + val toolbarConsumption = toolbarState.dispatchRawDelta(dy) + val offsetConsumption = (dy - toolbarConsumption).coerceAtLeast(-toolbarState.height.toFloat() - offsetY.value) + + scrollDelegate.doScroll(offsetConsumption) + + toolbarConsumption + offsetConsumption + } + + return Offset(0f, consumed) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + val dy = available.y + + return if(dy > 0) { + Offset(0f, toolbarState.dispatchRawDelta(dy)) + }else{ + Offset(0f, 0f) + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val dy = available.y + + val left = if(dy > 0) { + // onPostFling() has positive available scroll value only called if the main scroll + // has leftover scroll, i.e. the scroll of the main content has done. So we just process + // fling if the available value is positive. + toolbarState.fling(flingBehavior, dy) + }else{ + dy + } + + return Velocity(x = 0f, y = available.y - left) + } +} + +internal class ExitUntilCollapsedNestedScrollConnection( + private val toolbarState: CollapsingToolbarState, + private val flingBehavior: FlingBehavior +): NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val dy = available.y + + val consume = if(dy < 0) { // collapsing: toolbar -> body + toolbarState.dispatchRawDelta(dy) + }else{ + 0f + } + + return Offset(0f, consume) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + val dy = available.y + + val consume = if(dy > 0) { // expanding: body -> toolbar + toolbarState.dispatchRawDelta(dy) + }else{ + 0f + } + + return Offset(0f, consume) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val left = if(available.y < 0) { + toolbarState.fling(flingBehavior, available.y) + }else{ + available.y + } + + return Velocity(x = 0f, y = available.y - left) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val velocity = available.y + + val left = if(velocity > 0) { + toolbarState.fling(flingBehavior, velocity) + }else{ + velocity + } + + return Velocity(x = 0f, y = available.y - left) + } +} diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/ToolbarWithFabScaffold.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/ToolbarWithFabScaffold.kt new file mode 100644 index 0000000..581a12b --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/toolbar/ToolbarWithFabScaffold.kt @@ -0,0 +1,104 @@ +package com.aiosman.riderpro.ui.composables.toolbar + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +@ExperimentalToolbarApi +@Composable +fun ToolbarWithFabScaffold( + modifier: Modifier, + state: CollapsingToolbarScaffoldState, + scrollStrategy: ScrollStrategy, + toolbarModifier: Modifier = Modifier, + toolbarClipToBounds: Boolean = true, + toolbar: @Composable CollapsingToolbarScope.() -> Unit, + fab: @Composable () -> Unit, + fabPosition: FabPosition = FabPosition.End, + body: @Composable CollapsingToolbarScaffoldScope.() -> Unit +) { + SubcomposeLayout( + modifier = modifier + ) { constraints -> + + val toolbarScaffoldConstraints = constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = constraints.maxHeight + ) + + val toolbarScaffoldPlaceables = subcompose(ToolbarWithFabScaffoldContent.ToolbarScaffold) { + CollapsingToolbarScaffold( + modifier = modifier, + state = state, + scrollStrategy = scrollStrategy, + toolbarModifier = toolbarModifier, + toolbarClipToBounds = toolbarClipToBounds, + toolbar = toolbar, + body = body + ) + }.map { it.measure(toolbarScaffoldConstraints) } + + val fabConstraints = constraints.copy( + minWidth = 0, + minHeight = 0 + ) + + val fabPlaceables = subcompose( + ToolbarWithFabScaffoldContent.Fab, + fab + ).mapNotNull { measurable -> + measurable.measure(fabConstraints).takeIf { it.height != 0 && it.width != 0 } + } + + val fabPlacement = if (fabPlaceables.isNotEmpty()) { + val fabWidth = fabPlaceables.maxOfOrNull { it.width } ?: 0 + val fabHeight = fabPlaceables.maxOfOrNull { it.height } ?: 0 + // FAB distance from the left of the layout, taking into account LTR / RTL + val fabLeftOffset = if (fabPosition == FabPosition.End) { + if (layoutDirection == LayoutDirection.Ltr) { + constraints.maxWidth - 16.dp.roundToPx() - fabWidth + } else { + 16.dp.roundToPx() + } + } else { + (constraints.maxWidth - fabWidth) / 2 + } + + FabPlacement( + left = fabLeftOffset, + width = fabWidth, + height = fabHeight + ) + } else { + null + } + + val fabOffsetFromBottom = fabPlacement?.let { + it.height + 16.dp.roundToPx() + } + + val width = constraints.maxWidth + val height = constraints.maxHeight + + layout(width, height) { + toolbarScaffoldPlaceables.forEach { + it.place(0, 0) + } + + fabPlacement?.let { placement -> + fabPlaceables.forEach { + it.place(placement.left, height - fabOffsetFromBottom!!) + } + } + + } + + } +} + +private enum class ToolbarWithFabScaffoldContent { + ToolbarScaffold, Fab +} diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profile.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profile.kt index 538a18c..79158ed 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profile.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profile.kt @@ -6,8 +6,6 @@ import android.content.Intent import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.DrawableRes -import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -20,7 +18,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -41,11 +38,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,64 +54,41 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems import com.aiosman.riderpro.LocalNavController import com.aiosman.riderpro.R import com.aiosman.riderpro.entity.AccountProfileEntity import com.aiosman.riderpro.entity.MomentEntity -import com.aiosman.riderpro.exp.formatPostTime2 import com.aiosman.riderpro.ui.NavigationRoute import com.aiosman.riderpro.ui.composables.CustomAsyncImage import com.aiosman.riderpro.ui.composables.DropdownMenu import com.aiosman.riderpro.ui.composables.MenuItem import com.aiosman.riderpro.ui.composables.StatusBarSpacer +import com.aiosman.riderpro.ui.index.tabs.profile.composable.EmptyMomentPostUnit +import com.aiosman.riderpro.ui.index.tabs.profile.composable.GalleryItem +import com.aiosman.riderpro.ui.index.tabs.profile.composable.MomentPostUnit +import com.aiosman.riderpro.ui.index.tabs.profile.composable.OtherProfileAction +import com.aiosman.riderpro.ui.index.tabs.profile.composable.SelfProfileAction +import com.aiosman.riderpro.ui.index.tabs.profile.composable.UserContentPageIndicator +import com.aiosman.riderpro.ui.index.tabs.profile.composable.UserItem import com.aiosman.riderpro.ui.modifiers.noRippleClickable -import com.aiosman.riderpro.ui.navigateToPost import com.aiosman.riderpro.ui.post.NewPostViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -@Composable -fun ProfileWrap( - -) { - LaunchedEffect(Unit) { - MyProfileViewModel.loadProfile() - } - Profile( - onUpdateBanner = { uri, context -> - MyProfileViewModel.updateUserProfileBanner(uri, context) - }, - onLogout = { - MyProfileViewModel.viewModelScope.launch { - MyProfileViewModel.logout() - } - - }, - profile = MyProfileViewModel.profile, - sharedFlow = MyProfileViewModel.sharedFlow - ) -} - @OptIn(ExperimentalFoundationApi::class) @Composable fun Profile( @@ -188,14 +160,6 @@ fun Profile( return Offset(x = 0f, y = consumedHeader / speedFactor) } - -// override fun onPostScroll( -// consumed: Offset, -// available: Offset, -// source: NestedScrollSource -// ): Offset { -// return Offset.Zero -// } } } @@ -393,8 +357,6 @@ fun Profile( .alpha(1 - alpha) .background(Color(0xfff8f8f8)) .padding(horizontal = 16.dp) - - ) { StatusBarSpacer() @@ -425,57 +387,7 @@ fun Profile( } Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .clip(RoundedCornerShape(32.dp)) - .background(if (pagerState.currentPage == 0) Color(0xFFFFFFFF) else Color.Transparent) - .padding(horizontal = 16.dp, vertical = 4.dp) - .noRippleClickable { - // switch to gallery - scope.launch { - pagerState.scrollToPage(0) - } - } - ) { - Text( - text = stringResource(R.string.gallery), - fontSize = 14.sp, - fontWeight = FontWeight.W600, - color = Color.Black, - modifier = Modifier.padding(8.dp) - ) - } - Spacer(modifier = Modifier.width(4.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .clip(RoundedCornerShape(32.dp)) - .background(if (pagerState.currentPage == 1) Color(0xFFFFFFFF) else Color.Transparent) - .padding(horizontal = 16.dp, vertical = 8.dp) - .noRippleClickable { - // switch to moments - scope.launch { - pagerState.scrollToPage(1) - } - } - ) { - Text( - text = stringResource(R.string.moment), - fontSize = 14.sp, - fontWeight = FontWeight.W600, - color = Color.Black, - modifier = Modifier.padding(8.dp) - ) - } - } + UserContentPageIndicator(pagerState) Spacer(modifier = Modifier.height(16.dp)) HorizontalPager( state = pagerState, @@ -530,30 +442,7 @@ fun Profile( } items(moments.itemCount) { idx -> val moment = moments[idx] ?: return@items - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio( - if (idx % 3 == 0) 1.5f else 1f - ) - .clip(RoundedCornerShape(8.dp)) - .noRippleClickable { - navController.navigateToPost( - moment.id - ) - } - - - ) { - CustomAsyncImage( - LocalContext.current, - moment.images[0].thumbnail, - modifier = Modifier - .fillMaxSize(), - contentDescription = "", - contentScale = ContentScale.Crop, - ) - } + GalleryItem(moment) } items(2) { Spacer(modifier = Modifier.height(120.dp)) @@ -563,14 +452,15 @@ fun Profile( 1 -> { LazyColumn( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier.fillMaxSize(), state = scrollState ) { - item { - if (moments.itemCount == 0) { + if (moments.itemCount == 0) { + item { EmptyMomentPostUnit() } } + item { for (idx in 0 until moments.itemCount) { val moment = moments[idx] @@ -590,449 +480,4 @@ fun Profile( } } -@Composable -fun UserItem(accountProfileEntity: AccountProfileEntity) { - val navController = LocalNavController.current - Column( - modifier = Modifier - .fillMaxWidth() - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - // 头像 - CustomAsyncImage( - LocalContext.current, - accountProfileEntity.avatar, - modifier = Modifier - .clip(CircleShape) - .size(48.dp), - contentDescription = "", - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.width(32.dp)) - //个人统计 - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .weight(1f) - .noRippleClickable { - navController.navigate( - NavigationRoute.FollowerList.route.replace( - "{id}", - accountProfileEntity.id.toString() - ) - ) - } - ) { - Text( - text = accountProfileEntity.followerCount.toString(), - fontWeight = FontWeight.W600, - fontSize = 16.sp - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.followers_upper), - ) - } - Column( - verticalArrangement = Arrangement.Center, - modifier = Modifier - .weight(1f) - .noRippleClickable { - navController.navigate( - NavigationRoute.FollowingList.route.replace( - "{id}", - accountProfileEntity.id.toString() - ) - ) - }, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = accountProfileEntity.followingCount.toString(), - fontWeight = FontWeight.W600, - fontSize = 16.sp - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.following_upper), - ) - } - Column( - verticalArrangement = Arrangement.Center, - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - } - } - } - Spacer(modifier = Modifier.height(12.dp)) - // 昵称 - Text( - text = accountProfileEntity.nickName, - fontWeight = FontWeight.W600, - fontSize = 16.sp, - ) - Spacer(modifier = Modifier.height(4.dp)) - // 个人简介 - Text( - text = accountProfileEntity.bio, - fontSize = 14.sp, - color = Color.Gray - ) - - } - -} - -@Composable -fun SelfProfileAction( - onEditProfile: () -> Unit -) { - // 按钮 - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .clip(RoundedCornerShape(32.dp)) - .background(Color(0xffebebeb)) - .padding(horizontal = 16.dp, vertical = 4.dp) - .noRippleClickable { - onEditProfile() - } - ) { - Icon( - Icons.Default.Edit, - contentDescription = "", - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(R.string.edit_profile), - fontSize = 14.sp, - fontWeight = FontWeight.W600, - color = Color.Black, - modifier = Modifier.padding(8.dp) - ) - - } -} - -@Composable -fun OtherProfileAction( - profile: AccountProfileEntity, - onFollow: (() -> Unit)? = null, - onChat: (() -> Unit)? = null -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .clip(RoundedCornerShape(32.dp)) - .background(if (profile.isFollowing) Color(0xffebebeb) else Color(0xffda3833)) - .padding(horizontal = 16.dp, vertical = 4.dp) - .noRippleClickable { - onFollow?.invoke() - } - ) { - Icon( - Icons.Default.Add, - contentDescription = "", - modifier = Modifier.size(24.dp), - tint = if (profile.isFollowing) Color.Black else Color.White - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(if (profile.isFollowing) R.string.following_upper else R.string.follow_upper), - fontSize = 14.sp, - fontWeight = FontWeight.W600, - color = if (profile.isFollowing) Color.Black else Color.White, - modifier = Modifier.padding(8.dp), - ) - - } - Spacer(modifier = Modifier.width(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .clip(RoundedCornerShape(32.dp)) - .background(Color(0xffebebeb)) - .padding(horizontal = 16.dp, vertical = 4.dp) - .noRippleClickable { - onChat?.invoke() - } - ) { - Image( - painter = painterResource(id = R.drawable.rider_pro_comment), - contentDescription = "", - modifier = Modifier.size(24.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(R.string.chat_upper), - fontSize = 14.sp, - fontWeight = FontWeight.W600, - color = Color.Black, - modifier = Modifier.padding(8.dp), - ) - - } - } - // 按钮 - -} - - -@Composable -fun EmptyMomentPostUnit() { - TimeGroup(stringResource(R.string.empty_my_post_title)) - ProfileEmptyMomentCard() -} - -@Composable -fun ProfileEmptyMomentCard( - -) { - var columnHeight by remember { mutableStateOf(0) } - val navController = LocalNavController.current - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, top = 18.dp, end = 24.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - ) { - Canvas( - modifier = Modifier - .height(with(LocalDensity.current) { columnHeight.toDp() }) - .width(14.dp) - ) { - drawLine( - color = Color(0xff899DA9), - start = Offset(0f, 0f), - end = Offset(0f, size.height), - strokeWidth = 4f, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 20f), 0f) - ) - } - Spacer(modifier = Modifier.width(10.dp)) - Column( - modifier = Modifier - .weight(1f) - .onGloballyPositioned { coordinates -> - columnHeight = coordinates.size.height - } - ) { - Text(stringResource(R.string.empty_my_post_content), fontSize = 16.sp) - Spacer(modifier = Modifier.height(24.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(3f / 2f) - .background(Color.White) - .padding(16.dp) - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color(0xFFF5F5F5)) - .noRippleClickable { - NewPostViewModel.asNewPost() - navController.navigate(NavigationRoute.NewPost.route) - } - ) { - Icon( - Icons.Default.Add, - tint = Color(0xFFD8D8D8), - contentDescription = "New post", - modifier = Modifier - .size(32.dp) - .align(Alignment.Center) - ) - } - } - } - } - } -} - -@Composable -fun MomentPostUnit(momentEntity: MomentEntity) { - TimeGroup(momentEntity.time.formatPostTime2()) - ProfileMomentCard( - momentEntity.momentTextContent, - momentEntity.images[0].thumbnail, - momentEntity.likeCount.toString(), - momentEntity.commentCount.toString(), - momentEntity = momentEntity - ) -} - -@Composable -fun TimeGroup(time: String = "2024.06.08 12:23") { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, top = 40.dp, end = 24.dp), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - Image( - modifier = Modifier - .height(16.dp) - .width(14.dp), - painter = painterResource(id = R.drawable.rider_pro_moment_time_flag), - contentDescription = "" - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = time, - fontSize = 16.sp, - color = Color.Black, - style = TextStyle(fontWeight = FontWeight.W600) - ) - } -} - -@Composable -fun ProfileMomentCard( - content: String, - imageUrl: String, - like: String, - comment: String, - momentEntity: MomentEntity -) { - var columnHeight by remember { mutableStateOf(0) } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, top = 18.dp, end = 24.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - ) { - Canvas( - modifier = Modifier - .height(with(LocalDensity.current) { columnHeight.toDp() }) - .width(14.dp) - ) { - drawLine( - color = Color(0xff899DA9), - start = Offset(0f, 0f), - end = Offset(0f, size.height), - strokeWidth = 4f, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 20f), 0f) - ) - } - Spacer(modifier = Modifier.width(10.dp)) - Column( - modifier = Modifier - .background(Color.White) - .weight(1f) - .onGloballyPositioned { coordinates -> - columnHeight = coordinates.size.height - } - ) { - if (content.isNotEmpty()) { - MomentCardTopContent(content) - } - MomentCardPicture(imageUrl, momentEntity = momentEntity) - MomentCardOperation(like, comment) - } - } - } -} - -@Composable -fun MomentCardTopContent(content: String) { - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier.padding(top = 16.dp, bottom = 0.dp, start = 16.dp, end = 16.dp), - text = content, fontSize = 16.sp, color = Color.Black - ) - } -} - -@Composable -fun MomentCardPicture(imageUrl: String, momentEntity: MomentEntity) { - val navController = LocalNavController.current - val context = LocalContext.current - CustomAsyncImage( - context, - imageUrl, - modifier = Modifier - .fillMaxSize() - .aspectRatio(3f / 2f) - .padding(top = 16.dp) - .noRippleClickable { - navController.navigateToPost( - id = momentEntity.id, - highlightCommentId = 0, - initImagePagerIndex = 0 - ) - }, - contentDescription = "", - contentScale = ContentScale.Crop - ) - - -} - -@Composable -fun MomentCardOperation(like: String, comment: String) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { -// Spacer(modifier = Modifier.weight(1f)) - MomentCardOperationItem( - drawable = R.drawable.rider_pro_like, - number = like, - modifier = Modifier.padding(end = 32.dp) - ) - MomentCardOperationItem( - drawable = R.drawable.rider_pro_moment_comment, - number = comment, - modifier = Modifier.padding(end = 32.dp) - ) - } -} - -@Composable -fun MomentCardOperationItem(@DrawableRes drawable: Int, number: String, modifier: Modifier) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically - ) { - Image( - modifier = Modifier.padding(start = 16.dp, end = 8.dp), - painter = painterResource(id = drawable), contentDescription = "" - ) - Text(text = number) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/ProfileV3.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/ProfileV3.kt new file mode 100644 index 0000000..11b46d3 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/ProfileV3.kt @@ -0,0 +1,452 @@ +package com.aiosman.riderpro.ui.index.tabs.profile + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +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.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.composables.DropdownMenu +import com.aiosman.riderpro.ui.composables.MenuItem +import com.aiosman.riderpro.ui.composables.StatusBarSpacer +import com.aiosman.riderpro.ui.composables.toolbar.CollapsingToolbarScaffold +import com.aiosman.riderpro.ui.composables.toolbar.ScrollStrategy +import com.aiosman.riderpro.ui.composables.toolbar.rememberCollapsingToolbarScaffoldState +import com.aiosman.riderpro.ui.index.tabs.profile.composable.EmptyMomentPostUnit +import com.aiosman.riderpro.ui.index.tabs.profile.composable.GalleryItem +import com.aiosman.riderpro.ui.index.tabs.profile.composable.MomentPostUnit +import com.aiosman.riderpro.ui.index.tabs.profile.composable.OtherProfileAction +import com.aiosman.riderpro.ui.index.tabs.profile.composable.SelfProfileAction +import com.aiosman.riderpro.ui.index.tabs.profile.composable.UserContentPageIndicator +import com.aiosman.riderpro.ui.index.tabs.profile.composable.UserItem +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.aiosman.riderpro.ui.post.NewPostViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ProfileV3( + onUpdateBanner: ((Uri, Context) -> Unit)? = null, + profile: AccountProfileEntity? = null, + onLogout: () -> Unit = {}, + onFollowClick: () -> Unit = {}, + onChatClick: () -> Unit = {}, + sharedFlow: SharedFlow> = MutableStateFlow>( + PagingData.empty() + ).asStateFlow(), + isSelf: Boolean = true +) { + val state = rememberCollapsingToolbarScaffoldState() + val pagerState = rememberPagerState(pageCount = { 2 }) + var enabled by remember { mutableStateOf(true) } + val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues() + var expanded by remember { mutableStateOf(false) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + val navController = LocalNavController.current + var bannerHeight = 400 + val pickBannerImageLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val uri = result.data?.data + uri?.let { + onUpdateBanner?.invoke(it, context) + } + } + } + val moments = sharedFlow.collectAsLazyPagingItems() + + Box { + CollapsingToolbarScaffold( + modifier = Modifier.fillMaxSize(), + state = state, + scrollStrategy = ScrollStrategy.ExitUntilCollapsed, + enabled = enabled, + toolbar = { + Column( + modifier = Modifier + .fillMaxWidth() + // 保持在最低高度和当前高度之间 + .background(Color(0xfff8f8f8)) + .padding(horizontal = 16.dp) + + + ) { + StatusBarSpacer() + + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically + ) { + CustomAsyncImage( + LocalContext.current, + profile?.avatar, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentDescription = "", + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(16.dp)) + androidx.compose.material3.Text( + text = profile?.nickName ?: "", + fontSize = 16.sp, + fontWeight = FontWeight.W600, + color = Color.Black + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } +// Column( +// modifier = Modifier.fillMaxWidth() +// +// ) { +// StatusBarSpacer() +// Box( +// modifier = Modifier +// .background(Color.White) +// .fillMaxWidth() +// .height(64.dp) +// ) { +// +// } +// } + Box( + modifier = Modifier + .parallax(0.5f) + .fillMaxWidth() + .height(600.dp) + .graphicsLayer { + // change alpha of Image as the toolbar expands + alpha = state.toolbarState.progress + }, + + ) { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + // banner + Box( + modifier = Modifier + .fillMaxWidth() + .height(bannerHeight.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .noRippleClickable { + Intent(Intent.ACTION_PICK).apply { + type = "image/*" + pickBannerImageLauncher.launch(this) + } + } + .shadow( + elevation = 4.dp, + shape = RoundedCornerShape( + bottomStart = 32.dp, + bottomEnd = 32.dp + ) + ) + ) { + val banner = profile?.banner + + if (banner != null) { + CustomAsyncImage( + LocalContext.current, + banner, + modifier = Modifier + .fillMaxSize(), + contentDescription = "", + contentScale = ContentScale.Crop + ) + } else { + Image( + painter = painterResource(id = R.drawable.rider_pro_moment_demo_2), + modifier = Modifier + .fillMaxSize(), + contentDescription = "", + contentScale = ContentScale.Crop + ) + } + } + if (isSelf) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding( + top = statusBarPaddingValues.calculateTopPadding(), + start = 8.dp, + end = 8.dp + ) + ) { + Box( + modifier = Modifier + .padding(16.dp) + .clip(RoundedCornerShape(8.dp)) + .shadow( + elevation = 20.dp + ) + .background(Color.White.copy(alpha = 0.7f)) + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_more_horizon), + contentDescription = "", + modifier = Modifier.noRippleClickable { + expanded = true + }, + tint = Color.Black + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + width = 250, + menuItems = listOf( + MenuItem( + stringResource(R.string.logout), + R.mipmap.rider_pro_logout + ) { + expanded = false + scope.launch { + onLogout() + navController.navigate(NavigationRoute.Login.route) { + popUpTo(NavigationRoute.Index.route) { + inclusive = true + } + } + } + }, + MenuItem( + stringResource(R.string.change_password), + R.mipmap.rider_pro_change_password + ) { + expanded = false + scope.launch { + navController.navigate(NavigationRoute.ChangePasswordScreen.route) + } + }, + MenuItem( + stringResource(R.string.favourites), + R.drawable.rider_pro_favourite + ) { + expanded = false + scope.launch { + navController.navigate(NavigationRoute.FavouriteList.route) + } + } + ) + ) + } + } + + } + Box( + modifier = Modifier.fillMaxWidth() + ) { + // user info + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Spacer(modifier = Modifier.height(16.dp)) + // 个人信息 + Box( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + profile?.let { + UserItem(it) + } + } + Spacer(modifier = Modifier.height(16.dp)) + profile?.let { + Box( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + if (isSelf) { + SelfProfileAction { + navController.navigate(NavigationRoute.AccountEdit.route) + } + } else { + OtherProfileAction( + it, + onFollow = { + onFollowClick() + }, + onChat = { + onChatClick() + } + ) + } + + } + } + + // collapsed bar + + + } + + } + + } + } + } + ) { + Column { + UserContentPageIndicator( + pagerState = pagerState, + ) + Spacer(modifier = Modifier.height(8.dp)) + HorizontalPager( + state = pagerState, + ) { idx -> + when (idx) { + 0 -> + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy( + 8.dp + ), + verticalItemSpacing = 8.dp, + contentPadding = PaddingValues(8.dp) + ) { + if (isSelf) { + items(1) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.75f) + .clip( + RoundedCornerShape(8.dp) + ) + .background(Color.White) + .padding(8.dp) + .noRippleClickable { + NewPostViewModel.asNewPost() + navController.navigate(NavigationRoute.NewPost.route) + } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip( + RoundedCornerShape(8.dp) + ) + .background(Color(0xfff5f5f5)) + ) { + Icon( + Icons.Default.Add, + contentDescription = "", + modifier = Modifier + .size(32.dp) + .align(Alignment.Center) + ) + } + } + } + } + items(moments.itemCount) { idx -> + val moment = moments[idx] ?: return@items + GalleryItem(moment,idx) + } + items(2) { + Spacer(modifier = Modifier.height(120.dp)) + } + } + + 1 -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + ) { + if (moments.itemCount == 0) { + item { + EmptyMomentPostUnit() + } + } + item { + for (idx in 0 until moments.itemCount) { + val moment = moments[idx] + moment?.let { + MomentPostUnit(it) + } + } + } + item { + Spacer(modifier = Modifier.height(120.dp)) + } + } + } + } + } + + } + } +} diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/ProfileWrap.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/ProfileWrap.kt new file mode 100644 index 0000000..1a11a42 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/ProfileWrap.kt @@ -0,0 +1,41 @@ +package com.aiosman.riderpro.ui.index.tabs.profile + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch + +@Composable +fun ProfileWrap( + +) { + LaunchedEffect(Unit) { + MyProfileViewModel.loadProfile() + } +// ProfileV2( +// onUpdateBanner = { uri, context -> +// MyProfileViewModel.updateUserProfileBanner(uri, context) +// }, +// onLogout = { +// MyProfileViewModel.viewModelScope.launch { +// MyProfileViewModel.logout() +// } +// +// }, +// profile = MyProfileViewModel.profile, +// sharedFlow = MyProfileViewModel.sharedFlow +// ) + ProfileV3( + onUpdateBanner = { uri, context -> + MyProfileViewModel.updateUserProfileBanner(uri, context) + }, + onLogout = { + MyProfileViewModel.viewModelScope.launch { + MyProfileViewModel.logout() + } + + }, + profile = MyProfileViewModel.profile, + sharedFlow = MyProfileViewModel.sharedFlow + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profilev2.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profilev2.kt new file mode 100644 index 0000000..6476fa1 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profilev2.kt @@ -0,0 +1,481 @@ +package com.aiosman.riderpro.ui.index.tabs.profile + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +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.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.composables.DropdownMenu +import com.aiosman.riderpro.ui.composables.MenuItem +import com.aiosman.riderpro.ui.index.tabs.profile.composable.EmptyMomentPostUnit +import com.aiosman.riderpro.ui.index.tabs.profile.composable.GalleryItem +import com.aiosman.riderpro.ui.index.tabs.profile.composable.MomentPostUnit +import com.aiosman.riderpro.ui.index.tabs.profile.composable.OtherProfileAction +import com.aiosman.riderpro.ui.index.tabs.profile.composable.SelfProfileAction +import com.aiosman.riderpro.ui.index.tabs.profile.composable.UserContentPageIndicator +import com.aiosman.riderpro.ui.index.tabs.profile.composable.UserItem +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.aiosman.riderpro.ui.post.NewPostViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ProfileV2( + onUpdateBanner: ((Uri, Context) -> Unit)? = null, + profile: AccountProfileEntity? = null, + onLogout: () -> Unit = {}, + onFollowClick: () -> Unit = {}, + onChatClick: () -> Unit = {}, + sharedFlow: SharedFlow> = MutableStateFlow>( + PagingData.empty() + ).asStateFlow(), + isSelf: Boolean = true +) { + Box( + modifier = Modifier.background(Color(0xffFFFFFF)) + ) { + var parentScrollThreshold by remember { mutableStateOf(0) } + var remainScrollThreshold by remember { mutableStateOf(0) } + val bannerHeight = 500 + var scrollState = rememberLazyListState() + var gridScrollState = rememberLazyStaggeredGridState() + var pagerState = rememberPagerState(pageCount = { 2 }) + val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues() + val context = LocalContext.current + var expanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val navController = LocalNavController.current + val moments = sharedFlow.collectAsLazyPagingItems() + val rootScrollState = rememberScrollState() + val pickBannerImageLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val uri = result.data?.data + uri?.let { + onUpdateBanner?.invoke(it, context) + } + } + } + val parentScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource + ): Offset { + Log.d("ProfileV2", "onPreScroll: $available") + val delta = available.y.toInt() + if (delta < 0 && rootScrollState.value < parentScrollThreshold - remainScrollThreshold) { + val scrollAmount = minOf( + -delta, + parentScrollThreshold - remainScrollThreshold - rootScrollState.value + ) + scope.launch { + Log.d("ProfileV2", "scrollBy: $scrollAmount") + rootScrollState.scrollBy(scrollAmount.toFloat()) + } + return Offset(0f, -scrollAmount.toFloat()) + } + return Offset.Zero + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .nestedScroll(parentScrollConnection) + .verticalScroll( + state = rootScrollState + ) + .background(Color(0xfff8f8f8)) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { + parentScrollThreshold = it.size.height + } + + ) { + // banner + Box( + modifier = Modifier + .fillMaxWidth() + .height(bannerHeight.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .noRippleClickable { + Intent(Intent.ACTION_PICK).apply { + type = "image/*" + pickBannerImageLauncher.launch(this) + } + } + .shadow( + elevation = 4.dp, + shape = RoundedCornerShape( + bottomStart = 32.dp, + bottomEnd = 32.dp + ) + ) + ) { + val banner = profile?.banner + + if (banner != null) { + CustomAsyncImage( + LocalContext.current, + banner, + modifier = Modifier + .fillMaxSize(), + contentDescription = "", + contentScale = ContentScale.Crop + ) + } else { + Image( + painter = painterResource(id = R.drawable.rider_pro_moment_demo_2), + modifier = Modifier + .fillMaxSize(), + contentDescription = "", + contentScale = ContentScale.Crop + ) + } + } + if (isSelf) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding( + top = statusBarPaddingValues.calculateTopPadding(), + start = 8.dp, + end = 8.dp + ) + ) { + Box( + modifier = Modifier + .padding(16.dp) + .clip(RoundedCornerShape(8.dp)) + .shadow( + elevation = 20.dp + ) + .background(Color.White.copy(alpha = 0.7f)) + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_more_horizon), + contentDescription = "", + modifier = Modifier.noRippleClickable { + expanded = true + }, + tint = Color.Black + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + width = 250, + menuItems = listOf( + MenuItem( + stringResource(R.string.logout), + R.mipmap.rider_pro_logout + ) { + expanded = false + scope.launch { + onLogout() + navController.navigate(NavigationRoute.Login.route) { + popUpTo(NavigationRoute.Index.route) { + inclusive = true + } + } + } + }, + MenuItem( + stringResource(R.string.change_password), + R.mipmap.rider_pro_change_password + ) { + expanded = false + scope.launch { + navController.navigate(NavigationRoute.ChangePasswordScreen.route) + } + }, + MenuItem( + stringResource(R.string.favourites), + R.drawable.rider_pro_favourite + ) { + expanded = false + scope.launch { + navController.navigate(NavigationRoute.FavouriteList.route) + } + } + ) + ) + } + } + + } + Box( + modifier = Modifier.fillMaxWidth() + ) { + // user info + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Spacer(modifier = Modifier.height(16.dp)) + // 个人信息 + Box( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + profile?.let { + UserItem(it) + } + } + Spacer(modifier = Modifier.height(16.dp)) + profile?.let { + Box( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + if (isSelf) { + SelfProfileAction { + navController.navigate(NavigationRoute.AccountEdit.route) + } + } else { + OtherProfileAction( + it, + onFollow = { + onFollowClick() + }, + onChat = { + onChatClick() + } + ) + } + + } + } + + // collapsed bar + + + } + + } + + } + val miniBarAlpha = + if (rootScrollState.value >= parentScrollThreshold - remainScrollThreshold) { + 1f + } else { + 0f + } + Column( + modifier = Modifier + .fillMaxWidth() + .alpha(miniBarAlpha) + // 保持在最低高度和当前高度之间 + .background(Color(0xfff8f8f8)) + .padding(horizontal = 16.dp) + .onGloballyPositioned { + remainScrollThreshold = it.size.height + } + .align(Alignment.BottomCenter) + + ) { + Spacer(modifier = Modifier.height(48.dp)) + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically + ) { + CustomAsyncImage( + LocalContext.current, + profile?.avatar, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentDescription = "", + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = profile?.nickName ?: "", + fontSize = 16.sp, + fontWeight = FontWeight.W600, + color = Color.Black + ) + } + } + } + // 页面指示器 + Spacer(modifier = Modifier.height(16.dp)) + UserContentPageIndicator(pagerState) + Spacer(modifier = Modifier.height(16.dp)) + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .height(900.dp) + ) { page -> + when (page) { + 0 -> { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy( + 8.dp + ), + verticalItemSpacing = 8.dp, + modifier = Modifier.fillMaxSize(), + state = gridScrollState, + contentPadding = PaddingValues(8.dp) + ) { + items(1) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.75f) + .clip( + RoundedCornerShape(8.dp) + ) + .background(Color.White) + .padding(8.dp) + .noRippleClickable { + NewPostViewModel.asNewPost() + navController.navigate(NavigationRoute.NewPost.route) + } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip( + RoundedCornerShape(8.dp) + ) + .background(Color(0xfff5f5f5)) + ) { + Icon( + Icons.Default.Add, + contentDescription = "", + modifier = Modifier + .size(32.dp) + .align(Alignment.Center) + ) + } + } + } + items(moments.itemCount) { idx -> + val moment = moments[idx] ?: return@items + GalleryItem(moment) + } + items(2) { + Spacer(modifier = Modifier.height(120.dp)) + } + } + } + + 1 -> { + LazyColumn( + modifier = Modifier.fillMaxHeight(), + state = scrollState + ) { + if (moments.itemCount == 0) { + item { + EmptyMomentPostUnit() + } + } + item { + for (idx in 0 until moments.itemCount) { + val moment = moments[idx] + moment?.let { + MomentPostUnit(it) + } + } + } + item { + Spacer(modifier = Modifier.height(120.dp)) + } + } + } + } + } + } + } +} + diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/GalleryItem.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/GalleryItem.kt new file mode 100644 index 0000000..fe148c2 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/GalleryItem.kt @@ -0,0 +1,51 @@ +package com.aiosman.riderpro.ui.index.tabs.profile.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.aiosman.riderpro.ui.navigateToPost + +@Composable +fun GalleryItem( + moment: MomentEntity, + idx: Int = 0 +){ + val navController = LocalNavController.current + + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio( + if (idx % 3 == 0) 1.5f else 1f + ) + .clip(RoundedCornerShape(8.dp)) + .noRippleClickable { + navController.navigateToPost( + moment.id + ) + } + + + ) { + CustomAsyncImage( + LocalContext.current, + moment.images[0].thumbnail, + modifier = Modifier + .fillMaxSize(), + contentDescription = "", + contentScale = ContentScale.Crop, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/MomentCard.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/MomentCard.kt new file mode 100644 index 0000000..8bf684d --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/MomentCard.kt @@ -0,0 +1,292 @@ +package com.aiosman.riderpro.ui.index.tabs.profile.composable + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.exp.formatPostTime2 +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.aiosman.riderpro.ui.navigateToPost +import com.aiosman.riderpro.ui.post.NewPostViewModel + +@Composable +fun EmptyMomentPostUnit() { + TimeGroup(stringResource(R.string.empty_my_post_title)) + ProfileEmptyMomentCard() +} + +@Composable +fun ProfileEmptyMomentCard( + +) { + var columnHeight by remember { mutableStateOf(0) } + val navController = LocalNavController.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 18.dp, end = 24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Canvas( + modifier = Modifier + .height(with(LocalDensity.current) { columnHeight.toDp() }) + .width(14.dp) + ) { + drawLine( + color = Color(0xff899DA9), + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = 4f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 20f), 0f) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column( + modifier = Modifier + .weight(1f) + .onGloballyPositioned { coordinates -> + columnHeight = coordinates.size.height + } + ) { + Text(stringResource(R.string.empty_my_post_content), fontSize = 16.sp) + Spacer(modifier = Modifier.height(24.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(3f / 2f) + .background(Color.White) + .padding(16.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)) + .noRippleClickable { + NewPostViewModel.asNewPost() + navController.navigate(NavigationRoute.NewPost.route) + } + ) { + Icon( + Icons.Default.Add, + tint = Color(0xFFD8D8D8), + contentDescription = "New post", + modifier = Modifier + .size(32.dp) + .align(Alignment.Center) + ) + } + } + } + } + } +} + +@Composable +fun MomentPostUnit(momentEntity: MomentEntity) { + TimeGroup(momentEntity.time.formatPostTime2()) + ProfileMomentCard( + momentEntity.momentTextContent, + momentEntity.images[0].thumbnail, + momentEntity.likeCount.toString(), + momentEntity.commentCount.toString(), + momentEntity = momentEntity + ) +} + +@Composable +fun TimeGroup(time: String = "2024.06.08 12:23") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 40.dp, end = 24.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier + .height(16.dp) + .width(14.dp), + painter = painterResource(id = R.drawable.rider_pro_moment_time_flag), + contentDescription = "" + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = time, + fontSize = 16.sp, + color = Color.Black, + style = TextStyle(fontWeight = FontWeight.W600) + ) + } +} + +@Composable +fun ProfileMomentCard( + content: String, + imageUrl: String, + like: String, + comment: String, + momentEntity: MomentEntity +) { + var columnHeight by remember { mutableStateOf(0) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 18.dp, end = 24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Canvas( + modifier = Modifier + .height(with(LocalDensity.current) { columnHeight.toDp() }) + .width(14.dp) + ) { + drawLine( + color = Color(0xff899DA9), + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = 4f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 20f), 0f) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column( + modifier = Modifier + .background(Color.White) + .weight(1f) + .onGloballyPositioned { coordinates -> + columnHeight = coordinates.size.height + } + ) { + if (content.isNotEmpty()) { + MomentCardTopContent(content) + } + MomentCardPicture(imageUrl, momentEntity = momentEntity) + MomentCardOperation(like, comment) + } + } + } +} + +@Composable +fun MomentCardTopContent(content: String) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.padding(top = 16.dp, bottom = 0.dp, start = 16.dp, end = 16.dp), + text = content, fontSize = 16.sp, color = Color.Black + ) + } +} + +@Composable +fun MomentCardPicture(imageUrl: String, momentEntity: MomentEntity) { + val navController = LocalNavController.current + val context = LocalContext.current + CustomAsyncImage( + context, + imageUrl, + modifier = Modifier + .fillMaxSize() + .aspectRatio(3f / 2f) + .padding(top = 16.dp) + .noRippleClickable { + navController.navigateToPost( + id = momentEntity.id, + highlightCommentId = 0, + initImagePagerIndex = 0 + ) + }, + contentDescription = "", + contentScale = ContentScale.Crop + ) + + +} + +@Composable +fun MomentCardOperation(like: String, comment: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + MomentCardOperationItem( + drawable = R.drawable.rider_pro_like, + number = like, + modifier = Modifier.padding(end = 32.dp) + ) + MomentCardOperationItem( + drawable = R.drawable.rider_pro_moment_comment, + number = comment, + modifier = Modifier.padding(end = 32.dp) + ) + } +} + +@Composable +fun MomentCardOperationItem(@DrawableRes drawable: Int, number: String, modifier: Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier.padding(start = 16.dp, end = 8.dp), + painter = painterResource(id = drawable), contentDescription = "" + ) + Text(text = number) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/OtherProfileAction.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/OtherProfileAction.kt new file mode 100644 index 0000000..1fc20e5 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/OtherProfileAction.kt @@ -0,0 +1,97 @@ +package com.aiosman.riderpro.ui.index.tabs.profile.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.riderpro.R +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.ui.modifiers.noRippleClickable + +@Composable +fun OtherProfileAction( + profile: AccountProfileEntity, + onFollow: (() -> Unit)? = null, + onChat: (() -> Unit)? = null +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .clip(RoundedCornerShape(32.dp)) + .background(if (profile.isFollowing) Color(0xffebebeb) else Color(0xffda3833)) + .padding(horizontal = 16.dp, vertical = 4.dp) + .noRippleClickable { + onFollow?.invoke() + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "", + modifier = Modifier.size(24.dp), + tint = if (profile.isFollowing) Color.Black else Color.White + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(if (profile.isFollowing) R.string.following_upper else R.string.follow_upper), + fontSize = 14.sp, + fontWeight = FontWeight.W600, + color = if (profile.isFollowing) Color.Black else Color.White, + modifier = Modifier.padding(8.dp), + ) + + } + Spacer(modifier = Modifier.width(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .clip(RoundedCornerShape(32.dp)) + .background(Color(0xffebebeb)) + .padding(horizontal = 16.dp, vertical = 4.dp) + .noRippleClickable { + onChat?.invoke() + } + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_comment), + contentDescription = "", + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.chat_upper), + fontSize = 14.sp, + fontWeight = FontWeight.W600, + color = Color.Black, + modifier = Modifier.padding(8.dp), + ) + + } + } + // 按钮 + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/SelfProfileAction.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/SelfProfileAction.kt new file mode 100644 index 0000000..6ff36b3 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/SelfProfileAction.kt @@ -0,0 +1,58 @@ +package com.aiosman.riderpro.ui.index.tabs.profile.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Icon +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.modifiers.noRippleClickable + +@Composable +fun SelfProfileAction( + onEditProfile: () -> Unit +) { + // 按钮 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .clip(RoundedCornerShape(32.dp)) + .background(Color(0xffebebeb)) + .padding(horizontal = 16.dp, vertical = 4.dp) + .noRippleClickable { + onEditProfile() + } + ) { + Icon( + Icons.Default.Edit, + contentDescription = "", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.edit_profile), + fontSize = 14.sp, + fontWeight = FontWeight.W600, + color = Color.Black, + modifier = Modifier.padding(8.dp) + ) + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/UserContentPageIndicator.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/UserContentPageIndicator.kt new file mode 100644 index 0000000..8ae0b7f --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/UserContentPageIndicator.kt @@ -0,0 +1,85 @@ +package com.aiosman.riderpro.ui.index.tabs.profile.composable + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun UserContentPageIndicator( + pagerState: PagerState +){ + val scope = rememberCoroutineScope() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .clip(RoundedCornerShape(32.dp)) + .background(if (pagerState.currentPage == 0) Color(0xFFFFFFFF) else Color.Transparent) + .padding(horizontal = 16.dp, vertical = 4.dp) + .noRippleClickable { + // switch to gallery + scope.launch { + pagerState.scrollToPage(0) + } + } + ) { + Text( + text = stringResource(R.string.gallery), + fontSize = 14.sp, + fontWeight = FontWeight.W600, + color = Color.Black, + modifier = Modifier.padding(8.dp) + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .clip(RoundedCornerShape(32.dp)) + .background(if (pagerState.currentPage == 1) Color(0xFFFFFFFF) else Color.Transparent) + .padding(horizontal = 16.dp, vertical = 8.dp) + .noRippleClickable { + // switch to moments + scope.launch { + pagerState.scrollToPage(1) + } + } + ) { + Text( + text = stringResource(R.string.moment), + fontSize = 14.sp, + fontWeight = FontWeight.W600, + color = Color.Black, + modifier = Modifier.padding(8.dp) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/UserItem.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/UserItem.kt new file mode 100644 index 0000000..67173ee --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/composable/UserItem.kt @@ -0,0 +1,131 @@ +package com.aiosman.riderpro.ui.index.tabs.profile.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +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.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.modifiers.noRippleClickable + +@Composable +fun UserItem(accountProfileEntity: AccountProfileEntity) { + val navController = LocalNavController.current + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // 头像 + CustomAsyncImage( + LocalContext.current, + accountProfileEntity.avatar, + modifier = Modifier + .clip(CircleShape) + .size(48.dp), + contentDescription = "", + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(32.dp)) + //个人统计 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .noRippleClickable { + navController.navigate( + NavigationRoute.FollowerList.route.replace( + "{id}", + accountProfileEntity.id.toString() + ) + ) + } + ) { + Text( + text = accountProfileEntity.followerCount.toString(), + fontWeight = FontWeight.W600, + fontSize = 16.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.followers_upper), + ) + } + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .weight(1f) + .noRippleClickable { + navController.navigate( + NavigationRoute.FollowingList.route.replace( + "{id}", + accountProfileEntity.id.toString() + ) + ) + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = accountProfileEntity.followingCount.toString(), + fontWeight = FontWeight.W600, + fontSize = 16.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.following_upper), + ) + } + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + // 昵称 + Text( + text = accountProfileEntity.nickName, + fontWeight = FontWeight.W600, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + // 个人简介 + Text( + text = accountProfileEntity.bio, + fontSize = 14.sp, + color = Color.Gray + ) + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/login/login.kt b/app/src/main/java/com/aiosman/riderpro/ui/login/login.kt index 1e98b82..e2ba85e 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/login/login.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/login/login.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -17,8 +16,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -28,7 +25,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -44,7 +40,6 @@ import com.aiosman.riderpro.ui.NavigationRoute import com.aiosman.riderpro.ui.composables.ActionButton import com.aiosman.riderpro.ui.modifiers.noRippleClickable import com.aiosman.riderpro.utils.GoogleLogin -import com.google.accompanist.systemuicontroller.SystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/aiosman/riderpro/ui/profile/AccountProfileV2.kt b/app/src/main/java/com/aiosman/riderpro/ui/profile/AccountProfileV2.kt index 08d11a9..ef86a11 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/profile/AccountProfileV2.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/profile/AccountProfileV2.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.aiosman.riderpro.LocalNavController import com.aiosman.riderpro.exp.viewModelFactory import com.aiosman.riderpro.ui.index.tabs.profile.Profile +import com.aiosman.riderpro.ui.index.tabs.profile.ProfileV2 +import com.aiosman.riderpro.ui.index.tabs.profile.ProfileV3 import com.aiosman.riderpro.ui.navigateToChat @Composable @@ -17,7 +19,7 @@ fun AccountProfileV2(id: String){ LaunchedEffect(Unit) { model.loadProfile(id) } - Profile( + ProfileV3( sharedFlow = model.momentsFlow, profile = model.profile, isSelf = false,