diff --git a/.github/workflows/build.pkl b/.github/workflows/build.pkl index bbef904c..58aacc45 100644 --- a/.github/workflows/build.pkl +++ b/.github/workflows/build.pkl @@ -1,5 +1,5 @@ -extends "package://components.emilym.cl/actions/actions@0.1.16#/common/build-app-support.pkl" -import "package://components.emilym.cl/actions/actions@0.1.16#/common/common.pkl" as common +extends "package://components.emilym.cl/actions/actions@0.1.21#/common/build-app-support.pkl" +import "package://components.emilym.cl/actions/actions@0.1.21#/common/common.pkl" as common hidden modules: List(length == 0) = List() hidden extraBuildSteps: Listing @@ -10,6 +10,8 @@ hidden instantVersionNumberOffset = 0 hidden gradleVersionNameName = "version" hidden gradleVersionCodeName = "versionCode" +hidden javaVersion = "21" + local releaseFileName = "sinatra-${{ matrix.build.name }}-v${{ needs.version.outputs.version }}" local iosReleaseFileName = "sinatra-iosApp-release-v${{ needs.version.outputs.version }}" @@ -62,7 +64,7 @@ function buildJob( } steps = new Listing { common.checkout - common.setupJdk + common.setupJdk(javaVersion) ...extraBuildSteps new CommandStep { run = """ @@ -428,7 +430,7 @@ jobs = new Mapping { `runs-on` = "macos-latest" steps = new Listing { common.checkout - common.setupJdk + common.setupJdk(javaVersion) new ActionStep { uses = "ruby/setup-ruby@v1" with { diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96eab322..3490e312 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,10 +74,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: temurin - env: MAPS_API_KEY: ${{ secrets.MAPS_API_KEY }} @@ -198,10 +198,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: temurin - env: MAPS_API_KEY: ${{ secrets.MAPS_API_KEY }} @@ -303,10 +303,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: temurin - uses: ruby/setup-ruby@v1 with: diff --git a/.github/workflows/lint.pkl b/.github/workflows/lint.pkl index b540e18f..4a1d138e 100644 --- a/.github/workflows/lint.pkl +++ b/.github/workflows/lint.pkl @@ -1,8 +1,9 @@ -amends "package://components.emilym.cl/actions/actions@0.1.16#/common/simple-gradle.pkl" +amends "package://components.emilym.cl/actions/actions@0.1.21#/common/simple-gradle.pkl" jobName = "lint" gradleTask = "lintVitalRelease" name = "Lint" +javaVersion = "21" extraBuildSteps { new CommandStep { diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ebbc0e7e..36efc9a1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,10 +20,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: temurin - env: MAPS_API_KEY: ${{ secrets.MAPS_API_KEY }} diff --git a/.github/workflows/test.pkl b/.github/workflows/test.pkl index 0fd36258..1aee5732 100644 --- a/.github/workflows/test.pkl +++ b/.github/workflows/test.pkl @@ -1,7 +1,9 @@ -amends "package://components.emilym.cl/actions/actions@0.1.16#/common/simple-gradle.pkl" +amends "package://components.emilym.cl/actions/actions@0.1.21#/common/simple-gradle.pkl" jobName = "test" gradleTask = "testDebugUnitTest" name = "Unit Test" +javaVersion = "21" + modules = List("shared", "ui") \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1f9d370..e2dda11b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,10 +21,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: temurin - name: Setup gradle uses: gradle/actions/setup-gradle@v4 diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index f763ebdc..a0eecada 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -97,4 +97,10 @@ secrets { defaultPropertiesFileName = "local.defaults.properties" ignoreList.add("keyToIgnore") ignoreList.add("sdk.*") +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2da7a72..f957b67d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,41 +1,42 @@ [versions] -agp = "8.7.3" -buildkonfigGradlePlugin = "0.17.0" -kotlin = "2.1.10" -compose = "1.7.8" -compose-material3 = "1.3.1" +agp = "8.11.0" +buildkonfigGradlePlugin = "0.17.1" +kotlin = "2.2.0" +compose = "1.10.1" +compose-material3 = "1.3.2" androidx-activityCompose = "1.10.1" -lifecycleRuntimeCompose = "2.8.7" -mapsCompose = "6.4.1" -mockk = "1.13.16" +lifecycleRuntimeCompose = "2.9.1" +mapsCompose = "6.6.0" +mockk = "1.14.4" aakira-napier = "2.7.1" -ktorfit = "2.4.0" -ktor = "3.1.0" -compose-adaptive = "1.0.0" -compose-plugin = "1.7.0" +ktorfit = "2.6.1" +ktor = "3.2.1" +compose-adaptive = "1.1.2" +compose-plugin = "1.10.0" secretsGradlePlugin = "2.0.1" serializable = "0.1.1" -kotlin-datetime = "0.6.1" -coil-compose = "3.0.0-rc01" +kotlin-datetime = "0.7.1" +coil-compose = "3.2.0" units = "2.0.3" requeststate = "2.2.0" -errorwidget = "2.0.2" +errorwidget = "2.0.3" +standardbutton = "2.0.3" mediacontrol = "0.1.0" -koin = "4.0.0" -koin-annotations = "1.4.0" -kotlinx-serialization = "1.8.0" +koin = "4.1.0" +koin-annotations = "2.0.0-RC1" +kotlinx-serialization = "1.9.0" pbandk = "0.16.0" voyager = "1.1.0-beta03" -ksp = "2.1.10-1.0.31" -room = "2.7.0-rc02" -sqlite = "2.5.0-rc02" +ksp = "2.2.0-2.0.2" +room = "2.7.2" +sqlite = "2.5.2" mokoPermissions = "0.19.1" playServicesLocation = "21.3.0" -markdown = "0.31.0" -google-services = "4.4.2" -firebase-crashlytics = "3.0.3" -coroutines-test = "1.10.1" -datastore = "1.1.4" +markdown = "0.35.0" +google-services = "4.4.3" +firebase-crashlytics = "3.0.4" +coroutines-test = "1.10.2" +datastore = "1.1.7" reorderable = "2.5.1" kotlincrypto = "0.6.0" @@ -84,6 +85,7 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto emily-units = { module = "cl.emilym.compose:units", version.ref = "units" } emily-serializable = { module = "cl.emilym.kmp:serializable", version.ref = "serializable" } emily-requeststate = { module = "cl.emilym.compose:requeststate", version.ref = "requeststate" } +emily-standardbutton = { module = "cl.emilym.compose:standardbutton", version.ref = "standardbutton" } emily-errorwidget = { module = "cl.emilym.compose:errorwidget", version.ref = "errorwidget" } emily-mediacontrol = { module = "cl.emilym.compose:mediacontrol", version.ref = "mediacontrol" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9b9676ef..7870334b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Dec 03 10:07:45 AEDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/iosApp/Sinatra.xcodeproj/project.pbxproj b/iosApp/Sinatra.xcodeproj/project.pbxproj index 110b932b..0d3c9356 100644 --- a/iosApp/Sinatra.xcodeproj/project.pbxproj +++ b/iosApp/Sinatra.xcodeproj/project.pbxproj @@ -123,9 +123,9 @@ 7555FF77242A565900829871 /* Sources */, 7555FF79242A565900829871 /* Resources */, F8776C6237F0DAB0F9E14335 /* Frameworks */, - 2A174906683A2D4FF04FCB57 /* [CP] Copy Pods Resources */, D8C0013CD90BBDAF4FF00390 /* [CP] Embed Pods Frameworks */, 5144DD9D2D0C2395002C42B2 /* Firebase Crashlytics */, + 9D96ECC67CFE83FC450AD8D6 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -185,27 +185,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 2A174906683A2D4FF04FCB57 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - inputPaths = ( - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; 5104D8122D099A1D003C36ED /* Compile Kotlin Framework */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -287,6 +266,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 9D96ECC67CFE83FC450AD8D6 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; D8C0013CD90BBDAF4FF00390 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -295,14 +291,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n"; diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 2ba218af..55ddadae 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,5 +1,8 @@ import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING +import com.google.devtools.ksp.gradle.KspAATask import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask +import kotlin.jvm.java plugins { alias(libs.plugins.kotlinMultiplatform) @@ -13,6 +16,10 @@ plugins { } kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + } + androidTarget { compilations.all { compileTaskProvider.configure { diff --git a/shared/src/androidUnitTest/kotlin/cl/emilym/sinatra/data/models/ServiceTest.kt b/shared/src/androidUnitTest/kotlin/cl/emilym/sinatra/data/models/ServiceTest.kt index 6345ae0b..db1596fd 100644 --- a/shared/src/androidUnitTest/kotlin/cl/emilym/sinatra/data/models/ServiceTest.kt +++ b/shared/src/androidUnitTest/kotlin/cl/emilym/sinatra/data/models/ServiceTest.kt @@ -1,8 +1,11 @@ package cl.emilym.sinatra.data.models import kotlinx.datetime.* +import java.time.temporal.ChronoField import java.time.temporal.ChronoUnit +import java.time.temporal.TemporalField import kotlin.test.* +import kotlin.time.Clock import kotlin.time.Duration.Companion.days class ServiceTest { @@ -13,7 +16,7 @@ class ServiceTest { private fun dateAt(hour: Int, minute: Int, dayOfWeek: DayOfWeek): Instant { val today = now.toLocalDateTime(tz).date val targetDate = today.toJavaLocalDate() - .with(DayOfWeek.MONDAY) + .with(java.time.DayOfWeek.MONDAY) .plus((dayOfWeek.ordinal).toLong(), ChronoUnit.DAYS) .toKotlinLocalDate() return LocalDateTime(targetDate, LocalTime(hour, minute)).toInstant(tz) diff --git a/shared/src/androidUnitTest/kotlin/cl/emilym/sinatra/domain/LastDepartureForStopUseCaseTest.kt b/shared/src/androidUnitTest/kotlin/cl/emilym/sinatra/domain/LastDepartureForStopUseCaseTest.kt index 4a29ee29..39b69bd4 100644 --- a/shared/src/androidUnitTest/kotlin/cl/emilym/sinatra/domain/LastDepartureForStopUseCaseTest.kt +++ b/shared/src/androidUnitTest/kotlin/cl/emilym/sinatra/domain/LastDepartureForStopUseCaseTest.kt @@ -695,6 +695,52 @@ class LastDepartureForStopUseCaseTest { assertEquals(1.hours, result.first().departureTime.durationThroughDay) } + @Test + fun `invoke prioritizes next day departure before 3am from over current day future departure`() = runTest { + // Given + val todayService = mockk() + val tomorrowService = mockk() + + val todayFutureDeparture = DefaultStopTimetableTime.copy( + serviceId = "today-service", + routeId = "route1", + heading = "City", + departureTime = Time.create(23.hours) // Future departure today + ) + + val tomorrowEarlyDeparture = DefaultStopTimetableTime.copy( + serviceId = "tomorrow-service", + routeId = "route1", + heading = "City", + departureTime = Time.create(1.hours) // Future departure tomorrow (before 3am) + ) + + val servicesAndTimes = createServicesAndTimes( + services = listOf(todayService, tomorrowService), + times = listOf(todayFutureDeparture, tomorrowEarlyDeparture) + ) + + every { todayService.id } returns "today-service" + every { todayService.active(yesterday, testTimeZone) } returns false + every { todayService.active(today, testTimeZone) } returns true + every { todayService.active(tomorrow, testTimeZone) } returns false + + every { tomorrowService.id } returns "tomorrow-service" + every { tomorrowService.active(yesterday, testTimeZone) } returns false + every { tomorrowService.active(today, testTimeZone) } returns false + every { tomorrowService.active(tomorrow, testTimeZone) } returns true + + coEvery { servicesAndTimesForStopUseCase(testStopId) } returns servicesAndTimes + + // When + val result = useCase(testStopId).first() + + // Then + assertEquals(1, result.size) + assertEquals("tomorrow-service", result.first().serviceId) + assertEquals(1.hours, result.first().departureTime.durationThroughDay) + } + @Test fun `invoke filters out school services when ShowSchoolServices is false`() = runTest { // Given diff --git a/shared/src/commonMain/kotlin/cl/emilym/sinatra/SharedModule.kt b/shared/src/commonMain/kotlin/cl/emilym/sinatra/SharedModule.kt index 0186e2d7..7a601c8c 100644 --- a/shared/src/commonMain/kotlin/cl/emilym/sinatra/SharedModule.kt +++ b/shared/src/commonMain/kotlin/cl/emilym/sinatra/SharedModule.kt @@ -10,5 +10,5 @@ import org.koin.dsl.module class SharedModule val manualModule = module { - factory { Clock.System } + factory { kotlin.time.Clock.System } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/models/Realtime.kt b/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/models/Realtime.kt index f791779a..1459fb71 100644 --- a/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/models/Realtime.kt +++ b/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/models/Realtime.kt @@ -37,7 +37,7 @@ data class RealtimeInformationImpl( return RealtimeInformationImpl( pb.updates.map { RealtimeUpdateImpl.fromPb(it) }, pb.expireTimestamp?.let { Instant.parse(pb.expireTimestamp) } - ?: (Clock.System.now() + 2.minutes) + ?: (kotlin.time.Clock.System.now() + 2.minutes) ) } } diff --git a/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/AlertRepository.kt b/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/AlertRepository.kt index 457360fb..d5392a57 100644 --- a/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/AlertRepository.kt +++ b/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/AlertRepository.kt @@ -53,6 +53,20 @@ class AlertRepository( ))) } } + is AlertDisplayContext.Route -> { + flow { + emit(listOfNotNull(contentRepository.banner( + ContentRepository.ROUTE_BANNER_ID.replace("%s", context.routeId) + ))) + } + } + is AlertDisplayContext.Stop -> { + flow { + emit(listOfNotNull(contentRepository.banner( + ContentRepository.STOP_BANNER_ID.replace("%s", context.stopId) + ))) + } + } else -> flowOf(emptyList()) } } diff --git a/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/ContentRepository.kt b/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/ContentRepository.kt index fe2db2ee..b76fd25f 100644 --- a/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/ContentRepository.kt +++ b/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/ContentRepository.kt @@ -24,6 +24,8 @@ class ContentRepository( const val SERVICE_ALERT_ID = "service-alerts" const val INFORMATION_FOR_DEVELOPERS_ID = "information-for-developers" const val HOME_BANNER_ID = "home" + const val ROUTE_BANNER_ID = "route-%s" + const val STOP_BANNER_ID = "stop-%s" const val NATIVE_PREFERENCES_ID = "preferences" const val NATIVE_PREFERENCES_ROUTING_ID = "preferences-routing" diff --git a/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/PreferencesRepository.kt b/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/PreferencesRepository.kt index 724d7799..9f96018e 100644 --- a/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/PreferencesRepository.kt +++ b/shared/src/commonMain/kotlin/cl/emilym/sinatra/data/repository/PreferencesRepository.kt @@ -16,6 +16,7 @@ sealed interface Preference { data object ShowAccessibilityIconsNavigation: Preference data object MetricUnits: Preference data object Use24HourUnits: Preference + data object CountdownUntilArrival: Preference } @Factory @@ -32,6 +33,7 @@ class PreferencesRepository( internal val ROUTER_SHOW_ACCESSIBILITY_ICONS = booleanPreferencesKey("ROUTER_SHOW_ACCESSIBILITY_ICONS") internal val DISPLAY_METRIC_UNITS_KEY = booleanPreferencesKey("DISPLAY_METRIC_UNITS") internal val TIME_24H_KEY = stringPreferencesKey("TIME_24H") + internal val COUNTDOWN_UNTIL_ARRIVAL = booleanPreferencesKey("COUNTDOWN_UNTIL_ARRIVAL") } private val requiresWheelchair: PreferencesUnit = SimplePreferencesUnit( @@ -82,6 +84,12 @@ class PreferencesRepository( { it.name } ) + private val countdownUntilArrival: PreferencesUnit = SimplePreferencesUnit( + COUNTDOWN_UNTIL_ARRIVAL, + false, + preferencesPersistence + ) + fun preference(preference: Preference): PreferencesUnit = when (preference) { is Preference.MaximumWalkingTime -> maximumWalkingTime is Preference.MetricUnits -> metric @@ -90,6 +98,7 @@ class PreferencesRepository( is Preference.RequiresWheelchair -> requiresWheelchair is Preference.ShowAccessibilityIconsNavigation -> showAccessibilityIconsNavigation is Preference.Use24HourUnits -> use24Hour + is Preference.CountdownUntilArrival -> countdownUntilArrival } as PreferencesUnit } diff --git a/shared/src/commonMain/kotlin/cl/emilym/sinatra/domain/LastDepartureForStopUseCase.kt b/shared/src/commonMain/kotlin/cl/emilym/sinatra/domain/LastDepartureForStopUseCase.kt index 440293ea..2dc7d9b6 100644 --- a/shared/src/commonMain/kotlin/cl/emilym/sinatra/domain/LastDepartureForStopUseCase.kt +++ b/shared/src/commonMain/kotlin/cl/emilym/sinatra/domain/LastDepartureForStopUseCase.kt @@ -7,6 +7,7 @@ import cl.emilym.sinatra.data.models.RouteId import cl.emilym.sinatra.data.models.StopId import cl.emilym.sinatra.data.models.StopTimetableTime import cl.emilym.sinatra.data.models.startOfDay +import cl.emilym.sinatra.data.models.toTodayTime import cl.emilym.sinatra.data.repository.Preference import cl.emilym.sinatra.data.repository.PreferencesRepository import cl.emilym.sinatra.data.repository.RemoteConfigRepository @@ -33,41 +34,47 @@ class LastDepartureForStopUseCase( private val preferencesRepository: PreferencesRepository ) { + companion object { + private val CUTOFF_TIME = 3.hours // 3am + } + operator fun invoke( stopId: StopId, routeId: RouteId? = null ): Flow> = flow { val scheduleTimeZone = metadataRepository.timeZone() val now = clock.now() - val days = listOf(now - 1.days, now + 1.days, now) + val days = listOf(now - 1.days, now, now + 1.days) val timesAndServices = servicesAndTimesForStopUseCase(stopId) - val activeServices = days.map { now -> + val activeServicesByDay = days.map { day -> timesAndServices.item.services.filter { it.active( - now, + day, scheduleTimeZone ) } } - if (activeServices.all { it.isEmpty() }) return@flow emit(emptyList()) + if (activeServicesByDay.all { it.isEmpty() }) return@flow emit(emptyList()) val lasts = mutableMapOf>() - activeServices.forEachIndexed { i, activeServices -> - val startOfDay = days[i].startOfDay(scheduleTimeZone) + activeServicesByDay.forEachIndexed { dayIndex, activeServices -> + val startOfDay = days[dayIndex].startOfDay(scheduleTimeZone) activeServices.forEach { activeService -> val relevant = timesAndServices.item.times .filter { it.serviceId == activeService.id } .filterNot { it.last } .run { + // Filter for specific routes when provided when (routeId) { null -> this else -> filter { it.routeId == routeId } } } .run { - when (i) { - 1 -> filter { it.departureTime.durationThroughDay < 3.hours } + // Only look for departures before 3am on next day + when (dayIndex) { + 2 -> filter { it.departureTime.durationThroughDay < CUTOFF_TIME } else -> this } } @@ -77,8 +84,8 @@ class LastDepartureForStopUseCase( val referenced = stopTime.withTimeReference(startOfDay) val current = lasts.getOrPut(key){ Array(3) { null } } - if (current[i] == null || current[i]!!.departureTime < referenced.departureTime) - current[i] = referenced + if (current[dayIndex] == null || current[dayIndex]!!.departureTime < referenced.departureTime) + current[dayIndex] = referenced } } } @@ -87,7 +94,14 @@ class LastDepartureForStopUseCase( lasts .values .mapNotNull { - it.filterNotNull().firstOrNull { it.departureTime >= now } ?: it[2] + val yesterday = it[0] + val today = it[1] + val tomorrow = it[2] + when { + yesterday != null && yesterday.departureTime >= now -> yesterday + tomorrow != null -> tomorrow + else -> today + } } .map { when { diff --git a/shared/src/commonMain/kotlin/cl/emilym/sinatra/lib/MemoryCache.kt b/shared/src/commonMain/kotlin/cl/emilym/sinatra/lib/MemoryCache.kt index 377acfa0..7972265c 100644 --- a/shared/src/commonMain/kotlin/cl/emilym/sinatra/lib/MemoryCache.kt +++ b/shared/src/commonMain/kotlin/cl/emilym/sinatra/lib/MemoryCache.kt @@ -2,10 +2,10 @@ package cl.emilym.sinatra.lib import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant +import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant interface MemoryCache { suspend fun get(): T diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 8dbd62e6..025b595b 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -12,6 +12,10 @@ plugins { } kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + } + androidTarget { compilations.all { compileTaskProvider.configure { @@ -79,6 +83,7 @@ kotlin { implementation(libs.emily.serializable) implementation(libs.emily.units) implementation(libs.emily.errorwidget) + implementation(libs.emily.standardbutton) implementation(libs.emily.requeststate) // Coil @@ -123,8 +128,14 @@ dependencies { add("kspIosSimulatorArm64", libs.koin.ksp.compiler) } -project.tasks.withType(KotlinCompilationTask::class.java).configureEach { - if(name != "kspCommonMainKotlinMetadata") { +tasks.withType(KotlinCompilationTask::class).configureEach { + if (name != "kspCommonMainKotlinMetadata") { + dependsOn("kspCommonMainKotlinMetadata") + } +} + +tasks.withType().configureEach { + if (name == "kspKotlinIosArm64") { dependsOn("kspCommonMainKotlinMetadata") } } diff --git a/ui/src/androidMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.android.kt b/ui/src/androidMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.android.kt index c522df58..e7c06b14 100644 --- a/ui/src/androidMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.android.kt +++ b/ui/src/androidMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.android.kt @@ -36,7 +36,7 @@ internal actual fun platformCurrentLocation(accuracy: LocationAccuracy): Flow TimeUnit.MINUTES.toMillis(10) LocationAccuracy.MEDIUM -> TimeUnit.MINUTES.toMillis(1) - LocationAccuracy.HIGH -> TimeUnit.SECONDS.toMillis(30) + LocationAccuracy.HIGH -> TimeUnit.SECONDS.toMillis(5) } ) .setMinUpdateDistanceMeters(when (accuracy) { diff --git a/ui/src/commonMain/composeResources/values-zh/strings.xml b/ui/src/commonMain/composeResources/values-zh/strings.xml index e0860cc5..4fbb2f2f 100644 --- a/ui/src/commonMain/composeResources/values-zh/strings.xml +++ b/ui/src/commonMain/composeResources/values-zh/strings.xml @@ -217,6 +217,10 @@ 本服务无固定时刻表,车辆到达时间可能会有所变化。 无障碍设施 + + %1$s(晚点%2$s) + + %1$s(早%2$s) 计划到达%1$s @@ -230,14 +234,6 @@ 约%1$s到达 - 预计到达时间为%1$s(晚点%2$s) - - 预计到达时间为%1$s(晚点%2$s) - - 预计到达时间为%1$s(早%2$s) - - 预计到达时间为%1$s(早%2$s) - 于 %1$s 出发 于 %1$s 出发 @@ -246,14 +242,6 @@ 大约在 %1$s 出发 - 于%1$s出发(晚点 %2$s) - - 于%1$s出发(晚点 %2$s) - - 于%1$s出发(早于%2$s) - - 于%1$s出发(早于%2$s) - 预计于%1$s出发 预计于%1$s出发 @@ -266,49 +254,29 @@ 预计于%1$s出发 - 预计于%1$s出发(晚点%2$s) - - 预计于%1$s出发(晚点%2$s) - - 预计于%1$s出发(提前%2$s) - - 预计于%1$s出发(提前%2$s) - 将于%1$s抵达 - 将于%1$s抵达 - - 将于%1$s抵达(延迟%2$s) - - 将于%1$s抵达(延迟%2$s) + 将于%1$s后到达 - 将于%1$s抵达(提前%2$s) + 即将到达 - 将于%1$s抵达(提前%2$s) + 将于%1$s抵达 将于%1$s出发 - 将于%1$s出发 - - 将于%1$s出发(延迟%2$s) + 将于%1$s后出发 - 将于%1$s出发(延迟%2$s) + 即将出发 - 将于%1$s出发(提前%2$s) - - 将于%1$s出发(提前%2$s) + 将于%1$s出发 已于%1$s出发 - 已于%1$s出发 - - 已于%1$s出发(延迟%2$s) - - 已于%1$s出发(延迟%2$s) + 已于%1$s前出发 - 已于%1$s出发(提前%2$s) + 已出发 - 已于%1$s出发(提前%2$s) + 已于%1$s出发 %1$s 至 %2$s diff --git a/ui/src/commonMain/composeResources/values/strings.xml b/ui/src/commonMain/composeResources/values/strings.xml index 5de5dc46..a8852a9e 100644 --- a/ui/src/commonMain/composeResources/values/strings.xml +++ b/ui/src/commonMain/composeResources/values/strings.xml @@ -30,6 +30,8 @@ Highlight Accessibility Show whether a journey is bike or wheelchair accessible. Maximum walking time + Relative arrival times + Show vehicle arrival and departure times relative to current time. Distance Units Display distances in metric (km, m) or imperial (mi, ft) units. Metric @@ -103,6 +105,7 @@ %1$d sec %1$d secs + <1 min Current Location @@ -160,6 +163,8 @@ This service does not follow a fixed timetable and arrival times may vary. Accessibility + %1$s (%2$s late) + %1$s (%2$s early) Scheduled to arrive at %1$s Scheduled to arrive on %1$s @@ -167,48 +172,30 @@ Expected to arrive on %1$s Arriving at approximately %1$s Arriving at approximately %1$s - Expected to arrive at %1$s (%2$s late) - Expected to arrive on %1$s (%2$s late) - Expected to arrive at %1$s (%2$s early) - Expected to arrive on %1$s (%2$s early) Departed at %1$s Departed on %1$s Departed at approximately %1$s Departed on approximately %1$s - Departed at %1$s (%2$s late) - Departed on %1$s (%2$s late) - Departed at %1$s (%2$s early) - Departed on %1$s (%2$s early) Scheduled to depart at %1$s Scheduled to depart on %1$s Expected to depart at %1$s Expected to depart on %1$s Expected to depart at approximately %1$s Expected to depart on approximately %1$s - Expected to depart at %1$s (%2$s late) - Expected to depart on %1$s (%2$s late) - Expected to depart at %1$s (%2$s early) - Expected to depart on %1$s (%2$s early) Arriving at %1$s + Arriving in %1$s + Arriving now Arriving on %1$s - Arriving at %1$s (%2$s late) - Arriving on %1$s (%2$s late) - Arriving at %1$s (%2$s early) - Arriving on %1$s (%2$s early) Departing at %1$s + Departing in %1$s + Departing now Departing on %1$s - Departing at %1$s (%2$s late) - Departing on %1$s (%2$s late) - Departing at %1$s (%2$s early) - Departing on %1$s (%2$s early) Departed at %1$s + Departed %1$s ago + Departed now Departed on %1$s - Departed at %1$s (%2$s late) - Departed on %1$s (%2$s late) - Departed at %1$s (%2$s early) - Departed on %1$s (%2$s early) %1$s towards %2$s Towards %1$s diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/localization/TimeFormats.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/localization/TimeFormats.kt index 3d676c3d..4bbcfb03 100644 --- a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/localization/TimeFormats.kt +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/localization/TimeFormats.kt @@ -1,6 +1,7 @@ package cl.emilym.sinatra.ui.localization import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.text.intl.Locale import cl.emilym.sinatra.data.models.Time24HSetting import cl.emilym.sinatra.data.repository.Preference @@ -59,18 +60,21 @@ val timeFormat: DateTimeFormat get() { val amMarker = stringResource(Res.string.time_am) val pmMarker = stringResource(Res.string.time_pm) - return when (is24HourTimeFormat()) { - true -> LocalTime.Format { - hour() - char(':') - minute() - } - false -> when { - else -> LocalTime.Format { - amPmHour(Padding.NONE) + val is24 = is24HourTimeFormat() + return remember(is24, amMarker, pmMarker) { + when (is24) { + true -> LocalTime.Format { + hour() char(':') minute() - amPmMarker(amMarker, pmMarker) + } + false -> when { + else -> LocalTime.Format { + amPmHour(Padding.NONE) + char(':') + minute() + amPmMarker(amMarker, pmMarker) + } } } } @@ -81,15 +85,18 @@ val dayOfWeekDateTimeFormat: DateTimeFormat get() { val dayOfWeekNames = dayOfWeekNames val timeFormat = timeFormat - return when(Locale.current.toLanguageTag()) { - LanguageConsts.MAINLAND_CHINESE_BCP -> LocalDateTime.Format { - dayOfWeek(dayOfWeekNames) - time(timeFormat) - } - else -> LocalDateTime.Format { - dayOfWeek(dayOfWeekNames) - chars(", ") - time(timeFormat) + val locale = Locale.current + return remember(locale, timeFormat, dayOfWeekNames) { + when(locale.toLanguageTag()) { + LanguageConsts.MAINLAND_CHINESE_BCP -> LocalDateTime.Format { + dayOfWeek(dayOfWeekNames) + time(timeFormat) + } + else -> LocalDateTime.Format { + dayOfWeek(dayOfWeekNames) + chars(", ") + time(timeFormat) + } } } } @@ -98,28 +105,30 @@ val dateFormat: DateTimeFormat @Composable get() { val locale = Locale.current - return when(locale.toLanguageTag()) { - LanguageConsts.MAINLAND_CHINESE_BCP -> LocalDate.Format { - year() - char('年') - monthNumber() - char('月') - dayOfMonth() - char('日') - } - LanguageConsts.US_BCP -> LocalDate.Format { - monthNumber() - char('/') - dayOfMonth() - char('/') - year() - } - else -> LocalDate.Format { - dayOfMonth() - char('/') - monthNumber() - char('/') - year() + return remember(locale) { + when(locale.toLanguageTag()) { + LanguageConsts.MAINLAND_CHINESE_BCP -> LocalDate.Format { + year() + char('年') + monthNumber() + char('月') + dayOfMonth() + char('日') + } + LanguageConsts.US_BCP -> LocalDate.Format { + monthNumber() + char('/') + dayOfMonth() + char('/') + year() + } + else -> LocalDate.Format { + dayOfMonth() + char('/') + monthNumber() + char('/') + year() + } } } } \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/localization/TimeHelpers.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/localization/TimeHelpers.kt index 0bd18eea..58aad627 100644 --- a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/localization/TimeHelpers.kt +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/localization/TimeHelpers.kt @@ -1,25 +1,40 @@ package cl.emilym.sinatra.ui.localization import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import cl.emilym.sinatra.FeatureFlag import cl.emilym.sinatra.data.models.Time import cl.emilym.sinatra.data.models.isSameDay import cl.emilym.sinatra.data.models.startOfDay +import cl.emilym.sinatra.ui.text import cl.emilym.sinatra.ui.widgets.value +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.format import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import sinatra.ui.generated.resources.Res import sinatra.ui.generated.resources.time_local_timezone +import sinatra.ui.generated.resources.time_minute_less_than_min_short +import sinatra.ui.generated.resources.time_minute_short +import kotlin.math.abs +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes val LocalScheduleTimeZone = staticCompositionLocalOf { error("Schedule time zone not provided!") } val LocalLocalTimeZone = staticCompositionLocalOf { TimeZone.currentSystemDefault() } -val LocalClock = staticCompositionLocalOf { Clock.System } +val LocalClock = staticCompositionLocalOf { kotlin.time.Clock.System } @Composable fun scheduleStartOfDay(): Instant { @@ -32,14 +47,55 @@ fun startOfDay(timeZone: TimeZone): Instant { return clock.startOfDay(timeZone) } +@Composable +fun rememberCountdown(instant: kotlin.time.Instant): Duration { + val clock = LocalClock.current + var countdown by remember(instant, clock) { mutableStateOf(instant - clock.now()) } + + LaunchedEffect(instant, clock) { + while (isActive) { + delay( + countdown.inWholeMilliseconds.let { + when (abs(it) > 60000) { + true -> it - (it.floorDiv(60000L) * 60000L) + else -> 0L + } + }.coerceAtLeast(0) + 1000L + ) + countdown = instant - clock.now() + } + } + + return countdown +} + @Composable fun Time.toTodayInstant(): Instant { - return addReference(scheduleStartOfDay()).instant + val scheduleStartOfDay = scheduleStartOfDay() + return remember(scheduleStartOfDay, this) { + addReference(scheduleStartOfDay).instant + } } @Composable fun Time.isInPast(): Boolean { - return toTodayInstant() < LocalClock.current.now() + val instant = toTodayInstant() + val clock = LocalClock.current + val countdown = rememberCountdown(instant) + return remember(countdown, clock, instant) { + instant < clock.now() + } +} + +@Composable +fun Time.isNowish(): Boolean { + val instant = toTodayInstant() + val clock = LocalClock.current + val countdown = rememberCountdown(instant) + return remember(countdown, clock, instant) { + val now = clock.now() + instant > (now - 1.minutes) && instant < (now + 1.minutes) + } } @Composable @@ -63,23 +119,52 @@ fun Instant.format(): String { @Composable fun Instant.isSameDay(timeZone: TimeZone): Boolean { - return toLocalDateTime(timeZone).isSameDay(LocalClock.current.now().toLocalDateTime(timeZone)) + val clock = LocalClock.current + return remember(this, timeZone, clock) { + toLocalDateTime(timeZone).isSameDay(clock.now().toLocalDateTime(timeZone)) + } } @Composable private fun Instant.format(timeZone: TimeZone): String { - val inTz = toLocalDateTime(timeZone) + val inTz = remember(this, timeZone) { toLocalDateTime(timeZone) } + val sameDay = isSameDay(timeZone) val timeFormat = timeFormat + val dayOfWeekFormat = dayOfWeekDateTimeFormat - return when { - isSameDay(timeZone) -> inTz.format(LocalDateTime.Format { - time(timeFormat) - }) - else -> inTz.format(dayOfWeekDateTimeFormat) + return remember(inTz, this, sameDay, timeZone, timeFormat) { + when { + sameDay -> inTz.format(LocalDateTime.Format { + time(timeFormat) + }) + else -> inTz.format(dayOfWeekFormat) + } } } @Composable fun Time.format(): String { return toTodayInstant().format() +} + +@Composable +fun countdown(time: kotlin.time.Instant, negative: Boolean = false): String { + val remaining = rememberCountdown(time) + + val display by remember(remaining, negative) { + derivedStateOf { + when (negative) { + true -> -remaining + else -> remaining + } + } + } + + return when { + display.isPositive() && display <= 1.minutes -> + stringResource(Res.string.time_minute_less_than_min_short) + display.isNegative() && display >= (-1).minutes -> + pluralStringResource(Res.plurals.time_minute_short, 0, 0) + else -> display.text(true) + } } \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/presentation/screens/preferences/RoutingPreferencesScreen.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/presentation/screens/preferences/RoutingPreferencesScreen.kt index 1e2559ba..7d0486cb 100644 --- a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/presentation/screens/preferences/RoutingPreferencesScreen.kt +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/presentation/screens/preferences/RoutingPreferencesScreen.kt @@ -14,23 +14,22 @@ import cl.emilym.sinatra.FeatureFlag import cl.emilym.sinatra.data.repository.Preference import cl.emilym.sinatra.lib.FloatRange import cl.emilym.sinatra.ui.text -import cl.emilym.sinatra.ui.widgets.form.HorizontalLockup -import cl.emilym.sinatra.ui.widgets.form.PreferencesCheckbox +import cl.emilym.sinatra.ui.widgets.form.HorizontalPreferencesCheckboxLockup import cl.emilym.sinatra.ui.widgets.form.PreferencesFloatSlider import cl.emilym.sinatra.ui.widgets.form.VerticalLockup import cl.emilym.sinatra.ui.widgets.value import org.jetbrains.compose.resources.stringResource import sinatra.ui.generated.resources.Res +import sinatra.ui.generated.resources.preferences_routing_title import sinatra.ui.generated.resources.preferences_setting_bikes import sinatra.ui.generated.resources.preferences_setting_bikes_subtitle import sinatra.ui.generated.resources.preferences_setting_max_walking -import sinatra.ui.generated.resources.preferences_setting_wheelchair -import sinatra.ui.generated.resources.preferences_setting_wheelchair_subtitle -import sinatra.ui.generated.resources.preferences_routing_title import sinatra.ui.generated.resources.preferences_setting_school_service import sinatra.ui.generated.resources.preferences_setting_school_service_subtitle import sinatra.ui.generated.resources.preferences_setting_show_accessibility_icons_navigation import sinatra.ui.generated.resources.preferences_setting_show_accessibility_icons_navigation_subtitle +import sinatra.ui.generated.resources.preferences_setting_wheelchair +import sinatra.ui.generated.resources.preferences_setting_wheelchair_subtitle import kotlin.time.Duration.Companion.minutes class RoutingPreferencesScreen: PreferencesScreen() { @@ -45,41 +44,37 @@ class RoutingPreferencesScreen: PreferencesScreen() { val showSchoolServiceSettings = FeatureFlag.GLOBAL_ENABLE_SCHOOL_SERVICES.value() if (showAccessibilitySettings) { - HorizontalLockup( + HorizontalPreferencesCheckboxLockup( + Preference.RequiresWheelchair, stringResource(Res.string.preferences_setting_wheelchair), stringResource(Res.string.preferences_setting_wheelchair_subtitle), Modifier.fillMaxWidth() - ) { - PreferencesCheckbox(Preference.RequiresWheelchair) - } + ) - HorizontalLockup( + HorizontalPreferencesCheckboxLockup( + Preference.RequiresBikes, stringResource(Res.string.preferences_setting_bikes), stringResource(Res.string.preferences_setting_bikes_subtitle), Modifier.fillMaxWidth() - ) { - PreferencesCheckbox(Preference.RequiresBikes) - } + ) } if (showSchoolServiceSettings) { - HorizontalLockup( + HorizontalPreferencesCheckboxLockup( + Preference.ShowSchoolServices, stringResource(Res.string.preferences_setting_school_service), stringResource(Res.string.preferences_setting_school_service_subtitle), Modifier.fillMaxWidth() - ) { - PreferencesCheckbox(Preference.ShowSchoolServices) - } + ) } if (showAccessibilitySettings) { - HorizontalLockup( + HorizontalPreferencesCheckboxLockup( + Preference.ShowAccessibilityIconsNavigation, stringResource(Res.string.preferences_setting_show_accessibility_icons_navigation), stringResource(Res.string.preferences_setting_show_accessibility_icons_navigation_subtitle), Modifier.fillMaxWidth() - ) { - PreferencesCheckbox(Preference.ShowAccessibilityIconsNavigation) - } + ) } diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/presentation/screens/preferences/UnitsPreferencesScreen.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/presentation/screens/preferences/UnitsPreferencesScreen.kt index 11d45026..0ac70450 100644 --- a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/presentation/screens/preferences/UnitsPreferencesScreen.kt +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/presentation/screens/preferences/UnitsPreferencesScreen.kt @@ -8,10 +8,13 @@ import cafe.adriel.voyager.core.screen.ScreenKey import cl.emilym.sinatra.data.models.Time24HSetting import cl.emilym.sinatra.data.repository.Preference import cl.emilym.sinatra.ui.widgets.form.DropdownOption +import cl.emilym.sinatra.ui.widgets.form.HorizontalPreferencesCheckboxLockup import cl.emilym.sinatra.ui.widgets.form.PreferencesDropdown import cl.emilym.sinatra.ui.widgets.form.VerticalLockup import org.jetbrains.compose.resources.stringResource import sinatra.ui.generated.resources.Res +import sinatra.ui.generated.resources.preferences_setting_countdown +import sinatra.ui.generated.resources.preferences_setting_countdown_subtitle import sinatra.ui.generated.resources.preferences_setting_metric import sinatra.ui.generated.resources.preferences_setting_metric_imperial import sinatra.ui.generated.resources.preferences_setting_metric_metric @@ -31,6 +34,13 @@ class UnitsPreferencesScreen: PreferencesScreen() { @Composable override fun ColumnScope.Preferences() { + HorizontalPreferencesCheckboxLockup( + Preference.CountdownUntilArrival, + stringResource(Res.string.preferences_setting_countdown), + stringResource(Res.string.preferences_setting_countdown_subtitle), + Modifier.fillMaxWidth() + ) + VerticalLockup( stringResource(Res.string.preferences_setting_metric), stringResource(Res.string.preferences_setting_metric_subtitle), diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.kt index 3d3be9fd..0148e051 100644 --- a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.kt +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.kt @@ -21,7 +21,7 @@ enum class LocationAccuracy { internal expect fun platformCurrentLocation(accuracy: LocationAccuracy): Flow @Composable -fun currentLocation(accuracy: LocationAccuracy = LocationAccuracy.MEDIUM): MapLocation? { +fun currentLocation(accuracy: LocationAccuracy = LocationAccuracy.HIGH): MapLocation? { var hasPermission by remember { mutableStateOf(false) } val permissionRequestQueue = LocalPermissionRequestQueue.current diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/StopCard.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/StopCard.kt index df9569cc..e1d521da 100644 --- a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/StopCard.kt +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/StopCard.kt @@ -25,10 +25,14 @@ import cl.emilym.sinatra.data.models.StopAccessibility import cl.emilym.sinatra.data.models.StopWheelchairAccessibility import cl.emilym.sinatra.data.models.TimetableStationTime import cl.emilym.sinatra.data.models.merge +import cl.emilym.sinatra.data.repository.Preference import cl.emilym.sinatra.ui.localization.LocalScheduleTimeZone +import cl.emilym.sinatra.ui.localization.countdown import cl.emilym.sinatra.ui.localization.format import cl.emilym.sinatra.ui.localization.isInPast +import cl.emilym.sinatra.ui.localization.isNowish import cl.emilym.sinatra.ui.localization.isSameDay +import cl.emilym.sinatra.ui.localization.toTodayInstant import cl.emilym.sinatra.ui.text import org.jetbrains.compose.resources.stringResource import sinatra.ui.generated.resources.Res @@ -36,36 +40,32 @@ import sinatra.ui.generated.resources.approximate_arrival import sinatra.ui.generated.resources.approximate_arrival_day import sinatra.ui.generated.resources.estimated_arrival import sinatra.ui.generated.resources.estimated_arrival_day -import sinatra.ui.generated.resources.estimated_arrival_early -import sinatra.ui.generated.resources.estimated_arrival_early_day -import sinatra.ui.generated.resources.estimated_arrival_late -import sinatra.ui.generated.resources.estimated_arrival_late_day import sinatra.ui.generated.resources.future_approximate_departure import sinatra.ui.generated.resources.future_approximate_departure_day import sinatra.ui.generated.resources.future_estimated_departure import sinatra.ui.generated.resources.future_estimated_departure_day -import sinatra.ui.generated.resources.future_estimated_departure_early -import sinatra.ui.generated.resources.future_estimated_departure_early_day -import sinatra.ui.generated.resources.future_estimated_departure_late -import sinatra.ui.generated.resources.future_estimated_departure_late_day import sinatra.ui.generated.resources.future_scheduled_departure import sinatra.ui.generated.resources.future_scheduled_departure_day import sinatra.ui.generated.resources.generic_arrival +import sinatra.ui.generated.resources.generic_arrival_countdown +import sinatra.ui.generated.resources.generic_arrival_countdown_now import sinatra.ui.generated.resources.generic_arrival_day import sinatra.ui.generated.resources.generic_departure +import sinatra.ui.generated.resources.generic_departure_countdown +import sinatra.ui.generated.resources.generic_departure_countdown_now import sinatra.ui.generated.resources.generic_departure_day import sinatra.ui.generated.resources.generic_departure_past +import sinatra.ui.generated.resources.generic_departure_past_countdown +import sinatra.ui.generated.resources.generic_departure_past_countdown_now import sinatra.ui.generated.resources.generic_departure_past_day import sinatra.ui.generated.resources.past_departure import sinatra.ui.generated.resources.past_departure_approximate import sinatra.ui.generated.resources.past_departure_approximate_day import sinatra.ui.generated.resources.past_departure_day -import sinatra.ui.generated.resources.past_departure_early -import sinatra.ui.generated.resources.past_departure_early_day -import sinatra.ui.generated.resources.past_departure_late -import sinatra.ui.generated.resources.past_departure_late_day import sinatra.ui.generated.resources.scheduled_arrival import sinatra.ui.generated.resources.scheduled_arrival_day +import sinatra.ui.generated.resources.scheduled_delay_early +import sinatra.ui.generated.resources.scheduled_delay_late import sinatra.ui.generated.resources.semantics_stop_listing sealed interface StopStationTime { @@ -209,101 +209,91 @@ val StopStationTime.text: String val time = stationTime.time.format() val stationTime = stationTime val isInPast = stationTime.time.isInPast() + val isNowish = stationTime.time.isNowish() + val late = stationTime is StationTime.Live && stationTime.delay.inWholeSeconds < -60L + val early = stationTime is StationTime.Live && stationTime.delay.inWholeSeconds > 60L val hasDay = !stationTime.time.isSameDay(LocalScheduleTimeZone.current) - return when (FeatureFlag.STOP_DETAIL_CONCEAL_LIVENESS_STRING.value()) { - true -> stringResource(when (this) { - is StopStationTime.Arrival -> when (hasDay) { - true -> Res.string.generic_arrival_day - else -> Res.string.generic_arrival - } - is StopStationTime.Departure -> when (isInPast) { - true -> when (hasDay) { - true -> Res.string.generic_departure_past_day - else -> Res.string.generic_departure_past + val showCountdown by rememberPreferenceState(Preference.CountdownUntilArrival) + + val timeString = when (showCountdown) { + true -> when (isNowish) { + true -> stringResource( + when (this) { + is StopStationTime.Arrival -> Res.string.generic_arrival_countdown_now + is StopStationTime.Departure -> when (isInPast) { + true -> Res.string.generic_departure_past_countdown_now + else -> Res.string.generic_departure_countdown_now + } } - else -> when (hasDay) { - true -> Res.string.generic_departure_day - else -> Res.string.generic_departure + ) + + else -> stringResource( + when (this) { + is StopStationTime.Arrival -> Res.string.generic_arrival_countdown + is StopStationTime.Departure -> when (isInPast) { + true -> Res.string.generic_departure_past_countdown + else -> Res.string.generic_departure_countdown + } + }, + countdown( + stationTime.time.toTodayInstant(), + isInPast && this is StopStationTime.Departure + ) + ) + } + else -> when (FeatureFlag.STOP_DETAIL_CONCEAL_LIVENESS_STRING.value()) { + true -> stringResource(when (this) { + is StopStationTime.Arrival -> when (hasDay) { + true -> Res.string.generic_arrival_day + else -> Res.string.generic_arrival } - } - }, time) - else -> when (stationTime) { - is StationTime.Scheduled -> stringResource(when (this) { - is StopStationTime.Arrival -> when (stationTime.approximate) { + is StopStationTime.Departure -> when (isInPast) { true -> when (hasDay) { - true -> Res.string.approximate_arrival_day - else -> Res.string.approximate_arrival + true -> Res.string.generic_departure_past_day + else -> Res.string.generic_departure_past } else -> when (hasDay) { - true -> Res.string.scheduled_arrival_day - else -> Res.string.scheduled_arrival + true -> Res.string.generic_departure_day + else -> Res.string.generic_departure } } - is StopStationTime.Departure -> when (isInPast) { - true -> when(stationTime.approximate) { - true -> when (hasDay) { - true -> Res.string.past_departure_approximate_day - else -> Res.string.past_departure_approximate - } - else -> when (hasDay) { - true -> Res.string.past_departure_day - else -> Res.string.past_departure - } - } - else -> when(stationTime.approximate) { + }, time) + else -> when (stationTime) { + is StationTime.Scheduled -> stringResource(when (this) { + is StopStationTime.Arrival -> when (stationTime.approximate) { true -> when (hasDay) { - true -> Res.string.future_approximate_departure_day - else -> Res.string.future_approximate_departure + true -> Res.string.approximate_arrival_day + else -> Res.string.approximate_arrival } else -> when (hasDay) { - true -> Res.string.future_scheduled_departure_day - else -> Res.string.future_scheduled_departure + true -> Res.string.scheduled_arrival_day + else -> Res.string.scheduled_arrival } } - } - }, time) - is StationTime.Live -> when { - stationTime.delay.inWholeSeconds < -60L -> stringResource( - when (this) { - is StopStationTime.Arrival -> when (hasDay) { - true -> Res.string.estimated_arrival_early_day - else -> Res.string.estimated_arrival_early - } - is StopStationTime.Departure -> when (isInPast) { + is StopStationTime.Departure -> when (isInPast) { + true -> when(stationTime.approximate) { true -> when (hasDay) { - true -> Res.string.past_departure_early_day - else -> Res.string.past_departure_early + true -> Res.string.past_departure_approximate_day + else -> Res.string.past_departure_approximate } else -> when (hasDay) { - true -> Res.string.future_estimated_departure_early_day - else -> Res.string.future_estimated_departure_early + true -> Res.string.past_departure_day + else -> Res.string.past_departure } } - }, - time, - (-stationTime.delay).text - ) - stationTime.delay.inWholeSeconds > 60L -> stringResource( - when (this) { - is StopStationTime.Arrival -> when (hasDay) { - true -> Res.string.estimated_arrival_late_day - else -> Res.string.estimated_arrival_late - } - is StopStationTime.Departure -> when (isInPast) { + else -> when(stationTime.approximate) { true -> when (hasDay) { - true -> Res.string.past_departure_late_day - else -> Res.string.past_departure_late + true -> Res.string.future_approximate_departure_day + else -> Res.string.future_approximate_departure } else -> when (hasDay) { - true -> Res.string.future_estimated_departure_late_day - else -> Res.string.future_estimated_departure_late + true -> Res.string.future_scheduled_departure_day + else -> Res.string.future_scheduled_departure } } - }, - time, - stationTime.delay.text - ) - else -> stringResource( + } + }, time) + is StationTime.Live -> stringResource( when (this) { is StopStationTime.Arrival -> when (hasDay) { true -> Res.string.estimated_arrival_day @@ -325,6 +315,20 @@ val StopStationTime.text: String } } } + + return when { + late -> stringResource( + Res.string.scheduled_delay_late, + timeString, + (-stationTime.delay).text + ) + early -> stringResource( + Res.string.scheduled_delay_early, + timeString, + stationTime.delay.text + ) + else -> timeString + } } @Composable diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraAnchoredDraggableState.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraAnchoredDraggableState.kt index a750ff13..2e38c596 100644 --- a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraAnchoredDraggableState.kt +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraAnchoredDraggableState.kt @@ -2,19 +2,18 @@ package cl.emilym.sinatra.ui.widgets.bottomsheet import androidx.annotation.FloatRange import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.AnimationState -import androidx.compose.animation.core.DecayAnimationSpec import androidx.compose.animation.core.animate -import androidx.compose.animation.core.animateDecay -import androidx.compose.animation.core.calculateTargetValue -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.MutatorMutex import androidx.compose.foundation.gestures.AnchoredDragScope import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DragScope import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.snapTo -import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -24,6 +23,7 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.Modifier import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job @@ -31,23 +31,34 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlin.math.abs -import kotlin.math.max -import kotlin.math.min -import kotlin.math.sign -private fun Float.coerceToTarget(target: Float): Float { - if (target == 0f) return 0f - return if (target > 0) coerceAtMost(target) else coerceAtLeast(target) -} +internal fun Modifier.sinatraAnchoredDraggable( + state: SinatraAnchoredDraggableState, + orientation: Orientation, + enabled: Boolean = true, + reverseDirection: Boolean = false, + interactionSource: MutableInteractionSource? = null, +) = + this then draggable( + state = state.draggableState, + orientation = orientation, + enabled = enabled, + interactionSource = interactionSource, + reverseDirection = reverseDirection, + startDragImmediately = state.isAnimationRunning, + onDragStopped = { velocity -> launch { state.settle(velocity) } }, + ) -@OptIn(ExperimentalFoundationApi::class) -suspend fun SinatraAnchoredDraggableState.animateTo(targetValue: T) { - anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> - animateTo(lastVelocity, this, anchors, latestTarget) - } -} -@OptIn(ExperimentalFoundationApi::class) +/** + * Snap to a [targetValue] without any animation. If the [targetValue] is not in the set of anchors, + * the [AnchoredDraggableState.currentValue] will be updated to the [targetValue] without updating + * the offset. + * + * @param targetValue The target value of the animation + * @throws CancellationException if the interaction interrupted by another interaction like a + * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. + */ suspend fun SinatraAnchoredDraggableState.snapTo(targetValue: T) { anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> val targetOffset = anchors.positionOf(latestTarget) @@ -55,18 +66,15 @@ suspend fun SinatraAnchoredDraggableState.snapTo(targetValue: T) { } } -@OptIn(ExperimentalFoundationApi::class) -private suspend fun SinatraAnchoredDraggableState.animateTo( - velocity: Float, - anchoredDragScope: AnchoredDragScope, - anchors: DraggableAnchors, - latestTarget: T +internal suspend fun SinatraAnchoredDraggableState.animateTo( + targetValue: T, + velocity: Float = this.lastVelocity, ) { - with(anchoredDragScope) { + anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> val targetOffset = anchors.positionOf(latestTarget) - var prev = if (offset.isNaN()) 0f else offset - if (!targetOffset.isNaN() && prev != targetOffset) { - animate(prev, targetOffset, velocity, snapAnimationSpec) { value, velocity -> + if (!targetOffset.isNaN()) { + var prev = if (offset.isNaN()) 0f else offset + animate(prev, targetOffset, velocity, animationSpec.invoke()) { value, velocity -> // Our onDrag coerces the value within the bounds, but an animation may // overshoot, for example a spring animation or an overshooting interpolator // We respect the user's intention and allow the overshoot, but still use @@ -78,69 +86,13 @@ private suspend fun SinatraAnchoredDraggableState.animateTo( } } -@ExperimentalFoundationApi -suspend fun SinatraAnchoredDraggableState.animateToWithDecay( - targetValue: T, - velocity: Float, -): Float { - var remainingVelocity = velocity - anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> - val targetOffset = anchors.positionOf(latestTarget) - if (!targetOffset.isNaN()) { - var prev = if (offset.isNaN()) 0f else offset - if (prev != targetOffset) { - // If targetOffset is not in the same direction as the direction of the drag (sign - // of the velocity) we fall back to using target animation. - // If the component is at the target offset already, we use decay animation that will - // not consume any velocity. - if (velocity * (targetOffset - prev) < 0f || velocity == 0f) { - animateTo(velocity, this, anchors, latestTarget) - remainingVelocity = 0f - } else { - val projectedDecayOffset = - decayAnimationSpec.calculateTargetValue(prev, velocity) - - val canDecayToTarget = if (velocity > 0) { - projectedDecayOffset >= targetOffset - } else { - projectedDecayOffset <= targetOffset - } - if (canDecayToTarget) { - AnimationState(prev, velocity) - .animateDecay(decayAnimationSpec) { - if (abs(value) >= abs(targetOffset)) { - val finalValue = value.coerceToTarget(targetOffset) - dragTo(finalValue, this.velocity) - remainingVelocity = - if (this.velocity.isNaN()) 0f else this.velocity - prev = finalValue - cancelAnimation() - } else { - dragTo(value, this.velocity) - remainingVelocity = this.velocity - prev = value - } - } - } else { - animateTo(velocity, this, anchors, latestTarget) - remainingVelocity = 0f - } - } - } - } - } - return velocity - remainingVelocity -} - @Stable -@ExperimentalFoundationApi class SinatraAnchoredDraggableState( initialValue: T, internal val positionalThreshold: (totalDistance: Float) -> Float, internal val velocityThreshold: () -> Float, - val snapAnimationSpec: AnimationSpec, - val decayAnimationSpec: DecayAnimationSpec, - internal val confirmValueChange: (newValue: T) -> Boolean = { true } + val animationSpec: () -> AnimationSpec, + internal val confirmValueChange: (newValue: T) -> Boolean = { true }, ) { /** @@ -148,37 +100,31 @@ class SinatraAnchoredDraggableState( * * @param initialValue The initial value of the state. * @param anchors The anchors of the state. Use [updateAnchors] to update the anchors later. - * @param snapAnimationSpec The default animation spec that will be used to animate to a new - * state. - * @param decayAnimationSpec The animation spec that will be used when flinging with a large - * enough velocity to reach or cross the target state. + * @param animationSpec The default animation that will be used to animate to a new state. * @param confirmValueChange Optional callback invoked to confirm or veto a pending state - * change. + * change. * @param positionalThreshold The positional threshold, in px, to be used when calculating the - * target state while a drag is in progress and when settling after the drag ends. This is the - * distance from the start of a transition. It will be, depending on the direction of the - * interaction, added or subtracted from/to the origin offset. It should always be a positive - * value. + * target state while a drag is in progress and when settling after the drag ends. This is the + * distance from the start of a transition. It will be, depending on the direction of the + * interaction, added or subtracted from/to the origin offset. It should always be a positive + * value. * @param velocityThreshold The velocity threshold (in px per second) that the end velocity has - * to exceed in order to animate to the next state, even if the [positionalThreshold] has not - * been reached. + * to exceed in order to animate to the next state, even if the [positionalThreshold] has not + * been reached. */ - @ExperimentalFoundationApi constructor( initialValue: T, anchors: DraggableAnchors, positionalThreshold: (totalDistance: Float) -> Float, velocityThreshold: () -> Float, - snapAnimationSpec: AnimationSpec, - decayAnimationSpec: DecayAnimationSpec, - confirmValueChange: (newValue: T) -> Boolean = { true } + animationSpec: () -> AnimationSpec, + confirmValueChange: (newValue: T) -> Boolean = { true }, ) : this( initialValue, positionalThreshold, velocityThreshold, - snapAnimationSpec, - decayAnimationSpec, - confirmValueChange + animationSpec, + confirmValueChange, ) { this.anchors = anchors trySnapTo(initialValue) @@ -186,34 +132,62 @@ class SinatraAnchoredDraggableState( private val dragMutex = MutatorMutex() - /** - * The current value of the [AnchoredDraggableState]. - * - * That is the closest anchor point that the state has passed through. - */ + internal val draggableState = + object : DraggableState { + + private val dragScope = + object : DragScope { + override fun dragBy(pixels: Float) { + with(anchoredDragScope) { dragTo(newOffsetForDelta(pixels)) } + } + } + + override suspend fun drag( + dragPriority: MutatePriority, + block: suspend DragScope.() -> Unit, + ) { + this@SinatraAnchoredDraggableState.anchoredDrag(dragPriority) { + with(dragScope) { block() } + } + } + + override fun dispatchRawDelta(delta: Float) { + this@SinatraAnchoredDraggableState.dispatchRawDelta(delta) + } + } + + /** The current value of the [AnchoredDraggableState]. */ var currentValue: T by mutableStateOf(initialValue) private set /** - * The value the [AnchoredDraggableState] is currently settled at. - * - * When progressing through multiple anchors, e.g. `A -> B -> C`, [settledValue] will stay the - * same until settled at an anchor, while [currentValue] will update to the closest anchor. + * The target value. This is the closest value to the current offset, taking into account + * positional thresholds. If no interactions like animations or drags are in progress, this will + * be the current value. */ - var settledValue: T by mutableStateOf(initialValue) - private set + val targetValue: T by derivedStateOf { + dragTarget + ?: run { + val currentOffset = offset + if (!currentOffset.isNaN()) { + computeTarget(currentOffset, currentValue, velocity = 0f) + } else currentValue + } + } /** - * The target value. This is the closest value to the current offset. If no interactions like - * animations or drags are in progress, this will be the current value. + * The closest value in the swipe direction from the current offset, not considering thresholds. + * If an [anchoredDrag] is in progress, this will be the target of that anchoredDrag (if + * specified). */ - val targetValue: T by derivedStateOf { - dragTarget ?: run { - val currentOffset = offset - if (!currentOffset.isNaN()) { - anchors.closestAnchor(offset) ?: currentValue - } else currentValue - } + internal val closestValue: T by derivedStateOf { + dragTarget + ?: run { + val currentOffset = offset + if (!currentOffset.isNaN()) { + computeTargetWithoutThresholds(currentOffset, currentValue) + } else currentValue + } } /** @@ -230,9 +204,8 @@ class SinatraAnchoredDraggableState( /** * Require the current offset. * - * @see offset - * * @throws IllegalStateException If the offset has not been initialized yet + * @see offset */ fun requireOffset(): Float { check(!offset.isNaN()) { @@ -242,46 +215,19 @@ class SinatraAnchoredDraggableState( return offset } - /** - * Whether an animation is currently in progress. - */ - val isAnimationRunning: Boolean get() = dragTarget != null - - /** - * The fraction of the offset between [from] and [to], as a fraction between [0f..1f], or 1f if - * [from] is equal to [to]. - * - * @param from The starting value used to calculate the distance - * @param to The end value used to calculate the distance - */ - @FloatRange(from = 0.0, to = 1.0) - fun progress(from: T, to: T): Float { - val fromOffset = anchors.positionOf(from) - val toOffset = anchors.positionOf(to) - val currentOffset = offset.coerceIn( - min(fromOffset, toOffset), // fromOffset might be > toOffset - max(fromOffset, toOffset) - ) - val fraction = (currentOffset - fromOffset) / (toOffset - fromOffset) - return if (!fraction.isNaN()) { - // If we are very close to 0f or 1f, we round to the closest - if (fraction < 1e-6f) 0f else if (fraction > 1 - 1e-6f) 1f else abs(fraction) - } else 1f - } + /** Whether an animation is currently in progress. */ + val isAnimationRunning: Boolean + get() = dragTarget != null /** - * The fraction of the progress going from [settledValue] to [targetValue], within [0f..1f] + * The fraction of the progress going from [currentValue] to [closestValue], within [0f..1f] * bounds, or 1f if the [AnchoredDraggableState] is in a settled state. */ - @Deprecated( - message = "Use the progress function to query the progress between two specified " + - "anchors.", - replaceWith = ReplaceWith("progress(state.settledValue, state.targetValue)") - ) @get:FloatRange(from = 0.0, to = 1.0) - val progress: Float by derivedStateOf(structuralEqualityPolicy()) { - val a = anchors.positionOf(settledValue) - val b = anchors.positionOf(targetValue) + val progress: Float by + derivedStateOf(structuralEqualityPolicy()) { + val a = anchors.positionOf(currentValue) + val b = anchors.positionOf(closestValue) val distance = abs(b - a) if (!distance.isNaN() && distance > 1e-6f) { val progress = (this.requireOffset() - a) / (b - a) @@ -292,16 +238,15 @@ class SinatraAnchoredDraggableState( /** * The velocity of the last known animation. Gets reset to 0f when an animation completes - * successfully, but does not get reset when an animation gets interrupted. - * You can use this value to provide smooth reconciliation behavior when re-targeting an - * animation. + * successfully, but does not get reset when an animation gets interrupted. You can use this + * value to provide smooth reconciliation behavior when re-targeting an animation. */ var lastVelocity: Float by mutableFloatStateOf(0f) private set private var dragTarget: T? by mutableStateOf(null) - var anchors: DraggableAnchors by mutableStateOf(SinatraDraggableAnchors(mapOf())) + var anchors: DraggableAnchors by mutableStateOf(emptyDraggableAnchors()) private set /** @@ -311,19 +256,19 @@ class SinatraAnchoredDraggableState( * * If your anchors depend on the size of the layout, updateAnchors should be called in the * layout (placement) phase, e.g. through Modifier.onSizeChanged. This ensures that the - * state is set up within the same frame. - * For static anchors, or anchors with different data dependencies, [updateAnchors] is safe to - * be called from side effects or layout. + * state is set up within the same frame. For static anchors, or anchors with different data + * dependencies, [updateAnchors] is safe to be called from side effects or layout. * * @param newAnchors The new anchors. * @param newTarget The new target, by default the closest anchor or the current target if there - * are no anchors. + * are no anchors. */ fun updateAnchors( newAnchors: DraggableAnchors, - newTarget: T = if (!offset.isNaN()) { - newAnchors.closestAnchor(offset) ?: targetValue - } else targetValue + newTarget: T = + if (!offset.isNaN()) { + newAnchors.closestAnchor(offset) ?: targetValue + } else targetValue, ) { if (anchors != newAnchors) { anchors = newAnchors @@ -345,114 +290,87 @@ class SinatraAnchoredDraggableState( * [positionalThreshold] will be the target. If the [velocity] is higher than the * [velocityThreshold], the [positionalThreshold] will not be considered and the next * anchor in the direction indicated by the sign of the [velocity] will be the target. - * - * Based on the [velocity], either [snapAnimationSpec] or [decayAnimationSpec] will be used - * to animate towards the target. - * - * @return The velocity consumed in the animation */ - suspend fun settle(velocity: Float): Float { + suspend fun settle(velocity: Float) { val previousValue = this.currentValue - val targetValue = computeTarget( - offset = requireOffset(), - currentValue = previousValue, - velocity = velocity - ) - return if (confirmValueChange(targetValue)) { - animateToWithDecay(targetValue, velocity) + val targetValue = + computeTarget( + offset = requireOffset(), + currentValue = previousValue, + velocity = velocity, + ) + if (confirmValueChange(targetValue)) { + animateTo(targetValue, velocity) } else { // If the user vetoed the state change, rollback to the previous state. - animateToWithDecay(previousValue, velocity) + animateTo(previousValue, velocity) } } - private fun computeTarget( - offset: Float, - currentValue: T, - velocity: Float - ): T { + private fun computeTarget(offset: Float, currentValue: T, velocity: Float): T { val currentAnchors = anchors val currentAnchorPosition = currentAnchors.positionOf(currentValue) val velocityThresholdPx = velocityThreshold() return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) { currentValue + } else if (currentAnchorPosition < offset) { + // Swiping from lower to upper (positive). + if (velocity >= velocityThresholdPx) { + currentAnchors.closestAnchor(offset, true)!! + } else { + val upper = currentAnchors.closestAnchor(offset, true)!! + val distance = abs(currentAnchors.positionOf(upper) - currentAnchorPosition) + val relativeThreshold = abs(positionalThreshold(distance)) + val absoluteThreshold = abs(currentAnchorPosition + relativeThreshold) + if (offset < absoluteThreshold) currentValue else upper + } } else { - if (abs(velocity) >= abs(velocityThresholdPx)) { - currentAnchors.closestAnchor( - offset, - sign(velocity) > 0 - )!! + // Swiping from upper to lower (negative). + if (velocity <= -velocityThresholdPx) { + currentAnchors.closestAnchor(offset, false)!! } else { - val neighborAnchor = - currentAnchors.closestAnchor( - offset, - offset - currentAnchorPosition > 0 - )!! - val neighborAnchorPosition = currentAnchors.positionOf(neighborAnchor) - val distance = abs(currentAnchorPosition - neighborAnchorPosition) + val lower = currentAnchors.closestAnchor(offset, false)!! + val distance = abs(currentAnchorPosition - currentAnchors.positionOf(lower)) val relativeThreshold = abs(positionalThreshold(distance)) - val relativePosition = abs(currentAnchorPosition - offset) - if (relativePosition <= relativeThreshold) currentValue else neighborAnchor + val absoluteThreshold = abs(currentAnchorPosition - relativeThreshold) + if (offset < 0) { + // For negative offsets, larger absolute thresholds are closer to lower anchors + // than smaller ones. + if (abs(offset) < absoluteThreshold) currentValue else lower + } else { + if (offset > absoluteThreshold) currentValue else lower + } } } } - private val anchoredDragScope = object : AnchoredDragScope { - var leftBound: T? = null - var rightBound: T? = null - var distance = Float.NaN - - override fun dragTo(newOffset: Float, lastKnownVelocity: Float) { - val previousOffset = offset - offset = newOffset - lastVelocity = lastKnownVelocity - if (previousOffset.isNaN()) return - val isMovingForward = newOffset >= previousOffset - updateIfNeeded(isMovingForward) - } - - fun updateIfNeeded(isMovingForward: Boolean) { - updateBounds(isMovingForward) - val distanceToCurrentAnchor = abs(offset - anchors.positionOf(currentValue)) - val crossedThreshold = distanceToCurrentAnchor >= distance / 2f - if (crossedThreshold) { - val closestAnchor = (if (isMovingForward) rightBound else leftBound) ?: currentValue - if (confirmValueChange(closestAnchor)) { - currentValue = closestAnchor - } - } + private fun computeTargetWithoutThresholds(offset: Float, currentValue: T): T { + val currentAnchors = anchors + val currentAnchorPosition = currentAnchors.positionOf(currentValue) + return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) { + currentValue + } else if (currentAnchorPosition < offset) { + currentAnchors.closestAnchor(offset, true) ?: currentValue + } else { + currentAnchors.closestAnchor(offset, false) ?: currentValue } + } - fun updateBounds(isMovingForward: Boolean) { - val currentAnchorPosition = anchors.positionOf(currentValue) - if (offset == currentAnchorPosition) { - val searchStartPosition = offset + (if (isMovingForward) 1f else -1f) - val closestExcludingCurrent = - anchors.closestAnchor(searchStartPosition, isMovingForward) ?: currentValue - if (isMovingForward) { - leftBound = currentValue - rightBound = closestExcludingCurrent - } else { - leftBound = closestExcludingCurrent - rightBound = currentValue - } - } else { - val closestLeft = anchors.closestAnchor(offset, false) ?: currentValue - val closestRight = anchors.closestAnchor(offset, true) ?: currentValue - leftBound = closestLeft - rightBound = closestRight + private val anchoredDragScope: AnchoredDragScope = + object : AnchoredDragScope { + override fun dragTo(newOffset: Float, lastKnownVelocity: Float) { + offset = newOffset + lastVelocity = lastKnownVelocity } - distance = abs(anchors.positionOf(leftBound!!) - anchors.positionOf(rightBound!!)) } - } /** * Call this function to take control of drag logic and perform anchored drag with the latest * anchors. * * All actions that change the [offset] of this [AnchoredDraggableState] must be performed - * within an [anchoredDrag] block (even if they don't call any other methods on this object) - * in order to guarantee that mutual exclusion is enforced. + * within an [anchoredDrag] block (even if they don't call any other methods on this object) in + * order to guarantee that mutual exclusion is enforced. * * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing * drag, the ongoing drag will be cancelled. @@ -466,20 +384,22 @@ class SinatraAnchoredDraggableState( */ suspend fun anchoredDrag( dragPriority: MutatePriority = MutatePriority.Default, - block: suspend AnchoredDragScope.(anchors: DraggableAnchors) -> Unit + block: suspend AnchoredDragScope.(anchors: DraggableAnchors) -> Unit, ) { - dragMutex.mutate(dragPriority) { - restartable(inputs = { anchors }) { latestAnchors -> - anchoredDragScope.block(latestAnchors) + try { + dragMutex.mutateWith(dragPriority) { + restartable(inputs = { anchors }) { latestAnchors -> + anchoredDragScope.block(latestAnchors) + } } + } finally { val closest = anchors.closestAnchor(offset) - if (closest != null) { - val closestAnchorOffset = anchors.positionOf(closest) - val isAtClosestAnchor = abs(offset - closestAnchorOffset) < 0.5f - if (isAtClosestAnchor && confirmValueChange.invoke(closest)) { - settledValue = closest - currentValue = closest - } + if ( + closest != null && + abs(offset - anchors.positionOf(closest)) <= 0.5f && + confirmValueChange.invoke(closest) + ) { + currentValue = closest } } } @@ -489,8 +409,8 @@ class SinatraAnchoredDraggableState( * anchors and target. * * All actions that change the [offset] of this [AnchoredDraggableState] must be performed - * within an [anchoredDrag] block (even if they don't call any other methods on this object) - * in order to guarantee that mutual exclusion is enforced. + * within an [anchoredDrag] block (even if they don't call any other methods on this object) in + * order to guarantee that mutual exclusion is enforced. * * This overload allows the caller to hint the target value that this [anchoredDrag] is intended * to arrive to. This will set [AnchoredDraggableState.targetValue] to provided value so @@ -510,38 +430,39 @@ class SinatraAnchoredDraggableState( suspend fun anchoredDrag( targetValue: T, dragPriority: MutatePriority = MutatePriority.Default, - block: suspend AnchoredDragScope.(anchor: DraggableAnchors, targetValue: T) -> Unit + block: suspend AnchoredDragScope.(anchors: DraggableAnchors, targetValue: T) -> Unit, ) { - if (anchors.hasAnchorFor(targetValue)) { + if (anchors.hasPositionFor(targetValue)) { try { - dragMutex.mutate(dragPriority) { + dragMutex.mutateWith(dragPriority) { dragTarget = targetValue - restartable( - inputs = { anchors to this.targetValue } - ) { (anchors, latestTarget) -> - anchoredDragScope.block(anchors, latestTarget) - } - if (confirmValueChange(targetValue)) { - val latestTargetOffset = anchors.positionOf(targetValue) - anchoredDragScope.dragTo(latestTargetOffset, lastVelocity) - settledValue = targetValue - currentValue = targetValue + restartable(inputs = { anchors to this@SinatraAnchoredDraggableState.targetValue }) { + (latestAnchors, latestTarget) -> + anchoredDragScope.block(latestAnchors, latestTarget) } } } finally { dragTarget = null + val closest = anchors.closestAnchor(offset) + if ( + closest != null && + abs(offset - anchors.positionOf(closest)) <= 0.5f && + confirmValueChange.invoke(closest) + ) { + currentValue = closest + } } } else { - if (confirmValueChange(targetValue)) { - settledValue = targetValue - currentValue = targetValue - } + // Todo: b/283467401, revisit this behavior + currentValue = targetValue } } internal fun newOffsetForDelta(delta: Float) = - ((if (offset.isNaN()) 0f else offset) + delta) - .coerceIn(anchors.minAnchor(), anchors.maxAnchor()) + ((if (offset.isNaN()) 0f else offset) + delta).coerceIn( + anchors.minPosition(), + anchors.maxPosition(), + ) /** * Drag by the [delta], coerce it in the bounds and dispatch it to the [AnchoredDraggableState]. @@ -562,45 +483,43 @@ class SinatraAnchoredDraggableState( * * @return true if the synchronous snap was successful, or false if we couldn't snap synchronous */ - private fun trySnapTo(targetValue: T): Boolean = dragMutex.tryMutate { - with(anchoredDragScope) { - val targetOffset = anchors.positionOf(targetValue) - if (!targetOffset.isNaN()) { - dragTo(targetOffset) - dragTarget = null + private fun trySnapTo(targetValue: T): Boolean = + dragMutex.tryMutate { + with(anchoredDragScope) { + val targetOffset = anchors.positionOf(targetValue) + if (!targetOffset.isNaN()) { + dragTo(targetOffset) + dragTarget = null + } + currentValue = targetValue } - currentValue = targetValue - settledValue = targetValue } - } companion object { - /** - * The default [Saver] implementation for [AnchoredDraggableState]. - */ - @ExperimentalFoundationApi + /** The default [Saver] implementation for [AnchoredDraggableState]. */ fun Saver( - snapAnimationSpec: AnimationSpec, - decayAnimationSpec: DecayAnimationSpec, + animationSpec: () -> AnimationSpec, + confirmValueChange: (T) -> Boolean, positionalThreshold: (distance: Float) -> Float, velocityThreshold: () -> Float, - confirmValueChange: (T) -> Boolean = { true }, - ) = Saver, T>( - save = { it.currentValue }, - restore = { - AnchoredDraggableState( - initialValue = it, - snapAnimationSpec = snapAnimationSpec, - decayAnimationSpec = decayAnimationSpec, - confirmValueChange = confirmValueChange, - positionalThreshold = positionalThreshold, - velocityThreshold = velocityThreshold - ) - } - ) + ) = + Saver, T>( + save = { it.currentValue }, + restore = { + SinatraAnchoredDraggableState( + initialValue = it, + animationSpec = animationSpec, + confirmValueChange = confirmValueChange, + positionalThreshold = positionalThreshold, + velocityThreshold = velocityThreshold, + ) + }, + ) } } +private fun emptyDraggableAnchors() = SinatraDraggableAnchors(emptyMap()) + private suspend fun restartable(inputs: () -> I, block: suspend (I) -> Unit) { try { coroutineScope { diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraBottomSheet.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraBottomSheet.kt index b9b19fb7..5e0e23d6 100644 --- a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraBottomSheet.kt +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraBottomSheet.kt @@ -36,7 +36,6 @@ import androidx.compose.ui.unit.times import cl.emilym.compose.units.px import cl.emilym.sinatra.ui.widgets.viewportHeight import kotlinx.coroutines.launch -import kotlin.math.abs @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -61,7 +60,8 @@ fun SinatraBottomSheet( // Tween corner radius to 0 during swipe between halfHeight and expanded val viewportHeight = viewportHeight() val halfHeight = remember(viewportHeight, sheetHalfHeight) { sheetHalfHeight * viewportHeight } - val offsetPx = state.offset?.px ?: 0.dp + val offset = state.offset + val offsetPx = if (state.offset.isNaN()) 0.dp else offset.px val corner = remember(halfHeight, offsetPx, viewportHeight) { val adjustedHeight = viewportHeight - halfHeight (1f - ((viewportHeight - offsetPx - halfHeight) / adjustedHeight)).coerceIn(0f, 1f) * 28.dp @@ -89,7 +89,7 @@ fun SinatraBottomSheet( ) } ) - .anchoredDraggable( + .sinatraAnchoredDraggable( state = state.anchoredDraggableState, orientation = orientation, enabled = sheetSwipeEnabled @@ -99,10 +99,10 @@ fun SinatraBottomSheet( val newTarget = when (state.anchoredDraggableState.targetValue) { SinatraSheetValue.Hidden, SinatraSheetValue.PartiallyExpanded -> SinatraSheetValue.PartiallyExpanded SinatraSheetValue.Expanded -> { - if (newAnchors.hasAnchorFor(SinatraSheetValue.Expanded)) SinatraSheetValue.Expanded else SinatraSheetValue.PartiallyExpanded + if (newAnchors.hasPositionFor(SinatraSheetValue.Expanded)) SinatraSheetValue.Expanded else SinatraSheetValue.PartiallyExpanded } SinatraSheetValue.HalfExpanded -> { - if (newAnchors.hasAnchorFor(SinatraSheetValue.HalfExpanded)) SinatraSheetValue.HalfExpanded else SinatraSheetValue.PartiallyExpanded + if (newAnchors.hasPositionFor(SinatraSheetValue.HalfExpanded)) SinatraSheetValue.HalfExpanded else SinatraSheetValue.PartiallyExpanded } } state.anchoredDraggableState.updateAnchors(newAnchors, newTarget) @@ -136,69 +136,3 @@ fun SinatraBottomSheet( } } -@OptIn(ExperimentalFoundationApi::class) -fun sinatraDraggableAnchors( - builder: SinatraDraggableAnchorsConfig.() -> Unit -): DraggableAnchors = SinatraDraggableAnchors( - SinatraDraggableAnchorsConfig().apply(builder).anchors -) - -@OptIn(ExperimentalFoundationApi::class) -class SinatraDraggableAnchors(private val anchors: Map) : DraggableAnchors { - - override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN - override fun hasAnchorFor(value: T) = anchors.containsKey(value) - - override fun closestAnchor(position: Float): T? = anchors.minByOrNull { - abs(position - it.value) - }?.key - - override fun closestAnchor( - position: Float, - searchUpwards: Boolean - ): T? { - return anchors.minByOrNull { (_, anchor) -> - val delta = if (searchUpwards) anchor - position else position - anchor - if (delta < 0) Float.POSITIVE_INFINITY else delta - }?.key - } - - override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN - - override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN - - override val size: Int - get() = anchors.size - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is SinatraDraggableAnchors<*>) return false - - return anchors == other.anchors - } - - override fun forEach(block: (anchor: T, position: Float) -> Unit) { - for (i in anchors.entries) { - block(i.key, i.value) - } - } - - override fun hashCode() = 31 * anchors.hashCode() - - override fun toString() = "FuckJetpackDraggableAnchors($anchors)" -} - -class SinatraDraggableAnchorsConfig { - - val anchors = mutableMapOf() - - /** - * Set the anchor position for [this] anchor. - * - * @param position The anchor position. - */ - @Suppress("BuilderSetStyle") - infix fun T.at(position: Float) { - anchors[this] = position - } -} diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraBottomSheetScaffold.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraBottomSheetScaffold.kt index 1952cc8b..9863041a 100644 --- a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraBottomSheetScaffold.kt +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraBottomSheetScaffold.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -156,10 +155,6 @@ private fun SinatraBottomSheetScaffoldLayout( containerColor: Color, contentColor: Color, ) { - val density = LocalDensity.current - SideEffect { - sheetState.density = density - } SubcomposeLayout { constraints -> val layoutWidth = constraints.maxWidth val layoutHeight = constraints.maxHeight diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraDraggableAnchors.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraDraggableAnchors.kt new file mode 100644 index 00000000..135a8478 --- /dev/null +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraDraggableAnchors.kt @@ -0,0 +1,74 @@ +package cl.emilym.sinatra.ui.widgets.bottomsheet + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.DraggableAnchors +import kotlin.math.abs + +@OptIn(ExperimentalFoundationApi::class) +fun sinatraDraggableAnchors( + builder: SinatraDraggableAnchorsConfig.() -> Unit +): DraggableAnchors = SinatraDraggableAnchors( + SinatraDraggableAnchorsConfig().apply(builder).anchors +) + +@OptIn(ExperimentalFoundationApi::class) +class SinatraDraggableAnchors(private val anchors: Map) : DraggableAnchors { + + override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN + override fun hasPositionFor(value: T) = anchors.containsKey(value) + + override fun closestAnchor(position: Float): T? = anchors.minByOrNull { + abs(position - it.value) + }?.key + + override fun closestAnchor( + position: Float, + searchUpwards: Boolean + ): T? { + return anchors.minByOrNull { (_, anchor) -> + val delta = if (searchUpwards) anchor - position else position - anchor + if (delta < 0) Float.POSITIVE_INFINITY else delta + }?.key + } + + override fun minPosition() = anchors.values.minOrNull() ?: Float.NaN + + override fun maxPosition() = anchors.values.maxOrNull() ?: Float.NaN + + override val size: Int + get() = anchors.size + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SinatraDraggableAnchors<*>) return false + + return anchors == other.anchors + } + + override fun anchorAt(index: Int): T? { + TODO("Not yet implemented") + } + + override fun positionAt(index: Int): Float { + TODO("Not yet implemented") + } + + override fun hashCode() = 31 * anchors.hashCode() + + override fun toString() = "FuckJetpackDraggableAnchors($anchors)" +} + +class SinatraDraggableAnchorsConfig { + + val anchors = mutableMapOf() + + /** + * Set the anchor position for [this] anchor. + * + * @param position The anchor position. + */ + @Suppress("BuilderSetStyle") + infix fun T.at(position: Float) { + anchors[this] = position + } +} \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraSheetDefaults.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraSheetDefaults.kt index 81c087d2..6b4da2a7 100644 --- a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraSheetDefaults.kt +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraSheetDefaults.kt @@ -1,313 +1,23 @@ package cl.emilym.sinatra.ui.widgets.bottomsheet -import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.animateTo -import androidx.compose.foundation.gestures.animateToWithDecay -import androidx.compose.foundation.gestures.snapTo -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp -import cl.emilym.sinatra.nullIf -import kotlinx.coroutines.CancellationException import kotlin.jvm.JvmName -class SinatraSheetState @OptIn(ExperimentalMaterial3Api::class) constructor( - val skipPartiallyExpanded: Boolean, - initialValue: SinatraSheetValue = SinatraSheetValue.Hidden, - confirmValueChange: (SinatraSheetValue) -> Boolean = { true }, - val skipHiddenState: Boolean = true, -) { - - /** - * State of a sheet composable, such as [ModalBottomSheet] - * - * Contains states relating to its swipe position as well as animations between state values. - * - * @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large - * enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move - * to the [Hidden] state if available when hiding the sheet, either programmatically or by user - * interaction. - * @param initialValue The initial value of the state. - * @param density The density that this state can use to convert values to and from dp. - * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. - * @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always - * expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either - * programmatically or by user interaction. - */ - @ExperimentalMaterial3Api - @Suppress("Deprecation") - constructor( - skipPartiallyExpanded: Boolean, - density: Density, - initialValue: SinatraSheetValue = SinatraSheetValue.Hidden, - confirmValueChange: (SinatraSheetValue) -> Boolean = { true }, - skipHiddenState: Boolean = true, - ) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) { - this.density = density - } - init { - if (skipPartiallyExpanded) { - require(initialValue != SinatraSheetValue.PartiallyExpanded) { - "The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " + - "is set to true." - } - } - if (skipHiddenState) { - require(initialValue != SinatraSheetValue.Hidden) { - "The initial value must not be set to Hidden if skipHiddenState is set to true." - } - } - } - - /** - * The current value of the state. - * - * If no swipe or animation is in progress, this corresponds to the state the bottom sheet is - * currently in. If a swipe or an animation is in progress, this corresponds the state the sheet - * was in before the swipe or animation started. - */ - - @OptIn(ExperimentalFoundationApi::class) - val currentValue: SinatraSheetValue get() = anchoredDraggableState.currentValue - - /** - * The target value of the bottom sheet state. - * - * If a swipe is in progress, this is the value that the sheet would animate to if the - * swipe finishes. If an animation is running, this is the target value of that animation. - * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. - */ - @OptIn(ExperimentalFoundationApi::class) - val targetValue: SinatraSheetValue get() = anchoredDraggableState.targetValue - - /** - * Whether the modal bottom sheet is visible. - */ - @OptIn(ExperimentalFoundationApi::class) - val isVisible: Boolean - get() = anchoredDraggableState.currentValue != SinatraSheetValue.Hidden - - /** - * Require the current offset (in pixels) of the bottom sheet. - * - * The offset will be initialized during the first measurement phase of the provided sheet - * content. - * - * These are the phases: - * Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing - * - * During the first composition, an [IllegalStateException] is thrown. In subsequent - * compositions, the offset will be derived from the anchors of the previous pass. Always prefer - * accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next - * frame, after layout. - * - * @throws IllegalStateException If the offset has not been initialized yet - */ - @OptIn(ExperimentalFoundationApi::class) - fun requireOffset(): Float = anchoredDraggableState.requireOffset() - - /** - * Whether the sheet has an expanded state defined. - */ - - @OptIn(ExperimentalFoundationApi::class) - val hasExpandedState: Boolean - get() = anchoredDraggableState.anchors.hasAnchorFor(SinatraSheetValue.Expanded) - - @OptIn(ExperimentalFoundationApi::class) - val hasHalfExpandedState: Boolean - get() = anchoredDraggableState.anchors.hasAnchorFor(SinatraSheetValue.HalfExpanded) - - /** - * Whether the modal bottom sheet has a partially expanded state defined. - */ - @OptIn(ExperimentalFoundationApi::class) - val hasPartiallyExpandedState: Boolean - get() = anchoredDraggableState.anchors.hasAnchorFor(SinatraSheetValue.PartiallyExpanded) - - /** - * Fully expand the bottom sheet with animation and suspend until it is fully expanded or - * animation has been cancelled. - * * - * @throws [CancellationException] if the animation is interrupted - */ - @OptIn(ExperimentalFoundationApi::class) - suspend fun expand() { - anchoredDraggableState.animateTo(SinatraSheetValue.Expanded) - } - - @OptIn(ExperimentalFoundationApi::class) - suspend fun halfExpand() { - anchoredDraggableState.animateTo(SinatraSheetValue.HalfExpanded) - } - - /** - * Animate the bottom sheet and suspend until it is partially expanded or animation has been - * cancelled. - * @throws [CancellationException] if the animation is interrupted - * @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true - */ - suspend fun partialExpand() { - check(!skipPartiallyExpanded) { - "Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" + - " skipPartiallyExpanded to false to use this function." - } - animateTo(SinatraSheetValue.PartiallyExpanded) - } - - /** - * Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined - * else [Expanded]. - * @throws [CancellationException] if the animation is interrupted - */ - suspend fun show() { - val targetValue = when { - hasPartiallyExpandedState -> SinatraSheetValue.PartiallyExpanded - else -> SinatraSheetValue.Expanded - } - animateTo(targetValue) - } - - /** - * Hide the bottom sheet with animation and suspend until it is fully hidden or animation has - * been cancelled. - * @throws [CancellationException] if the animation is interrupted - */ - suspend fun hide() { - check(!skipHiddenState) { - "Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" + - " to false to use this function." - } - animateTo(SinatraSheetValue.Hidden) - } - - /** - * Animate to a [targetValue]. - * If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the - * [targetValue] without updating the offset. - * - * @throws CancellationException if the interaction interrupted by another interaction like a - * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. - * - * @param targetValue The target value of the animation - */ - @OptIn(ExperimentalFoundationApi::class) - internal suspend fun animateTo( - targetValue: SinatraSheetValue, - velocity: Float = anchoredDraggableState.lastVelocity - ) { - anchoredDraggableState.animateToWithDecay(targetValue, velocity) - } - - /** - * Snap to a [targetValue] without any animation. - * - * @throws CancellationException if the interaction interrupted by another interaction like a - * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. - * - * @param targetValue The target value of the animation - */ - @OptIn(ExperimentalFoundationApi::class) - internal suspend fun snapTo(targetValue: SinatraSheetValue) { - anchoredDraggableState.snapTo(targetValue) - } - - /** - * Find the closest anchor taking into account the velocity and settle at it with an animation. - */ - @OptIn(ExperimentalFoundationApi::class) - internal suspend fun settle(velocity: Float) { - anchoredDraggableState.settle(velocity) - } - - @OptIn(ExperimentalFoundationApi::class) - internal var anchoredDraggableState = AnchoredDraggableState( - initialValue = initialValue, - snapAnimationSpec = SpringSpec(), - decayAnimationSpec = exponentialDecay(), - positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } }, - velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } }, - confirmValueChange = confirmValueChange - ) - - @OptIn(ExperimentalFoundationApi::class) - val offset: Float? get() = anchoredDraggableState.offset.nullIf { it.isNaN() } - - internal var density: Density? = null - private fun requireDensity() = requireNotNull(density) { - "SheetState did not have a density attached. Are you using SheetState with " + - "BottomSheetScaffold or ModalBottomSheet component?" - } - - companion object { - /** - * The default [Saver] implementation for [SheetState]. - */ - @OptIn(ExperimentalMaterial3Api::class) - fun Saver( - skipPartiallyExpanded: Boolean, - confirmValueChange: (SinatraSheetValue) -> Boolean, - density: Density - ) = Saver( - save = { it.currentValue }, - restore = { savedValue -> - SinatraSheetState(skipPartiallyExpanded, density, savedValue, confirmValueChange) - } - ) - - /** - * The default [Saver] implementation for [SheetState]. - */ - @Deprecated( - message = "This function is deprecated. Please use the overload where Density is" + - " provided.", - replaceWith = ReplaceWith( - "Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)" - ) - ) - @Suppress("Deprecation") - fun Saver( - skipPartiallyExpanded: Boolean, - confirmValueChange: (SinatraSheetValue) -> Boolean - ) = Saver( - save = { it.currentValue }, - restore = { savedValue -> - SinatraSheetState(skipPartiallyExpanded, savedValue, confirmValueChange) - } - ) - } -} - /** * Possible values of [SheetState]. */ @@ -330,77 +40,6 @@ enum class SinatraSheetValue { PartiallyExpanded, } -/** - * Contains the default values used by [ModalBottomSheet] and [BottomSheetScaffold]. - */ -object BottomSheetDefaults { - /** The default shape for bottom sheets in a [Hidden] state. */ - val HiddenShape: Shape - @Composable get() = RectangleShape - - /** The default shape for a bottom sheets in [PartiallyExpanded] and [Expanded] states. */ - val ExpandedShape: Shape - // REALLY YOU MADE _TOP_ INTERNAL - @Composable get() = MaterialTheme.shapes.extraLarge.copy(bottomStart = CornerSize(0f), bottomEnd = CornerSize(0f)) - - /** The default container color for a bottom sheet. */ - val ContainerColor: Color - @Composable - get() = MaterialTheme.colorScheme.primaryContainer - - /** The default elevation for a bottom sheet. */ - val Elevation = 1.dp - - /** The default color of the scrim overlay for background content. */ - val ScrimColor: Color - @Composable get() = MaterialTheme.colorScheme.scrim.copy(0.32f) - - /** - * The default peek height used by [BottomSheetScaffold]. - */ - val SheetPeekHeight = 56.dp - - /** - * The default max width used by [ModalBottomSheet] and [BottomSheetScaffold] - */ - val SheetMaxWidth = 640.dp - - /** - * Default insets to be used and consumed by the [ModalBottomSheet] window. - */ - val windowInsets: WindowInsets - @Composable - get() = WindowInsets.systemBars.only(WindowInsetsSides.Vertical) - - /** - * The optional visual marker placed on top of a bottom sheet to indicate it may be dragged. - */ - @Composable - fun DragHandle( - modifier: Modifier = Modifier, - width: Dp = 32.0.dp, - height: Dp = 4.0.dp, - shape: Shape = MaterialTheme.shapes.extraLarge, - color: Color = MaterialTheme.colorScheme.onSurfaceVariant - .copy(alpha = 0.4f), - ) { - Surface( - modifier = modifier - .padding(vertical = DragHandleVerticalPadding), - color = color, - shape = shape - ) { - Box( - Modifier - .size( - width = width, - height = height - ) - ) - } - } -} - @OptIn(ExperimentalFoundationApi::class) internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( sheetState: SinatraSheetState, @@ -431,7 +70,7 @@ internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( override suspend fun onPreFling(available: Velocity): Velocity { val toFling = available.toFloat() val currentOffset = sheetState.requireOffset() - val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor() + val minAnchor = sheetState.anchoredDraggableState.anchors.minPosition() return if (toFling < 0 && currentOffset > minAnchor) { onFling(toFling) // since we go to the anchor with tween settling, consume all for the best UX @@ -465,25 +104,37 @@ internal fun rememberSinatraSheetState( confirmValueChange: (SinatraSheetValue) -> Boolean = { true }, initialValue: SinatraSheetValue = SinatraSheetValue.Hidden, skipHiddenState: Boolean = true, + positionalThreshold: Dp = PositionalThreshold, + velocityThreshold: Dp = VelocityThreshold, ): SinatraSheetState { val density = LocalDensity.current + val positionalThresholdToPx = { with(density) { positionalThreshold.toPx() } } + val velocityThresholdToPx = { with(density) { velocityThreshold.toPx() } } return rememberSaveable( - skipPartiallyExpanded, confirmValueChange, - saver = SinatraSheetState.Saver( - skipPartiallyExpanded = skipPartiallyExpanded, - confirmValueChange = confirmValueChange, - density = density - ) + skipPartiallyExpanded, + confirmValueChange, + skipHiddenState, + saver = + SinatraSheetState.Saver( + skipPartiallyExpanded = skipPartiallyExpanded, + positionalThreshold = positionalThresholdToPx, + velocityThreshold = velocityThresholdToPx, + confirmValueChange = confirmValueChange, + skipHiddenState = skipHiddenState, + ), ) { SinatraSheetState( skipPartiallyExpanded, - density, + positionalThresholdToPx, + velocityThresholdToPx, initialValue, confirmValueChange, - skipHiddenState + skipHiddenState, ) } } -private val DragHandleVerticalPadding = 22.dp \ No newline at end of file +internal val PositionalThreshold = 56.dp + +internal val VelocityThreshold = 125.dp \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraSheetState.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraSheetState.kt new file mode 100644 index 00000000..4ae32400 --- /dev/null +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/bottomsheet/SinatraSheetState.kt @@ -0,0 +1,249 @@ +package cl.emilym.sinatra.ui.widgets.bottomsheet + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.ui.unit.dp + +class SinatraSheetState @OptIn(ExperimentalMaterial3Api::class) constructor( + val skipPartiallyExpanded: Boolean, + positionalThreshold: () -> Float, + velocityThreshold: () -> Float, + initialValue: SinatraSheetValue = SinatraSheetValue.Hidden, + val confirmValueChange: (SinatraSheetValue) -> Boolean = { true }, + val skipHiddenState: Boolean = false, +) { + + init { + if (skipPartiallyExpanded) { + require(initialValue != SinatraSheetValue.PartiallyExpanded) { + "The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " + + "is set to true." + } + } + if (skipHiddenState) { + require(initialValue != SinatraSheetValue.Hidden) { + "The initial value must not be set to Hidden if skipHiddenState is set to true." + } + } + } + /** + * The current value of the state. + * + * If no swipe or animation is in progress, this corresponds to the state the bottom sheet is + * currently in. If a swipe or an animation is in progress, this corresponds the state the sheet + * was in before the swipe or animation started. + */ + val currentValue: SinatraSheetValue + get() = anchoredDraggableState.currentValue + + /** + * The target value of the bottom sheet state. + * + * If a swipe is in progress, this is the value that the sheet would animate to if the swipe + * finishes. If an animation is running, this is the target value of that animation. Finally, if + * no swipe or animation is in progress, this is the same as the [currentValue]. + */ + val targetValue: SinatraSheetValue + get() = anchoredDraggableState.targetValue + + /** Whether the modal bottom sheet is visible. */ + val isVisible: Boolean + get() = anchoredDraggableState.currentValue != SinatraSheetValue.Hidden + + /** + * Whether an expanding or collapsing sheet animation is currently in progress. + * + * See [expand], [partialExpand], [show] or [hide] for more information. + */ + val isAnimationRunning: Boolean + get() = anchoredDraggableState.isAnimationRunning + + /** + * Require the current offset (in pixels) of the bottom sheet. + * + * The offset will be initialized during the first measurement phase of the provided sheet + * content. + * + * These are the phases: Composition { -> Effects } -> Layout { Measurement -> Placement } -> + * Drawing + * + * During the first composition, an [IllegalStateException] is thrown. In subsequent + * compositions, the offset will be derived from the anchors of the previous pass. Always prefer + * accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next + * frame, after layout. + * + * @throws IllegalStateException If the offset has not been initialized yet + */ + fun requireOffset(): Float = anchoredDraggableState.requireOffset() + + /** Whether the sheet has an expanded state defined. */ + val hasExpandedState: Boolean + get() = anchoredDraggableState.anchors.hasPositionFor(SinatraSheetValue.Expanded) + + /** Whether the modal bottom sheet has a partially expanded state defined. */ + val hasPartiallyExpandedState: Boolean + get() = anchoredDraggableState.anchors.hasPositionFor(SinatraSheetValue.PartiallyExpanded) + + /** + * If [confirmValueChange] returns true, fully expand the bottom sheet with animation and + * suspend until it is fully expanded or animation has been cancelled. + * + * @throws [kotlinx.coroutines.CancellationException] if the animation is interrupted + */ + suspend fun expand() { + if (confirmValueChange(SinatraSheetValue.Expanded)) animateTo(SinatraSheetValue.Expanded, showMotionSpec) + } + + /** + * If [confirmValueChange] returns true, animate the bottom sheet and suspend until it is + * partially expanded or animation has been cancelled. + * + * @throws [kotlinx.coroutines.CancellationException] if the animation is interrupted + * @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true + */ + suspend fun partialExpand() { + check(!skipPartiallyExpanded) { + "Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" + + " skipPartiallyExpanded to false to use this function." + } + if (confirmValueChange(SinatraSheetValue.PartiallyExpanded)) animateTo(SinatraSheetValue.PartiallyExpanded, hideMotionSpec) + } + + suspend fun halfExpand() { + if (confirmValueChange(SinatraSheetValue.HalfExpanded)) animateTo(SinatraSheetValue.HalfExpanded, hideMotionSpec) + } + + /** + * If [confirmValueChange] returns true, expand the bottom sheet with animation and suspend + * until it is [PartiallyExpanded] if defined, else [androidx.compose.material3.adaptive.layout.PaneAdaptedValue.Companion.Expanded]. + * + * @throws [kotlinx.coroutines.CancellationException] if the animation is interrupted + */ + suspend fun show() { + val targetValue = + when { + hasPartiallyExpandedState -> SinatraSheetValue.PartiallyExpanded + else -> SinatraSheetValue.Expanded + } + if (confirmValueChange(targetValue)) animateTo(targetValue, showMotionSpec) + } + + /** + * If [confirmValueChange] returns true, hide the bottom sheet with animation and suspend until + * it is fully hidden or animation has been cancelled. + * + * @throws [kotlinx.coroutines.CancellationException] if the animation is interrupted + */ + suspend fun hide() { + check(!skipHiddenState) { + "Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" + + " to false to use this function." + } + if (confirmValueChange(SinatraSheetValue.Hidden)) animateTo(SinatraSheetValue.Hidden, hideMotionSpec) + } + + /** + * Animate to a [targetValue]. If the [targetValue] is not in the set of anchors, the + * [currentValue] will be updated to the [targetValue] without updating the offset. + * + * @param targetValue The target value of the animation + * @param animationSpec an [androidx.compose.animation.core.AnimationSpec] + * @param velocity an initial velocity for the animation + * @throws kotlinx.coroutines.CancellationException if the interaction interrupted by another interaction like a + * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] + * call. + */ + internal suspend fun animateTo( + targetValue: SinatraSheetValue, + animationSpec: FiniteAnimationSpec, + velocity: Float = anchoredDraggableState.lastVelocity, + ) { + anchoredDraggableState.anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> + val targetOffset = anchors.positionOf(latestTarget) + if (!targetOffset.isNaN()) { + var prev = if (offset.isNaN()) 0f else offset + animate(prev, targetOffset, velocity, animationSpec) { value, velocity -> + // Our onDrag coerces the value within the bounds, but an animation may + // overshoot, for example a spring animation or an overshooting interpolator + // We respect the user's intention and allow the overshoot, but still use + // DraggableState's drag for its mutex. + dragTo(value, velocity) + prev = value + } + } + } + } + + /** + * Snap to a [targetValue] without any animation. + * + * @param targetValue The target value of the animation + * @throws kotlinx.coroutines.CancellationException if the interaction interrupted by another interaction like a + * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] + * call. + */ + internal suspend fun snapTo(targetValue: SinatraSheetValue) { + anchoredDraggableState.snapTo(targetValue) + } + + /** + * Find the closest anchor taking into account the velocity and settle at it with an animation. + */ + internal suspend fun settle(velocity: Float) { + anchoredDraggableState.settle(velocity) + } + + internal var anchoredDraggableMotionSpec: AnimationSpec = BottomSheetAnimationSpec + + internal var anchoredDraggableState = + SinatraAnchoredDraggableState( + initialValue = initialValue, + animationSpec = { anchoredDraggableMotionSpec }, + confirmValueChange = confirmValueChange, + positionalThreshold = { positionalThreshold() }, + velocityThreshold = velocityThreshold, + ) + + internal val offset: Float + get() = anchoredDraggableState.offset + + internal var showMotionSpec: FiniteAnimationSpec = snap() + + internal var hideMotionSpec: FiniteAnimationSpec = snap() + + companion object { + /** The default [Saver] implementation for [androidx.compose.material3.SheetState]. */ + fun Saver( + skipPartiallyExpanded: Boolean, + positionalThreshold: () -> Float, + velocityThreshold: () -> Float, + confirmValueChange: (SinatraSheetValue) -> Boolean, + skipHiddenState: Boolean, + ) = + androidx.compose.runtime.saveable.Saver( + save = { it.currentValue }, + restore = { savedValue -> + SinatraSheetState( + skipPartiallyExpanded, + positionalThreshold, + velocityThreshold, + savedValue, + confirmValueChange, + skipHiddenState, + ) + }, + ) + } +} + +private val DragHandleVerticalPadding = 22.dp + +/** A function that provides the default animation spec used by [SheetState]. */ +private val BottomSheetAnimationSpec: AnimationSpec = + tween(durationMillis = 300, easing = FastOutSlowInEasing) \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/form/PreferencesCheckbox.kt b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/form/PreferencesCheckbox.kt index 49d94d99..28f124c9 100644 --- a/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/form/PreferencesCheckbox.kt +++ b/ui/src/commonMain/kotlin/cl/emilym/sinatra/ui/widgets/form/PreferencesCheckbox.kt @@ -3,6 +3,7 @@ package cl.emilym.sinatra.ui.widgets.form import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import cl.emilym.sinatra.data.repository.Preference +import cl.emilym.sinatra.ui.widgets.noRippleClickable import cl.emilym.sinatra.ui.widgets.rememberPreferenceState @Composable @@ -16,4 +17,26 @@ fun PreferencesCheckbox( { value = it }, modifier ) +} + +@Composable +fun HorizontalPreferencesCheckboxLockup( + preference: Preference, + title: String, + subtitle: String?, + modifier: Modifier = Modifier +) { + var value by rememberPreferenceState(preference) + HorizontalLockup( + title, + subtitle, + Modifier.then(modifier).noRippleClickable({ + value = !value + }) + ) { + SinatraCheckbox( + value, + { value = it }, + ) + } } \ No newline at end of file diff --git a/ui/src/iosMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.ios.kt b/ui/src/iosMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.ios.kt index fa9286ee..7ce94f54 100644 --- a/ui/src/iosMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.ios.kt +++ b/ui/src/iosMain/kotlin/cl/emilym/sinatra/ui/widgets/CurrentLocation.ios.kt @@ -34,7 +34,7 @@ internal actual fun platformCurrentLocation(accuracy: LocationAccuracy): Flow