diff --git a/app/build.gradle b/app/build.gradle index d0eeca7..9986467 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.com.google.devtools.ksp) alias(libs.plugins.com.google.dagger.hilt.android) alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.serialization) } android { @@ -11,7 +12,7 @@ android { defaultConfig { applicationId "org.stypox.tridenta" - minSdk 21 + minSdk 23 targetSdk 36 versionCode 60 versionName "1.5" @@ -65,8 +66,14 @@ hilt { } dependencies { + implementation libs.androidx.work.runtime.ktx + implementation libs.androidx.hilt.common + implementation libs.androidx.hilt.work + implementation libs.androidx.hilt.compiler coreLibraryDesugaring libs.desugar.jdk.libs + implementation libs.work + // Core implementation libs.core.ktx implementation libs.lifecycle.runtime.ktx @@ -74,6 +81,10 @@ dependencies { implementation libs.okhttp implementation libs.preference.ktx + // Serialization + implementation libs.kotlin.serialization + implementation libs.kotlin.serialization.json + // Compose and Material3 implementation libs.activity.compose implementation libs.compose.ui @@ -84,6 +95,11 @@ dependencies { debugImplementation libs.compose.androidx.ui.test.manifest implementation libs.compose.androidx.runtime.livedata implementation libs.androidx.material3 + implementation libs.glance + implementation libs.glance.appwidget + implementation libs.glance.material3 + implementation libs.glance.preview + implementation libs.glance.appwidget.preview // Navigation implementation libs.compose.destinations.core diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67c9c3c..26a936c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,6 +37,38 @@ android:name="android.app.shortcuts" android:resource="@xml/shortcuts" /> + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/TridentaApplication.kt b/app/src/main/java/org/stypox/tridenta/TridentaApplication.kt index cc1bf58..fa914b0 100644 --- a/app/src/main/java/org/stypox/tridenta/TridentaApplication.kt +++ b/app/src/main/java/org/stypox/tridenta/TridentaApplication.kt @@ -1,7 +1,14 @@ package org.stypox.tridenta import android.app.Application +import android.util.Log +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp +import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.MainScope import org.stypox.tridenta.db.LogDao import org.stypox.tridenta.log.AppUncaughtExceptionHandler @@ -10,7 +17,7 @@ import org.stypox.tridenta.log.setupLogger import javax.inject.Inject @HiltAndroidApp -class TridentaApplication : Application() { +class TridentaApplication : Application(), Configuration.Provider { // store logger's DAO and scope here, so that they are correctly garbage collected when // Application is destroyed (probably this is not needed, but let's be sure) @@ -25,4 +32,17 @@ class TridentaApplication : Application() { // cleanup old logs (we don't want the database to be cluttered with those) clearOldLogs() } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface HiltWorkerFactoryEntryPoint { + fun workerFactory(): HiltWorkerFactory + } + + override val workManagerConfiguration by lazy {Configuration.Builder() + .setWorkerFactory( + EntryPointAccessors.fromApplication(this, HiltWorkerFactoryEntryPoint::class.java).workerFactory() + ) + .setMinimumLoggingLevel(Log.INFO) + .build()} } \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/db/data/DbLine.kt b/app/src/main/java/org/stypox/tridenta/db/data/DbLine.kt index 8d099a1..8c991bf 100644 --- a/app/src/main/java/org/stypox/tridenta/db/data/DbLine.kt +++ b/app/src/main/java/org/stypox/tridenta/db/data/DbLine.kt @@ -5,13 +5,16 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.Index +import kotlinx.serialization.Serializable import org.stypox.tridenta.enums.Area import org.stypox.tridenta.enums.StopLineType +import org.stypox.tridenta.util.OffsetDateTimeSerializer import java.time.OffsetDateTime @Entity( primaryKeys = ["lineId", "type"] ) +@Serializable data class DbLine( // some testing exposed that a line is always identified by the (lineId, type) tuple val lineId: Int, @@ -49,9 +52,12 @@ data class DbLine( Index("lineId", "lineType") ] ) +@Serializable data class DbNewsItem( val serviceType: String, + @Serializable(with = OffsetDateTimeSerializer::class) val startDate: OffsetDateTime, + @Serializable(with = OffsetDateTimeSerializer::class) val endDate: OffsetDateTime, val header: String, val details: String, diff --git a/app/src/main/java/org/stypox/tridenta/db/data/DbStop.kt b/app/src/main/java/org/stypox/tridenta/db/data/DbStop.kt index 59ce92e..b7709a0 100644 --- a/app/src/main/java/org/stypox/tridenta/db/data/DbStop.kt +++ b/app/src/main/java/org/stypox/tridenta/db/data/DbStop.kt @@ -1,12 +1,14 @@ package org.stypox.tridenta.db.data import androidx.room.* +import kotlinx.serialization.Serializable import org.stypox.tridenta.enums.CardinalPoint import org.stypox.tridenta.enums.StopLineType @Entity( primaryKeys = ["stopId", "type"] ) +@Serializable data class DbStop( // some testing exposed that a stop is always identified by the (stopId, type) tuple val stopId: Int, diff --git a/app/src/main/java/org/stypox/tridenta/repo/data/UiLine.kt b/app/src/main/java/org/stypox/tridenta/repo/data/UiLine.kt index b872708..0059ca8 100644 --- a/app/src/main/java/org/stypox/tridenta/repo/data/UiLine.kt +++ b/app/src/main/java/org/stypox/tridenta/repo/data/UiLine.kt @@ -1,13 +1,14 @@ package org.stypox.tridenta.repo.data import androidx.annotation.ColorInt +import kotlinx.serialization.Serializable import org.stypox.tridenta.db.data.DbLine import org.stypox.tridenta.db.data.DbNewsItem import org.stypox.tridenta.enums.Area import org.stypox.tridenta.enums.StopLineType import java.util.regex.Pattern import kotlin.math.min - +@Serializable data class UiLine( val lineId: Int, val type: StopLineType, diff --git a/app/src/main/java/org/stypox/tridenta/repo/data/UiTrip.kt b/app/src/main/java/org/stypox/tridenta/repo/data/UiTrip.kt index a6e8bb6..7b88448 100644 --- a/app/src/main/java/org/stypox/tridenta/repo/data/UiTrip.kt +++ b/app/src/main/java/org/stypox/tridenta/repo/data/UiTrip.kt @@ -1,19 +1,23 @@ package org.stypox.tridenta.repo.data +import kotlinx.serialization.Serializable import org.stypox.tridenta.db.data.DbLine import org.stypox.tridenta.db.data.DbStop import org.stypox.tridenta.enums.Direction import org.stypox.tridenta.enums.StopLineType import org.stypox.tridenta.extractor.data.ExStopTime import org.stypox.tridenta.extractor.data.ExTrip +import org.stypox.tridenta.util.OffsetDateTimeSerializer import java.time.OffsetDateTime /** * Same as [ExTrip] but with line and stop data loaded */ +@Serializable data class UiTrip( val delay: Int, val direction: Direction, + @Serializable(with = OffsetDateTimeSerializer::class) val lastEventReceivedAt: OffsetDateTime?, val lineId: Int, // needed for when DbLine could not be loaded, and so it is null val line: DbLine?, @@ -28,8 +32,11 @@ data class UiTrip( /** * Same as [ExStopTime] but with stop data loaded */ + @Serializable data class UiStopTime( + @Serializable(with = OffsetDateTimeSerializer::class) val arrivalTime: OffsetDateTime?, + @Serializable(with = OffsetDateTimeSerializer::class) val departureTime: OffsetDateTime?, val stop: DbStop?, ) diff --git a/app/src/main/java/org/stypox/tridenta/ui/error/ErrorPanel.kt b/app/src/main/java/org/stypox/tridenta/ui/error/ErrorPanel.kt index 6080bdc..6776921 100644 --- a/app/src/main/java/org/stypox/tridenta/ui/error/ErrorPanel.kt +++ b/app/src/main/java/org/stypox/tridenta/ui/error/ErrorPanel.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.unit.dp import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import org.stypox.tridenta.R -import org.stypox.tridenta.ui.destinations.LogsScreenDestination +import org.stypox.tridenta.destinations.LogsScreenDestination import org.stypox.tridenta.ui.theme.TitleText diff --git a/app/src/main/java/org/stypox/tridenta/ui/error/ErrorRow.kt b/app/src/main/java/org/stypox/tridenta/ui/error/ErrorRow.kt index e6539a6..ea7f8d9 100644 --- a/app/src/main/java/org/stypox/tridenta/ui/error/ErrorRow.kt +++ b/app/src/main/java/org/stypox/tridenta/ui/error/ErrorRow.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import org.stypox.tridenta.R -import org.stypox.tridenta.ui.destinations.LogsScreenDestination +import org.stypox.tridenta.destinations.LogsScreenDestination import org.stypox.tridenta.ui.theme.BodyText @Composable diff --git a/app/src/main/java/org/stypox/tridenta/ui/line_trips/LineTripsScreen.kt b/app/src/main/java/org/stypox/tridenta/ui/line_trips/LineTripsScreen.kt index 041d227..7e29616 100644 --- a/app/src/main/java/org/stypox/tridenta/ui/line_trips/LineTripsScreen.kt +++ b/app/src/main/java/org/stypox/tridenta/ui/line_trips/LineTripsScreen.kt @@ -27,7 +27,7 @@ import org.stypox.tridenta.repo.data.UiLine import org.stypox.tridenta.repo.data.UiTrip import org.stypox.tridenta.sample.SampleUiLineProvider import org.stypox.tridenta.sample.SampleUiTripProvider -import org.stypox.tridenta.ui.destinations.LineTripsScreenDestination +import org.stypox.tridenta.destinations.LineTripsScreenDestination import org.stypox.tridenta.ui.lines.LineShortName import org.stypox.tridenta.ui.nav.AppBarDrawerIcon import org.stypox.tridenta.ui.nav.AppBarFavoriteIcon diff --git a/app/src/main/java/org/stypox/tridenta/ui/line_trips/LineTripsViewModel.kt b/app/src/main/java/org/stypox/tridenta/ui/line_trips/LineTripsViewModel.kt index 8eb956f..d2aad02 100644 --- a/app/src/main/java/org/stypox/tridenta/ui/line_trips/LineTripsViewModel.kt +++ b/app/src/main/java/org/stypox/tridenta/ui/line_trips/LineTripsViewModel.kt @@ -17,7 +17,7 @@ import org.stypox.tridenta.log.logError import org.stypox.tridenta.repo.LineTripsRepository import org.stypox.tridenta.repo.LinesRepository import org.stypox.tridenta.repo.data.UiTrip -import org.stypox.tridenta.ui.navArgs +import org.stypox.tridenta.navArgs import java.time.ZonedDateTime import javax.inject.Inject @@ -78,7 +78,7 @@ class LineTripsViewModel @Inject constructor( mutableUiState.update { it.copy(directionFilter = newDirectionFilter) } if (newDirectionFilter == Direction.ForwardAndBackward) { - val state = uiState.value; + val state = uiState.value if (state.trip == null) { // the trip can be null if there is no trip in that direction loadIndex(state.tripIndex) @@ -94,7 +94,7 @@ class LineTripsViewModel @Inject constructor( } } else { - val state = uiState.value; + val state = uiState.value if (state.trip?.direction != newDirectionFilter) { // we need to load another trip, since the current one has the wrong direction loadIndex(state.tripIndex) diff --git a/app/src/main/java/org/stypox/tridenta/ui/lines/LinesScreen.kt b/app/src/main/java/org/stypox/tridenta/ui/lines/LinesScreen.kt index cc94c16..d3f0d0a 100644 --- a/app/src/main/java/org/stypox/tridenta/ui/lines/LinesScreen.kt +++ b/app/src/main/java/org/stypox/tridenta/ui/lines/LinesScreen.kt @@ -25,7 +25,7 @@ import org.stypox.tridenta.R import org.stypox.tridenta.db.data.DbLine import org.stypox.tridenta.enums.Area import org.stypox.tridenta.sample.SampleDbLineProvider -import org.stypox.tridenta.ui.destinations.LineTripsScreenDestination +import org.stypox.tridenta.destinations.LineTripsScreenDestination import org.stypox.tridenta.ui.error.ErrorPanel import org.stypox.tridenta.ui.error.ErrorRow import org.stypox.tridenta.ui.nav.AppBarDrawerIcon diff --git a/app/src/main/java/org/stypox/tridenta/ui/nav/Drawer.kt b/app/src/main/java/org/stypox/tridenta/ui/nav/Drawer.kt index e2afa97..4689070 100644 --- a/app/src/main/java/org/stypox/tridenta/ui/nav/Drawer.kt +++ b/app/src/main/java/org/stypox/tridenta/ui/nav/Drawer.kt @@ -30,7 +30,7 @@ import org.stypox.tridenta.R import org.stypox.tridenta.db.data.DbLine import org.stypox.tridenta.db.data.DbStop import org.stypox.tridenta.db.views.HistoryLineOrStop -import org.stypox.tridenta.ui.destinations.* +import org.stypox.tridenta.destinations.* import org.stypox.tridenta.ui.lines.LineShortName import org.stypox.tridenta.ui.theme.* diff --git a/app/src/main/java/org/stypox/tridenta/ui/nav/Navigation.kt b/app/src/main/java/org/stypox/tridenta/ui/nav/Navigation.kt index f3a144e..c41f267 100644 --- a/app/src/main/java/org/stypox/tridenta/ui/nav/Navigation.kt +++ b/app/src/main/java/org/stypox/tridenta/ui/nav/Navigation.kt @@ -30,11 +30,11 @@ import com.ramcosta.composedestinations.rememberNavHostEngine import com.ramcosta.composedestinations.spec.Direction import kotlinx.coroutines.launch import org.stypox.tridenta.R -import org.stypox.tridenta.ui.NavGraphs -import org.stypox.tridenta.ui.destinations.LinesScreenDestination import org.stypox.tridenta.ui.theme.HeadlineText import org.stypox.tridenta.util.PreferenceKeys import androidx.core.content.edit +import org.stypox.tridenta.NavGraphs +import org.stypox.tridenta.destinations.LinesScreenDestination const val DEEP_LINK_PREFIX = "tridenta://" const val DEEP_LINK_URL_PATTERN = DEEP_LINK_PREFIX + FULL_ROUTE_PLACEHOLDER diff --git a/app/src/main/java/org/stypox/tridenta/ui/stop_trips/StopTripsScreen.kt b/app/src/main/java/org/stypox/tridenta/ui/stop_trips/StopTripsScreen.kt index ea74018..3c40c02 100644 --- a/app/src/main/java/org/stypox/tridenta/ui/stop_trips/StopTripsScreen.kt +++ b/app/src/main/java/org/stypox/tridenta/ui/stop_trips/StopTripsScreen.kt @@ -26,7 +26,7 @@ import org.stypox.tridenta.db.data.DbStop import org.stypox.tridenta.repo.data.UiTrip import org.stypox.tridenta.sample.SampleDbStopProvider import org.stypox.tridenta.sample.SampleUiTripProvider -import org.stypox.tridenta.ui.destinations.LineTripsScreenDestination +import org.stypox.tridenta.destinations.LineTripsScreenDestination import org.stypox.tridenta.ui.nav.AppBarDrawerIcon import org.stypox.tridenta.ui.nav.AppBarFavoriteIcon import org.stypox.tridenta.ui.nav.DEEP_LINK_URL_PATTERN diff --git a/app/src/main/java/org/stypox/tridenta/ui/stop_trips/StopTripsViewModel.kt b/app/src/main/java/org/stypox/tridenta/ui/stop_trips/StopTripsViewModel.kt index 5b24e86..14b067a 100644 --- a/app/src/main/java/org/stypox/tridenta/ui/stop_trips/StopTripsViewModel.kt +++ b/app/src/main/java/org/stypox/tridenta/ui/stop_trips/StopTripsViewModel.kt @@ -14,7 +14,7 @@ import org.stypox.tridenta.extractor.ROME_ZONE_ID import org.stypox.tridenta.log.logError import org.stypox.tridenta.repo.StopTripsRepository import org.stypox.tridenta.repo.StopsRepository -import org.stypox.tridenta.ui.navArgs +import org.stypox.tridenta.navArgs import java.time.ZonedDateTime import javax.inject.Inject diff --git a/app/src/main/java/org/stypox/tridenta/ui/stops/StopsScreen.kt b/app/src/main/java/org/stypox/tridenta/ui/stops/StopsScreen.kt index fa8bd4c..ba45455 100644 --- a/app/src/main/java/org/stypox/tridenta/ui/stops/StopsScreen.kt +++ b/app/src/main/java/org/stypox/tridenta/ui/stops/StopsScreen.kt @@ -33,8 +33,8 @@ import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import org.stypox.tridenta.R import org.stypox.tridenta.repo.data.UiStop import org.stypox.tridenta.sample.SampleUiStopProvider -import org.stypox.tridenta.ui.destinations.LineTripsScreenDestination -import org.stypox.tridenta.ui.destinations.StopTripsScreenDestination +import org.stypox.tridenta.destinations.LineTripsScreenDestination +import org.stypox.tridenta.destinations.StopTripsScreenDestination import org.stypox.tridenta.ui.error.ErrorPanel import org.stypox.tridenta.ui.error.ErrorRow import org.stypox.tridenta.ui.nav.AppBarDrawerIcon diff --git a/app/src/main/java/org/stypox/tridenta/ui/trip/TripView.kt b/app/src/main/java/org/stypox/tridenta/ui/trip/TripView.kt index 8198d72..5bb2eaf 100644 --- a/app/src/main/java/org/stypox/tridenta/ui/trip/TripView.kt +++ b/app/src/main/java/org/stypox/tridenta/ui/trip/TripView.kt @@ -28,8 +28,8 @@ import org.stypox.tridenta.repo.data.UiLine import org.stypox.tridenta.repo.data.UiTrip import org.stypox.tridenta.sample.SampleDbStopProvider import org.stypox.tridenta.sample.SampleUiTripProvider -import org.stypox.tridenta.ui.destinations.LineTripsScreenDestination -import org.stypox.tridenta.ui.destinations.StopTripsScreenDestination +import org.stypox.tridenta.destinations.LineTripsScreenDestination +import org.stypox.tridenta.destinations.StopTripsScreenDestination import org.stypox.tridenta.ui.error.ErrorPanel import org.stypox.tridenta.ui.error.ErrorRow import org.stypox.tridenta.ui.theme.* diff --git a/app/src/main/java/org/stypox/tridenta/util/SerializerUtils.kt b/app/src/main/java/org/stypox/tridenta/util/SerializerUtils.kt new file mode 100644 index 0000000..976457f --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/util/SerializerUtils.kt @@ -0,0 +1,36 @@ +package org.stypox.tridenta.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.OffsetDateTime +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +object ZonedDateTimeSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("ZonedDateTime", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: ZonedDateTime) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): ZonedDateTime { + return ZonedDateTime.parse(decoder.decodeString()) + } +} + +object OffsetDateTimeSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.STRING) + + private val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME + + override fun serialize(encoder: Encoder, value: OffsetDateTime) { + encoder.encodeString(value.format(formatter)) + } + + override fun deserialize(decoder: Decoder): OffsetDateTime { + return OffsetDateTime.parse(decoder.decodeString(), formatter) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/util/ShortcutUtils.kt b/app/src/main/java/org/stypox/tridenta/util/ShortcutUtils.kt index f9245ac..553b346 100644 --- a/app/src/main/java/org/stypox/tridenta/util/ShortcutUtils.kt +++ b/app/src/main/java/org/stypox/tridenta/util/ShortcutUtils.kt @@ -17,8 +17,8 @@ import org.stypox.tridenta.db.data.DbLine import org.stypox.tridenta.db.data.DbStop import org.stypox.tridenta.enums.CardinalPoint import org.stypox.tridenta.enums.StopLineType -import org.stypox.tridenta.ui.destinations.LineTripsScreenDestination -import org.stypox.tridenta.ui.destinations.StopTripsScreenDestination +import org.stypox.tridenta.destinations.LineTripsScreenDestination +import org.stypox.tridenta.destinations.StopTripsScreenDestination import org.stypox.tridenta.ui.nav.DEEP_LINK_PREFIX import java.lang.Float.min diff --git a/app/src/main/java/org/stypox/tridenta/widget/LineTripWidgetReceiver.kt b/app/src/main/java/org/stypox/tridenta/widget/LineTripWidgetReceiver.kt new file mode 100644 index 0000000..cd5b121 --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/LineTripWidgetReceiver.kt @@ -0,0 +1,26 @@ +package org.stypox.tridenta.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class LineTripWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget get() = LineTripWidget() + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + LineTripWidgetWorker.enqueue(context) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + LineTripWidgetWorker.cancel(context) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/LineTripWidgetStateDefinition.kt b/app/src/main/java/org/stypox/tridenta/widget/LineTripWidgetStateDefinition.kt new file mode 100644 index 0000000..b6f1b5f --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/LineTripWidgetStateDefinition.kt @@ -0,0 +1,53 @@ +package org.stypox.tridenta.widget + +import android.content.Context +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.Serializer +import androidx.datastore.dataStoreFile +import androidx.glance.state.GlanceStateDefinition +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import org.stypox.tridenta.log.logError +import java.io.File +import java.io.InputStream +import java.io.OutputStream + +object LineTripWidgetStateDefinition : GlanceStateDefinition { + private const val DATA_STORE_FILENAME_PREFIX = "line_trip_widget_state_" + override suspend fun getDataStore( + context: Context, + fileKey: String + ): DataStore = DataStoreFactory.create( + serializer = WidgetStateSerializer, + produceFile = { getLocation(context, fileKey) } + ) + + override fun getLocation( + context: Context, + fileKey: String + ): File = + context.dataStoreFile(DATA_STORE_FILENAME_PREFIX + fileKey.lowercase()) + + object WidgetStateSerializer : Serializer { + override suspend fun readFrom(input: InputStream): WidgetState = try { + Json.decodeFromString(WidgetState.serializer(), input.readBytes().decodeToString()) + } catch (exception: SerializationException) { + logError(exception.message ?: "Could not read widget state", exception.cause) + throw CorruptionException("Could not read widget state: ${exception.message}") + } + + override suspend fun writeTo( + t: WidgetState, + output: OutputStream + ) { + output.use { + it.write(Json.encodeToString(WidgetState.serializer(), t).encodeToByteArray()) + } + } + + override val defaultValue: WidgetState + get() = WidgetState.Unavailable("Something went wrong") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/LineTripWidgetWorker.kt b/app/src/main/java/org/stypox/tridenta/widget/LineTripWidgetWorker.kt new file mode 100644 index 0000000..fd43334 --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/LineTripWidgetWorker.kt @@ -0,0 +1,171 @@ +package org.stypox.tridenta.widget + +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.hilt.android.EntryPointAccessors +import org.stypox.tridenta.extractor.ROME_ZONE_ID +import org.stypox.tridenta.log.logInfo +import org.stypox.tridenta.widget.actions.WidgetEntryPoint +import org.stypox.tridenta.widget.actions.WidgetStopTripsEntryPoint +import java.time.ZonedDateTime +import java.util.concurrent.TimeUnit + +class LineTripWidgetWorker( + private val context: Context, params: WorkerParameters, + //private val tripsRepository: LineTripsRepository +) : + CoroutineWorker(context, params) { + companion object { + private val uniqueWorkName = LineTripWidgetWorker::class.java.simpleName + + fun enqueue(context: Context, force: Boolean = false) { + val workManager = WorkManager.getInstance(context) + val request = + PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() + + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = uniqueWorkName, existingPeriodicWorkPolicy = if (force) { + ExistingPeriodicWorkPolicy.UPDATE + } else { + ExistingPeriodicWorkPolicy.KEEP + }, request = request + ) + } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName) + } + } + + override suspend fun doWork(): Result { + val appWidgetManager = GlanceAppWidgetManager(context) + appWidgetManager.getGlanceIds(LineTripWidget::class.java).forEach { glanceId -> + val currentState: WidgetState = getAppWidgetState( + context, + LineTripWidgetStateDefinition, glanceId + ) + when (currentState) { + is WidgetState.LineTripsAvailable -> { + if (currentState.referenceDateTime.isAfter( + ZonedDateTime.now().minusMinutes(15) + ) + ) { + return@forEach + } + updateAppWidgetState( + context, + LineTripWidgetStateDefinition, + glanceId + ) { oldState -> + if (oldState is WidgetState.LineTripsAvailable) oldState.copy( + loading = true + ) else oldState + } + LineTripWidget().update(context, glanceId) + + if (currentState.line == null) { + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + WidgetState.Unavailable("Something went wrong") + } + LineTripWidget().update(context, glanceId) + return@forEach + } + + val hiltEntryPoint = + EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java) + val tripsRepository = hiltEntryPoint.lineTripsRepository() + val referenceDateTime = ZonedDateTime.now().withZoneSameInstant(ROME_ZONE_ID) + val (tripsInDayCount, tripIndex, trip) = tripsRepository.getUiTrip( + currentState.line.lineId, + currentState.line.type, + referenceDateTime, + currentState.directionFilter + ) + logInfo("trip: $trip") + + updateAppWidgetState( + context, + LineTripWidgetStateDefinition, + glanceId + ) { oldState -> + oldState as WidgetState.LineTripsAvailable + if (trip != null) { + oldState.copy( + trip = trip, + referenceDateTime = referenceDateTime, + tripsInDayCount = tripsInDayCount, + tripIndex = tripIndex, + prevEnabled = tripIndex > 0, + nextEnabled = tripIndex < tripsInDayCount - 1, + loading = false + ) + } else { + oldState.copy(error = true, loading = false) + } + } + } + + is WidgetState.StopTripsAvailable -> { + if (currentState.referenceDateTime.isAfter( + ZonedDateTime.now().minusMinutes(15) + ) + ) { + return@forEach + } + updateAppWidgetState( + context, + LineTripWidgetStateDefinition, + glanceId + ) { oldState -> + if (oldState is WidgetState.StopTripsAvailable) oldState.copy( + loading = true + ) else oldState + } + LineTripWidget().update(context, glanceId) + + if (currentState.stop == null) { + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + WidgetState.Unavailable("Something went wrong") + } + LineTripWidget().update(context, glanceId) + return@forEach + } + + val hiltEntryPoint = + EntryPointAccessors.fromApplication( + context, + WidgetStopTripsEntryPoint::class.java + ) + val referenceDateTime = ZonedDateTime.now().withZoneSameInstant(ROME_ZONE_ID) + hiltEntryPoint.setReferenceDateTimeAsync( + referenceDateTime, + currentState.stop.stopId, + currentState.stop.type, + context, + glanceId + ) + updateAppWidgetState( + context, + LineTripWidgetStateDefinition, + glanceId + ) { oldState -> + if (oldState is WidgetState.StopTripsAvailable) oldState.copy( + loading = false + ) else oldState + } + } + + else -> {} + } + LineTripWidget().update(context, glanceId) + } + return Result.success() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/TripWidget.kt b/app/src/main/java/org/stypox/tridenta/widget/TripWidget.kt new file mode 100644 index 0000000..664a609 --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/TripWidget.kt @@ -0,0 +1,259 @@ +package org.stypox.tridenta.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.action.actionStartActivity +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import org.stypox.tridenta.R +import org.stypox.tridenta.db.data.DbLine +import org.stypox.tridenta.db.data.DbStop +import org.stypox.tridenta.enums.Area +import org.stypox.tridenta.enums.CardinalPoint +import org.stypox.tridenta.enums.Direction +import org.stypox.tridenta.enums.StopLineType +import org.stypox.tridenta.repo.data.UiStopTime +import org.stypox.tridenta.repo.data.UiTrip +import org.stypox.tridenta.ui.MainActivity +import org.stypox.tridenta.widget.actions.NextTripAction +import org.stypox.tridenta.widget.actions.PrevTripAction +import org.stypox.tridenta.widget.actions.ReloadTripAction +import org.stypox.tridenta.widget.actions.ToggleDirectionAction +import org.stypox.tridenta.widget.ui.LineTripsWidgetScreen +import org.stypox.tridenta.widget.ui.StopTripsScreenGlance +import org.stypox.tridenta.widget.ui.TripViewGlance +import java.time.OffsetDateTime +import java.time.ZoneOffset + +class LineTripWidget : GlanceAppWidget() { + override val stateDefinition = LineTripWidgetStateDefinition + override suspend fun provideGlance( + context: Context, id: GlanceId + ) { + + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) + val configIntent = Intent(context, WidgetConfigurationActivity::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + } + provideContent { + val state = currentState() + + GlanceTheme { + when (state) { + is WidgetState.LineTripsAvailable -> + LineTripsWidgetScreen( + line = state.line, + trip = state.trip, + error = state.error, + loading = state.loading, + onReloadAction = actionRunCallback(), + onPrevAction = actionRunCallback(), + onNextAction = actionRunCallback(), + onLineClickAction = actionStartActivity(configIntent), + directionFilter = state.directionFilter, + onDirectionClickAction = actionRunCallback(), + prevEnabled = state.prevEnabled, + nextEnabled = state.nextEnabled, + isFavorite = state.line?.isFavorite ?: false, + showPrevStop = state.showPrevStop, + ) + + is WidgetState.StopTripsAvailable -> { + StopTripsScreenGlance( + stop = state.stop, + trip = state.trip, + error = state.error, + loading = state.loading, + onReloadAction = actionRunCallback(), + onPrevAction = actionRunCallback(), + onNextAction = actionRunCallback(), + onLineClickAction = actionStartActivity(configIntent), + prevEnabled = state.prevEnabled, + nextEnabled = state.nextEnabled, + isFavorite = state.stop?.isFavorite ?: false, + showPrevStop = state.showPrevStop + ) + } + + is WidgetState.Unavailable -> Box( + contentAlignment = Alignment.Center, + modifier = GlanceModifier.fillMaxSize() + .background(GlanceTheme.colors.background) + ) { + Text( + text = context.getString(R.string.error), + style = TextStyle(color = GlanceTheme.colors.error), + modifier = GlanceModifier.padding(8.dp) + ) + } + } + } + } + } +} + +@OptIn(ExperimentalGlancePreviewApi::class) +// 3x3 Widget +@Preview(widthDp = 250, heightDp = 203) +// 3x4 Widget +@Preview(widthDp = 250, heightDp = 276) +@Composable +fun MyWidgetPreview() { + val sampleDbStops: List = listOf( + DbStop( + stopId = 0, + latitude = 0.0, + longitude = 0.0, + name = "Sorni", + street = "Sorni", + town = "", + type = StopLineType.Urban, + wheelchairAccessible = false, + cardinalPoint = CardinalPoint.North, + isFavorite = false, + ), DbStop( + stopId = 1, + latitude = 0.0, + longitude = 0.0, + name = "Funivia-Staz. di Monte-Sardagna lorem ipsum dolor sit amet", + street = "Cembra - Via 4 Novembre - Dir.Cavalese lorem ipsum dolor sit", + town = "Appiano sulla strada del vino", + type = StopLineType.Suburban, + wheelchairAccessible = true, + cardinalPoint = null, + isFavorite = true, + ), DbStop( + stopId = 2, + latitude = 0.0, + longitude = 0.0, + name = "Pinè Bivio", + street = "Civezzano-Loc.La Mochena", + town = "Civezzano", + type = StopLineType.Suburban, + wheelchairAccessible = false, + cardinalPoint = CardinalPoint.NorthWest, + isFavorite = true, + ), DbStop( + stopId = 3, + latitude = 0.0, + longitude = 0.0, + name = "Verona Big Center", + street = "Verona \"Big Center\"", + town = "Trento", + type = StopLineType.Suburban, + wheelchairAccessible = true, + cardinalPoint = null, + isFavorite = false, + ) + ) + val sampleDbLines: Sequence = sequenceOf( + DbLine( + lineId = 396, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0xc52720, + longName = "Cortesano Gardolo P.Dante Villazzano 3", + shortName = "3", + isFavorite = false + ), + DbLine( + lineId = 404, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0x52332a, + longName = "Centochiavi Piazza Dante Mattarello", + shortName = "8", + isFavorite = true + ), + DbLine( + lineId = 466, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0xbf6092, + longName = "P.Dante Rosmini S.Rocco Povo Polo Soc.", + shortName = "13", + isFavorite = false + ), + DbLine( + lineId = 415, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0xe490b0, + longName = "P.Dante Via Sanseverino Belvedere Ravina", + shortName = "14", + isFavorite = true + ), + DbLine( + lineId = 109, + type = StopLineType.Suburban, + area = Area.Suburban1, + color = null, + longName = "Cavalese - Masi di Cavalese", + shortName = "B109", + isFavorite = true + ), + DbLine( + lineId = 201, + type = StopLineType.Suburban, + area = Area.Suburban2, + color = null, + longName = "Trento-Vezzano-Sarche-Tione", + shortName = "B201", + isFavorite = false + ), + ) + val referenceDateTime = OffsetDateTime.of(2022, 9, 26, 9, 33, 17, 328943849, ZoneOffset.UTC) + // Provide mock data to your content + GlanceTheme { + TripViewGlance( + UiTrip( + delay = 1, + direction = Direction.Backward, + lastEventReceivedAt = referenceDateTime.minusMinutes(3), + lineId = sampleDbLines.first().lineId, + line = sampleDbLines.first(), + headSign = "Conci \"Villazzano 3\"", + tripId = "0003726592022061120220911", + type = StopLineType.Urban, + completedStops = 2, + stopTimes = sampleDbStops.mapIndexed { index, dbStop -> + UiStopTime( + arrivalTime = referenceDateTime.plusMinutes((index - 2).toLong()), + departureTime = referenceDateTime.plusMinutes((index + index % 2 - 2).toLong()), + stop = dbStop + ) + }, + busId = 886, + ), + error = false, loading = false, + onReloadAction = actionStartActivity(), + onPrevAction = actionStartActivity(), + onNextAction = actionStartActivity(), + prevEnabled = true, + nextEnabled = true, + stopIdToHighlight = null, + stopTypeToHighlight = null, + showPrevStop = false + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/WidgetConfigurationActivity.kt b/app/src/main/java/org/stypox/tridenta/widget/WidgetConfigurationActivity.kt new file mode 100644 index 0000000..3674a30 --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/WidgetConfigurationActivity.kt @@ -0,0 +1,148 @@ +package org.stypox.tridenta.widget + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.hilt.navigation.compose.hiltViewModel +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.stypox.tridenta.db.data.DbLine +import org.stypox.tridenta.enums.Direction +import org.stypox.tridenta.extractor.ROME_ZONE_ID +import org.stypox.tridenta.log.logError +import org.stypox.tridenta.ui.lines.LinesViewModel +import org.stypox.tridenta.ui.theme.AppTheme +import org.stypox.tridenta.widget.actions.WidgetEntryPoint +import org.stypox.tridenta.widget.ui.LineSelectionScreenWidget +import java.time.ZonedDateTime + +@AndroidEntryPoint +class WidgetConfigurationActivity : + ComponentActivity() { + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // 1. Get the Widget ID from the Intent that launched this Activity + appWidgetId = intent?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + // If the intent doesn't have a valid ID, bail out early. + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + // Set the result to CANCELED right away. This ensures that if the user backs + // out of the activity without picking a line, the widget is removed from the home screen. + setResult(RESULT_CANCELED) + + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box(modifier = Modifier.safeDrawingPadding()) { + LineSelectionScreenWidget( + onLineSelected = { selectedLine -> + saveWidgetConfiguration(selectedLine) + }, + ) + } + } + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + private fun saveWidgetConfiguration(line: DbLine) { + val context = applicationContext + + // We use GlobalScope or a dedicated CoroutineScope because the activity + // will finish immediately, and we need this save to complete. + GlobalScope.launch(Dispatchers.IO) { + // 2. Map the standard Android appWidgetId to a Jetpack GlanceId + val glanceManager = GlanceAppWidgetManager(context) + val glanceId = glanceManager.getGlanceIdBy(appWidgetId) + val hiltEntryPoint = + EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java) + val line = withContext(Dispatchers.IO) { + try { + hiltEntryPoint.lineRepository().getUiLine(line.lineId, line.type).also { + if (it == null) { + logError( + "UI line (${line.lineId}, ${line.type}) not found" + ) + } + + // register a view for this line (assuming loadLine is called once) + hiltEntryPoint.historyDao().registerAccessed(true, line.lineId, line.type) + } + } catch (e: Throwable) { + logError("Could not load UI line (${line.lineId}, ${line.type})", e) + null + } + } + if (line == null) { + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + WidgetState.Unavailable(message = "Something went wrong") + } + return@launch + } + val tripsRepository = hiltEntryPoint.lineTripsRepository() + val referenceDateTime = ZonedDateTime.now().withZoneSameInstant(ROME_ZONE_ID) + val (tripsInDayCount, tripIndex, trip) = tripsRepository.getUiTrip( + line.lineId, + line.type, + referenceDateTime, + Direction.ForwardAndBackward + ) + + // 3. Save the selected data to this specific widget's Preferences + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + WidgetState.LineTripsAvailable( + line, + trip, + ZonedDateTime.now(), + tripsInDayCount, + tripIndex, + prevEnabled = tripIndex > 0, + nextEnabled = tripIndex < tripsInDayCount - 1, + loading = false, + ) + } + LineTripWidget().update(this@WidgetConfigurationActivity, glanceId) + + // 5. Tell the Android OS that the configuration was successful + withContext(Dispatchers.Main) { + val resultValue = Intent().apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } + setResult(RESULT_OK, resultValue) + finish() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/WidgetState.kt b/app/src/main/java/org/stypox/tridenta/widget/WidgetState.kt new file mode 100644 index 0000000..0e47465 --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/WidgetState.kt @@ -0,0 +1,45 @@ +package org.stypox.tridenta.widget + +import kotlinx.serialization.Serializable +import org.stypox.tridenta.db.data.DbStop +import org.stypox.tridenta.enums.Direction +import org.stypox.tridenta.extractor.ROME_ZONE_ID +import org.stypox.tridenta.repo.data.UiLine +import org.stypox.tridenta.repo.data.UiTrip +import org.stypox.tridenta.util.ZonedDateTimeSerializer +import java.time.ZonedDateTime + +@Serializable +sealed interface WidgetState { + @Serializable + data class LineTripsAvailable( + val line: UiLine?, + val trip: UiTrip?, + @Serializable(with = ZonedDateTimeSerializer::class) + val referenceDateTime: ZonedDateTime, + val tripsInDayCount: Int = 0, + val tripIndex: Int = 0, + val prevEnabled: Boolean = false, + val nextEnabled: Boolean = false, + val directionFilter: Direction = Direction.ForwardAndBackward, + val error: Boolean = false, + val loading: Boolean = true, + val showPrevStop: Boolean = false, + ) : WidgetState + + @Serializable + data class StopTripsAvailable( + val stop: DbStop? = null, + val tripIndex: Int = 0, + val trip: UiTrip? = null, + val prevEnabled: Boolean = false, + val nextEnabled: Boolean = false, + @Serializable(with = ZonedDateTimeSerializer::class) + val referenceDateTime: ZonedDateTime = ZonedDateTime.now().withZoneSameInstant(ROME_ZONE_ID), + val loading: Boolean = true, + val error: Boolean = false, + val showPrevStop: Boolean = false, + ) : WidgetState + @Serializable + data class Unavailable(val message: String) : WidgetState +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/actions/WidgetActions.kt b/app/src/main/java/org/stypox/tridenta/widget/actions/WidgetActions.kt new file mode 100644 index 0000000..3acf6ef --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/actions/WidgetActions.kt @@ -0,0 +1,763 @@ +package org.stypox.tridenta.widget.actions + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.stypox.tridenta.db.HistoryDao +import org.stypox.tridenta.enums.Direction +import org.stypox.tridenta.enums.StopLineType +import org.stypox.tridenta.extractor.ROME_ZONE_ID +import org.stypox.tridenta.log.logError +import org.stypox.tridenta.log.logInfo +import org.stypox.tridenta.log.logWarning +import org.stypox.tridenta.repo.LineTripsRepository +import org.stypox.tridenta.repo.LinesRepository +import org.stypox.tridenta.repo.StopTripsRepository +import org.stypox.tridenta.repo.StopsRepository +import org.stypox.tridenta.repo.data.UiTrip +import org.stypox.tridenta.widget.LineTripWidget +import org.stypox.tridenta.widget.LineTripWidgetStateDefinition +import org.stypox.tridenta.widget.WidgetState +import java.time.ZonedDateTime + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface WidgetEntryPoint { + fun lineTripsRepository(): LineTripsRepository + fun lineRepository(): LinesRepository + fun historyDao(): HistoryDao + + suspend fun loadIndex(index: Int, glanceId: GlanceId, context: Context) { + logInfo("loadIndex: $index") + val state: WidgetState = getAppWidgetState( + context, + LineTripWidgetStateDefinition, glanceId + ) + if (state !is WidgetState.LineTripsAvailable) return + if (index < 0) { + logWarning("index must be positive") + return + } + if (index > state.tripsInDayCount - 1) { + logWarning("index must be less than tripsInDayCount") + return + } + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + state.copy(loading = true) + } + LineTripWidget().update(context, glanceId) + // only cancel any currently running job if there is something to do; the above + // condition will be false e.g. when there are no trips in a day (but not only for that) + loadIndexAsync(index, state, context, glanceId) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { s -> + if (s is WidgetState.LineTripsAvailable) s.copy(loading = false) else s + } + LineTripWidget().update(context, glanceId) + } + + private suspend fun loadIndexAsync( + index: Int, + state: WidgetState.LineTripsAvailable, + context: Context, + glanceId: GlanceId + ) { + // hide the current trip, as it's going to change + val prevState = state + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { state -> + if (state is WidgetState.LineTripsAvailable) state.copy( + tripIndex = index, + trip = null, + prevEnabled = index > 0, + nextEnabled = index < state.tripsInDayCount - 1, + ) else state + } + LineTripWidget().update(context, glanceId) + + // load the trip, and show it right after it gets loaded (see the related functions) + val (trip, network) = if (prevState.directionFilter == Direction.ForwardAndBackward) { + loadIndexNoFilterAsync(index, prevState, context, glanceId) + } else { + loadIndexDirectionAsync(index, prevState, context, glanceId) + } + + if (!network && trip != null && trip.completedStops < trip.stopTimes.size) { + // after showing the (possibly) outdated trip fast, reload it to show latest updates + // (but reload it only if there actually is a trip and it is not completed) + onReloadAsync(context, glanceId) + } + } + + suspend fun setReferenceDateTimeAsync( + referenceDateTimeCurrentZone: ZonedDateTime, + state: WidgetState.LineTripsAvailable, + context: Context, + glanceId: GlanceId + ) { + val referenceDateTime = referenceDateTimeCurrentZone.withZoneSameInstant(ROME_ZONE_ID) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { state -> + if (state is WidgetState.LineTripsAvailable) state.copy( + tripsInDayCount = 0, + tripIndex = 0, + trip = null, + prevEnabled = false, + nextEnabled = false, + referenceDateTime = referenceDateTime, + ) else state + } + LineTripWidget().update(context, glanceId) + + var error = false + val (tripsInDayCount, tripIndex, trip) = withContext(Dispatchers.IO) { + try { + lineTripsRepository().getUiTrip( + lineId = state.line?.lineId!!, + lineType = state.line.type, + referenceDateTime = referenceDateTime, + directionFilter = state.directionFilter, + ) + } catch (e: Throwable) { + logError( + "Could not load trip for UI line (${state.line?.lineId}, " + "${state.line?.type}) at time $referenceDateTime", + e + ) + error = true + Triple(0, 0, null) + } + } + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { state -> + if (state is WidgetState.LineTripsAvailable) state.copy( + tripsInDayCount = tripsInDayCount, tripIndex = tripIndex, trip = trip, + prevEnabled = tripIndex > 0, + nextEnabled = tripIndex < tripsInDayCount - 1, + referenceDateTime = referenceDateTime, + error = error + ) else state + } + LineTripWidget().update(context, glanceId) + } + + suspend fun onReloadAsync(context: Context, glanceId: GlanceId) { + val state: WidgetState = getAppWidgetState( + context, + LineTripWidgetStateDefinition, glanceId + ) + if (state !is WidgetState.LineTripsAvailable) return + val previousTrip = state.trip + if (previousTrip == null) {// this could happen if an error happened while loading initial/more trips + if (state.tripsInDayCount > 0) { + // more trips failed loading, try to load again the currently set index + loadIndexAsync(state.tripIndex, state, context, glanceId) + } else { + // initial trips failed loading, try to load again the current day + setReferenceDateTimeAsync(state.referenceDateTime, state, context, glanceId) + } + return + } + + val trip = withContext(Dispatchers.IO) { + logInfo("guilty") + try { + lineTripsRepository().reloadUiTrip( + uiTrip = previousTrip, + index = state.tripIndex, + referenceDateTime = state.referenceDateTime + ) + } catch (e: Throwable) { + logError( + "Could not load trip ${previousTrip.tripId} for UI line " + "(${state.line?.lineId}, ${state.line?.type})", + e + ) + null + } + } + + if (trip == null) { + // keep previous trip intact, we don't want to hide information that we do have! + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + state.copy(error = true) + } + LineTripWidget().update(context, glanceId) + } else { + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + state.copy(trip = trip) + } + LineTripWidget().update(context, glanceId) + } + } + + private suspend fun loadIndexNoFilterAsync( + index: Int, + state: WidgetState.LineTripsAvailable, + context: Context, + glanceId: GlanceId + ): Pair { + val res = withContext(Dispatchers.IO) { + try { + lineTripsRepository().getUiTrip( + lineId = state.line?.lineId!!, + lineType = state.line.type, + referenceDateTime = state.referenceDateTime, + index = index, + ) + } catch (e: Throwable) { + logError( + "Could not load trip at index $index for UI line " + "(${state.line?.lineId}, ${state.line?.type})", + e + ) + Pair(null, true /* <- useless when trip == null */) + } + } + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { state -> + if (state is WidgetState.LineTripsAvailable) state.copy( + trip = res.first, + error = res.first == null + ) else state + } + LineTripWidget().update(context, glanceId) + logInfo("loadIndexNoFilterAsync") + + return res + } + + private suspend fun loadIndexDirectionAsync( + index: Int, prevState: WidgetState.LineTripsAvailable, context: Context, glanceId: GlanceId + ): Pair { + val res = withContext(Dispatchers.IO) { + try { + lineTripsRepository().getUiTripWithDirection( + lineId = prevState.line?.lineId!!, + lineType = prevState.line.type, + referenceDateTime = prevState.referenceDateTime, + index = index, + direction = prevState.directionFilter, + prevIndex = prevState.tripIndex, + ) + } catch (e: Throwable) { + logError( + "Could not load trip at index $index for UI line " + "(${prevState.line?.lineId}, ${prevState.line?.type})" + "in direction ${prevState.directionFilter}", + e + ) + null + } + } + + if (res == null) { + // no trip could be loaded in the set direction, restore previous state, + // but update prevEnabled or nextEnabled + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { state -> + if (state is WidgetState.LineTripsAvailable) state.copy( + tripIndex = prevState.tripIndex, + trip = prevState.trip, + prevEnabled = if (index < prevState.tripIndex) false else prevState.prevEnabled, + nextEnabled = if (index > prevState.tripIndex) false else prevState.nextEnabled, + ) else state + } + LineTripWidget().update(context, glanceId) + + return Pair(null, true /* <- useless when trip == null */) + + } else { + val (trip, newIndex, loadedFromNetwork) = res + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { state -> + if (state is WidgetState.LineTripsAvailable) state.copy( + tripIndex = newIndex, + trip = trip, + prevEnabled = newIndex > 0, + nextEnabled = newIndex < prevState.tripsInDayCount - 1 + ) else state + } + LineTripWidget().update(context, glanceId) + + return Pair(trip, loadedFromNetwork) + } + } +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface WidgetStopTripsEntryPoint { + fun stopsRepository(): StopsRepository + fun tripsRepository(): StopTripsRepository + fun historyDao(): HistoryDao + + suspend fun loadStop( + stopId: Int, + stopType: StopLineType, + context: Context, + glanceId: GlanceId + ) { + val stop = withContext(Dispatchers.IO) { + try { + stopsRepository().getDbStop(stopId, stopType).also { + if (it == null) { + logError( + "DB stop (${stopId}, ${stopType}) not found" + ) + } + + // register a view for this stop (assuming loadStop is called once) + historyDao().registerAccessed(false, stopId, stopType) + } + } catch (e: Throwable) { + logError("Could not load DB stop (${stopId}, ${stopType})", e) + null + } + } + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + if (stop == null) { + WidgetState.Unavailable("Some error has occurred") + } else { + WidgetState.StopTripsAvailable(stop = stop) + } + } + } + + suspend fun setReferenceDateTimeAsync( + referenceDateTimeCurrentZone: ZonedDateTime, + stopId: Int, + stopType: StopLineType, + context: Context, + glanceId: GlanceId + ) { + val referenceDateTime = referenceDateTimeCurrentZone.withZoneSameInstant(ROME_ZONE_ID) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { oldState -> + if (oldState is WidgetState.StopTripsAvailable) oldState.copy( + tripIndex = 0, + trip = null, + prevEnabled = false, + nextEnabled = false, + referenceDateTime = referenceDateTime + ) else oldState + } + LineTripWidget().update(context, glanceId) + + val tripsAtDateTimeList = withContext(Dispatchers.IO) { + try { + tripsRepository().getTrips( + stopId = stopId, + stopType = stopType, + referenceDateTime = referenceDateTime + ) + } catch (e: Throwable) { + logError( + "Could not load trips for DB stop (${stopId}, " + + "${stopType}) at time $referenceDateTime", + e + ) + null + } + } + + if (tripsAtDateTimeList == null) { + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { oldState -> + if (oldState is WidgetState.StopTripsAvailable) oldState.copy(error = true) else oldState + } + } else { + // show the first trip, which should be the next one arriving at the stop; + // requestedByUser is false since the trip is surely up-to-date, as it was just fetched + loadIndexAsync(0, false, stopId, stopType, context, glanceId) + } + } + + suspend fun loadIndex( + index: Int, + context: Context, + glanceId: GlanceId, + stopId: Int, + stopType: StopLineType + ) { + val tripsAtDateTimeList = tripsRepository().getTrips( + stopId = stopId, stopType = stopType, + ZonedDateTime.now().withZoneSameInstant(ROME_ZONE_ID) + ) + if (index >= 0 && index < (tripsAtDateTimeList.tripCount)) { + // only cancel any currently running job if there is something to do; the above + // condition will be false e.g. when there are no trips in a day (but not only for that) + loadIndexAsync(index, true, stopId, stopType, context, glanceId) + } + } + + private suspend fun loadIndexAsync( + index: Int, + requestedByUser: Boolean, + stopId: Int, + stopType: StopLineType, + context: Context, + glanceId: GlanceId + ) { + val tripsAtDateTimeList = tripsRepository().getTrips( + stopId = stopId, stopType = stopType, + ZonedDateTime.now().withZoneSameInstant(ROME_ZONE_ID) + ) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { oldState -> + if (oldState is WidgetState.StopTripsAvailable) oldState.copy( + tripIndex = index, + trip = null, + prevEnabled = index > 0, + nextEnabled = index < (tripsAtDateTimeList.tripCount) - 1, + ) else oldState + } + LineTripWidget().update(context, glanceId) + + val trip = withContext(Dispatchers.IO) { + try { + tripsAtDateTimeList.getUiTripAtIndex(index) + } catch (e: Throwable) { + logError( + "Could not load trip at index $index for DB stop " + + "(${stopId}, ${stopType})", + e + ) + null + } + } + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { oldState -> + if (oldState is WidgetState.StopTripsAvailable) oldState.copy( + trip = trip, + loading = false, + error = trip == null, + ) else oldState + } + LineTripWidget().update(context, glanceId) + + if (requestedByUser && trip != null && trip.completedStops < trip.stopTimes.size) { + // after showing the (possibly) outdated trip fast, reload it to show latest updates + // (but reload it only if there actually is a trip and it is not completed) + onReloadAsync(context, glanceId, stopId, stopType) + } + } + + + suspend fun onReloadAsync( + context: Context, + glanceId: GlanceId, + stopId: Int, + stopType: StopLineType + ) { + val currentState = getAppWidgetState( + context, + LineTripWidgetStateDefinition, glanceId + ) + if (currentState !is WidgetState.StopTripsAvailable) return + val previousTrip = currentState.trip + if (previousTrip == null) { + // initial trips failed loading, try to load again the current day + setReferenceDateTimeAsync( + currentState.referenceDateTime, + stopId, + stopType, + context, + glanceId + ) + return + } + val tripsAtDateTimeList = tripsRepository().getTrips( + stopId = stopId, stopType = stopType, + ZonedDateTime.now().withZoneSameInstant(ROME_ZONE_ID) + ) + + val trip = withContext(Dispatchers.IO) { + try { + tripsAtDateTimeList.reloadUiTrip( + uiTrip = previousTrip, + index = currentState.tripIndex, + referenceDateTime = currentState.referenceDateTime + ) + } catch (e: Throwable) { + logError( + "Could not load trip ${previousTrip.tripId} for DB stop " + + "(${stopId}, ${stopType})", + e + ) + null + } + } + + if (trip == null) { + // keep previous trip intact, we don't want to hide information that we do have! + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { oldState -> + if (oldState is WidgetState.StopTripsAvailable) oldState.copy( + error = true + ) else oldState + } + LineTripWidget().update(context, glanceId) + } else { + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { oldState -> + if (oldState is WidgetState.StopTripsAvailable) oldState.copy( + trip = trip + ) else oldState + } + LineTripWidget().update(context, glanceId) + } + } +} + +class NextTripAction : ActionCallback { + override suspend fun onAction( + context: Context, glanceId: GlanceId, parameters: ActionParameters + ) { + val currentState: WidgetState = getAppWidgetState( + context, + LineTripWidgetStateDefinition, glanceId + ) + when (currentState) { + is WidgetState.LineTripsAvailable -> { + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + currentState.copy(loading = true) + } + LineTripWidget().update(context, glanceId) + val hiltEntryPoint = + EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java) + hiltEntryPoint.loadIndex(currentState.tripIndex + 1, glanceId, context) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { oldState -> + if (oldState is WidgetState.LineTripsAvailable) oldState.copy(loading = false) else oldState + } + LineTripWidget().update(context, glanceId) + } + + is WidgetState.StopTripsAvailable -> { + if (currentState.stop == null) return + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + currentState.copy(loading = true) + } + LineTripWidget().update(context, glanceId) + val hiltEntryPoint = + EntryPointAccessors.fromApplication( + context, + WidgetStopTripsEntryPoint::class.java + ) + hiltEntryPoint.loadIndex( + currentState.tripIndex + 1, + context, + glanceId, + currentState.stop.stopId, + currentState.stop.type + ) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { oldState -> + if (oldState is WidgetState.StopTripsAvailable) oldState.copy(loading = false) else oldState + } + LineTripWidget().update(context, glanceId) + } + + else -> {} + } + + logInfo("NextTripAction performed") + } +} + +class PrevTripAction : ActionCallback { + override suspend fun onAction( + context: Context, glanceId: GlanceId, parameters: ActionParameters + ) { + val currentState: WidgetState = getAppWidgetState( + context, + LineTripWidgetStateDefinition, glanceId + ) + when (currentState) { + is WidgetState.LineTripsAvailable -> { + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + currentState.copy(loading = true) + } + LineTripWidget().update(context, glanceId) + val hiltEntryPoint = + EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java) + hiltEntryPoint.loadIndex(currentState.tripIndex - 1, glanceId, context) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { oldState -> + if (oldState is WidgetState.LineTripsAvailable) oldState.copy(loading = false) else oldState + } + LineTripWidget().update(context, glanceId) + } + + is WidgetState.StopTripsAvailable -> { + if (currentState.stop == null) return + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + currentState.copy(loading = true) + } + LineTripWidget().update(context, glanceId) + val hiltEntryPoint = + EntryPointAccessors.fromApplication( + context, + WidgetStopTripsEntryPoint::class.java + ) + hiltEntryPoint.loadIndex( + currentState.tripIndex - 1, + context, + glanceId, + currentState.stop.stopId, + currentState.stop.type + ) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { oldState -> + if (oldState is WidgetState.StopTripsAvailable) oldState.copy(loading = false) else oldState + } + LineTripWidget().update(context, glanceId) + } + + else -> {} + } + + logInfo("PrevTripAction performed") + } +} + +class ReloadTripAction : ActionCallback { + override suspend fun onAction( + context: Context, glanceId: GlanceId, parameters: ActionParameters + ) { + val currentState: WidgetState = getAppWidgetState( + context, + LineTripWidgetStateDefinition, glanceId + ) + when (currentState) { + is WidgetState.LineTripsAvailable -> { + val hiltEntryPoint = + EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + currentState.copy(loading = true) + } + LineTripWidget().update(context, glanceId) + hiltEntryPoint.onReloadAsync(context, glanceId) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { state -> + if (state is WidgetState.LineTripsAvailable) state.copy(loading = false) else state + } + LineTripWidget().update(context, glanceId) + } + + is WidgetState.StopTripsAvailable -> { + if (currentState.stop == null) return + val hiltEntryPoint = + EntryPointAccessors.fromApplication( + context, + WidgetStopTripsEntryPoint::class.java + ) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + currentState.copy(loading = true) + } + LineTripWidget().update(context, glanceId) + hiltEntryPoint.onReloadAsync( + context, + glanceId, + currentState.stop.stopId, + currentState.stop.type + ) + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { state -> + if (state is WidgetState.StopTripsAvailable) state.copy(loading = false) else state + } + LineTripWidget().update(context, glanceId) + } + + else -> {} + } + } +} + +class ToggleDirectionAction : ActionCallback { + override suspend fun onAction( + context: Context, glanceId: GlanceId, parameters: ActionParameters + ) { + val currentState: WidgetState = getAppWidgetState( + context, + LineTripWidgetStateDefinition, glanceId + ) + + if (currentState !is WidgetState.LineTripsAvailable) return + val hiltEntryPoint = + EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java) + val newDirectionFilter = when (currentState.directionFilter) { + Direction.Forward -> Direction.Backward + Direction.Backward -> Direction.ForwardAndBackward + Direction.ForwardAndBackward -> Direction.Forward + } + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + currentState.copy(directionFilter = newDirectionFilter) + } + LineTripWidget().update(context, glanceId) + + if (newDirectionFilter == Direction.ForwardAndBackward) { + if (currentState.trip == null) { + // the trip can be null if there is no trip in that direction + hiltEntryPoint.loadIndex(currentState.tripIndex, glanceId, context) + } else { + // no need to load the trip, as it's already loaded + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { + currentState.copy( + prevEnabled = currentState.tripIndex > 0, + nextEnabled = currentState.tripIndex < currentState.tripsInDayCount - 1, + directionFilter = newDirectionFilter, + ) + } + LineTripWidget().update(context, glanceId) + } + + } else { + if (currentState.trip?.direction != newDirectionFilter) { + // we need to load another trip, since the current one has the wrong direction + hiltEntryPoint.loadIndex( + currentState.tripIndex, + glanceId, + context + ) + } + } + logInfo("ToggleDirectionAction performed, directionFilter: ${currentState.directionFilter}") + } +} + +class OnStopClickAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + logInfo("OnStopClickAction") + val stopId = parameters[WidgetKeys.STOP_ID] ?: return + val stopTypeRaw = parameters[WidgetKeys.STOP_TYPE] ?: return + val stopType = StopLineType.valueOf(stopTypeRaw) + logInfo("OnStopClickAction") + val currentState: WidgetState = getAppWidgetState( + context, + LineTripWidgetStateDefinition, glanceId + ) + + if (currentState is WidgetState.Unavailable) return + val hiltEntryPoint = + EntryPointAccessors.fromApplication(context, WidgetStopTripsEntryPoint::class.java) + hiltEntryPoint.loadStop(stopId, stopType, context, glanceId) + hiltEntryPoint.setReferenceDateTimeAsync( + ZonedDateTime.now(), + stopId, + stopType, + context, + glanceId + ) + + LineTripWidget().update(context, glanceId) + } +} + +class ToggleShowPrevStop : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + logInfo("ToggleShowPrevStop") + updateAppWidgetState(context, LineTripWidgetStateDefinition, glanceId) { oldState -> + when (oldState) { + is WidgetState.LineTripsAvailable -> oldState.copy(showPrevStop = !oldState.showPrevStop) + is WidgetState.StopTripsAvailable -> oldState.copy(showPrevStop = !oldState.showPrevStop) + else -> oldState + } + } + LineTripWidget().update(context, glanceId) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/actions/WidgetKeys.kt b/app/src/main/java/org/stypox/tridenta/widget/actions/WidgetKeys.kt new file mode 100644 index 0000000..51e26ca --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/actions/WidgetKeys.kt @@ -0,0 +1,8 @@ +package org.stypox.tridenta.widget.actions + +import androidx.glance.action.ActionParameters + +object WidgetKeys { + val STOP_ID = ActionParameters.Key("stop_id") + val STOP_TYPE = ActionParameters.Key("stop_type") +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/theme/SmallCircularProgressIndicatorGlance.kt b/app/src/main/java/org/stypox/tridenta/widget/theme/SmallCircularProgressIndicatorGlance.kt new file mode 100644 index 0000000..0e7136d --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/theme/SmallCircularProgressIndicatorGlance.kt @@ -0,0 +1,20 @@ +package org.stypox.tridenta.widget.theme + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.CircularProgressIndicator +import androidx.glance.layout.size +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview + +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview +@Composable +fun SmallCircularProgressIndicatorGlance(modifier: GlanceModifier = GlanceModifier) { + CircularProgressIndicator( + color = GlanceTheme.colors.onPrimaryContainer, + modifier = modifier.size(16.dp), + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/ui/LineSelectionScreenWidget.kt b/app/src/main/java/org/stypox/tridenta/widget/ui/LineSelectionScreenWidget.kt new file mode 100644 index 0000000..0f90e1d --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/ui/LineSelectionScreenWidget.kt @@ -0,0 +1,116 @@ +package org.stypox.tridenta.widget.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.annotation.Destination +import org.stypox.tridenta.R +import org.stypox.tridenta.db.data.DbLine +import org.stypox.tridenta.enums.Area +import org.stypox.tridenta.ui.lines.AreaChip +import org.stypox.tridenta.ui.lines.LineItem +import org.stypox.tridenta.ui.lines.LinesUiState +import org.stypox.tridenta.ui.lines.LinesViewModel +import org.stypox.tridenta.ui.lines.SelectAreaDialog +import org.stypox.tridenta.ui.nav.DEEP_LINK_URL_PATTERN + +@Destination( + deepLinks = [DeepLink(uriPattern = DEEP_LINK_URL_PATTERN)] +) +@Composable +fun LineSelectionScreenWidget( + onLineSelected: (DbLine) -> Unit, +) { + val linesViewModel: LinesViewModel = hiltViewModel() + + val linesUiState by linesViewModel.uiState.collectAsState() + val lastReloadWasError by linesViewModel.lastReloadWasError.collectAsState() + return LineSelectionScreenWidget( + criticalError = linesUiState.error, + minorError = lastReloadWasError, + loading = linesUiState.loading, + onReload = linesViewModel::onReload, + state = linesUiState, + onLineSelected = onLineSelected, + setSelectedArea = linesViewModel::setSelectedArea + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LineSelectionScreenWidget( + criticalError: Boolean, + minorError: Boolean, + loading: Boolean, + onReload: () -> Unit, + state: LinesUiState, + onLineSelected: (DbLine) -> Unit, + setSelectedArea: (Area) -> Unit +) { + var showAreaDialog by rememberSaveable { mutableStateOf(false) } + if (showAreaDialog) { + SelectAreaDialog( + selectedArea = state.selectedArea, + setSelectedArea = setSelectedArea, + onDismiss = { showAreaDialog = false } + ) + } + Scaffold(topBar = { + TopAppBar(title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(text = stringResource(R.string.selected_area)) + AreaChip( + area = state.selectedArea, + onClick = { showAreaDialog = true } + ) + } + }) + }) { paddingValues -> + PullToRefreshBox( + isRefreshing = loading, + state = rememberPullToRefreshState(), + onRefresh = onReload, + modifier = Modifier + .padding(paddingValues) + .fillMaxHeight() + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + ) { + items(state.lines) { line -> + LineItem( + line, true, + modifier = Modifier.clickable { onLineSelected(line) }, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/ui/LineTripsScreenGlance.kt b/app/src/main/java/org/stypox/tridenta/widget/ui/LineTripsScreenGlance.kt new file mode 100644 index 0000000..09eb1a4 --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/ui/LineTripsScreenGlance.kt @@ -0,0 +1,183 @@ +package org.stypox.tridenta.widget.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.background +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import org.stypox.tridenta.R +import org.stypox.tridenta.enums.Direction +import org.stypox.tridenta.repo.data.UiLine +import org.stypox.tridenta.repo.data.UiTrip +import org.stypox.tridenta.util.textColorOnBackground +import org.stypox.tridenta.util.toLineColor +import org.stypox.tridenta.widget.theme.SmallCircularProgressIndicatorGlance + +@Composable +fun LineTripsWidgetScreen( + line: UiLine?, + trip: UiTrip?, + error: Boolean, + loading: Boolean, + prevEnabled: Boolean, + nextEnabled: Boolean, + isFavorite: Boolean, + showPrevStop: Boolean, + directionFilter: Direction, + onReloadAction: Action, + onPrevAction: Action, + onNextAction: Action, + onLineClickAction: Action, + onDirectionClickAction: Action +) { + Column( + modifier = GlanceModifier.fillMaxSize().background(GlanceTheme.colors.background) + ) { + LineAppBar( + line = line, + isFavorite = isFavorite, + directionFilter = directionFilter, + onDirectionAction = onDirectionClickAction, + onLineClickAction = onLineClickAction, + ) + TripViewGlance( + trip = trip, + error = error, + loading = loading, + onReloadAction = onReloadAction, + onPrevAction = onPrevAction, + onNextAction = onNextAction, + prevEnabled = prevEnabled, + nextEnabled = nextEnabled, + stopIdToHighlight = null, + stopTypeToHighlight = null, + showPrevStop + ) + } +} + +@Composable +fun LineAppBar( + line: UiLine?, + isFavorite: Boolean, + directionFilter: Direction, + onDirectionAction: Action, + onLineClickAction: Action, +) { + val context = LocalContext.current + // The main container Row + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + provider = ImageProvider(R.drawable.menu), + contentDescription = "menu", + modifier = GlanceModifier.clickable(onLineClickAction).padding(end = 8.dp), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), + ) + + // 1. The Title (Left Side) + // defaultWeight() makes the text take up all leftover space, + // pushing the icons to the far right. + + if (line == null) { + SmallCircularProgressIndicatorGlance() + } else { + val shortNameBackground = line.color.toLineColor() + val textColor = textColorOnBackground(shortNameBackground) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = context.getString(R.string.trips_for_line), + style = TextStyle( + color = GlanceTheme.colors.onSurface, + ), + modifier = GlanceModifier.defaultWeight() + ) + Spacer(GlanceModifier.size(8.dp)) + Box( + modifier = GlanceModifier + .background(shortNameBackground) + .padding(8.dp) + ) { + Text( + text = line.shortName, + maxLines = 1, + style = TextStyle( + color = ColorProvider(day = textColor, night = textColor), + fontWeight = FontWeight.Bold + ) + ) + } + } + } + Spacer(GlanceModifier.defaultWeight()) + + // 2. The Action Icons (Right Side) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // News/Warning Icon + if (line != null && line.newsItems.isNotEmpty()) { + Image( + provider = ImageProvider(R.drawable.warning_filled), + contentDescription = "News", + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface), + modifier = GlanceModifier + .padding(end = 8.dp) + ) + } + + // Direction Toggle Icon + Image( + provider = ImageProvider(getDirectionDrawable(directionFilter)), + contentDescription = "Toggle Direction", + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), + modifier = GlanceModifier + .padding(end = 8.dp) + .clickable(onDirectionAction) + ) + + // Favorite Toggle Icon + Image( + provider = ImageProvider( + if (isFavorite) R.drawable.favorite_filled else R.drawable.favorite + ), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface), + contentDescription = context.getString(R.string.favorite), + ) + } + } +} + +// Helper to resolve drawables for Glance +private fun getDirectionDrawable(direction: Direction): Int { + return when (direction) { + Direction.Forward -> R.drawable.turn_sharp_right + Direction.Backward -> R.drawable.u_turn_left + Direction.ForwardAndBackward -> R.drawable.swap_calls + } +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/ui/StopTripsScreenGlance.kt b/app/src/main/java/org/stypox/tridenta/widget/ui/StopTripsScreenGlance.kt new file mode 100644 index 0000000..a9fc9fb --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/ui/StopTripsScreenGlance.kt @@ -0,0 +1,105 @@ +package org.stypox.tridenta.widget.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import org.stypox.tridenta.R +import org.stypox.tridenta.db.data.DbStop +import org.stypox.tridenta.repo.data.UiTrip +import org.stypox.tridenta.widget.theme.SmallCircularProgressIndicatorGlance + +@Composable +fun StopTripsScreenGlance( + stop: DbStop?, + trip: UiTrip?, + error: Boolean, + loading: Boolean, + prevEnabled: Boolean, + nextEnabled: Boolean, + onReloadAction: Action, + onPrevAction: Action, + onNextAction: Action, + onLineClickAction: Action, + isFavorite: Boolean, + showPrevStop: Boolean, +) { + Column( + modifier = GlanceModifier.fillMaxSize().background(GlanceTheme.colors.background) + ) { + StopAppBar(stop, isFavorite, onLineClickAction) + TripViewGlance( + trip = trip, + error = error, + loading = loading, + onReloadAction = onReloadAction, + onPrevAction = onPrevAction, + onNextAction = onNextAction, + prevEnabled = prevEnabled, + nextEnabled = nextEnabled, + stopIdToHighlight = stop?.stopId, + stopTypeToHighlight = stop?.type, + showPrevStop, + ) + } +} + +@Composable +fun StopAppBar( + stop: DbStop?, + isFavorite: Boolean, + onLineClickAction: Action, +) { + val context = LocalContext.current + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + provider = ImageProvider(R.drawable.menu), + contentDescription = "menu", + modifier = GlanceModifier.clickable(onLineClickAction).padding(end = 8.dp), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), + ) + if (stop == null) { + SmallCircularProgressIndicatorGlance() + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stop.name, + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurface) + ) + } + } + Spacer(GlanceModifier.defaultWeight()) + + Image( + provider = ImageProvider( + if (isFavorite) R.drawable.favorite_filled else R.drawable.favorite + ), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface), + contentDescription = context.getString(R.string.favorite), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/ui/TripViewGlance.kt b/app/src/main/java/org/stypox/tridenta/widget/ui/TripViewGlance.kt new file mode 100644 index 0000000..ab577c1 --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/ui/TripViewGlance.kt @@ -0,0 +1,522 @@ +package org.stypox.tridenta.widget.ui + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.Button +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.Action +import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable +import androidx.glance.appwidget.CircularProgressIndicator +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import org.stypox.tridenta.R +import org.stypox.tridenta.db.data.DbLine +import org.stypox.tridenta.db.data.DbStop +import org.stypox.tridenta.enums.Area +import org.stypox.tridenta.enums.CardinalPoint +import org.stypox.tridenta.enums.Direction +import org.stypox.tridenta.enums.StopLineType +import org.stypox.tridenta.repo.data.UiStopTime +import org.stypox.tridenta.repo.data.UiTrip +import org.stypox.tridenta.ui.MainActivity +import org.stypox.tridenta.util.formatDateFull +import org.stypox.tridenta.util.textColorOnBackground +import org.stypox.tridenta.util.toLineColor +import java.time.OffsetDateTime +import java.time.ZoneOffset + +fun formatDurationMinutes(context: Context, minutes: Int): String { + return context.getString(R.string.short_minute_format, minutes) +} + +@Composable +fun TripViewGlance( + trip: UiTrip?, + error: Boolean, + loading: Boolean, + onReloadAction: Action, + onPrevAction: Action, + onNextAction: Action, + prevEnabled: Boolean, + nextEnabled: Boolean, + stopIdToHighlight: Int?, + stopTypeToHighlight: StopLineType?, + showPrevStop: Boolean, + modifier: GlanceModifier = GlanceModifier +) { + val context = LocalContext.current + + Column( + modifier = modifier.fillMaxSize(), + ) { + Box( + modifier = GlanceModifier.defaultWeight().fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + if (trip != null) { + Column( + modifier = GlanceModifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TripViewTopRowGlance( + trip = trip, + modifier = GlanceModifier.padding( + start = 12.dp, + top = 4.dp, + end = 12.dp, + bottom = 12.dp + ).fillMaxWidth() + ) + + if (error) { + Text( + text = context.getString(R.string.error), + style = TextStyle(color = GlanceTheme.colors.error), + modifier = GlanceModifier.padding(8.dp) + ) + } + + TripViewStopsGlance( + trip = trip, + stopIdToHighlight = stopIdToHighlight, + stopTypeToHighlight = stopTypeToHighlight, + showPrevStop, + modifier = GlanceModifier.defaultWeight() // Crucial for lists in Columns + ) + } + } else if (loading) { + CircularProgressIndicator() + } else if (error) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = context.getString(R.string.error), + style = TextStyle(color = GlanceTheme.colors.error) + ) + Button(text = context.getString(R.string.reload), onClick = onReloadAction) + } + } else { + Column( + modifier = GlanceModifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = context.getString(R.string.no_trip_found), + style = TextStyle( + fontWeight = FontWeight.Bold, + color = GlanceTheme.colors.onBackground + ), + modifier = GlanceModifier.padding(bottom = 4.dp) + ) + Text( + text = context.getString(R.string.no_trip_found_description), + style = TextStyle( + textAlign = TextAlign.Center, + color = GlanceTheme.colors.onBackground + ) + ) + } + } + } + + // Bottom Row is aligned to the bottom using a Box setup + TripViewBottomRowGlance( + loading = loading, + onReloadAction = onReloadAction, + onPrevAction = onPrevAction, + onNextAction = onNextAction, + prevEnabled = prevEnabled, + nextEnabled = nextEnabled, + modifier = GlanceModifier.fillMaxWidth() + ) + } +} + +@Composable +fun StopLineTypeIcon( + stopLineType: StopLineType, + context: Context, + modifier: GlanceModifier = GlanceModifier +) { + Image( + provider = ImageProvider( + when (stopLineType) { + StopLineType.Urban -> R.drawable.location_city + StopLineType.Suburban -> R.drawable.landscape + } + ), + contentDescription = context.getString( + when (stopLineType) { + StopLineType.Urban -> R.string.urban + StopLineType.Suburban -> R.string.suburban + } + ), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface), + modifier = modifier, + ) +} + +@Composable +fun DirectionIconGlance( + direction: Direction, + context: Context, + modifier: GlanceModifier = GlanceModifier +) { + Image( + provider = ImageProvider( + when (direction) { + Direction.Forward -> R.drawable.turn_sharp_right + Direction.Backward -> R.drawable.u_turn_left + Direction.ForwardAndBackward -> R.drawable.swap_calls + } + ), + contentDescription = context.getString( + when (direction) { + Direction.Forward -> R.string.forward + Direction.Backward -> R.string.backward + Direction.ForwardAndBackward -> R.string.forward_and_backward + } + ), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface), + modifier = modifier + ) +} + +@Composable +private fun TripViewTopRowGlance( + trip: UiTrip, + modifier: GlanceModifier = GlanceModifier +) { + val context = LocalContext.current + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + if (trip.line != null) { + // Replaced Surface with Box + background + val shortNameBackground = trip.line.color.toLineColor() + val textColor = textColorOnBackground(shortNameBackground) + Box( + modifier = GlanceModifier + .background(shortNameBackground) + .padding(8.dp) + ) { + Text( + text = trip.line.shortName, + maxLines = 1, + style = TextStyle( + color = ColorProvider(day = textColor, night = textColor), + fontWeight = FontWeight.Bold + ) + ) + } + } + + Column( + modifier = GlanceModifier.defaultWeight().padding(horizontal = 12.dp) + ) { + Text( + text = trip.headSign, + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.onBackground, + fontWeight = FontWeight.Medium + ) + ) + + val dateOrDelayText = if (trip.lastEventReceivedAt == null) { + trip.stopTimes.firstNotNullOfOrNull { it.arrivalTime } + ?.let { firstArrival -> formatDateFull(firstArrival) } + ?: context.getString(R.string.no_date_time_information) + } else { + if (trip.delay < 0) + context.getString(R.string.early, formatDurationMinutes(context, -trip.delay)) + else if (trip.delay == 0) + context.getString(R.string.on_time) + else + context.getString(R.string.late, formatDurationMinutes(context, trip.delay)) + } + Text( + text = dateOrDelayText, + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant) + ) + } + + Column { + StopLineTypeIcon(trip.type, context) + DirectionIconGlance(trip.direction, context) + } + } +} + +@Composable +private fun TripViewBottomRowGlance( + loading: Boolean, + onReloadAction: Action, + onPrevAction: Action, + onNextAction: Action, + prevEnabled: Boolean, + nextEnabled: Boolean, + modifier: GlanceModifier = GlanceModifier +) { + val context = LocalContext.current + val buttonModifier = GlanceModifier.size(48.dp) + .cornerRadius(12.dp) + .background(GlanceTheme.colors.primaryContainer) + val buttonModifierDisabled = buttonModifier.background( + GlanceTheme.colors.primaryContainer.getColor(context).copy(alpha = 0.5f) + ) + + // Widgets don't support FloatingActionButtons. Use standard Buttons or Images. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .background(GlanceTheme.colors.widgetBackground) + .padding(16.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = if (prevEnabled) buttonModifier.clickable(onPrevAction) else buttonModifierDisabled + ) { + Image( + provider = ImageProvider(R.drawable.arrow_left), + contentDescription = context.getString(R.string.previous), + modifier = GlanceModifier + .size(32.dp), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer), + //alpha = if (prevEnabled) 1.0f else 0.5f //TODO upgrade to glance 1.2 for this feature + ) + } + Spacer(GlanceModifier.defaultWeight()) + + + Box( + contentAlignment = Alignment.Center, + modifier = buttonModifier.clickable(onReloadAction) + ) { + if (loading) { + CircularProgressIndicator( + color = GlanceTheme.colors.onPrimaryContainer, + modifier = GlanceModifier.defaultWeight() + .size(24.dp) + ) + } else { + Image( + provider = ImageProvider(R.drawable.refresh), + contentDescription = context.getString(R.string.reload), + modifier = GlanceModifier + .size(24.dp), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer) + ) + } + } + Spacer(GlanceModifier.defaultWeight()) + + Box( + contentAlignment = Alignment.Center, + modifier = if (nextEnabled) buttonModifier.clickable(onNextAction) else buttonModifierDisabled + ) { + Image( + provider = ImageProvider(R.drawable.arrow_right), + contentDescription = context.getString(R.string.next), + GlanceModifier.size(32.dp), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer) + //alpha = if (prevEnabled) 1.0f else 0.5f //TODO upgrade to glance 1.2 for this feature + ) + } + } +} + +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview +@Composable +private fun TripViewPreview() { + val sampleDbStops: List = listOf( + DbStop( + stopId = 0, + latitude = 0.0, + longitude = 0.0, + name = "Sorni", + street = "Sorni", + town = "", + type = StopLineType.Urban, + wheelchairAccessible = false, + cardinalPoint = CardinalPoint.North, + isFavorite = false, + ), + DbStop( + stopId = 1, + latitude = 0.0, + longitude = 0.0, + name = "Funivia-Staz. di Monte-Sardagna lorem ipsum dolor sit amet", + street = "Cembra - Via 4 Novembre - Dir.Cavalese lorem ipsum dolor sit", + town = "Appiano sulla strada del vino", + type = StopLineType.Suburban, + wheelchairAccessible = true, + cardinalPoint = null, + isFavorite = true, + ), + DbStop( + stopId = 2, + latitude = 0.0, + longitude = 0.0, + name = "Pinè Bivio", + street = "Civezzano-Loc.La Mochena", + town = "Civezzano", + type = StopLineType.Suburban, + wheelchairAccessible = false, + cardinalPoint = CardinalPoint.NorthWest, + isFavorite = true, + ), + DbStop( + stopId = 3, + latitude = 0.0, + longitude = 0.0, + name = "Verona Big Center", + street = "Verona \"Big Center\"", + town = "Trento", + type = StopLineType.Suburban, + wheelchairAccessible = true, + cardinalPoint = null, + isFavorite = false, + ) + ) + val sampleDbLines: Sequence = sequenceOf( + DbLine( + lineId = 396, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0xc52720, + longName = "Cortesano Gardolo P.Dante Villazzano 3", + shortName = "3", + isFavorite = false + ), + DbLine( + lineId = 404, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0x52332a, + longName = "Centochiavi Piazza Dante Mattarello", + shortName = "8", + isFavorite = true + ), + DbLine( + lineId = 466, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0xbf6092, + longName = "P.Dante Rosmini S.Rocco Povo Polo Soc.", + shortName = "13", + isFavorite = false + ), + DbLine( + lineId = 415, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0xe490b0, + longName = "P.Dante Via Sanseverino Belvedere Ravina", + shortName = "14", + isFavorite = true + ), + DbLine( + lineId = 109, + type = StopLineType.Suburban, + area = Area.Suburban1, + color = null, + longName = "Cavalese - Masi di Cavalese", + shortName = "B109", + isFavorite = true + ), + DbLine( + lineId = 201, + type = StopLineType.Suburban, + area = Area.Suburban2, + color = null, + longName = "Trento-Vezzano-Sarche-Tione", + shortName = "B201", + isFavorite = false + ), + ) + val referenceDateTime = + OffsetDateTime.of(2022, 9, 26, 9, 33, 17, 328943849, ZoneOffset.UTC) + GlanceTheme { + TripViewGlance( + trip = UiTrip( + delay = 1, + direction = Direction.Backward, + lastEventReceivedAt = referenceDateTime.minusMinutes(3), + lineId = sampleDbLines.first().lineId, + line = sampleDbLines.first(), + headSign = "Conci \"Villazzano 3\"", + tripId = "0003726592022061120220911", + type = StopLineType.Urban, + completedStops = 2, + stopTimes = sampleDbStops.mapIndexed { index, dbStop -> + UiStopTime( + arrivalTime = referenceDateTime.plusMinutes((index - 2).toLong()), + departureTime = referenceDateTime.plusMinutes((index + index % 2 - 2).toLong()), + stop = dbStop + ) + }, + busId = 886, + ), + error = false, + loading = false, + onReloadAction = actionStartActivity(), + onPrevAction = actionStartActivity(), + onNextAction = actionStartActivity(), + prevEnabled = true, + nextEnabled = true, + stopIdToHighlight = null, + stopTypeToHighlight = null, + showPrevStop = false, + ) + } +} + +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview +@Composable +private fun TripViewPreviewLoading() { + val loading = true + GlanceTheme { + TripViewGlance( + trip = null, + error = false, + loading = loading, + onReloadAction = actionStartActivity(), + onPrevAction = actionStartActivity(), + onNextAction = actionStartActivity(), + prevEnabled = true, + nextEnabled = true, + stopIdToHighlight = null, + stopTypeToHighlight = null, + showPrevStop = false + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/stypox/tridenta/widget/ui/TripViewStopsGlance.kt b/app/src/main/java/org/stypox/tridenta/widget/ui/TripViewStopsGlance.kt new file mode 100644 index 0000000..a6f1991 --- /dev/null +++ b/app/src/main/java/org/stypox/tridenta/widget/ui/TripViewStopsGlance.kt @@ -0,0 +1,455 @@ +package org.stypox.tridenta.widget.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.itemsIndexed +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextDecoration +import androidx.glance.text.TextStyle +import org.stypox.tridenta.R +import org.stypox.tridenta.db.data.DbLine +import org.stypox.tridenta.db.data.DbStop +import org.stypox.tridenta.enums.Area +import org.stypox.tridenta.enums.CardinalPoint +import org.stypox.tridenta.enums.Direction +import org.stypox.tridenta.enums.StopLineType +import org.stypox.tridenta.extractor.data.ExTrip +import org.stypox.tridenta.repo.data.UiStopTime +import org.stypox.tridenta.repo.data.UiTrip +import org.stypox.tridenta.util.formatConcatStrings +import org.stypox.tridenta.util.formatTime +import org.stypox.tridenta.widget.actions.OnStopClickAction +import org.stypox.tridenta.widget.actions.ToggleShowPrevStop +import org.stypox.tridenta.widget.actions.WidgetKeys +import java.time.OffsetDateTime +import java.time.ZoneOffset + +@Composable +fun TripViewStopsGlance( + trip: UiTrip, + stopIdToHighlight: Int?, + stopTypeToHighlight: StopLineType?, + showPrevStop: Boolean, + modifier: GlanceModifier = GlanceModifier, +) { + val context = LocalContext.current + LazyColumn( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + val index = 0 + val stopTime = trip.stopTimes[index] + TripViewStopItemGlance( + trip = trip, + highlight = stopTime.stop != null && + stopTime.stop.stopId == stopIdToHighlight && + stopTime.stop.type == stopTypeToHighlight, + completed = index < trip.completedStops, + stopTime = stopTime, + modifier = if (stopTime.stop == null) { + GlanceModifier // not clickable, since there is no stop + } else { + GlanceModifier.clickable( + actionRunCallback( + actionParametersOf( + WidgetKeys.STOP_ID to stopTime.stop.stopId, + WidgetKeys.STOP_TYPE to stopTime.stop.type.name + ) + ) + ) + } + ) + } + + item { + if (trip.completedStops < 2) return@item + Row( + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + modifier = GlanceModifier.fillMaxWidth() + .clickable(actionRunCallback()) + ) { + Text( + if (showPrevStop) "Hide completed stops" else "Show completed stops", + style = TextStyle( + color = GlanceTheme.colors.onSurface + ), + modifier = GlanceModifier.padding(8.dp) + ) + Image( + ImageProvider(if (showPrevStop) R.drawable.arrow_up else R.drawable.arrow_down), + contentDescription = "toggle show stops", + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), + modifier = GlanceModifier.padding(8.dp) + ) + } + } + + itemsIndexed(trip.stopTimes) { index, stopTime -> + if (!(trip.stopTimes.size - 1 > index && index > 0)) return@itemsIndexed + if (!showPrevStop && index < trip.completedStops) return@itemsIndexed + TripViewStopItemGlance( + trip = trip, + highlight = stopTime.stop != null && + stopTime.stop.stopId == stopIdToHighlight && + stopTime.stop.type == stopTypeToHighlight, + completed = index < trip.completedStops, + stopTime = stopTime, + modifier = if (stopTime.stop == null) { + GlanceModifier // not clickable, since there is no stop + } else { + GlanceModifier.clickable( + actionRunCallback( + actionParametersOf( + WidgetKeys.STOP_ID to stopTime.stop.stopId, + WidgetKeys.STOP_TYPE to stopTime.stop.type.name + ) + ) + ) + } + ) + } + + item { + val index = trip.stopTimes.size - 1 + val stopTime = trip.stopTimes[index] + TripViewStopItemGlance( + trip = trip, + highlight = stopTime.stop != null && + stopTime.stop.stopId == stopIdToHighlight && + stopTime.stop.type == stopTypeToHighlight, + completed = index < trip.completedStops, + stopTime = stopTime, + modifier = if (stopTime.stop == null) { + GlanceModifier // not clickable, since there is no stop + } else { + GlanceModifier.clickable( + actionRunCallback( + actionParametersOf( + WidgetKeys.STOP_ID to stopTime.stop.stopId, + WidgetKeys.STOP_TYPE to stopTime.stop.type.name + ) + ) + ) + } + ) + } + + item { + Text( + text = formatConcatStrings( + if (trip.lastEventReceivedAt == null) { + context.getString(R.string.no_update) + } else { + context.getString( + R.string.last_update, + formatTime(trip.lastEventReceivedAt) + ) + }, + if (trip.busId == ExTrip.BUS_ID_UNKNOWN) { + null + } else { + context.getString(R.string.bus_id, trip.busId) + } + ), + style = TextStyle( + color = GlanceTheme.colors.onSurface + ), + modifier = GlanceModifier.padding(8.dp) + ) + } + } +} + +@Composable +private fun TripViewStopItemGlance( + trip: UiTrip, + highlight: Boolean, + completed: Boolean, + stopTime: UiStopTime, + modifier: GlanceModifier = GlanceModifier, +) { + val context = LocalContext.current + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(horizontal = 16.dp, vertical = 0.dp).fillMaxWidth() + ) { + Image( + provider = ImageProvider( + if (trip.lastEventReceivedAt == null) { + R.drawable.arrow_right + } else if (completed) { + R.drawable.check_circle + } else { + R.drawable.radio_button_unchecked + } + ), + contentDescription = null, + colorFilter = ColorFilter.tint(GlanceTheme.colors.primary), + modifier = GlanceModifier.padding( + end = 6.dp, + ), + ) + + if (stopTime.stop?.isFavorite == true) { + Image( + provider = ImageProvider(R.drawable.favorite_filled), + contentDescription = context.getString(R.string.favorite), + colorFilter = ColorFilter.tint(GlanceTheme.colors.primary), + modifier = GlanceModifier.padding(end = 3.dp) + .height(16.dp), + ) + } + + stopTime.stop?.cardinalPoint?.let { + Text( + text = context.getString(it.shortName), + style = TextStyle( + color = GlanceTheme.colors.primary + ), + modifier = GlanceModifier.padding(end = 3.dp), + ) + } + + val textColor = if (stopTime.stop == null) { + GlanceTheme.colors.error + } else if (highlight) { + GlanceTheme.colors.primary + } else { + GlanceTheme.colors.onSurface + } + + Text( + text = stopTime.stop?.name ?: context.getString(R.string.error), + maxLines = 1, + style = TextStyle( + fontWeight = if (highlight) FontWeight.Bold else null, + color = textColor + ), + modifier = GlanceModifier.defaultWeight() + .run { + if (stopTime.arrivalTime == null && stopTime.departureTime == null) { + this // do not apply end padding if there is nothing after + } else { + padding(end = 6.dp) + } + }, + ) + + val isLate = trip.lastEventReceivedAt != null + && !completed + && trip.delay > 0 + val lateDecoration = if (isLate) TextDecoration.LineThrough else null + val highlightWeight = if (highlight) FontWeight.Bold else null + + if (stopTime.arrivalTime != null) { + // TODO `key` forces recompositions when `lateDecoration` changes, needed because of + // probably a bug in Compose (also see below) + Text( + text = formatTime(stopTime.arrivalTime), + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontWeight = highlightWeight, + textDecoration = lateDecoration + ), + ) + } + if (stopTime.arrivalTime != stopTime.departureTime) { + if (stopTime.arrivalTime != null) { + Image( + provider = ImageProvider(R.drawable.double_arrow), + contentDescription = null, + modifier = GlanceModifier.size(8.dp), + ) + } + if (stopTime.departureTime != null) { + Text( + text = formatTime(stopTime.departureTime), + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontWeight = highlightWeight, + textDecoration = lateDecoration + ) + ) + } + } + if (isLate) { + sequenceOf(stopTime.arrivalTime, stopTime.departureTime).firstOrNull { it != null } + ?.let { time -> + Text( + text = formatTime( + time.plusMinutes(trip.delay.toLong()) + ), + modifier = GlanceModifier.padding(start = 5.dp), + maxLines = 1, + style = TextStyle( + fontWeight = highlightWeight, + color = GlanceTheme.colors.error + ), + ) + } + } + } +} + +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 250, heightDp = 130) +@Composable +fun TripViewStopsPreview() { + val sampleDbStops: List = listOf( + DbStop( + stopId = 0, + latitude = 0.0, + longitude = 0.0, + name = "Sorni", + street = "Sorni", + town = "", + type = StopLineType.Urban, + wheelchairAccessible = false, + cardinalPoint = CardinalPoint.North, + isFavorite = false, + ), + DbStop( + stopId = 1, + latitude = 0.0, + longitude = 0.0, + name = "Funivia-Staz. di Monte-Sardagna lorem ipsum dolor sit amet", + street = "Cembra - Via 4 Novembre - Dir.Cavalese lorem ipsum dolor sit", + town = "Appiano sulla strada del vino", + type = StopLineType.Suburban, + wheelchairAccessible = true, + cardinalPoint = null, + isFavorite = true, + ), + DbStop( + stopId = 2, + latitude = 0.0, + longitude = 0.0, + name = "Pinè Bivio", + street = "Civezzano-Loc.La Mochena", + town = "Civezzano", + type = StopLineType.Suburban, + wheelchairAccessible = false, + cardinalPoint = CardinalPoint.NorthWest, + isFavorite = true, + ), + DbStop( + stopId = 3, + latitude = 0.0, + longitude = 0.0, + name = "Verona Big Center", + street = "Verona \"Big Center\"", + town = "Trento", + type = StopLineType.Suburban, + wheelchairAccessible = true, + cardinalPoint = null, + isFavorite = false, + ) + ) + val sampleDbLines: Sequence = sequenceOf( + DbLine( + lineId = 396, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0xc52720, + longName = "Cortesano Gardolo P.Dante Villazzano 3", + shortName = "3", + isFavorite = false + ), + DbLine( + lineId = 404, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0x52332a, + longName = "Centochiavi Piazza Dante Mattarello", + shortName = "8", + isFavorite = true + ), + DbLine( + lineId = 466, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0xbf6092, + longName = "P.Dante Rosmini S.Rocco Povo Polo Soc.", + shortName = "13", + isFavorite = false + ), + DbLine( + lineId = 415, + type = StopLineType.Urban, + area = Area.UrbanTrento, + color = 0xe490b0, + longName = "P.Dante Via Sanseverino Belvedere Ravina", + shortName = "14", + isFavorite = true + ), + DbLine( + lineId = 109, + type = StopLineType.Suburban, + area = Area.Suburban1, + color = null, + longName = "Cavalese - Masi di Cavalese", + shortName = "B109", + isFavorite = true + ), + DbLine( + lineId = 201, + type = StopLineType.Suburban, + area = Area.Suburban2, + color = null, + longName = "Trento-Vezzano-Sarche-Tione", + shortName = "B201", + isFavorite = false + ), + ) + val referenceDateTime = + OffsetDateTime.of(2022, 9, 26, 9, 33, 17, 328943849, ZoneOffset.UTC) + GlanceTheme { + TripViewStopsGlance( + trip = UiTrip( + delay = 1, + direction = Direction.Backward, + lastEventReceivedAt = referenceDateTime.minusMinutes(3), + lineId = sampleDbLines.first().lineId, + line = sampleDbLines.first(), + headSign = "Conci \"Villazzano 3\"", + tripId = "0003726592022061120220911", + type = StopLineType.Urban, + completedStops = 2, + stopTimes = sampleDbStops.mapIndexed { index, dbStop -> + UiStopTime( + arrivalTime = referenceDateTime.plusMinutes((index - 2).toLong()), + departureTime = referenceDateTime.plusMinutes((index + index % 2 - 2).toLong()), + stop = dbStop + ) + }, + busId = 886, + ), + stopIdToHighlight = null, + stopTypeToHighlight = null, + showPrevStop = false, + ) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/arrow_down.xml b/app/src/main/res/drawable/arrow_down.xml new file mode 100644 index 0000000..4eba1ba --- /dev/null +++ b/app/src/main/res/drawable/arrow_down.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/arrow_left.xml b/app/src/main/res/drawable/arrow_left.xml new file mode 100644 index 0000000..95b0c36 --- /dev/null +++ b/app/src/main/res/drawable/arrow_left.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/arrow_right.xml b/app/src/main/res/drawable/arrow_right.xml new file mode 100644 index 0000000..8437cde --- /dev/null +++ b/app/src/main/res/drawable/arrow_right.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/arrow_up.xml b/app/src/main/res/drawable/arrow_up.xml new file mode 100644 index 0000000..cda82d6 --- /dev/null +++ b/app/src/main/res/drawable/arrow_up.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/check_circle.xml b/app/src/main/res/drawable/check_circle.xml new file mode 100644 index 0000000..7f747c6 --- /dev/null +++ b/app/src/main/res/drawable/check_circle.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/double_arrow.xml b/app/src/main/res/drawable/double_arrow.xml new file mode 100644 index 0000000..dfa8591 --- /dev/null +++ b/app/src/main/res/drawable/double_arrow.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/favorite.xml b/app/src/main/res/drawable/favorite.xml new file mode 100644 index 0000000..fdc35fb --- /dev/null +++ b/app/src/main/res/drawable/favorite.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/favorite_filled.xml b/app/src/main/res/drawable/favorite_filled.xml new file mode 100644 index 0000000..2c2751e --- /dev/null +++ b/app/src/main/res/drawable/favorite_filled.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/landscape.xml b/app/src/main/res/drawable/landscape.xml new file mode 100644 index 0000000..6f1d078 --- /dev/null +++ b/app/src/main/res/drawable/landscape.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/location_city.xml b/app/src/main/res/drawable/location_city.xml new file mode 100644 index 0000000..9a13d6f --- /dev/null +++ b/app/src/main/res/drawable/location_city.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/menu.xml b/app/src/main/res/drawable/menu.xml new file mode 100644 index 0000000..68a77c2 --- /dev/null +++ b/app/src/main/res/drawable/menu.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/radio_button_unchecked.xml b/app/src/main/res/drawable/radio_button_unchecked.xml new file mode 100644 index 0000000..5fd804a --- /dev/null +++ b/app/src/main/res/drawable/radio_button_unchecked.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/refresh.xml b/app/src/main/res/drawable/refresh.xml new file mode 100644 index 0000000..536c0dd --- /dev/null +++ b/app/src/main/res/drawable/refresh.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/swap_calls.xml b/app/src/main/res/drawable/swap_calls.xml new file mode 100644 index 0000000..df12c26 --- /dev/null +++ b/app/src/main/res/drawable/swap_calls.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/turn_sharp_right.xml b/app/src/main/res/drawable/turn_sharp_right.xml new file mode 100644 index 0000000..3597a99 --- /dev/null +++ b/app/src/main/res/drawable/turn_sharp_right.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/u_turn_left.xml b/app/src/main/res/drawable/u_turn_left.xml new file mode 100644 index 0000000..bc3bbe7 --- /dev/null +++ b/app/src/main/res/drawable/u_turn_left.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/warning_filled.xml b/app/src/main/res/drawable/warning_filled.xml new file mode 100644 index 0000000..7c1baad --- /dev/null +++ b/app/src/main/res/drawable/warning_filled.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/xml/my_app_widget_info.xml b/app/src/main/res/xml/my_app_widget_info.xml new file mode 100644 index 0000000..5f85f32 --- /dev/null +++ b/app/src/main/res/xml/my_app_widget_info.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 00a9d59..8a2d9f3 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.org.jetbrains.kotlin.android) apply false alias(libs.plugins.com.google.devtools.ksp) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.kotlin.serialization) apply false } tasks.register('clean', Delete) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76d835b..511cb48 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.9.1" +agp = "8.13.0" composeDestinations = "1.9.54" espressoCore = "3.6.1" hilt = "2.55" @@ -16,9 +16,16 @@ material3 = "1.3.1" okhttp = "4.12.0" preference = "1.2.1" compose = "1.7.8" -kotlin = "2.1.10" -ksp = "2.1.10-1.0.30" +kotlin = "2.2.21" +ksp = "2.3.0" java = "11" +glance = "1.1.1" +kotlinSerializationPlugin = "2.2.21" +kotlinSerialization = "1.9.0" +work = "2.10.0" +workRuntimeKtx = "2.11.1" +hiltCommon = "1.2.0" +hiltWork = "1.2.0" [libraries] activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -49,8 +56,21 @@ compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } +glance = { module = "androidx.glance:glance", version.ref = "glance" } +glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } +glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" } +glance-preview = { module = "androidx.glance:glance-preview", version.ref = "glance" } +glance-appwidget-preview = { module = "androidx.glance:glance-appwidget-preview", version.ref = "glance" } +kotlin-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinSerialization" } +kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerialization" } +work = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" } +androidx-hilt-common = { group = "androidx.hilt", name = "hilt-common", version.ref = "hiltCommon" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWork" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltWork"} [plugins] +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerializationPlugin"} com-android-application = { id = "com.android.application", version.ref = "agp" } org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } com-google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c8..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME