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