diff --git a/core/designsystem/src/commonMain/kotlin/io/github/droidkaigi/confsched/designsystem/theme/Shape.kt b/core/designsystem/src/commonMain/kotlin/io/github/droidkaigi/confsched/designsystem/theme/Shape.kt deleted file mode 100644 index 70e28ed43..000000000 --- a/core/designsystem/src/commonMain/kotlin/io/github/droidkaigi/confsched/designsystem/theme/Shape.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.github.droidkaigi.confsched.designsystem.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Shapes -import androidx.compose.ui.unit.dp - -val Shapes = Shapes( - small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(4.dp), - large = RoundedCornerShape(0.dp), -) diff --git a/core/droidkaigiui/src/iosMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/OrientationSensorManager.ios.kt b/core/droidkaigiui/src/iosMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/OrientationSensorManager.ios.kt index 3e81b37db..55614fdbc 100644 --- a/core/droidkaigiui/src/iosMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/OrientationSensorManager.ios.kt +++ b/core/droidkaigiui/src/iosMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/OrientationSensorManager.ios.kt @@ -22,12 +22,12 @@ internal class IosOrientationSensorManager( private val motionManager = CMMotionManager() override fun start() { - if (!motionManager.deviceMotionActive) { + if (!motionManager.deviceMotionAvailable) { return } - NSOperationQueue.currentQueue()?.let { + NSOperationQueue.mainQueue().let { queue -> motionManager.startDeviceMotionUpdatesToQueue( - it, + queue, ) { motion, _ -> if (motion == null) { return@startDeviceMotionUpdatesToQueue diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/MiniRobots.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/MiniRobots.kt index 42dd9923f..c8c18643c 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/MiniRobots.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/MiniRobots.kt @@ -1,6 +1,11 @@ package io.github.droidkaigi.confsched.testing.robot +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorManager import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.test.core.app.ApplicationProvider.getApplicationContext import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.github.takahirom.roborazzi.provideRoborazziContext import com.github.takahirom.roborazzi.roboOutputName @@ -38,7 +43,13 @@ import io.github.droidkaigi.confsched.testing.rules.RobotTestRule import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.test.TestDispatcher import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.SensorEventBuilder import org.robolectric.shadows.ShadowLooper +import org.robolectric.shadows.ShadowSensor +import org.robolectric.shadows.ShadowSensorManager import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -171,6 +182,144 @@ class DefaultDeviceSetupRobot @Inject constructor() : DeviceSetupRobot { } } +interface SensorRobot { + fun setupMockSensors(sensorTypes: List) + fun cleanUpSensors() + fun tiltPitch(pitch: Float = 10f) + fun tiltRoll(roll: Float = 10f) + fun tiltAzimuth(azimuth: Float = 10f) + fun tiltAllAxes(pitch: Float = 10f, roll: Float = 10f, azimuth: Float = 10f) +} + +class DefaultSensorRobot @Inject constructor() : SensorRobot { + private val sensorManager: SensorManager = + getApplicationContext().getSystemService(Context.SENSOR_SERVICE) as SensorManager + private val shadowSensorManager = shadowOf(sensorManager) + + private lateinit var mockAccelerometerSensor: Sensor + private lateinit var mockMagneticFieldSensor: Sensor + + override fun setupMockSensors(sensorTypes: List) { + sensorTypes.forEach { sensorType -> + val sensor = ShadowSensor.newInstance(sensorType) + shadowSensorManager.addSensor(sensor) + when (sensorType) { + Sensor.TYPE_ACCELEROMETER -> mockAccelerometerSensor = sensor + Sensor.TYPE_MAGNETIC_FIELD -> mockMagneticFieldSensor = sensor + else -> throw IllegalArgumentException("Unsupported sensor type: $sensorType") + } + } + } + + override fun cleanUpSensors() { + CustomShadowSensorManager.setCustomRotationMatrix(floatArrayOf()) + CustomShadowSensorManager.setCustomOrientationAngles(floatArrayOf()) + } + + override fun tiltPitch(pitch: Float) { + sendTiltEvent(mockAccelerometerSensor, pitch = pitch) + sendTiltEvent(mockMagneticFieldSensor, pitch = pitch) + } + + override fun tiltRoll(roll: Float) { + sendTiltEvent(mockAccelerometerSensor, roll = roll) + sendTiltEvent(mockMagneticFieldSensor, roll = roll) + } + + override fun tiltAzimuth(azimuth: Float) { + sendTiltEvent(mockAccelerometerSensor, azimuth = azimuth) + sendTiltEvent(mockMagneticFieldSensor, azimuth = azimuth) + } + + override fun tiltAllAxes(pitch: Float, roll: Float, azimuth: Float) { + sendTiltEvent(mockAccelerometerSensor, pitch, roll, azimuth) + sendTiltEvent(mockMagneticFieldSensor, pitch, roll, azimuth) + } + + private fun sendTiltEvent( + sensor: Sensor?, + pitch: Float = 0f, + roll: Float = 0f, + azimuth: Float = 0f, + ) { + if (sensor != null) { + val event = createTiltEvent(sensor, pitch, roll, azimuth) + CustomShadowSensorManager.setCustomRotationMatrix( + FloatArray(9).apply { + SensorManager.getRotationMatrix( + this, + null, + floatArrayOf(pitch, roll, azimuth), + floatArrayOf(0f, 0f, 0f), + ) + }, + ) + CustomShadowSensorManager.setCustomOrientationAngles(floatArrayOf(azimuth, pitch, roll)) + shadowSensorManager.sendSensorEventToListeners(event) + } + } + + private fun createTiltEvent( + sensor: Sensor, + pitch: Float, + roll: Float, + azimuth: Float, + ): SensorEvent { + return SensorEventBuilder.newBuilder() + .setSensor(sensor) + .setTimestamp(System.currentTimeMillis()) + .setValues(floatArrayOf(pitch, roll, azimuth)) + .build() + } + + @Implements(SensorManager::class) + class CustomShadowSensorManager : ShadowSensorManager() { + + @Suppress("UNUSED_PARAMETER") + companion object { + private var customRotationMatrix: FloatArray? = null + private var customOrientationAngles: FloatArray? = null + + fun setCustomRotationMatrix(rotationMatrix: FloatArray) { + customRotationMatrix = rotationMatrix + } + + @Implementation + @JvmStatic + fun getRotationMatrix( + r: FloatArray?, + i: FloatArray?, + gravity: FloatArray?, + geomagnetic: FloatArray?, + ): Boolean { + customRotationMatrix?.let { + if (r != null && it.size == r.size) { + System.arraycopy(it, 0, r, 0, it.size) + } + return true + } + return false + } + + fun setCustomOrientationAngles(orientationAngles: FloatArray) { + customOrientationAngles = orientationAngles + } + + @Implementation + @JvmStatic + fun getOrientation(r: FloatArray?, values: FloatArray?): FloatArray { + customOrientationAngles?.let { + if (values != null && it.size == values.size) { + System.arraycopy(it, 0, values, 0, it.size) + } + return it + } + return r!! + } + } + } +} + interface TimetableServerRobot { enum class ServerStatus { Operational, @@ -340,6 +489,7 @@ class DefaultSettingsDataStoreRobot @Inject constructor( ), ) } + UseSystemDefaultFont -> { settingsDataStore.save( Settings.Exists( diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt index d45bdf96d..2a51b61d8 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt @@ -1,6 +1,7 @@ package io.github.droidkaigi.confsched.testing.robot import android.graphics.RenderNode +import android.hardware.Sensor import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotDisplayed @@ -25,12 +26,16 @@ import io.github.droidkaigi.confsched.profilecard.component.ProfileCardFlipCardF import io.github.droidkaigi.confsched.profilecard.component.ProfileCardFlipCardTestTag import org.robolectric.util.ReflectionHelpers import javax.inject.Inject +import kotlin.math.PI class ProfileCardScreenRobot @Inject constructor( screenRobot: DefaultScreenRobot, profileCardRepositoryRobot: DefaultProfileCardDataStoreRobot, + sensorRobot: DefaultSensorRobot, ) : ScreenRobot by screenRobot, - ProfileCardDataStoreRobot by profileCardRepositoryRobot { + ProfileCardDataStoreRobot by profileCardRepositoryRobot, + SensorRobot by sensorRobot { + fun setupScreenContent() { robotTestRule.setContent { KaigiTheme { @@ -50,6 +55,15 @@ class ProfileCardScreenRobot @Inject constructor( waitUntilIdle() } + fun setupMockSensor() { + setupMockSensors( + listOf( + Sensor.TYPE_ACCELEROMETER, + Sensor.TYPE_MAGNETIC_FIELD, + ), + ) + } + fun inputNickName( nickName: String, ) { @@ -100,6 +114,70 @@ class ProfileCardScreenRobot @Inject constructor( waitUntilIdle() } + fun tiltToHorizontal() { + tiltAllAxes( + pitch = degreeToRadian(0f), + roll = degreeToRadian(0f), + ) + waitUntilIdle() + } + + fun tiltToMidRange() { + tiltAllAxes( + pitch = degreeToRadian(45f), + roll = degreeToRadian(45f), + ) + waitUntilIdle() + } + + fun tiltToUpperBound() { + tiltAllAxes( + pitch = degreeToRadian(75f), + roll = degreeToRadian(75f), + ) + waitUntilIdle() + } + + fun tiltPitchOutOfBounds() { + tiltAllAxes( + pitch = degreeToRadian(-80f), + roll = degreeToRadian(0f), + ) + waitUntilIdle() + } + + fun tiltRollOutOfBounds() { + tiltAllAxes( + pitch = degreeToRadian(0f), + roll = degreeToRadian(80f), + ) + waitUntilIdle() + } + + fun tiltBothAxesOutOfBounds() { + tiltAllAxes( + pitch = degreeToRadian(-80f), + roll = degreeToRadian(80f), + ) + waitUntilIdle() + } + + fun tiltToPitchRollBoundary() { + tiltAllAxes( + pitch = degreeToRadian(-75f), + roll = degreeToRadian(75f), + ) + waitUntilIdle() + } + + fun tiltToPitchRollBoundaryOpposite() { + tiltAllAxes( + pitch = degreeToRadian(75f), + roll = degreeToRadian(-75f), + ) + waitUntilIdle() + } + fun checkCreateButtonDisabled() { composeTestRule .onNode(hasTestTag(ProfileCardCreateButtonTestTag)) @@ -175,4 +253,12 @@ class ProfileCardScreenRobot @Inject constructor( .onNode(hasTestTag(ProfileCardFlipCardBackTestTag)) .assertIsDisplayed() } + + fun cleanUp() { + cleanUpSensors() + } + + private fun degreeToRadian(degree: Float): Float { + return (degree * PI / 180f).toFloat() + } } diff --git a/feature/about/src/commonMain/composeResources/drawable/icon_medium.png b/feature/about/src/commonMain/composeResources/drawable/icon_medium.png deleted file mode 100644 index 7e671c6b5..000000000 Binary files a/feature/about/src/commonMain/composeResources/drawable/icon_medium.png and /dev/null differ diff --git a/feature/about/src/commonMain/composeResources/drawable/icon_medium.webp b/feature/about/src/commonMain/composeResources/drawable/icon_medium.webp new file mode 100644 index 000000000..bd71e8cd6 Binary files /dev/null and b/feature/about/src/commonMain/composeResources/drawable/icon_medium.webp differ diff --git a/feature/about/src/commonMain/composeResources/drawable/icon_x.png b/feature/about/src/commonMain/composeResources/drawable/icon_x.png deleted file mode 100644 index 85d18bf21..000000000 Binary files a/feature/about/src/commonMain/composeResources/drawable/icon_x.png and /dev/null differ diff --git a/feature/about/src/commonMain/composeResources/drawable/icon_x.webp b/feature/about/src/commonMain/composeResources/drawable/icon_x.webp new file mode 100644 index 000000000..6f3d423ca Binary files /dev/null and b/feature/about/src/commonMain/composeResources/drawable/icon_x.webp differ diff --git a/feature/about/src/commonMain/composeResources/drawable/icon_youtube.png b/feature/about/src/commonMain/composeResources/drawable/icon_youtube.png deleted file mode 100644 index 9cc13e11f..000000000 Binary files a/feature/about/src/commonMain/composeResources/drawable/icon_youtube.png and /dev/null differ diff --git a/feature/about/src/commonMain/composeResources/drawable/icon_youtube.webp b/feature/about/src/commonMain/composeResources/drawable/icon_youtube.webp new file mode 100644 index 000000000..9ae84e18d Binary files /dev/null and b/feature/about/src/commonMain/composeResources/drawable/icon_youtube.webp differ diff --git a/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt b/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt index 0a8b0b1be..6df3b9b84 100644 --- a/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt +++ b/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt @@ -5,17 +5,23 @@ import dagger.hilt.android.testing.HiltAndroidTest import io.github.droidkaigi.confsched.testing.DescribedBehavior import io.github.droidkaigi.confsched.testing.describeBehaviors import io.github.droidkaigi.confsched.testing.execute +import io.github.droidkaigi.confsched.testing.robot.DefaultSensorRobot.CustomShadowSensorManager import io.github.droidkaigi.confsched.testing.robot.ProfileCardDataStoreRobot.ProfileCardInputStatus import io.github.droidkaigi.confsched.testing.robot.ProfileCardScreenRobot import io.github.droidkaigi.confsched.testing.robot.runRobot import io.github.droidkaigi.confsched.testing.rules.RobotTestRule +import org.junit.After import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config import javax.inject.Inject @RunWith(ParameterizedRobolectricTestRunner::class) +@Config( + shadows = [CustomShadowSensorManager::class], +) @HiltAndroidTest class ProfileCardScreenTest( private val testCase: DescribedBehavior, @@ -34,6 +40,11 @@ class ProfileCardScreenTest( } } + @After + fun tearDown() { + robot.cleanUp() + } + companion object { @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") @@ -121,6 +132,99 @@ class ProfileCardScreenTest( checkProfileCardFrontDisplayed() } } + describe("tilt tests") { + doIt { + setupMockSensor() + } + describe("tilt to horizontal") { + doIt { + tiltToHorizontal() + } + itShould("show card in horizontal") { + captureScreenWithChecks { + checkCardScreenDisplayed() + checkProfileCardFrontDisplayed() + } + } + } + describe("tilt to mid-range") { + doIt { + tiltToMidRange() + } + itShould("show card at mid-range") { + captureScreenWithChecks { + checkCardScreenDisplayed() + checkProfileCardFrontDisplayed() + } + } + } + describe("tilt to upper bound") { + doIt { + tiltToUpperBound() + } + itShould("show card at upper bound") { + captureScreenWithChecks { + checkCardScreenDisplayed() + checkProfileCardFrontDisplayed() + } + } + } + describe("tilt pitch out of bounds") { + doIt { + tiltPitchOutOfBounds() + } + itShould("keep last valid pitch") { + captureScreenWithChecks { + checkCardScreenDisplayed() + checkProfileCardFrontDisplayed() + } + } + } + describe("tilt roll out of bounds") { + doIt { + tiltRollOutOfBounds() + } + itShould("keep last valid roll") { + captureScreenWithChecks { + checkCardScreenDisplayed() + checkProfileCardFrontDisplayed() + } + } + } + describe("tilt both axes out of bounds") { + doIt { + tiltBothAxesOutOfBounds() + } + itShould("keep last valid orientation") { + captureScreenWithChecks { + checkCardScreenDisplayed() + checkProfileCardFrontDisplayed() + } + } + } + describe("tilt to boundary") { + doIt { + tiltToPitchRollBoundary() + } + itShould("show card at boundary") { + captureScreenWithChecks { + checkCardScreenDisplayed() + checkProfileCardFrontDisplayed() + } + } + } + describe("tilt to opposite boundary") { + doIt { + tiltToPitchRollBoundaryOpposite() + } + itShould("show card at opposite boundary") { + captureScreenWithChecks { + checkCardScreenDisplayed() + checkProfileCardFrontDisplayed() + } + } + } + } describe("flip profile card") { doIt { flipProfileCard() diff --git a/feature/profilecard/src/commonMain/composeResources/drawable/droidkaigi_logo.png b/feature/profilecard/src/commonMain/composeResources/drawable/droidkaigi_logo.png deleted file mode 100644 index ab633dd2a..000000000 Binary files a/feature/profilecard/src/commonMain/composeResources/drawable/droidkaigi_logo.png and /dev/null differ diff --git a/feature/profilecard/src/commonMain/composeResources/drawable/droidkaigi_logo.webp b/feature/profilecard/src/commonMain/composeResources/drawable/droidkaigi_logo.webp new file mode 100644 index 000000000..780680117 Binary files /dev/null and b/feature/profilecard/src/commonMain/composeResources/drawable/droidkaigi_logo.webp differ diff --git a/feature/profilecard/src/commonMain/composeResources/drawable/icon_qr.png b/feature/profilecard/src/commonMain/composeResources/drawable/icon_qr.png deleted file mode 100644 index a5882f2bd..000000000 Binary files a/feature/profilecard/src/commonMain/composeResources/drawable/icon_qr.png and /dev/null differ diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/TiltEffectModifier.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/TiltEffectModifier.kt new file mode 100644 index 000000000..b634562a8 --- /dev/null +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/TiltEffectModifier.kt @@ -0,0 +1,135 @@ +package io.github.droidkaigi.confsched.profilecard + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import io.github.droidkaigi.confsched.droidkaigiui.DeviceOrientationScope +import io.github.droidkaigi.confsched.droidkaigiui.Orientation +import kotlin.math.PI +import kotlin.math.roundToInt + +/** + * Extension function to apply tilt effect based on device orientation. + * + * @param deviceOrientationScope The scope containing the device orientation information. + * @return A [Modifier] with the tilt effect applied. + */ +fun Modifier.tiltEffect(deviceOrientationScope: DeviceOrientationScope) = + this then TiltEffectElement( + orientation = deviceOrientationScope.orientation, + ) + +/** + * Modifier element to handle tilt effect based on device orientation. + * + * @property orientation The current orientation of the device. + */ +private data class TiltEffectElement( + private val orientation: Orientation, +) : ModifierNodeElement() { + override fun create() = TiltEffectNode(orientation) + + override fun update(node: TiltEffectNode) { + node.orientation = orientation + } + + override fun InspectorInfo.inspectableProperties() { + name = "TiltEffectNode" + properties["orientation"] = orientation + } +} + +/** + * A node to apply tilt effect to a layout based on device orientation. + * + * @property orientation The current orientation of the device. + * @property previousTiltRoll The last computed X-axis tilt value (roll), used for stabilization. + * @property previousTiltPitch The last computed Y-axis tilt value (pitch), used for stabilization. + */ +private data class TiltEffectNode( + var orientation: Orientation, + var previousTiltRoll: Float = 0f, + var previousTiltPitch: Float = 0f, +) : Modifier.Node(), LayoutModifierNode { + + /** Maximum tilt angle applied to the element. */ + private val maxTiltAngle = 5f + + /** The minimum and maximum allowable angles for tilt to be considered valid. */ + private val minTiltAngle = -75f + private val maxTiltAngleRange = 75f + + /** Computes the pitch in degrees from the current orientation in radians. */ + private val pitchDegree: Float + get() = radianToDegree(orientation.pitch) + + /** Computes the roll in degrees from the current orientation in radians. */ + private val rollDegree: Float + get() = radianToDegree(orientation.roll) + + /** Checks if the pitch is within the valid tilt range. */ + private val isPitchWithinValidRange: Boolean + get() = pitchDegree in minTiltAngle..maxTiltAngleRange + + /** Checks if the roll is within the valid tilt range. */ + private val isRollWithinValidRange: Boolean + get() = rollDegree in minTiltAngle..maxTiltAngleRange + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val placeable = measurable.measure(constraints) + + val (tiltRoll, tiltPitch) = calculateTilt() + + return layout(placeable.width, placeable.height) { + placeable.placeRelativeWithLayer( + x = tiltRoll.roundToInt(), + y = tiltPitch.roundToInt(), + layerBlock = { + // > Note that this definition of yaw, pitch and roll is different from the traditional definition used in aviation where the X axis is along the long side of the plane (tail to nose). + // https://developer.android.com/reference/android/hardware/SensorListener.html + rotationX = tiltPitch + rotationY = tiltRoll + }, + ) + } + } + + /** + * Calculates the tilt values (roll and pitch) based on the current device orientation. + * If the orientation is out of the valid range, the last known values are used. + * + * @return A pair of Floats representing the roll and pitch tilt values. + */ + private fun calculateTilt(): Pair { + return if (isPitchWithinValidRange && isRollWithinValidRange) { + val tiltPitch = (pitchDegree / 90f) * maxTiltAngle + val tiltRoll = (rollDegree / 90f).coerceIn(-1f, 1f) * maxTiltAngle + + // Save the current values for future use + previousTiltRoll = tiltRoll + previousTiltPitch = tiltPitch + + tiltRoll to tiltPitch + } else { + previousTiltRoll to previousTiltPitch + } + } + + /** + * Converts radians to degrees. + * + * @param radian The value in radians to convert. + * @return The value converted to degrees. + */ + private fun radianToDegree(radian: Float): Float { + return (radian * 180f / PI).toFloat() + } +} diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardBack.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardBack.kt index 5b9b5f81b..0bcdb6f59 100644 --- a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardBack.kt +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardBack.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -18,6 +19,8 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import co.touchlab.kermit.Logger import coil3.compose.AsyncImagePainter @@ -58,19 +61,26 @@ internal fun BackgroundCapturableCardBack( } }, ) { - FlipCardBack( - uiState, - qrCodeImagePainter, - modifier = Modifier - .size(width = 300.dp, height = 380.dp) - .border( - 3.dp, - Color.Black, - RoundedCornerShape(8.dp), - ) - .graphicsLayer { - rotationY = 180f - }, - ) + CompositionLocalProvider( + LocalDensity provides Density( + density = 1f, + fontScale = 1f, + ), + ) { + FlipCardBack( + uiState, + qrCodeImagePainter, + modifier = Modifier + .size(width = 300.dp, height = 380.dp) + .border( + 3.dp, + Color.Black, + RoundedCornerShape(8.dp), + ) + .graphicsLayer { + rotationY = 180f + }, + ) + } } } diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardFront.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardFront.kt index 57a290bfa..2f7a8966a 100644 --- a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardFront.kt +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/CaptureableCardFront.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -16,6 +17,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import co.touchlab.kermit.Logger import coil3.compose.AsyncImagePainter @@ -56,16 +59,23 @@ internal fun BackgroundCapturableCardFront( } }, ) { - FlipCardFront( - uiState, - profileImagePainter, - modifier = Modifier - .size(width = 300.dp, height = 380.dp) - .border( - 3.dp, - Color.Black, - RoundedCornerShape(8.dp), - ), - ) + CompositionLocalProvider( + LocalDensity provides Density( + density = 1f, + fontScale = 1f, + ), + ) { + FlipCardFront( + uiState, + profileImagePainter, + modifier = Modifier + .size(width = 300.dp, height = 380.dp) + .border( + 3.dp, + Color.Black, + RoundedCornerShape(8.dp), + ), + ) + } } } diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCard.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCard.kt index aa41037e5..53a0c9969 100644 --- a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCard.kt +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/FlipCard.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp import io.github.droidkaigi.confsched.droidkaigiui.WithDeviceOrientation import io.github.droidkaigi.confsched.profilecard.ProfileCardUiState.Card import io.github.droidkaigi.confsched.profilecard.hologramaticEffect +import io.github.droidkaigi.confsched.profilecard.tiltEffect import kotlinx.coroutines.delay const val ProfileCardFlipCardTestTag = "ProfileCardFlipCardTestTag" @@ -51,32 +52,33 @@ internal fun FlipCard( ) val isBack by remember { derivedStateOf { rotation > 90f } } - Card( - modifier = modifier - .testTag(ProfileCardFlipCardTestTag) - .size(width = 300.dp, height = 380.dp) - .clickable { isFlipped = isFlipped.not() } - .draggable( - orientation = Orientation.Horizontal, - state = rememberDraggableState { delta -> - if (isFlipped && delta > ChangeFlipCardDeltaThreshold) { - isFlipped = false - } - if (isFlipped.not() && delta < -ChangeFlipCardDeltaThreshold) { - isFlipped = true - } - }, - ) - .graphicsLayer { - rotationY = rotation - cameraDistance = 12f * density - }, - elevation = CardDefaults.cardElevation(10.dp), - ) { - if (isBack) { // Back - FlipCardBack(uiState, qrCodeImagePainter) - } else { // Front - WithDeviceOrientation { + WithDeviceOrientation { + Card( + modifier = modifier + .testTag(ProfileCardFlipCardTestTag) + .size(width = 300.dp, height = 380.dp) + .clickable { isFlipped = isFlipped.not() } + .draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + if (isFlipped && delta > ChangeFlipCardDeltaThreshold) { + isFlipped = false + } + if (isFlipped.not() && delta < -ChangeFlipCardDeltaThreshold) { + isFlipped = true + } + }, + ) + .graphicsLayer { + rotationY = rotation + cameraDistance = 12f * density + } + .tiltEffect(this@WithDeviceOrientation), + elevation = CardDefaults.cardElevation(10.dp), + ) { + if (isBack) { // Back + FlipCardBack(uiState, qrCodeImagePainter) + } else { // Front FlipCardFront( modifier = Modifier.hologramaticEffect(this@WithDeviceOrientation), uiState = uiState, diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/ShareableCard.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/ShareableCard.kt index e3889cfe2..79816929f 100644 --- a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/ShareableCard.kt +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/component/ShareableCard.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp import coil3.compose.AsyncImagePainter import com.preat.peekaboo.image.picker.toImageBitmap import io.github.droidkaigi.confsched.designsystem.theme.KaigiTheme @@ -96,6 +95,7 @@ private fun ShareableCardContent( val offsetYBackPx = 76f val offsetXFrontPx = -136f val offsetYFrontPx = -61f + val verticalPaddingPx = 30f val density = LocalDensity.current @@ -108,7 +108,7 @@ private fun ShareableCardContent( ) .background(LocalProfileCardTheme.current.primaryColor), ) { - Box(modifier = Modifier.padding(vertical = 30.dp)) { + Box(modifier = Modifier.padding(vertical = with(density) { verticalPaddingPx.toDp() })) { backImage?.let { Image( bitmap = it, diff --git a/feature/profilecard/src/iosMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardViewController.kt b/feature/profilecard/src/iosMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardViewController.kt index eaf2053ab..798e8ebaa 100644 --- a/feature/profilecard/src/iosMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardViewController.kt +++ b/feature/profilecard/src/iosMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardViewController.kt @@ -1,5 +1,7 @@ package io.github.droidkaigi.confsched.profilecard +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.dp import io.github.droidkaigi.confsched.compose.EventFlow import io.github.droidkaigi.confsched.data.Repositories import io.github.droidkaigi.confsched.droidkaigiui.composeViewController @@ -22,6 +24,10 @@ fun profileCardViewController( imageBitmap.toUiImage() ?: UIImage(), ) }, + // FIXME This is a workaround. For permanent support, we will get the inset value etc. from the iOS side and respond. + contentPadding = PaddingValues( + bottom = 30.dp, // Height of bottom tab bar + ), ) } diff --git a/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/component/TimetableGridItem.kt b/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/component/TimetableGridItem.kt index 4b0a30bdc..17fb3bb4e 100644 --- a/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/component/TimetableGridItem.kt +++ b/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/component/TimetableGridItem.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -151,45 +152,79 @@ fun TimetableGridItem( }, ), ) { - Column( + Layout( modifier = Modifier.weight(3f), - verticalArrangement = Arrangement.spacedBy( - space = TimetableGridItemSizes.scheduleToTitleSpace, - alignment = if (isShowingAllContent) Alignment.Top else Alignment.CenterVertically, - ), - ) { - if (isShowingAllContent) { - Row( - modifier = Modifier.weight(1f, fill = false), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - modifier = Modifier.height(TimetableGridItemSizes.scheduleHeight), - imageVector = vectorResource(checkNotNull(timetableItem.room.icon)), - contentDescription = timetableItem.room.name.currentLangTitle, - tint = LocalRoomTheme.current.primaryColor, + content = { + if (isShowingAllContent) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.height(TimetableGridItemSizes.scheduleHeight), + imageVector = vectorResource(checkNotNull(timetableItem.room.icon)), + contentDescription = timetableItem.room.name.currentLangTitle, + tint = LocalRoomTheme.current.primaryColor, + ) + Spacer(modifier = Modifier.width(4.dp)) + var scheduleTextStyle = MaterialTheme.typography.labelSmall + if (titleTextStyle.fontSize < scheduleTextStyle.fontSize) { + scheduleTextStyle = + scheduleTextStyle.copy(fontSize = titleTextStyle.fontSize) + } + Text( + text = "${timetableItem.startsTimeString}~${timetableItem.endsTimeString}", + style = scheduleTextStyle, + color = LocalRoomTheme.current.primaryColor, + ) + } + } + + Text( + text = timetableItem.title.currentLangTitle, + style = titleTextStyle, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(bottom = TimetableGridItemSizes.titleToSpeakerSpace), + ) + }, + measurePolicy = { measurables, constraints -> + if (isShowingAllContent && measurables.size > 1) { + // Layout if `isShowingAllContent` is `true` + val scheduleRowHeight = measurables[0].minIntrinsicHeight(constraints.maxWidth) + val remainingHeight = constraints.maxHeight - scheduleRowHeight - TimetableGridItemSizes.scheduleToTitleSpace.roundToPx() + + // Measure title text + val titlePlaceable = measurables[measurables.lastIndex].measure( + constraints.copy(minHeight = remainingHeight, maxHeight = remainingHeight), ) - Spacer(modifier = Modifier.width(4.dp)) - var scheduleTextStyle = MaterialTheme.typography.labelSmall - if (titleTextStyle.fontSize < scheduleTextStyle.fontSize) { - scheduleTextStyle = - scheduleTextStyle.copy(fontSize = titleTextStyle.fontSize) + + layout(constraints.maxWidth, constraints.maxHeight) { + // Measure schedule text line + val schedulePlaceable = measurables[0].measure( + constraints.copy(minHeight = scheduleRowHeight, maxHeight = scheduleRowHeight), + ) + // Place schedule text line at the top + schedulePlaceable.placeRelative(0, 0) + // Title text placed below the schedule + titlePlaceable.placeRelative(0, scheduleRowHeight + TimetableGridItemSizes.scheduleToTitleSpace.roundToPx()) } - Text( - text = "${timetableItem.startsTimeString}~${timetableItem.endsTimeString}", - style = scheduleTextStyle, - color = LocalRoomTheme.current.primaryColor, + } else { + // Layout if only title is displayed + val titleMinHeight = measurables[0].minIntrinsicHeight(constraints.maxWidth) + // If `isShowingAllContent` is `false`, center the title. + // (Same as Alignment.CenterVertically in Column) + val titlePlaceable = measurables[measurables.lastIndex].measure( + constraints.copy(minHeight = titleMinHeight, maxHeight = titleMinHeight), ) - } - } - Text( - modifier = Modifier.weight(1f, fill = false), - text = timetableItem.title.currentLangTitle, - style = titleTextStyle, - overflow = TextOverflow.Ellipsis, - ) - } + layout(constraints.maxWidth, constraints.maxHeight) { + val titleY = (constraints.maxHeight - titlePlaceable.height) / 2 + titlePlaceable.placeRelative(0, titleY) + } + } + }, + ) val shouldShowError = timetableItem is Session && timetableItem.message != null @@ -449,6 +484,7 @@ object TimetableGridItemSizes { val padding = 12.dp val scheduleToTitleSpace = 6.dp val scheduleHeight = 16.dp + val titleToSpeakerSpace = 4.dp val errorHeight = 16.dp val speakerHeight = 32.dp val minTitleFontSize = 10.sp @@ -558,3 +594,23 @@ fun PreviewTimetableGridItemWelcomeTalk() { } } } + +@Preview +@Composable +fun PreviewTimetableGridItemMoreLongTitleItem() { + KaigiTheme { + Surface { + TimetableGridItem( + timetableItem = Session.fake().let { + val longTitle = it.title.copy( + jaTitle = it.title.jaTitle.repeat(10), + enTitle = it.title.enTitle.repeat(10), + ) + it.copy(title = longTitle, speakers = persistentListOf(it.speakers.first())) + }, + onTimetableItemClick = {}, + gridItemHeightPx = 500, + ) + } + } +} diff --git a/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableGrid.kt b/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableGrid.kt index c0f74e210..5ef9b74e3 100644 --- a/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableGrid.kt +++ b/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableGrid.kt @@ -139,7 +139,7 @@ fun TimetableGrid( val animatedScope = LocalAnimatedVisibilityScope.current Row( - modifier = Modifier + modifier = modifier .testTag(TimetableGridTestTag) .padding( top = contentPadding.calculateTopPadding(), @@ -171,7 +171,6 @@ fun TimetableGrid( timetableState = timetableState, timeLine = timeLine, selectedDay = selectedDay, - modifier = modifier, contentPadding = PaddingValues( top = 16.dp + contentPadding.calculateTopPadding(), bottom = 16.dp + 80.dp + contentPadding.calculateBottomPadding(), diff --git a/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableList.kt b/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableList.kt index 12bede6e0..9d735709b 100644 --- a/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableList.kt +++ b/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableList.kt @@ -93,9 +93,6 @@ internal fun TimetableList( LazyColumn( modifier = modifier.testTag(TimetableListTestTag) - .offset { - IntOffset(x = 0, y = nestedScrollStateHolder.uiState.dayTabOffsetY.toInt()) - } .nestedScroll(nestedScrollConnection), state = scrollState, verticalArrangement = Arrangement.spacedBy(32.dp), diff --git a/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableSheet.kt b/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableSheet.kt index edf842438..5b3d8cca9 100644 --- a/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableSheet.kt +++ b/feature/sessions/src/commonMain/kotlin/io/github/droidkaigi/confsched/sessions/section/TimetableSheet.kt @@ -1,7 +1,7 @@ package io.github.droidkaigi.confsched.sessions.section +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding @@ -20,7 +20,9 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -29,6 +31,7 @@ import io.github.droidkaigi.confsched.model.DroidKaigi2024Day import io.github.droidkaigi.confsched.model.TimeLine import io.github.droidkaigi.confsched.model.TimetableItem import io.github.droidkaigi.confsched.sessions.component.TimetableDayTab +import io.github.droidkaigi.confsched.sessions.component.TimetableNestedScrollStateHolder import io.github.droidkaigi.confsched.sessions.component.rememberTimetableNestedScrollStateHolder import io.github.droidkaigi.confsched.sessions.section.TimetableUiState.Empty import io.github.droidkaigi.confsched.sessions.section.TimetableUiState.GridTimetable @@ -71,9 +74,8 @@ fun Timetable( Surface( modifier = modifier.padding(contentPadding.calculateTopPadding()), ) { - Column( - modifier = Modifier - .fillMaxSize(), + Box( + modifier = Modifier.fillMaxSize(), ) { TimetableDayTab( selectedDay = selectedDay, @@ -100,9 +102,7 @@ fun Timetable( scrollState = scrollStates.getValue(selectedDay), onTimetableItemClick = onTimetableItemClick, onBookmarkClick = onFavoriteClick, - modifier = Modifier - .fillMaxSize() - .weight(1f), + modifier = timetableModifier(nestedScrollStateHolder), contentPadding = PaddingValues( bottom = contentPadding.calculateBottomPadding(), start = contentPadding.calculateStartPadding(layoutDirection), @@ -119,9 +119,7 @@ fun Timetable( timeLine = uiState.timeLine, selectedDay = selectedDay, onTimetableItemClick = onTimetableItemClick, - modifier = Modifier - .fillMaxSize() - .weight(1f), + modifier = timetableModifier(nestedScrollStateHolder), contentPadding = PaddingValues( bottom = contentPadding.calculateBottomPadding(), start = contentPadding.calculateStartPadding(layoutDirection), @@ -160,3 +158,19 @@ private fun rememberGridTimetableStates(): Map