diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e2f9c91..d02a571 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { // Compose implementation("androidx.compose.ui:ui:1.4.3") + implementation("androidx.compose.ui:ui-util:1.4.3") implementation("androidx.compose.ui:ui-graphics:1.4.3") implementation("androidx.compose.ui:ui-tooling-preview:1.4.3") implementation("androidx.compose.material:material:1.4.3") diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt index f66ff4b..40efea1 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt @@ -2,12 +2,22 @@ package com.pixelized.rplexicon.ui.screens.authentication import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.border 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -18,9 +28,15 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString @@ -28,16 +44,22 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.pixelized.rplexicon.LocalActivity import com.pixelized.rplexicon.R import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost -import com.pixelized.rplexicon.ui.navigation.screens.navigateToLexicon import com.pixelized.rplexicon.ui.navigation.rootOption +import com.pixelized.rplexicon.ui.navigation.screens.navigateToLexicon import com.pixelized.rplexicon.ui.theme.LexiconTheme import com.pixelized.rplexicon.ui.theme.colors.GoogleColorPalette +import com.pixelized.rplexicon.utilitary.sensor.Gyroscope import kotlinx.coroutines.CoroutineScope +import kotlin.math.E +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow @Composable fun AuthenticationScreen( @@ -85,6 +107,9 @@ private fun AuthenticationScreenContent( onGoogleSignIn: () -> Unit, ) { val typography = MaterialTheme.typography + + PartyBackground() + Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom), @@ -111,6 +136,148 @@ private fun AuthenticationScreenContent( } } +@Composable +private fun PartyBackground( + modifier: Modifier = Modifier, + spacerWidth: Dp = 1.dp, + images: List = rememberPortrait(), +) { + Gyroscope { + val balance = remember { + derivedStateOf { + max(-1f, min(1f, this.gyroscope.value.roll)) + } + } + val colorFilter = remember { + ColorFilter.colorMatrix( + ColorMatrix().also { it.setToSaturation(0f) } + ) + } + Row(modifier = modifier) { + Image( + modifier = Modifier + .fillMaxHeight() + .weight(weight = animatedWeight(progress = balance, divergence = 0.95f, position = 1f)), + contentScale = ContentScale.FillHeight, + colorFilter = colorFilter, + painter = painterResource(id = images[0]), + contentDescription = null, + ) + Spacer( + modifier = Modifier + .width(width = spacerWidth) + .fillMaxHeight() + .background(color = MaterialTheme.colorScheme.surface) + ) + Image( + modifier = Modifier + .fillMaxHeight() + .weight(weight = animatedWeight(progress = balance, divergence = 0.93f, position = .5f)), + contentScale = ContentScale.FillHeight, + colorFilter = colorFilter, + painter = painterResource(id = images[1]), + contentDescription = null, + ) + Spacer( + modifier = Modifier + .width(width = spacerWidth) + .fillMaxHeight() + .background(color = MaterialTheme.colorScheme.surface) + ) + Image( + modifier = Modifier + .fillMaxHeight() + .weight(weight = animatedWeight(progress = balance, divergence = 0.91f, position = 0f)), + contentScale = ContentScale.FillHeight, + colorFilter = colorFilter, + painter = painterResource(id = images[2]), + contentDescription = null, + ) + Spacer( + modifier = Modifier + .width(width = spacerWidth) + .fillMaxHeight() + .background(color = MaterialTheme.colorScheme.surface) + ) + Image( + modifier = Modifier + .fillMaxHeight() + .weight(weight = animatedWeight(progress = balance, divergence = 0.93f, position = -.5f)), + contentScale = ContentScale.FillHeight, + colorFilter = colorFilter, + painter = painterResource(id = images[3]), + contentDescription = null, + ) + Spacer( + modifier = Modifier + .width(width = spacerWidth) + .fillMaxHeight() + .background(color = MaterialTheme.colorScheme.surface) + ) + Image( + modifier = Modifier + .fillMaxHeight() + .weight(weight = animatedWeight(progress = balance, divergence = 0.95f, position = -1f)), + contentScale = ContentScale.FillHeight, + colorFilter = colorFilter, + painter = painterResource(id = images[4]), + contentDescription = null, + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .background(brush = rememberBackgroundGradient()) + ) + } +} + +@Composable +private fun rememberPortrait(): List = remember { + listOf( + R.drawable.im_tigrane, + R.drawable.im_unathana, + R.drawable.im_brulkhai, + R.drawable.im_nelia, + R.drawable.im_leandre, + ) +} + +@Composable +private fun animatedWeight( + progress: State, + amplitude: Float = E.toFloat(), + divergence: Float = 0.95f, + position: Float, + step: Float = .16f, +): Float { + val animatedWeight = animateFloatAsState( + targetValue = divergence * amplitude.pow(-(position - progress.value).pow(2f) / step) + (1f - divergence), + label = "AnimatedBackgroundWeight", + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow, + ) + ) + return animatedWeight.value +} + +@Composable +private fun rememberBackgroundGradient(): Brush { + val colorScheme = MaterialTheme.colorScheme + return remember { + Brush.verticalGradient( + colors = listOf( + colorScheme.surface.copy(alpha = 0.25f), + colorScheme.surface.copy(alpha = 0.5f), + colorScheme.surface.copy(alpha = 0.75f), + colorScheme.surface.copy(alpha = 1.0f), + colorScheme.surface.copy(alpha = 1.0f), + ) + ) + } +} + @Composable private fun rememeberGoogleStringResource(): AnnotatedString { val default = LocalTextStyle.current.toSpanStyle() diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt index 833b3c1..449cf66 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt @@ -7,6 +7,10 @@ import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.with import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -118,8 +122,9 @@ fun LexiconScreen( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class, - ExperimentalFoundationApi::class +@OptIn( + ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class, + ExperimentalFoundationApi::class, ExperimentalAnimationApi::class ) @Composable private fun LexiconScreenContent( @@ -141,7 +146,7 @@ private fun LexiconScreenContent( }, ) }, - floatingActionButton = { +// floatingActionButton = { // FloatingActionButton( // modifier = Modifier.padding(start = 32.dp), // expended = isFabExpended.value, @@ -162,50 +167,63 @@ private fun LexiconScreenContent( // ) // }, // ) - } +// } ) { padding -> Box( modifier = Modifier.padding(paddingValues = padding), contentAlignment = Alignment.TopCenter, ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .pullRefresh(state = refreshState), - state = lazyColumnState, - contentPadding = PaddingValues( - top = 8.dp, - bottom = 8.dp + 16.dp // + 56.dp + 16.dp, - ), - ) { - if (items.value.isEmpty()) { - items( - count = 6, - key = { it }, - contentType = { "Lexicon" }, + AnimatedContent( + targetState = items.value.isEmpty(), + transitionSpec = { fadeIn() with fadeOut() }, + label = "AnimatedLexicon" + ) { empty -> + when (empty) { + true -> LazyColumn( + modifier = Modifier + .fillMaxSize() + .pullRefresh(state = refreshState), + state = lazyColumnState, + contentPadding = PaddingValues( + top = 8.dp, + bottom = 8.dp + 16.dp // + 56.dp + 16.dp, + ), ) { - LexiconItem( - modifier = Modifier - .animateItemPlacement() - .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = 16.dp), - item = LexiconItemUio.Brulkhai, - ) + items(count = 6) { + LexiconItem( + modifier = Modifier + .animateItemPlacement() + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + item = LexiconItemUio.Brulkhai, + ) + } } - } else { - items( - items = items.value, - key = { it.id }, - contentType = { "Lexicon" }, + + else -> LazyColumn( + modifier = Modifier + .fillMaxSize() + .pullRefresh(state = refreshState), + state = lazyColumnState, + contentPadding = PaddingValues( + top = 8.dp, + bottom = 8.dp + 16.dp // + 56.dp + 16.dp, + ), ) { - LexiconItem( - modifier = Modifier - .animateItemPlacement() - .clickable { onItem(it) } - .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = 16.dp), - item = it, - ) + items( + items = items.value, + key = { it.id }, + contentType = { "Lexicon" }, + ) { + LexiconItem( + modifier = Modifier + .animateItemPlacement() + .clickable { onItem(it) } + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + item = it, + ) + } } } } diff --git a/app/src/main/java/com/pixelized/rplexicon/utilitary/sensor/Gyroscope.kt b/app/src/main/java/com/pixelized/rplexicon/utilitary/sensor/Gyroscope.kt new file mode 100644 index 0000000..b0c471d --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/utilitary/sensor/Gyroscope.kt @@ -0,0 +1,187 @@ +package com.pixelized.rplexicon.utilitary.sensor + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import kotlin.math.cos +import kotlin.math.sin + +@Composable +fun Gyroscope( + content: @Composable GyroscopeScope.() -> Unit, +) { + val context = LocalContext.current + + val gyroscope = remember { + mutableStateOf(GyroscopeData.Zero) + } + + val scope = remember { + GyroscopeScopeImpl( + gyroscope = gyroscope, + initialAngle = GyroscopeScopeImpl.INITIAL_ANGLE_X, + ) + } + + if (LocalInspectionMode.current.not()) { + val onSensorChanged = remember { + object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent?) { + if (event?.sensor?.type == Sensor.TYPE_GRAVITY) { + scope.gravity = event.values + } + if (event?.sensor?.type == Sensor.TYPE_MAGNETIC_FIELD) { + scope.geomagnetic = event.values + } + val orientation = getGyroscopeData( + gravity = scope.gravity, + geomagnetic = scope.geomagnetic, + deviceRotationMatrix = scope.deviceRotationMatrix, + rotationMatrix = scope.localRotationMatrix, + localRotationMatrix = scope.localOrientationMatrix, + orientationMatrix = scope.orientationMatrix, + ) + if (orientation != null) { + gyroscope.value = orientation + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + } + } + + DisposableEffect(key1 = "SensorServices") { + val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + val gravitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) + val magnetometerSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) + + sensorManager.registerListener( + onSensorChanged, + gravitySensor, + SensorManager.SENSOR_DELAY_UI, + ) + sensorManager.registerListener( + onSensorChanged, + magnetometerSensor, + SensorManager.SENSOR_DELAY_UI, + ) + + onDispose { + sensorManager.unregisterListener(onSensorChanged) + } + } + } + + scope.content() +} + +////////////////////////////////////// +// region: GyroscopeScope + +@Immutable +interface GyroscopeScope { + val gyroscope: State +} + +@Immutable +class GyroscopeScopeImpl( + override val gyroscope: State, + initialAngle: Float, +) : GyroscopeScope { + var gravity: FloatArray? = null + var geomagnetic: FloatArray? = null + val deviceRotationMatrix: FloatArray = FloatArray(9) + val orientationMatrix: FloatArray = FloatArray(3) + val localRotationMatrix: FloatArray = getRotationMatrix(angle = initialAngle) + val localOrientationMatrix: FloatArray = FloatArray(9) + + // https://fr.wikipedia.org/wiki/Matrice_de_rotation + private fun getRotationMatrix(angle: Float): FloatArray { + return floatArrayOf(1f, 0f, 0f, 0f, cos(angle), -sin(angle), 0f, sin(angle), cos(angle)) + } + + companion object { + const val INITIAL_ANGLE_X: Float = -Math.PI.toFloat() / 6f + } +} + +// endregion +////////////////////////////////////// +// region: Helper method. + +/** + * Helper method to build a [GyroscopeData] instance containing the computation Pitch and Roll. + * + * @param gravity a FloatArray? of 3 elements representing the gravity acceleration. + * @param geomagnetic a FloatArray? of 3 elements representing the magnetosphere acceleration. + * @param deviceRotationMatrix a FloatArray of 9 elements representing the device rotation. + * @param rotationMatrix a FloatArray of 9 elements representing the wanted initial rotation. + * @param localRotationMatrix a FloatArray of 9 elements representing the device rotation with the initial angle set. + * @param orientationMatrix a FloatArray of 3 elements representing the Azimuth, Pitch and Roll. + */ +private fun getGyroscopeData( + gravity: FloatArray?, + geomagnetic: FloatArray?, + deviceRotationMatrix: FloatArray, + rotationMatrix: FloatArray, + localRotationMatrix: FloatArray, + orientationMatrix: FloatArray, +): GyroscopeData? { + if (gravity != null && geomagnetic != null) { + // populate the rotation matrix. + if (SensorManager.getRotationMatrix(deviceRotationMatrix, null, gravity, geomagnetic)) { + // rotate the orientation matrix + rotate( + a = deviceRotationMatrix, + b = rotationMatrix, + out = localRotationMatrix, + ) + // populate the orientation matrix. + SensorManager.getOrientation(localRotationMatrix, orientationMatrix) + // return the orientation. + return GyroscopeData( + pitch = orientationMatrix[1], + roll = orientationMatrix[2], + ) + } + } + return null +} + +/** + * This method is use to introduce an initial angle for the gyroscope by multiplying + * [a] by [b] and store the result into [out]. + * + * @param a a FloatArray of length 9 + * @param b a FloatArray of length 9 + * @param out a FloatArray of length 9 + */ +private fun rotate( + a: FloatArray, + b: FloatArray, + out: FloatArray, +) { + if (a.size == 9 && b.size == 9 && out.size == 9) { + out[0] = a[0] * b[0] + a[1] * b[3] + a[2] * b[6] + out[1] = a[0] * b[1] + a[1] * b[4] + a[2] * b[7] + out[2] = a[0] * b[2] + a[1] * b[5] + a[2] * b[8] + out[3] = a[3] * b[0] + a[4] * b[3] + a[5] * b[6] + out[4] = a[3] * b[1] + a[4] * b[4] + a[5] * b[7] + out[5] = a[3] * b[2] + a[4] * b[5] + a[5] * b[8] + out[6] = a[6] * b[0] + a[7] * b[3] + a[8] * b[6] + out[7] = a[6] * b[1] + a[7] * b[4] + a[8] * b[7] + out[8] = a[6] * b[2] + a[7] * b[5] + a[8] * b[8] + } +} + +// endregion \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/utilitary/sensor/GyroscopeData.kt b/app/src/main/java/com/pixelized/rplexicon/utilitary/sensor/GyroscopeData.kt new file mode 100644 index 0000000..70431a2 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/utilitary/sensor/GyroscopeData.kt @@ -0,0 +1,64 @@ +package com.pixelized.rplexicon.utilitary.sensor + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.util.packFloats +import androidx.compose.ui.util.unpackFloat1 +import androidx.compose.ui.util.unpackFloat2 + +@Stable +fun GyroscopeData(pitch: Float, roll: Float): GyroscopeData = + GyroscopeData(packFloats(pitch, roll)) + +@Immutable +@JvmInline +value class GyroscopeData internal constructor(@PublishedApi internal val packedValue: Long) { + @Stable + val pitch: Float + get() = unpackFloat1(packedValue) + + @Stable + val roll: Float + get() = unpackFloat2(packedValue) + + @Stable + operator fun component1(): Float = pitch + + @Stable + operator fun component2(): Float = roll + + @Suppress("unused") + fun copy(pitch: Float = this.pitch, roll: Float = this.roll): GyroscopeData = + GyroscopeData(pitch = pitch, roll = roll) + + @Stable + operator fun minus(other: GyroscopeData): GyroscopeData = + GyroscopeData(pitch = pitch - other.pitch, roll = roll - other.roll) + + @Stable + operator fun plus(other: GyroscopeData): GyroscopeData = + GyroscopeData(pitch = pitch + other.pitch, roll = roll + other.roll) + + @Stable + operator fun unaryMinus(): GyroscopeData = + GyroscopeData(pitch = -pitch, roll = -roll) + + @Stable + operator fun times(operand: Float): GyroscopeData = + GyroscopeData(pitch = pitch * operand, roll = roll * operand) + + @Stable + operator fun div(operand: Float): GyroscopeData = + GyroscopeData(pitch = pitch / operand, roll = roll / operand) + + @Stable + operator fun rem(operand: Int): GyroscopeData = + GyroscopeData(pitch = pitch % operand, roll = roll % operand) + + @Stable + override fun toString(): String = "(pitch:$pitch, roll:$roll)" + + companion object { + val Zero = GyroscopeData(0f, 0f) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/im_brulkhai.webp b/app/src/main/res/drawable/im_brulkhai.webp new file mode 100644 index 0000000..6e61c16 Binary files /dev/null and b/app/src/main/res/drawable/im_brulkhai.webp differ diff --git a/app/src/main/res/drawable/im_leandre.webp b/app/src/main/res/drawable/im_leandre.webp new file mode 100644 index 0000000..d93c5d3 Binary files /dev/null and b/app/src/main/res/drawable/im_leandre.webp differ diff --git a/app/src/main/res/drawable/im_nelia.webp b/app/src/main/res/drawable/im_nelia.webp new file mode 100644 index 0000000..5cd6144 Binary files /dev/null and b/app/src/main/res/drawable/im_nelia.webp differ diff --git a/app/src/main/res/drawable/im_tigrane.webp b/app/src/main/res/drawable/im_tigrane.webp new file mode 100644 index 0000000..cfc7aee Binary files /dev/null and b/app/src/main/res/drawable/im_tigrane.webp differ diff --git a/app/src/main/res/drawable/im_unathana.webp b/app/src/main/res/drawable/im_unathana.webp new file mode 100644 index 0000000..de61af7 Binary files /dev/null and b/app/src/main/res/drawable/im_unathana.webp differ