Add profile and profile picture selection
continuous-integration/drone/push Build is passing Details

pull/5/head
avalin 8 months ago
parent c53f9e8b91
commit bcc735f713

@ -24,6 +24,17 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</provider>
</application>
</manifest>

@ -75,7 +75,8 @@ import fr.iut.alldev.allin.vo.bet.BetDisplayer
import java.util.Locale
class BetStatusBottomSheetBetDisplayer(
val openParticipateSheet: () -> Unit
val openParticipateSheet: () -> Unit,
val getImageUrl: (id: String) -> String
) : BetDisplayer {
@Composable
private fun DisplayBetDail(
@ -154,7 +155,7 @@ class BetStatusBottomSheetBetDisplayer(
BetStatusParticipant(
username = it.username,
allCoinsAmount = it.stake,
image = null // TODO : Image
image = getImageUrl(it.id)
)
HorizontalDivider(
color = AllInTheme.colors.border,
@ -167,7 +168,7 @@ class BetStatusBottomSheetBetDisplayer(
BetStatusParticipant(
username = it.username,
allCoinsAmount = it.stake,
image = null // TODO : Image
image = getImageUrl(it.id)
)
Spacer(modifier = Modifier.height(8.dp))
}
@ -413,7 +414,8 @@ private fun BetStatusBottomSheetPreview(
) {
AllInTheme {
BetStatusBottomSheetBetDisplayer(
openParticipateSheet = {}
openParticipateSheet = {},
getImageUrl = { "" }
).DisplayBet(
betDetail = bet,
currentUser = User(

@ -53,7 +53,7 @@ fun ProfilePicture(
modifier = Modifier
.width(300.dp)
.height(174.dp)
.clip(RoundedCornerShape(14.dp)),
.clip(shape),
model = ImageRequest.Builder(LocalContext.current)
.data(image)
.crossfade(true)
@ -95,15 +95,4 @@ private fun ProfilePictureDefaultPreview() {
AllInTheme {
ProfilePicture(image = null, fallback = "LS")
}
}
@Preview
@Composable
private fun ProfilePicturePreview() {
AllInTheme {
ProfilePicture(
fallback = "LS",
image = "https://cdn.myanimelist.net/s/common/userimages/6076ae8b-54ed-4924-bb81-4d2d51806b1a_225w?s=965262aa50355e917a7ef9579c58fffc"
)
}
}

@ -57,7 +57,7 @@ private val topLevelDestinations = listOf(
TopLevelDestination.CurrentBets,
TopLevelDestination.BetHistory,
TopLevelDestination.Friends,
TopLevelDestination.Ranking,
TopLevelDestination.Ranking
)
@OptIn(ExperimentalMaterial3Api::class)
@ -113,7 +113,8 @@ fun MainScreen(
)
val betStatusDisplayer = remember {
BetStatusBottomSheetBetDisplayer(
openParticipateSheet = { setParticipateSheetVisibility(true) }
openParticipateSheet = { setParticipateSheetVisibility(true) },
getImageUrl = { mainViewModel.getImageUrl(it) }
)
}
@ -123,7 +124,7 @@ fun MainScreen(
AllInDrawer(
drawerState = drawerState,
destinations = topLevelDestinations,
scope = scope,
id = currentUser?.id ?: "",
username = currentUser?.username ?: "",
nbFriends = currentUser?.nbFriends ?: 0,
nbBets = currentUser?.nbBets ?: 0,
@ -131,6 +132,7 @@ fun MainScreen(
image = currentUser?.image,
navigateTo = { route ->
mainViewModel.fetchEvents()
scope.launch { drawerState.close() }
navController.popUpTo(route, startDestination)
},
logout = {

@ -118,6 +118,8 @@ class MainViewModel @Inject constructor(
}
}
fun getImageUrl(id: String) = userRepository.getImageUrl(id)
fun participateToBet(stake: Int, response: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
@ -127,6 +129,7 @@ class MainViewModel @Inject constructor(
selectedBet.value?.let {
val participation = Participation(
betId = it.bet.id,
id = user.id,
username = user.username,
response = response,
stake = stake

@ -14,9 +14,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import fr.iut.alldev.allin.R
import fr.iut.alldev.allin.data.model.bet.Bet
import fr.iut.alldev.allin.theme.AllInTheme
@ -29,6 +31,7 @@ import fr.iut.alldev.allin.ui.friends.FriendsScreen
import fr.iut.alldev.allin.ui.login.LoginScreen
import fr.iut.alldev.allin.ui.main.MainScreen
import fr.iut.alldev.allin.ui.main.MainViewModel
import fr.iut.alldev.allin.ui.profile.ProfileScreen
import fr.iut.alldev.allin.ui.ranking.RankingScreen
import fr.iut.alldev.allin.ui.register.RegisterScreen
import fr.iut.alldev.allin.ui.splash.SplashScreen
@ -46,6 +49,7 @@ object Routes {
const val BET_CURRENT = "BET_CURRENT"
const val FRIENDS = "FRIENDS"
const val RANKING = "RANKING"
const val PROFILE = "PROFILE"
}
object Arguments {
@ -165,6 +169,14 @@ internal fun AllInDrawerNavHost(
backHandlers()
BetCurrentScreen(selectBet = selectBet)
}
composable(
route = "${Routes.PROFILE}/{${Arguments.USER_ID}}",
arguments = listOf(navArgument(Arguments.USER_ID) { type = NavType.StringType })
) {
backHandlers()
ProfileScreen()
}
}
}

@ -24,17 +24,16 @@ import androidx.compose.ui.unit.sp
import fr.iut.alldev.allin.R
import fr.iut.alldev.allin.theme.AllInColorToken
import fr.iut.alldev.allin.theme.AllInTheme
import fr.iut.alldev.allin.ui.navigation.Routes
import fr.iut.alldev.allin.ui.navigation.TopLevelDestination
import fr.iut.alldev.allin.ui.navigation.drawer.components.DrawerCell
import fr.iut.alldev.allin.ui.navigation.drawer.components.DrawerHeader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
fun AllInDrawer(
drawerState: DrawerState,
destinations: List<TopLevelDestination>,
scope: CoroutineScope,
id: String,
username: String,
nbBets: Int,
bestWin: Int,
@ -42,7 +41,7 @@ fun AllInDrawer(
image: String?,
navigateTo: (String) -> Unit,
logout: () -> Unit,
content: @Composable () -> Unit
content: @Composable () -> Unit,
) {
ModalNavigationDrawer(
drawerState = drawerState,
@ -57,17 +56,15 @@ fun AllInDrawer(
nbFriends = nbFriends,
username = username,
image = image,
modifier = Modifier.padding(top = 39.dp, bottom = 26.dp)
modifier = Modifier.padding(top = 39.dp, bottom = 26.dp),
navigateToProfile = { navigateTo("${Routes.PROFILE}/$id") }
)
destinations.forEach { item ->
DrawerCell(
title = stringResource(item.title).uppercase(),
subtitle = stringResource(item.subtitle),
emoji = painterResource(id = item.emoji),
onClick = {
scope.launch { drawerState.close() }
navigateTo(item.route)
},
onClick = { navigateTo(item.route) },
modifier = Modifier.padding(vertical = 5.dp, horizontal = 13.dp)
)
}

@ -1,5 +1,7 @@
package fr.iut.alldev.allin.ui.navigation.drawer.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -30,7 +33,9 @@ fun DrawerHeader(
username: String,
image: String?,
modifier: Modifier = Modifier,
navigateToProfile: () -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
@ -38,7 +43,12 @@ fun DrawerHeader(
ProfilePicture(
image = image,
fallback = username.asFallbackProfileUsername(),
borderWidth = 1.dp
borderWidth = 1.dp,
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = navigateToProfile
)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
@ -72,7 +82,8 @@ private fun DrawerHeaderPreview() {
bestWin = 360,
nbFriends = 5,
username = "Pseudo",
image = null
image = null,
navigateToProfile = { }
)
}
}

@ -1,12 +1,12 @@
package fr.iut.alldev.allin.ui.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import fr.iut.alldev.allin.data.model.bet.BinaryBet
import fr.iut.alldev.allin.data.model.bet.CustomBet
import fr.iut.alldev.allin.data.model.bet.MatchBet
import fr.iut.alldev.allin.data.model.bet.NO_VALUE
import fr.iut.alldev.allin.data.model.bet.Participation
import fr.iut.alldev.allin.data.model.bet.YES_VALUE
import fr.iut.alldev.allin.data.model.bet.BinaryBet
import fr.iut.alldev.allin.data.model.bet.vo.BetAnswerDetail
import fr.iut.alldev.allin.data.model.bet.vo.BetDetail
@ -44,6 +44,7 @@ class BetDetailPreviewProvider : PreviewParameterProvider<BetDetail> {
odds = 1.0f
)
)
is MatchBet -> listOf(
BetAnswerDetail(
response = "The Monarchs",
@ -60,6 +61,7 @@ class BetDetailPreviewProvider : PreviewParameterProvider<BetDetail> {
odds = 2.0f
)
)
is BinaryBet -> listOf(
BetAnswerDetail(
response = YES_VALUE,
@ -84,12 +86,14 @@ class BetDetailPreviewProvider : PreviewParameterProvider<BetDetail> {
participations = listOf(
Participation(
betId = it.id,
id = "1",
username = "User1",
response = answers.first().response,
stake = 100
),
Participation(
betId = it.id,
id = "2",
username = "User 2",
response = answers.last().response,
stake = 150
@ -97,12 +101,14 @@ class BetDetailPreviewProvider : PreviewParameterProvider<BetDetail> {
),
userParticipation = Participation(
betId = it.id,
id = "1",
username = "User1",
response = answers.first().response,
stake = 100
),
wonParticipation = Participation(
betId = it.id,
id = "1",
username = "User1",
response = answers.first().response,
stake = 100

@ -1,27 +1,64 @@
package fr.iut.alldev.allin.ui.profile
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityOptionsCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.iut.alldev.allin.ui.profile.components.ProfileScreenHeader
import fr.iut.alldev.allin.ui.profile.components.ProfileScreenContent
import fr.iut.alldev.allin.utils.AskPicture
import fr.iut.alldev.allin.utils.AskPictureParams
import fr.iut.alldev.allin.utils.AskPictureResult
import fr.iut.alldev.allin.utils.createImageFile
import java.io.ByteArrayOutputStream
import java.io.File
@Composable
fun ProfileScreen(
viewModel: ProfileViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(contract = AskPicture()) { result ->
when (result) {
is AskPictureResult.PictureResult -> {
val resolver = context.contentResolver
val inputStream = resolver.openInputStream(result.pickedFile!!)
val file = File.createTempFile("image", null, context.cacheDir)
file.outputStream().use { outputStream ->
inputStream?.copyTo(outputStream)
}
val baos = ByteArrayOutputStream()
BitmapFactory.decodeFile(file.path).compress(Bitmap.CompressFormat.JPEG, 100, baos)
val encodedImage = Base64.encodeToString(baos.toByteArray(), Base64.DEFAULT)
viewModel.selectNewProfilePicture(encodedImage)
}
null -> Unit
}
}
Column {
when (val s = state) {
is ProfileViewModel.State.Loaded -> {
ProfileScreenHeader(
image = null,
ProfileScreenContent(
image = s.user.image,
username = s.user.username,
totalBets = 333,
bestWin = 365,
friends = 3
totalBets = s.user.nbBets,
bestWin = s.user.bestWin,
friends = s.user.nbFriends,
selectNewProfilePicture = {
val file = createImageFile(context = context)
launcher.launch(AskPictureParams(file), ActivityOptionsCompat.makeBasic())
}
)
}

@ -6,17 +6,21 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import fr.iut.alldev.allin.data.model.User
import fr.iut.alldev.allin.data.repository.UserRepository
import fr.iut.alldev.allin.keystore.AllInKeystoreManager
import fr.iut.alldev.allin.ui.navigation.Arguments
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class ProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val userRepository: UserRepository
private val userRepository: UserRepository,
private val keystoreManager: AllInKeystoreManager
) : ViewModel() {
private val userId: String? = savedStateHandle[Arguments.USER_ID]
@ -28,15 +32,34 @@ class ProfileViewModel @Inject constructor(
userRepository.currentUserState.value?.let { currentUser ->
if (userId == currentUser.id) {
_state.emit(State.Loaded(currentUser))
userRepository.currentUserState.filterNotNull().collect { user ->
_state.emit(State.Loaded(user = user, isCurrentUser = true))
}
}
}
}
}
fun selectNewProfilePicture(base64: String) {
viewModelScope.launch {
try {
userRepository.setImage(
token = keystoreManager.getTokenOrEmpty(),
base64 = base64
)
userRepository.currentUserState.value?.let {
userRepository.updateCurrentUserImage(value = userRepository.getImageUrl(it.id))
}
} catch (e: Exception) {
Timber.e(e)
}
}
}
sealed interface State {
data object Loading : State
data class Loaded(val user: User) : State
data class Loaded(val user: User, val isCurrentUser: Boolean) : State
}
}

@ -0,0 +1,52 @@
package fr.iut.alldev.allin.ui.profile.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import fr.iut.alldev.allin.theme.AllInTheme
@Composable
fun ProfileScreenContent(
username: String,
image: String?,
totalBets: Int,
bestWin: Int,
friends: Int,
selectNewProfilePicture: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 18.dp, horizontal = 21.dp)
) {
ProfileScreenHeader(
image = image,
username = username,
totalBets = totalBets,
bestWin = bestWin,
friends = friends,
selectNewProfilePicture = selectNewProfilePicture
)
}
}
@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ProfileScreenContentPreview() {
AllInTheme {
ProfileScreenContent(
username = "User 1",
image = null,
totalBets = 12,
bestWin = 365,
friends = 5,
selectNewProfilePicture = { }
)
}
}

@ -1,12 +1,15 @@
package fr.iut.alldev.allin.ui.profile.components
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -24,8 +27,11 @@ fun ProfileScreenHeader(
image: String?,
totalBets: Int,
bestWin: Int,
friends: Int
friends: Int,
selectNewProfilePicture: () -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
@ -33,7 +39,12 @@ fun ProfileScreenHeader(
ProfilePicture(
image = image,
fallback = username.asFallbackProfileUsername(),
size = 68.dp
size = 68.dp,
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = selectNewProfilePicture
)
)
Column(
verticalArrangement = Arrangement.SpaceEvenly,
@ -102,7 +113,8 @@ private fun ProfileScreenHeaderPreview() {
image = null,
totalBets = 12,
bestWin = 365,
friends = 5
friends = 5,
selectNewProfilePicture = { }
)
}
}

@ -0,0 +1,59 @@
package fr.iut.alldev.allin.utils
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.content.FileProvider
import fr.iut.alldev.allin.R
import java.io.File
class AskPicture : ActivityResultContract<AskPictureParams, AskPictureResult?>() {
private var uri: Uri? = null
override fun createIntent(context: Context, input: AskPictureParams): Intent {
val pickerIntent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
.also { intent ->
input.cameraFile.also { file ->
val photoUri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
).also { uri = it }
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
}
}
return Intent.createChooser(cameraIntent, context.getText(R.string.profile_pick_profile_picture))
.also { intent ->
intent.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(pickerIntent))
}
}
override fun parseResult(resultCode: Int, intent: Intent?): AskPictureResult? {
return if (resultCode != Activity.RESULT_OK) {
null
} else {
(intent?.data ?: uri)?.let {
AskPictureResult.PictureResult(it)
}
}
}
}
data class AskPictureParams(val cameraFile: File)
sealed class AskPictureResult {
data class PictureResult(val pickedFile: Uri?) : AskPictureResult()
}
fun createImageFile(context: Context): File {
val directory = File(context.cacheDir, ".").apply {
mkdirs()
}
return File.createTempFile("picture_", ".jpg", directory)
}

@ -187,4 +187,7 @@
<string name="daily_reward_title">Récompense quotidienne</string>
<string name="daily_reward_subtitle">Votre récompense quotidienne est débloquée tous les jours à 00:00 UTC et vous permets dobtenir entre 10 et 150 Allcoins.</string>
<!--Profile-->
<string name="profile_pick_profile_picture">Choisissez une nouvelle image de profil</string>
</resources>

@ -184,4 +184,7 @@
<string name="daily_reward_title">Daily reward</string>
<string name="daily_reward_subtitle">Your daily reward is unlocked every day at 00:00 UTC and allows you to get between 10 and 150 Allcoins.</string>
<!--Profile-->
<string name="profile_pick_profile_picture">Pick a new profile picture</string>
</resources>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="allin"
path="." />
</paths>

@ -42,6 +42,12 @@ interface AllInApi {
@GET("users/gift")
suspend fun dailyGift(@Header("Authorization") token: String): Int
@POST("users/images")
suspend fun setImage(
@Header("Authorization") token: String,
@Body base64: RequestBody
)
// FRIENDS
// ---------------------

@ -154,6 +154,10 @@ class MockAllInApi : AllInApi {
} else throw MockAllInApiException("Gift already taken today")
}
override suspend fun setImage(token: String, base64: RequestBody) {
val user = getUserFromToken(token) ?: throw MockAllInApiException("Invalid login/password.")
}
override suspend fun getFriends(token: String): List<ResponseUser> {
val user = getUserFromToken(token) ?: throw MockAllInApiException("Invalid login/password.")
return mockFriends

@ -16,6 +16,7 @@ data class ResponseParticipation(
fun toParticipation() =
Participation(
betId = betId,
id = id,
username = username,
response = answer,
stake = stake

@ -4,6 +4,7 @@ import fr.iut.alldev.allin.data.api.model.RequestParticipation
data class Participation(
val betId: String,
val id: String,
val username: String,
val response: String,
val stake: Int

@ -23,8 +23,25 @@ abstract class UserRepository {
}
}
suspend fun updateCurrentUserImage(value: String?) {
currentUserState.value?.let { user ->
currentUser.emit(
user.copy(
image = null
)
)
currentUser.emit(
user.copy(
image = value
)
)
}
}
abstract suspend fun login(username: String, password: String): String?
abstract suspend fun login(token: String): String?
abstract suspend fun register(username: String, email: String, password: String): String?
abstract suspend fun dailyGift(token: String): Int
abstract suspend fun setImage(token: String, base64: String)
abstract fun getImageUrl(id: String): String
}

@ -1,16 +1,22 @@
package fr.iut.alldev.allin.data.repository.impl
import fr.iut.alldev.allin.data.api.AllInApi
import fr.iut.alldev.allin.data.api.AllInApi.Companion.asRequestBody
import fr.iut.alldev.allin.data.api.AllInApi.Companion.formatBearerToken
import fr.iut.alldev.allin.data.api.model.CheckUser
import fr.iut.alldev.allin.data.api.model.RequestUser
import fr.iut.alldev.allin.data.di.AllInUrl
import fr.iut.alldev.allin.data.repository.UserRepository
import okhttp3.HttpUrl
import javax.inject.Inject
class UserRepositoryImpl @Inject constructor(
private val api: AllInApi
private val api: AllInApi,
@AllInUrl private val apiUrl: HttpUrl
) : UserRepository() {
override fun getImageUrl(id: String) = "$apiUrl/users/images/$id"
override suspend fun login(username: String, password: String): String? {
val response = api.login(
CheckUser(
@ -41,5 +47,9 @@ class UserRepositoryImpl @Inject constructor(
}
override suspend fun dailyGift(token: String): Int =
api.dailyGift(token)
api.dailyGift(token.formatBearerToken())
override suspend fun setImage(token: String, base64: String) {
api.setImage(token = token.formatBearerToken(), base64 = base64.asRequestBody())
}
}
Loading…
Cancel
Save