From 5c3c3111ae0163c86141fda4b192798b2827cebe Mon Sep 17 00:00:00 2001 From: AllenTom Date: Thu, 12 Sep 2024 23:13:11 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=E5=AF=86=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aiosman/riderpro/data/AccountService.kt | 12 ++ .../aiosman/riderpro/data/api/ApiClient.kt | 1 - .../aiosman/riderpro/data/api/RiderProAPI.kt | 9 +- .../main/java/com/aiosman/riderpro/ui/Navi.kt | 9 + .../riderpro/ui/account/ResetPassword.kt | 160 ++++++++++++++++++ .../riderpro/ui/composables/TextInputField.kt | 4 +- .../com/aiosman/riderpro/ui/login/userauth.kt | 5 +- 7 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/account/ResetPassword.kt diff --git a/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt b/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt index 67d3274..8226e4a 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt @@ -6,6 +6,7 @@ import com.aiosman.riderpro.data.api.GoogleRegisterRequestBody import com.aiosman.riderpro.data.api.LoginUserRequestBody import com.aiosman.riderpro.data.api.RegisterMessageChannelRequestBody import com.aiosman.riderpro.data.api.RegisterRequestBody +import com.aiosman.riderpro.data.api.ResetPasswordRequestBody import com.aiosman.riderpro.data.api.UpdateNoticeRequestBody import com.aiosman.riderpro.entity.AccountFavouriteEntity import com.aiosman.riderpro.entity.AccountLikeEntity @@ -339,6 +340,9 @@ interface AccountService { suspend fun updateNotice(payload: UpdateNoticeRequestBody) suspend fun registerMessageChannel(client: String, identifier: String) + + suspend fun resetPassword(email: String) + } class AccountServiceImpl : AccountService { @@ -455,4 +459,12 @@ class AccountServiceImpl : AccountService { ApiClient.api.registerMessageChannel(RegisterMessageChannelRequestBody(client, identifier)) } + override suspend fun resetPassword(email: String) { + ApiClient.api.resetPassword( + ResetPasswordRequestBody( + username = email + ) + ) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/data/api/ApiClient.kt b/app/src/main/java/com/aiosman/riderpro/data/api/ApiClient.kt index 368b045..42c0153 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/api/ApiClient.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/api/ApiClient.kt @@ -4,7 +4,6 @@ import android.icu.text.SimpleDateFormat import android.icu.util.TimeZone import com.aiosman.riderpro.AppStore import com.aiosman.riderpro.ConstVars -import com.aiosman.riderpro.data.ServiceException import com.auth0.android.jwt.JWT import kotlinx.coroutines.runBlocking import okhttp3.Interceptor diff --git a/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt b/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt index 53141d8..473c54f 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt @@ -92,7 +92,10 @@ data class RegisterMessageChannelRequestBody( @SerializedName("identifier") val identifier: String, ) - +data class ResetPasswordRequestBody( + @SerializedName("username") + val username: String, +) interface RiderProAPI { @POST("register") suspend fun register(@Body body: RegisterRequestBody): Response @@ -270,4 +273,8 @@ interface RiderProAPI { @Path("id") id: Int ): Response + @POST("account/my/password/reset") + suspend fun resetPassword( + @Body body: ResetPasswordRequestBody + ): Response } \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt b/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt index c73e941..b21919c 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt @@ -28,6 +28,7 @@ import com.aiosman.riderpro.LocalAnimatedContentScope import com.aiosman.riderpro.LocalNavController import com.aiosman.riderpro.LocalSharedTransitionScope import com.aiosman.riderpro.ui.account.AccountEditScreen2 +import com.aiosman.riderpro.ui.account.ResetPasswordScreen import com.aiosman.riderpro.ui.comment.CommentsScreen import com.aiosman.riderpro.ui.favourite.FavouriteNoticeScreen import com.aiosman.riderpro.ui.follower.FollowerListScreen @@ -80,6 +81,7 @@ sealed class NavigationRoute( data object Search : NavigationRoute("Search") data object FollowerList : NavigationRoute("FollowerList/{id}") data object FollowingList : NavigationRoute("FollowingList/{id}") + data object ResetPassword : NavigationRoute("ResetPassword") } @@ -262,6 +264,13 @@ fun NavigationController( FollowingListScreen(it.arguments?.getInt("id")!!) } } + composable(route = NavigationRoute.ResetPassword.route) { + CompositionLocalProvider( + LocalAnimatedContentScope provides this, + ) { + ResetPasswordScreen() + } + } } diff --git a/app/src/main/java/com/aiosman/riderpro/ui/account/ResetPassword.kt b/app/src/main/java/com/aiosman/riderpro/ui/account/ResetPassword.kt new file mode 100644 index 0000000..82eef66 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/account/ResetPassword.kt @@ -0,0 +1,160 @@ +package com.aiosman.riderpro.ui.account + +import android.widget.Toast +import androidx.compose.foundation.Image +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.fillMaxSize +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.CircularProgressIndicator +import androidx.compose.material.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.graphics.Color +import androidx.compose.ui.platform.LocalContext +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.data.AccountService +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.ui.comment.NoticeScreenHeader +import com.aiosman.riderpro.ui.composables.ActionButton +import com.aiosman.riderpro.ui.composables.StatusBarSpacer +import com.aiosman.riderpro.ui.composables.TextInputField +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +@Composable +fun ResetPasswordScreen() { + var username by remember { mutableStateOf("") } + val accountService: AccountService = AccountServiceImpl() + val scope = rememberCoroutineScope() + val context = LocalContext.current + var isSendSuccess by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + val navController = LocalNavController.current + + fun resetPassword() { + scope.launch { + isLoading = true + try { + accountService.resetPassword(username) + isSendSuccess = true + } catch (e: Exception) { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + isSendSuccess = false + } finally { + isLoading = false + } + } + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + StatusBarSpacer() + Box( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 0.dp + ) + ) { + NoticeScreenHeader( + "RECOVER ACCOUNT", + moreIcon = false + ) + } + + Column( + modifier = Modifier.padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(36.dp)) + + if (isSendSuccess != null) { + if (isSendSuccess!!) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Reset password email has been sent to your email address", + style = TextStyle( + color = Color(0xFF333333), + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + ) + } else { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Failed to send reset password email", + style = TextStyle( + color = Color(0xFF333333), + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + ) + } + Spacer(modifier = Modifier.height(40.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.noRippleClickable { + navController.popBackStack() + } + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_back), + contentDescription = "Back", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + androidx.compose.material3.Text( + stringResource(R.string.back_upper), + color = Color.Black, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } else { + Spacer(modifier = Modifier.height(120.dp)) + TextInputField( + text = username, + onValueChange = { username = it }, + label = stringResource(R.string.login_email_label), + hint = stringResource(R.string.text_hint_email), + enabled = !isLoading + ) + Spacer(modifier = Modifier.height(72.dp)) + if (isLoading) { + CircularProgressIndicator() + } else { + ActionButton( + modifier = Modifier + .width(345.dp) + .height(48.dp), + text = "Recover Account", + backgroundImage = R.mipmap.rider_pro_signup_red_bg + ) { + resetPassword() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/TextInputField.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/TextInputField.kt index b738685..de547dc 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/composables/TextInputField.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/TextInputField.kt @@ -43,7 +43,8 @@ fun TextInputField( password: Boolean = false, label: String? = null, hint: String? = null, - error: String? = null + error: String? = null, + enabled: Boolean = true ) { var showPassword by remember { mutableStateOf(!password) } var isFocused by remember { mutableStateOf(false) } @@ -74,6 +75,7 @@ fun TextInputField( ), visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), singleLine = true, + enabled = enabled ) if (password) { Image( diff --git a/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt b/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt index 2de5a13..6015312 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt @@ -197,7 +197,9 @@ fun UserAuthScreen() { fontSize = 12.sp ) Spacer(modifier = Modifier.weight(1f)) - Text(stringResource(R.string.forgot_password), fontSize = 12.sp) + Text(stringResource(R.string.forgot_password), fontSize = 12.sp, modifier = Modifier.noRippleClickable { + navController.navigate(NavigationRoute.ResetPassword.route) + }) } } Spacer(modifier = Modifier.height(64.dp)) @@ -210,7 +212,6 @@ fun UserAuthScreen() { ) { onLogin() } - Spacer(modifier = Modifier.height(48.dp)) Text(stringResource(R.string.or_login_with), color = Color(0xFF999999)) Spacer(modifier = Modifier.height(16.dp)) From de5088dc0224ee99dc85e6f6c9cf49e2b258235e Mon Sep 17 00:00:00 2001 From: AllenTom Date: Fri, 13 Sep 2024 23:20:38 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aiosman/riderpro/data/MomentService.kt | 3 +- .../aiosman/riderpro/data/api/RiderProAPI.kt | 3 + .../com/aiosman/riderpro/entity/Moment.kt | 24 ++- .../main/java/com/aiosman/riderpro/ui/Navi.kt | 10 +- .../riderpro/ui/composables/DropdownMenu.kt | 8 +- .../aiosman/riderpro/ui/composables/Image.kt | 25 --- .../ui/favourite/FavouriteListPage.kt | 121 +++++++++++++ .../ui/favourite/FavouriteListViewModel.kt | 53 ++++++ .../riderpro/ui/imageviewer/imageviewer.kt | 160 +++++++++++++++--- .../riderpro/ui/index/tabs/profile/Profile.kt | 131 +++++++------- .../com/aiosman/riderpro/ui/like/LikePage.kt | 2 +- .../java/com/aiosman/riderpro/utils/File.kt | 92 ++++++++++ .../main/res/drawable/rider_pro_delete.xml | 5 + .../main/res/drawable/rider_pro_download.xml | 5 + app/src/main/res/drawable/rider_pro_raw.xml | 9 + 15 files changed, 514 insertions(+), 137 deletions(-) create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/favourite/FavouriteListPage.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/favourite/FavouriteListViewModel.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/utils/File.kt create mode 100644 app/src/main/res/drawable/rider_pro_delete.xml create mode 100644 app/src/main/res/drawable/rider_pro_download.xml create mode 100644 app/src/main/res/drawable/rider_pro_raw.xml diff --git a/app/src/main/java/com/aiosman/riderpro/data/MomentService.kt b/app/src/main/java/com/aiosman/riderpro/data/MomentService.kt index a72a932..fea49c0 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/MomentService.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/MomentService.kt @@ -120,7 +120,8 @@ interface MomentService { author: Int? = null, timelineId: Int? = null, contentSearch: String? = null, - trend: Boolean? = false + trend: Boolean? = false, + favoriteUserId: Int? = null ): ListContainer /** diff --git a/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt b/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt index 473c54f..476bc02 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt @@ -92,10 +92,12 @@ data class RegisterMessageChannelRequestBody( @SerializedName("identifier") val identifier: String, ) + data class ResetPasswordRequestBody( @SerializedName("username") val username: String, ) + interface RiderProAPI { @POST("register") suspend fun register(@Body body: RegisterRequestBody): Response @@ -120,6 +122,7 @@ interface RiderProAPI { @Query("contentSearch") contentSearch: String? = null, @Query("postUser") postUser: Int? = null, @Query("trend") trend: String? = null, + @Query("favouriteUserId") favouriteUserId: Int? = null, ): Response> @Multipart diff --git a/app/src/main/java/com/aiosman/riderpro/entity/Moment.kt b/app/src/main/java/com/aiosman/riderpro/entity/Moment.kt index d1641f1..5979544 100644 --- a/app/src/main/java/com/aiosman/riderpro/entity/Moment.kt +++ b/app/src/main/java/com/aiosman/riderpro/entity/Moment.kt @@ -25,7 +25,8 @@ class MomentPagingSource( private val author: Int? = null, private val timelineId: Int? = null, private val contentSearch: String? = null, - private val trend: Boolean? = false + private val trend: Boolean? = false, + private val favoriteUserId: Int? = null ) : PagingSource() { override suspend fun load(params: LoadParams): LoadResult { return try { @@ -35,7 +36,8 @@ class MomentPagingSource( author = author, timelineId = timelineId, contentSearch = contentSearch, - trend = trend + trend = trend, + favoriteUserId = favoriteUserId ) LoadResult.Page( @@ -62,14 +64,16 @@ class MomentRemoteDataSource( author: Int?, timelineId: Int?, contentSearch: String?, - trend: Boolean? + trend: Boolean?, + favoriteUserId: Int? ): ListContainer { return momentService.getMoments( pageNumber = pageNumber, author = author, timelineId = timelineId, contentSearch = contentSearch, - trend = trend + trend = trend, + favoriteUserId = favoriteUserId ) } } @@ -82,14 +86,16 @@ class MomentServiceImpl() : MomentService { author: Int?, timelineId: Int?, contentSearch: String?, - trend: Boolean? + trend: Boolean?, + favoriteUserId: Int? ): ListContainer { return momentBackend.fetchMomentItems( pageNumber = pageNumber, author = author, timelineId = timelineId, contentSearch = contentSearch, - trend = trend + trend = trend, + favoriteUserId = favoriteUserId ) } @@ -136,7 +142,8 @@ class MomentBackend { author: Int? = null, timelineId: Int?, contentSearch: String?, - trend: Boolean? + trend: Boolean?, + favoriteUserId: Int? = null ): ListContainer { val resp = ApiClient.api.getPosts( pageSize = DataBatchSize, @@ -144,7 +151,8 @@ class MomentBackend { timelineId = timelineId, authorId = author, contentSearch = contentSearch, - trend = if (trend == true) "true" else "" + trend = if (trend == true) "true" else "", + favouriteUserId = favoriteUserId ) val body = resp.body() ?: throw ServiceException("Failed to get moments") return ListContainer( diff --git a/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt b/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt index b21919c..10c6141 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt @@ -30,6 +30,7 @@ import com.aiosman.riderpro.LocalSharedTransitionScope import com.aiosman.riderpro.ui.account.AccountEditScreen2 import com.aiosman.riderpro.ui.account.ResetPasswordScreen import com.aiosman.riderpro.ui.comment.CommentsScreen +import com.aiosman.riderpro.ui.favourite.FavouriteListPage import com.aiosman.riderpro.ui.favourite.FavouriteNoticeScreen import com.aiosman.riderpro.ui.follower.FollowerListScreen import com.aiosman.riderpro.ui.follower.FollowerNoticeScreen @@ -82,6 +83,7 @@ sealed class NavigationRoute( data object FollowerList : NavigationRoute("FollowerList/{id}") data object FollowingList : NavigationRoute("FollowingList/{id}") data object ResetPassword : NavigationRoute("ResetPassword") + data object FavouriteList : NavigationRoute("FavouriteList") } @@ -271,7 +273,13 @@ fun NavigationController( ResetPasswordScreen() } } - + composable(route = NavigationRoute.FavouriteList.route) { + CompositionLocalProvider( + LocalAnimatedContentScope provides this, + ) { + FavouriteListPage() + } + } } diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/DropdownMenu.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/DropdownMenu.kt index a3cbfce..948fc82 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/composables/DropdownMenu.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/DropdownMenu.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontVariation.width import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.aiosman.riderpro.R @@ -29,6 +30,7 @@ data class MenuItem( val action: () -> Unit ) + @Composable fun DropdownMenu( expanded: Boolean = false, @@ -47,8 +49,8 @@ fun DropdownMenu( expanded = expanded, onDismissRequest = onDismissRequest, modifier = Modifier - .apply { - width?.let { width(width.dp) } + .let { + if (width != null) it.width(width.dp) else it } .background(Color.White) ) { @@ -67,7 +69,7 @@ fun DropdownMenu( if (width != null) { Spacer(modifier = Modifier.weight(1f)) - }else{ + } else { Spacer(modifier = Modifier.width(16.dp)) } Icon( diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/Image.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/Image.kt index f077bc9..bc8bfe2 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/composables/Image.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/Image.kt @@ -61,31 +61,6 @@ fun CustomAsyncImage( val localContext = LocalContext.current val imageLoader = getImageLoader(context ?: localContext) -// val blurBitmap = remember(blurHash) { -// blurHash?.let { -// BlurHashDecoder.decode( -// blurHash = it, -// width = DEFAULT_HASHED_BITMAP_WIDTH, -// height = DEFAULT_HASHED_BITMAP_HEIGHT -// ) -// } -// } - -// var bitmap by remember(imageUrl) { mutableStateOf(null) } -// -// LaunchedEffect(imageUrl) { -// if (bitmap == null) { -// val request = ImageRequest.Builder(context) -// .data(imageUrl) -// .crossfade(3000) -// .build() -// -// val result = withContext(Dispatchers.IO) { -// (imageLoader.execute(request) as? SuccessResult)?.drawable?.toBitmap() -// } -// bitmap = result -// } -// } AsyncImage( model = ImageRequest.Builder(context ?: localContext) diff --git a/app/src/main/java/com/aiosman/riderpro/ui/favourite/FavouriteListPage.kt b/app/src/main/java/com/aiosman/riderpro/ui/favourite/FavouriteListPage.kt new file mode 100644 index 0000000..e7d1b2a --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/favourite/FavouriteListPage.kt @@ -0,0 +1,121 @@ +package com.aiosman.riderpro.ui.favourite + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.comment.NoticeScreenHeader +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.composables.StatusBarSpacer +import com.aiosman.riderpro.ui.modifiers.noRippleClickable + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun FavouriteListPage() { + val model = FavouriteListViewModel + var dataFlow = model.favouriteMomentsFlow + var moments = dataFlow.collectAsLazyPagingItems() + val context = LocalContext.current + val navController = LocalNavController.current + val state = rememberPullRefreshState(FavouriteListViewModel.isLoading, onRefresh = { + model.refreshPager(force = true) + }) + Column( + modifier = Modifier.fillMaxSize() + ) { + StatusBarSpacer() + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f).pullRefresh(state) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + ) { + NoticeScreenHeader("Favourite") + } + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxSize() + ) { + items(moments.itemCount) { idx -> + val momentItem = moments[idx] ?: return@items + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .padding(2.dp) + + .noRippleClickable { + navController.navigate( + NavigationRoute.Post.route.replace( + "{id}", + momentItem.id.toString() + ) + ) + } + ) { + CustomAsyncImage( + imageUrl = momentItem.images[0].thumbnail, + contentDescription = "", + modifier = Modifier + .fillMaxSize(), + context = context + ) + if (momentItem.images.size > 1) { + Box( + modifier = Modifier + .padding(top = 8.dp, end = 8.dp) + .align(Alignment.TopEnd) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.rider_pro_picture_more), + contentDescription = "", + ) + } + } + + + } + + } + } + + } + + PullRefreshIndicator( + FavouriteListViewModel.isLoading, + state, + Modifier.align(Alignment.TopCenter) + ) + + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/favourite/FavouriteListViewModel.kt b/app/src/main/java/com/aiosman/riderpro/ui/favourite/FavouriteListViewModel.kt new file mode 100644 index 0000000..975751a --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/favourite/FavouriteListViewModel.kt @@ -0,0 +1,53 @@ +package com.aiosman.riderpro.ui.favourite + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.aiosman.riderpro.data.MomentService +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.entity.MomentPagingSource +import com.aiosman.riderpro.entity.MomentRemoteDataSource +import com.aiosman.riderpro.entity.MomentServiceImpl +import com.aiosman.riderpro.ui.index.tabs.moment.MomentViewModel.accountService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +object FavouriteListViewModel:ViewModel() { + private val momentService: MomentService = MomentServiceImpl() + private val _favouriteMomentsFlow = + MutableStateFlow>(PagingData.empty()) + val favouriteMomentsFlow = _favouriteMomentsFlow.asStateFlow() + var isLoading by mutableStateOf(false) + init { + refreshPager() + } + fun refreshPager(force:Boolean = false) { + viewModelScope.launch { + if (force) { + isLoading = true + } + val profile = accountService.getMyAccountProfile() + isLoading = false + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + MomentPagingSource( + MomentRemoteDataSource(momentService), + favoriteUserId = profile.id + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _favouriteMomentsFlow.value = it + } + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/imageviewer/imageviewer.kt b/app/src/main/java/com/aiosman/riderpro/ui/imageviewer/imageviewer.kt index 074f8a8..06a7f66 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/imageviewer/imageviewer.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/imageviewer/imageviewer.kt @@ -1,42 +1,79 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.ExperimentalFoundationApi 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +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.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.aiosman.riderpro.LocalAnimatedContentScope import com.aiosman.riderpro.LocalNavController import com.aiosman.riderpro.LocalSharedTransitionScope +import com.aiosman.riderpro.R import com.aiosman.riderpro.ui.composables.CustomAsyncImage import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout import com.aiosman.riderpro.ui.imageviewer.ImageViewerViewModel +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.aiosman.riderpro.utils.File.saveImageToGallery import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.launch import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable -@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class, + ExperimentalMaterial3Api::class +) @Composable fun ImageViewer() { val model = ImageViewerViewModel val images = model.imageList - val pagerState = rememberPagerState(pageCount = { images.size }, initialPage = model.initialIndex) - val systemUiController = rememberSystemUiController() + val pagerState = + rememberPagerState(pageCount = { images.size }, initialPage = model.initialIndex) val navController = LocalNavController.current - val sharedTransitionScope = LocalSharedTransitionScope.current - val animatedVisibilityScope = LocalAnimatedContentScope.current val context = LocalContext.current - LaunchedEffect(Unit) { - - } + val navigationBarPaddings = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp + val scope = rememberCoroutineScope() + val showRawImageStates = remember { mutableStateListOf(*Array(images.size) { false }) } + var showBottomSheet by remember { mutableStateOf(false) } + var isDownloading by remember { mutableStateOf(false) } StatusBarMaskLayout( modifier = Modifier.background(Color.Black), ) { @@ -50,27 +87,100 @@ fun ImageViewer() { modifier = Modifier.fillMaxSize() ) { page -> val zoomState = rememberZoomState() - with(sharedTransitionScope) { - CustomAsyncImage( - context, - images[page].url, - contentDescription = null, - modifier = Modifier.sharedElement( - rememberSharedContentState(key = images[page]), - animatedVisibilityScope = animatedVisibilityScope - ) - .fillMaxSize() - .zoomable( - zoomState = zoomState, - onTap = { - navController.popBackStack() - } + CustomAsyncImage( + context, + if (showRawImageStates[page]) images[page].url else images[page].thumbnail, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .zoomable( + zoomState = zoomState, + onTap = { + navController.popBackStack() + } + ), + contentScale = ContentScale.Fit, + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black ), - contentScale = ContentScale.Fit, + ) ) + .padding(start = 16.dp, end = 16.dp, bottom = navigationBarPaddings), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.Center + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.noRippleClickable { + if (isDownloading) { + return@noRippleClickable + } + isDownloading = true + scope.launch { + saveImageToGallery(context, images[pagerState.currentPage].url) + isDownloading = false + } + } + ) { + if (isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = Color.White + ) + }else{ + Icon( + painter = painterResource(id = R.drawable.rider_pro_download), + contentDescription = "", + modifier = Modifier.size(32.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Download", + color = Color.White + ) + } + if (!showRawImageStates[pagerState.currentPage]) { + Spacer(modifier = Modifier.width(32.dp)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.noRippleClickable { + showRawImageStates[pagerState.currentPage] = true + } + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_raw), + contentDescription = "", + modifier = Modifier.size(32.dp), + tint = Color.White + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Original", + color = Color.White + ) + } + } } } } - } } \ No newline at end of file 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 076d1cd..50750de 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 @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons @@ -75,6 +76,7 @@ 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.MenuItem import com.aiosman.riderpro.ui.modifiers.noRippleClickable import com.aiosman.riderpro.ui.post.PostViewModel import kotlinx.coroutines.launch @@ -118,6 +120,7 @@ fun ProfilePage() { }) .pullRefresh(state) ) { + LazyColumn( modifier = Modifier.fillMaxSize() ) { @@ -166,83 +169,65 @@ fun ProfilePage() { .align(Alignment.TopEnd) .padding( top = statusBarPaddingValues.calculateTopPadding(), - start = 16.dp, - end = 16.dp + start = 8.dp, + end = 8.dp ) ) { - Icon( - painter = painterResource(id = R.drawable.rider_pro_more_horizon), - contentDescription = "", - modifier = Modifier.noRippleClickable { - expanded = true - }, - tint = Color.White - ) - MaterialTheme( - shapes = MaterialTheme.shapes.copy( - extraSmall = RoundedCornerShape( - 16.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 }, - modifier = Modifier - .width(250.dp) - .background(Color.White) - ) { - Box( - modifier = Modifier - .padding(vertical = 14.dp, horizontal = 24.dp) - .noRippleClickable { - expanded = false - scope.launch { - model.logout() - navController.navigate(NavigationRoute.Login.route) { - popUpTo(NavigationRoute.Index.route) { - inclusive = true - } - } - } - }) { - Row { - Text( - stringResource(R.string.logout), - fontWeight = FontWeight.W500 - ) - Spacer(modifier = Modifier.weight(1f)) - Icon( - painter = painterResource(id = R.mipmap.rider_pro_logout), - contentDescription = "", - modifier = Modifier.size(24.dp) - ) - } - } - Box( - modifier = Modifier - .padding(vertical = 14.dp, horizontal = 24.dp) - .noRippleClickable { - expanded = false - scope.launch { - navController.navigate(NavigationRoute.ChangePasswordScreen.route) - } - }) { - Row { - Text( - stringResource(R.string.change_password), - fontWeight = FontWeight.W500 - ) - Spacer(modifier = Modifier.weight(1f)) - Icon( - painter = painterResource(id = R.mipmap.rider_pro_change_password), - contentDescription = "", - modifier = Modifier.size(24.dp) - ) - } - } - } } + + com.aiosman.riderpro.ui.composables.DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + width = 300, + menuItems = listOf( + MenuItem( + stringResource(R.string.logout), + R.mipmap.rider_pro_logout + ) { + expanded = false + scope.launch { + model.logout() + 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( + "Favourite", + R.drawable.rider_pro_favourite + ) { + expanded = false + scope.launch { + navController.navigate(NavigationRoute.FavouriteList.route) + } + } + ), + + ) } } Spacer(modifier = Modifier.height(32.dp)) diff --git a/app/src/main/java/com/aiosman/riderpro/ui/like/LikePage.kt b/app/src/main/java/com/aiosman/riderpro/ui/like/LikePage.kt index 30b2ec0..d875630 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/like/LikePage.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/like/LikePage.kt @@ -125,7 +125,7 @@ fun ActionPostNoticeItem( val context = LocalContext.current val navController = LocalNavController.current Box( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) ) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/aiosman/riderpro/utils/File.kt b/app/src/main/java/com/aiosman/riderpro/utils/File.kt new file mode 100644 index 0000000..73be24a --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/utils/File.kt @@ -0,0 +1,92 @@ +package com.aiosman.riderpro.utils + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.widget.Toast +import coil.ImageLoader +import coil.request.ImageRequest +import coil.request.SuccessResult +import com.aiosman.riderpro.utils.Utils.getImageLoader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.FileNotFoundException +import java.io.OutputStream + +object File { + suspend fun saveImageToGallery(context: Context, url: String) { + val loader = getImageLoader(context) + + val request = ImageRequest.Builder(context) + .data(url) + .allowHardware(false) // Disable hardware bitmaps. + .build() + + val result = (loader.execute(request) as SuccessResult).drawable + val bitmap = (result as BitmapDrawable).bitmap + + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "image_${System.currentTimeMillis()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) + } + + val uri = context.contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues + ) + uri?.let { + val outputStream: OutputStream? = context.contentResolver.openOutputStream(it) + outputStream.use { stream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream!!) + } + withContext(Dispatchers.Main) { + Toast.makeText(context, "Image saved to gallery", Toast.LENGTH_SHORT).show() + } + } + } + + fun saveImageToMediaStore(context: Context, displayName: String, bitmap: Bitmap): Uri? { + val imageCollections = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + + val imageDetails = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, displayName) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpg") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Images.Media.IS_PENDING, 1) + } + } + + val resolver = context.applicationContext.contentResolver + val imageContentUri = resolver.insert(imageCollections, imageDetails) ?: return null + + return try { + resolver.openOutputStream(imageContentUri, "w").use { os -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, os!!) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + imageDetails.clear() + imageDetails.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(imageContentUri, imageDetails, null, null) + } + + imageContentUri + } catch (e: FileNotFoundException) { + // Some legacy devices won't create directory for the Uri if dir not exist, resulting in + // a FileNotFoundException. To resolve this issue, we should use the File API to save the + // image, which allows us to create the directory ourselves. + null + } + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_delete.xml b/app/src/main/res/drawable/rider_pro_delete.xml new file mode 100644 index 0000000..5eaf9e8 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_delete.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_download.xml b/app/src/main/res/drawable/rider_pro_download.xml new file mode 100644 index 0000000..2c7b7ac --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_download.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_raw.xml b/app/src/main/res/drawable/rider_pro_raw.xml new file mode 100644 index 0000000..8016091 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_raw.xml @@ -0,0 +1,9 @@ + + + + + + + + +