From 46dad407441eb75e710b90c194c8f61ef93fd0f6 Mon Sep 17 00:00:00 2001 From: GerardPaligot Date: Fri, 3 May 2024 09:07:04 +0200 Subject: [PATCH] feat(androidApp): create widget to see next talks from the agenda. --- androidApp/build.gradle.kts | 3 + androidApp/src/main/AndroidManifest.xml | 10 +++ .../android/widgets/AgendaAppWidget.kt | 76 +++++++++++++++++++ .../android/widgets/AppWidgetReceiver.kt | 9 +++ .../src/main/res/xml/agenda_widget_info.xml | 13 ++++ gradle/libs.versions.toml | 10 ++- settings.gradle.kts | 3 + .../org/gdglille/devfest/database/EventDao.kt | 15 +++- .../gdglille/devfest/database/ScheduleDao.kt | 29 +++++++ .../devfest/repositories/AgendaRepository.kt | 76 +++++++++---------- .../devfest/repositories/EventRepository.kt | 5 +- .../devfest/repositories/SpeakerRepository.kt | 2 +- .../devfest/repositories/UserRepository.kt | 22 +++--- .../devfest/android/theme/MainNavigation.kt | 7 +- widgets/widgets-feature/build.gradle.kts | 20 +++++ .../widgets/feature/SessionsViewModel.kt | 46 +++++++++++ .../android/widgets/feature/SessionsWidget.kt | 46 +++++++++++ widgets/widgets-screens/build.gradle.kts | 18 +++++ .../android/widgets/screens/SessionsScreen.kt | 47 ++++++++++++ widgets/widgets-ui/build.gradle.kts | 18 +++++ .../android/widgets/ui/PlainMessage.kt | 45 +++++++++++ .../devfest/android/widgets/ui/SessionItem.kt | 58 ++++++++++++++ .../devfest/android/widgets/ui/SessionList.kt | 37 +++++++++ .../devfest/android/widgets/ui/Tag.kt | 44 +++++++++++ .../devfest/android/widgets/ui/TopBar.kt | 30 ++++++++ .../src/main/res/drawable/refresh.xml | 9 +++ .../src/main/res/drawable/schedule.xml | 9 +++ .../src/main/res/drawable/today.xml | 9 +++ .../src/main/res/drawable/videocam.xml | 10 +++ .../src/main/res/values/strings.xml | 7 ++ 30 files changed, 677 insertions(+), 56 deletions(-) create mode 100644 androidApp/src/main/java/org/gdglille/devfest/android/widgets/AgendaAppWidget.kt create mode 100644 androidApp/src/main/java/org/gdglille/devfest/android/widgets/AppWidgetReceiver.kt create mode 100644 androidApp/src/main/res/xml/agenda_widget_info.xml create mode 100644 widgets/widgets-feature/build.gradle.kts create mode 100644 widgets/widgets-feature/src/main/kotlin/org/gdglille/devfest/android/widgets/feature/SessionsViewModel.kt create mode 100644 widgets/widgets-feature/src/main/kotlin/org/gdglille/devfest/android/widgets/feature/SessionsWidget.kt create mode 100644 widgets/widgets-screens/build.gradle.kts create mode 100644 widgets/widgets-screens/src/main/kotlin/org/gdglille/devfest/android/widgets/screens/SessionsScreen.kt create mode 100644 widgets/widgets-ui/build.gradle.kts create mode 100644 widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/PlainMessage.kt create mode 100644 widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/SessionItem.kt create mode 100644 widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/SessionList.kt create mode 100644 widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/Tag.kt create mode 100644 widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/TopBar.kt create mode 100644 widgets/widgets-ui/src/main/res/drawable/refresh.xml create mode 100644 widgets/widgets-ui/src/main/res/drawable/schedule.xml create mode 100644 widgets/widgets-ui/src/main/res/drawable/today.xml create mode 100644 widgets/widgets-ui/src/main/res/drawable/videocam.xml create mode 100644 widgets/widgets-ui/src/main/res/values/strings.xml diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index e765883d1..1dacb5ce7 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -70,6 +70,8 @@ android { dependencies { implementation(projects.themeM3.main.main) implementation(projects.themeM3.main.mainDi) + implementation(projects.themeM3.navigation) + implementation(projects.widgets.widgetsFeature) implementation(projects.shared.core) implementation(projects.shared.coreDi) implementation(projects.shared.resources) @@ -88,6 +90,7 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.profile) implementation(libs.androidx.workmanager.ktx) + implementation(libs.bundles.androidx.glance) implementation(libs.koin.core) implementation(libs.koin.android) diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 623566c1f..e6a52f0de 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -45,6 +45,16 @@ + + + + + + + + get() = PreferencesGlanceStateDefinition + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val date = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).toString() + provideContent { + GlanceTheme { + val prefs = currentState() + SessionsWidget( + eventRepository = eventRepository, + agendaRepository = agendaRepository, + date = prefs.lastUpdate ?: date, + iconId = R.drawable.ic_launcher_foreground, + onUpdate = { + prefs.toMutablePreferences().apply { + this.lastUpdate = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + .toString() + } + update(context, id) + }, + onItemClick = { + actionStartActivity( + intent = Intent( + Intent.ACTION_VIEW, + "c4h://event/schedules/$it".toUri() + ) + ) + } + ) + } + } + } +} + +var MutablePreferences.lastUpdate: String? + set(value) { + this[stringPreferencesKey("last_update")] = value ?: "" + } + get() = this[stringPreferencesKey("last_update")] + +val Preferences.lastUpdate: String? + get() = this[stringPreferencesKey("last_update")] diff --git a/androidApp/src/main/java/org/gdglille/devfest/android/widgets/AppWidgetReceiver.kt b/androidApp/src/main/java/org/gdglille/devfest/android/widgets/AppWidgetReceiver.kt new file mode 100644 index 000000000..2db5db422 --- /dev/null +++ b/androidApp/src/main/java/org/gdglille/devfest/android/widgets/AppWidgetReceiver.kt @@ -0,0 +1,9 @@ +package org.gdglille.devfest.android.widgets + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class AppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget + get() = AgendaAppWidget() +} diff --git a/androidApp/src/main/res/xml/agenda_widget_info.xml b/androidApp/src/main/res/xml/agenda_widget_info.xml new file mode 100644 index 000000000..77c015787 --- /dev/null +++ b/androidApp/src/main/res/xml/agenda_widget_info.xml @@ -0,0 +1,13 @@ + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 652842a4c..08a878440 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,8 @@ androidx-compose-compiler = "1.5.8" androidx-compose-adaptive = "1.0.0-alpha10" androidx-compose-adaptive-navigation = "1.0.0-alpha05" androidx-espresso = "3.5.1" +androidx-glance = "1.1.0-beta02" +androidx-glance-preview = "1.0.0-alpha06" androidx-junit = "1.1.5" androidx-lifecycle = "2.7.0" androidx-navigation-compose = "2.7.6" @@ -72,6 +74,8 @@ androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.mat androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite", version.ref = "androidx-compose-adaptive-navigation" } androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso" } +androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "androidx-glance" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose" } @@ -166,4 +170,8 @@ androidx-compose-adaptive = [ "androidx-compose-material3-adaptive", "androidx-compose-material3-adaptive-layout", "androidx-compose-material3-adaptive-navigation" -] \ No newline at end of file +] +androidx-glance = [ + "androidx-glance-appwidget", + "androidx-glance-material3" +] diff --git a/settings.gradle.kts b/settings.gradle.kts index 7e717af5e..071888782 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -61,4 +61,7 @@ include(":theme-m3:style:schedules") include(":theme-m3:style:speakers") include(":theme-m3:style:theme") include(":ui-camera") +include(":widgets:widgets-feature") +include(":widgets:widgets-screens") +include(":widgets:widgets-ui") include(":baselineprofile") diff --git a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/database/EventDao.kt b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/database/EventDao.kt index 1a20d716a..210fef6b5 100644 --- a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/database/EventDao.kt +++ b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/database/EventDao.kt @@ -3,13 +3,16 @@ package org.gdglille.devfest.database import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToOne +import app.cash.sqldelight.coroutines.mapToOneOrNull import com.russhwolf.settings.ExperimentalSettingsApi import com.russhwolf.settings.ObservableSettings +import com.russhwolf.settings.coroutines.getStringOrNullFlow import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import org.gdglille.devfest.db.Conferences4HallDatabase import org.gdglille.devfest.exceptions.EventSavedException @@ -98,9 +101,12 @@ class EventDao( fun deleteEventId() = settings.remove("EVENT_ID") - fun fetchEventId(): String = + fun getEventId(): String = settings.getStringOrNull("EVENT_ID") ?: throw EventSavedException() + fun fetchEventId(): Flow = + settings.getStringOrNullFlow("EVENT_ID").map { it ?: throw EventSavedException() } + fun fetchEvent(eventId: String): Flow = db.transactionWithResult { return@transactionWithResult db.eventQueries.selectEvent(eventId, eventMapper).asFlow() .combineTransform( @@ -111,6 +117,13 @@ class EventDao( } } + fun fetchCurrentEvent(): Flow { + val eventId = settings.getStringOrNull("EVENT_ID") ?: return flow { emit(null) } + return db.eventQueries.selectEvent(eventId, eventMapper) + .asFlow() + .mapToOneOrNull(dispatcher) + } + fun fetchQAndA(eventId: String): Flow> = db.transactionWithResult { return@transactionWithResult combine( db.qAndAQueries.selectQAndA(eventId).asFlow().mapToList(dispatcher), diff --git a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/database/ScheduleDao.kt b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/database/ScheduleDao.kt index c434ead8b..8d91d9cbb 100644 --- a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/database/ScheduleDao.kt +++ b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/database/ScheduleDao.kt @@ -6,6 +6,7 @@ import cafe.adriel.lyricist.Lyricist import com.russhwolf.settings.ExperimentalSettingsApi import com.russhwolf.settings.ObservableSettings import com.russhwolf.settings.coroutines.getBooleanFlow +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap @@ -13,6 +14,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.datetime.toLocalDateTime import org.gdglille.devfest.android.shared.resources.Strings import org.gdglille.devfest.database.mappers.convertCategoryUi import org.gdglille.devfest.database.mappers.convertFormatUi @@ -25,6 +28,7 @@ import org.gdglille.devfest.models.ui.AgendaUi import org.gdglille.devfest.models.ui.CategoryUi import org.gdglille.devfest.models.ui.FiltersUi import org.gdglille.devfest.models.ui.FormatUi +import org.gdglille.devfest.models.ui.TalkItemUi import kotlin.coroutines.CoroutineContext @FlowPreview @@ -105,6 +109,31 @@ class ScheduleDao( } ) + fun fetchNextTalks(eventId: String, date: String): Flow> { + val dateTime = date.toLocalDateTime() + return db.sessionQueries + .selectSessions(eventId) + .asFlow() + .mapToList(dispatcher) + .map { sessions -> + val nextAgenda = sessions + .filter { dateTime < it.start_time.toLocalDateTime() } + .map { + val speakers = if (it.talk_id != null) { + db.sessionQueries + .selectSpeakersByTalkId(eventId, it.talk_id) + .executeAsList() + } else { + emptyList() + } + it.convertTalkItemUi(speakers = speakers, strings = lyricist.strings) + } + .groupBy { it.startTime } + val toImmutableList = nextAgenda.values.first().toImmutableList() + return@map toImmutableList + } + } + fun fetchFilters(eventId: String): Flow { return combine( db.categoryQueries diff --git a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/AgendaRepository.kt b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/AgendaRepository.kt index 41854889c..e9d176346 100644 --- a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/AgendaRepository.kt +++ b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/AgendaRepository.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.map import org.gdglille.devfest.database.EventDao import org.gdglille.devfest.database.FeaturesActivatedDao @@ -29,6 +30,7 @@ import org.gdglille.devfest.models.ui.PartnerItemUi import org.gdglille.devfest.models.ui.QuestionAndResponseUi import org.gdglille.devfest.models.ui.ScaffoldConfigUi import org.gdglille.devfest.models.ui.SpeakerUi +import org.gdglille.devfest.models.ui.TalkItemUi import org.gdglille.devfest.models.ui.TalkUi import org.gdglille.devfest.network.ConferenceApi @@ -43,6 +45,7 @@ interface AgendaRepository { fun menus(): Flow> fun coc(): Flow fun agenda(): Flow> + fun fetchNextTalks(date: String): Flow> fun filters(): Flow fun scheduleItem(scheduleId: String): Flow fun hasFilterApplied(): Flow @@ -95,7 +98,7 @@ class AgendaRepositoryImpl( private val coroutineScope: CoroutineScope = MainScope() override suspend fun fetchAndStoreAgenda() { - val eventId = eventDao.fetchEventId() + val eventId = eventDao.getEventId() val etag = scheduleDao.lastEtag(eventId) try { val (newEtag, agenda) = api.fetchAgenda(eventId, etag) @@ -112,7 +115,7 @@ class AgendaRepositoryImpl( } override suspend fun insertOrUpdateTicket(barcode: String) { - val eventId = eventDao.fetchEventId() + val eventId = eventDao.getEventId() val attendee = try { val attendee = api.fetchAttendee(eventId, barcode) attendee @@ -125,64 +128,55 @@ class AgendaRepositoryImpl( override fun scaffoldConfig(): Flow = featuresDao.fetchFeatures() - override fun event(): Flow = eventDao.fetchEvent( - eventId = eventDao.fetchEventId() - ) + override fun event(): Flow = eventDao.fetchEventId() + .flatMapConcat { eventDao.fetchEvent(eventId = it) } - override fun partners(): Flow = partnerDao.fetchPartners( - eventId = eventDao.fetchEventId() - ) + override fun partners(): Flow = eventDao.fetchEventId() + .flatMapConcat { partnerDao.fetchPartners(eventId = it) } - override fun partner(id: String): Flow = partnerDao.fetchPartner( - eventId = eventDao.fetchEventId(), - id = id - ) + override fun partner(id: String): Flow = eventDao.fetchEventId() + .flatMapConcat { partnerDao.fetchPartner(eventId = it, id = id) } - override fun qanda(): Flow> = eventDao.fetchQAndA( - eventId = eventDao.fetchEventId() - ) + override fun qanda(): Flow> = eventDao.fetchEventId() + .flatMapConcat { eventDao.fetchQAndA(eventId = it) } - override fun menus(): Flow> = eventDao.fetchMenus( - eventId = eventDao.fetchEventId() - ) + override fun menus(): Flow> = eventDao.fetchEventId() + .flatMapConcat { eventDao.fetchMenus(eventId = it) } - override fun coc(): Flow = eventDao.fetchCoC( - eventId = eventDao.fetchEventId() - ) + override fun coc(): Flow = eventDao.fetchEventId() + .flatMapConcat { eventDao.fetchCoC(eventId = it) } - override fun agenda(): Flow> = scheduleDao.fetchSchedules( - eventId = eventDao.fetchEventId() - ) + override fun agenda(): Flow> = eventDao.fetchEventId() + .flatMapConcat { scheduleDao.fetchSchedules(eventId = it) } - override fun filters(): Flow = scheduleDao.fetchFilters( - eventId = eventDao.fetchEventId() - ) + override fun fetchNextTalks(date: String): Flow> = + eventDao.fetchEventId() + .flatMapConcat { scheduleDao.fetchNextTalks(eventId = it, date = date) } + + override fun filters(): Flow = eventDao.fetchEventId() + .flatMapConcat { scheduleDao.fetchFilters(it) } - override fun hasFilterApplied(): Flow = scheduleDao.fetchFiltersAppliedCount( - eventId = eventDao.fetchEventId() - ).map { it > 0 } + override fun hasFilterApplied(): Flow = eventDao.fetchEventId() + .flatMapConcat { scheduleDao.fetchFiltersAppliedCount(eventId = it) } + .map { it > 0 } override fun applyFavoriteFilter(selected: Boolean) = scheduleDao.applyFavoriteFilter(selected) override fun applyCategoryFilter(categoryUi: CategoryUi, selected: Boolean) = - scheduleDao.applyCategoryFilter(categoryUi, eventDao.fetchEventId(), selected) + scheduleDao.applyCategoryFilter(categoryUi, eventDao.getEventId(), selected) override fun applyFormatFilter(formatUi: FormatUi, selected: Boolean) = - scheduleDao.applyFormatFilter(formatUi, eventDao.fetchEventId(), selected) + scheduleDao.applyFormatFilter(formatUi, eventDao.getEventId(), selected) - override fun speaker(speakerId: String): Flow = speakerDao.fetchSpeaker( - eventId = eventDao.fetchEventId(), - speakerId = speakerId - ) + override fun speaker(speakerId: String): Flow = eventDao.fetchEventId() + .flatMapConcat { speakerDao.fetchSpeaker(eventId = it, speakerId = speakerId) } override fun markAsRead(sessionId: String, isFavorite: Boolean) = scheduleDao.markAsFavorite( - eventId = eventDao.fetchEventId(), + eventId = eventDao.getEventId(), sessionId = sessionId, isFavorite = isFavorite ) - override fun scheduleItem(scheduleId: String): Flow = talkDao.fetchTalk( - eventId = eventDao.fetchEventId(), - talkId = scheduleId - ) + override fun scheduleItem(scheduleId: String): Flow = eventDao.fetchEventId() + .flatMapConcat { talkDao.fetchTalk(eventId = it, talkId = scheduleId) } } diff --git a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/EventRepository.kt b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/EventRepository.kt index 54ef16101..5d16c22ef 100644 --- a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/EventRepository.kt +++ b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/EventRepository.kt @@ -7,12 +7,14 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import org.gdglille.devfest.database.EventDao import org.gdglille.devfest.exceptions.EventSavedException +import org.gdglille.devfest.models.ui.EventInfoUi import org.gdglille.devfest.models.ui.EventItemListUi import org.gdglille.devfest.network.ConferenceApi interface EventRepository { suspend fun fetchAndStoreEventList() fun events(): Flow + fun currentEvent(): Flow fun isInitialized(): Boolean fun saveEventId(eventId: String) fun deleteEventId() @@ -36,9 +38,10 @@ class EventRepositoryImpl( } override fun events(): Flow = eventDao.fetchEventList() + override fun currentEvent(): Flow = eventDao.fetchCurrentEvent() override fun isInitialized(): Boolean = try { - eventDao.fetchEventId() + eventDao.getEventId() true } catch (_: EventSavedException) { false diff --git a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/SpeakerRepository.kt b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/SpeakerRepository.kt index aea6c31e2..6c46b9156 100644 --- a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/SpeakerRepository.kt +++ b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/SpeakerRepository.kt @@ -28,6 +28,6 @@ class SpeakerRepositoryImpl( private val coroutineScope: CoroutineScope = MainScope() override fun speakers(): Flow> = speakerDao.fetchSpeakers( - eventId = eventDao.fetchEventId() + eventId = eventDao.getEventId() ) } diff --git a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/UserRepository.kt b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/UserRepository.kt index 2880da8da..517b03164 100644 --- a/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/UserRepository.kt +++ b/shared/core/src/commonMain/kotlin/org/gdglille/devfest/repositories/UserRepository.kt @@ -3,9 +3,11 @@ package org.gdglille.devfest.repositories import com.rickclephas.kmp.nativecoroutines.NativeCoroutineScope import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapConcat import org.gdglille.devfest.database.EventDao import org.gdglille.devfest.database.UserDao import org.gdglille.devfest.models.ui.ExportNetworkingUi @@ -44,8 +46,8 @@ class UserRepositoryImpl( private val coroutineScope: CoroutineScope = MainScope() override fun fetchProfile(): Flow = combine( - userDao.fetchProfile(eventId = eventDao.fetchEventId()), - userDao.fetchUserPreview(eventId = eventDao.fetchEventId()), + userDao.fetchProfile(eventId = eventDao.getEventId()), + userDao.fetchUserPreview(eventId = eventDao.getEventId()), transform = { profile, preview -> return@combine profile ?: preview } @@ -56,27 +58,27 @@ class UserRepositoryImpl( UserNetworkingUi(email, firstName, lastName, company).encodeToString() ) val profile = UserProfileUi(email, firstName, lastName, company, qrCode) - userDao.insertUser(eventId = eventDao.fetchEventId(), user = profile) + userDao.insertUser(eventId = eventDao.getEventId(), user = profile) } - override fun fetchNetworking(): Flow> = userDao.fetchNetworking( - eventId = eventDao.fetchEventId() - ) + @OptIn(ExperimentalCoroutinesApi::class) + override fun fetchNetworking(): Flow> = eventDao.fetchEventId() + .flatMapConcat { userDao.fetchNetworking(eventId = it) } override fun insertNetworkingProfile(user: UserNetworkingUi): Boolean { val hasRequiredFields = user.email != "" && user.lastName != "" && user.firstName != "" if (!hasRequiredFields) return false - userDao.insertEmailNetworking(eventId = eventDao.fetchEventId(), userNetworkingUi = user) + userDao.insertEmailNetworking(eventId = eventDao.getEventId(), userNetworkingUi = user) return true } override fun deleteNetworkProfile(email: String) = userDao.deleteNetworking( - eventId = eventDao.fetchEventId(), + eventId = eventDao.getEventId(), email = email ) override fun exportNetworking(): ExportNetworkingUi = ExportNetworkingUi( - mailto = userDao.getEmailProfile(eventDao.fetchEventId()), - filePath = userDao.exportNetworking(eventId = eventDao.fetchEventId()) + mailto = userDao.getEmailProfile(eventDao.getEventId()), + filePath = userDao.exportNetworking(eventId = eventDao.getEventId()) ) } diff --git a/theme-m3/main/main/src/main/kotlin/org/gdglille/devfest/android/theme/MainNavigation.kt b/theme-m3/main/main/src/main/kotlin/org/gdglille/devfest/android/theme/MainNavigation.kt index f15dc1492..fb1d45483 100644 --- a/theme-m3/main/main/src/main/kotlin/org/gdglille/devfest/android/theme/MainNavigation.kt +++ b/theme-m3/main/main/src/main/kotlin/org/gdglille/devfest/android/theme/MainNavigation.kt @@ -183,7 +183,12 @@ fun MainNavigation( } composable( route = Screen.Schedule.route, - arguments = listOf(navArgument("scheduleId") { type = NavType.StringType }) + arguments = listOf(navArgument("scheduleId") { type = NavType.StringType }), + deepLinks = listOf( + navDeepLink { + uriPattern = "$rootUri/${Screen.Schedule.route}" + } + ) ) { ScheduleDetailOrientableVM( scheduleId = it.arguments?.getString("scheduleId")!!, diff --git a/widgets/widgets-feature/build.gradle.kts b/widgets/widgets-feature/build.gradle.kts new file mode 100644 index 000000000..047116ddd --- /dev/null +++ b/widgets/widgets-feature/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("conferences4hall.android.library") + id("conferences4hall.android.library.compose") + id("conferences4hall.quality") +} + +android { + namespace = "org.gdglille.devfest.android.widgets.feature" +} + +dependencies { + implementation(projects.widgets.widgetsUi) + implementation(projects.widgets.widgetsScreens) + implementation(projects.shared.core) + + implementation(libs.bundles.androidx.glance) + + implementation(libs.jetbrains.kotlinx.collections) + implementation(libs.jetbrains.kotlinx.datetime) +} diff --git a/widgets/widgets-feature/src/main/kotlin/org/gdglille/devfest/android/widgets/feature/SessionsViewModel.kt b/widgets/widgets-feature/src/main/kotlin/org/gdglille/devfest/android/widgets/feature/SessionsViewModel.kt new file mode 100644 index 000000000..ebf7c26fd --- /dev/null +++ b/widgets/widgets-feature/src/main/kotlin/org/gdglille/devfest/android/widgets/feature/SessionsViewModel.kt @@ -0,0 +1,46 @@ +package org.gdglille.devfest.android.widgets.feature + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.gdglille.devfest.models.ui.EventInfoUi +import org.gdglille.devfest.models.ui.TalkItemUi +import org.gdglille.devfest.repositories.AgendaRepository +import org.gdglille.devfest.repositories.EventRepository + +sealed class SessionsUiState { + data object Loading : SessionsUiState() + data class Success(val event: EventInfoUi?, val sessions: ImmutableList) : + SessionsUiState() +} + +class SessionsViewModel( + agendaRepository: AgendaRepository, + eventRepository: EventRepository, + date: String, + coroutineScope: CoroutineScope = CoroutineScope(Job()) +) { + val uiState: StateFlow = combine( + flow = eventRepository.currentEvent() + .catch { emit(null) }, + flow2 = agendaRepository.fetchNextTalks(date) + .catch { emit(persistentListOf()) }, + transform = { event, sessions -> + if (sessions.isEmpty()) { + SessionsUiState.Loading + } else { + SessionsUiState.Success(event, sessions) + } + } + ).stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = SessionsUiState.Loading + ) +} diff --git a/widgets/widgets-feature/src/main/kotlin/org/gdglille/devfest/android/widgets/feature/SessionsWidget.kt b/widgets/widgets-feature/src/main/kotlin/org/gdglille/devfest/android/widgets/feature/SessionsWidget.kt new file mode 100644 index 000000000..25bd64847 --- /dev/null +++ b/widgets/widgets-feature/src/main/kotlin/org/gdglille/devfest/android/widgets/feature/SessionsWidget.kt @@ -0,0 +1,46 @@ +package org.gdglille.devfest.android.widgets.feature + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.glance.action.Action +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.gdglille.devfest.android.widgets.screens.SessionsScreen +import org.gdglille.devfest.android.widgets.ui.Loading +import org.gdglille.devfest.repositories.AgendaRepository +import org.gdglille.devfest.repositories.EventRepository + +@Composable +fun SessionsWidget( + eventRepository: EventRepository, + agendaRepository: AgendaRepository, + date: String, + @DrawableRes iconId: Int, + onUpdate: suspend CoroutineScope.() -> Unit, + onItemClick: (String) -> Action +) { + val scope = rememberCoroutineScope() + val viewModel = remember(date) { SessionsViewModel(agendaRepository, eventRepository, date) } + when (val uiState = viewModel.uiState.collectAsState().value) { + is SessionsUiState.Loading -> { + Loading() + } + + is SessionsUiState.Success -> { + LaunchedEffect(uiState.sessions.isNotEmpty()) { + onUpdate() + } + SessionsScreen( + iconId = iconId, + onClick = { scope.launch { onUpdate() } }, + onItemClick = onItemClick, + eventInfoUi = uiState.event, + talks = uiState.sessions + ) + } + } +} diff --git a/widgets/widgets-screens/build.gradle.kts b/widgets/widgets-screens/build.gradle.kts new file mode 100644 index 000000000..ce17c2e21 --- /dev/null +++ b/widgets/widgets-screens/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("conferences4hall.android.library") + id("conferences4hall.android.library.compose") + id("conferences4hall.quality") +} + +android { + namespace = "org.gdglille.devfest.android.widgets.screens" +} + +dependencies { + implementation(projects.widgets.widgetsUi) + implementation(projects.shared.core) + + implementation(libs.bundles.androidx.glance) + + implementation(libs.jetbrains.kotlinx.collections) +} diff --git a/widgets/widgets-screens/src/main/kotlin/org/gdglille/devfest/android/widgets/screens/SessionsScreen.kt b/widgets/widgets-screens/src/main/kotlin/org/gdglille/devfest/android/widgets/screens/SessionsScreen.kt new file mode 100644 index 000000000..d10fe64a3 --- /dev/null +++ b/widgets/widgets-screens/src/main/kotlin/org/gdglille/devfest/android/widgets/screens/SessionsScreen.kt @@ -0,0 +1,47 @@ +package org.gdglille.devfest.android.widgets.screens + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.LocalContext +import androidx.glance.action.Action +import androidx.glance.appwidget.components.Scaffold +import kotlinx.collections.immutable.ImmutableList +import org.gdglille.devfest.android.widgets.ui.Loading +import org.gdglille.devfest.android.widgets.ui.NoEvent +import org.gdglille.devfest.android.widgets.ui.R +import org.gdglille.devfest.android.widgets.ui.SessionList +import org.gdglille.devfest.android.widgets.ui.TopBar +import org.gdglille.devfest.models.ui.EventInfoUi +import org.gdglille.devfest.models.ui.TalkItemUi + +@Composable +fun SessionsScreen( + @DrawableRes iconId: Int, + onClick: () -> Unit, + eventInfoUi: EventInfoUi?, + talks: ImmutableList, + onItemClick: (String) -> Action, + modifier: GlanceModifier = GlanceModifier +) { + Scaffold( + titleBar = { + TopBar( + title = eventInfoUi?.name + ?: LocalContext.current.getString(R.string.widget_title_no_event), + iconId = iconId, + onClick = onClick + ) + }, + content = { + if (eventInfoUi == null) { + NoEvent() + } else if (talks.isEmpty()) { + Loading() + } else { + SessionList(talks = talks, onItemClick = onItemClick) + } + }, + modifier = modifier + ) +} diff --git a/widgets/widgets-ui/build.gradle.kts b/widgets/widgets-ui/build.gradle.kts new file mode 100644 index 000000000..9a6210e3a --- /dev/null +++ b/widgets/widgets-ui/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("conferences4hall.android.library") + id("conferences4hall.android.library.compose") + id("conferences4hall.quality") +} + +android { + namespace = "org.gdglille.devfest.android.widgets.ui" +} + +dependencies { + implementation(projects.shared.core) + + implementation(libs.bundles.androidx.glance) + + implementation(libs.jetbrains.kotlinx.collections) + implementation(libs.jetbrains.kotlinx.datetime) +} diff --git a/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/PlainMessage.kt b/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/PlainMessage.kt new file mode 100644 index 000000000..ed346bbe1 --- /dev/null +++ b/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/PlainMessage.kt @@ -0,0 +1,45 @@ +package org.gdglille.devfest.android.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.text.Text +import androidx.glance.text.TextDefaults +import androidx.glance.unit.ColorProvider + +@Composable +fun Loading(modifier: GlanceModifier = GlanceModifier) { + PlainMessage( + text = LocalContext.current.getString(R.string.widget_text_loading), + modifier = modifier + ) +} + +@Composable +fun NoEvent(modifier: GlanceModifier = GlanceModifier) { + PlainMessage( + text = LocalContext.current.getString(R.string.widget_text_no_event), + modifier = modifier + ) +} + +@Composable +private fun PlainMessage( + text: String, + modifier: GlanceModifier = GlanceModifier, + color: ColorProvider = GlanceTheme.colors.onSurface +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = TextDefaults.defaultTextStyle.copy(color = color) + ) + } +} diff --git a/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/SessionItem.kt b/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/SessionItem.kt new file mode 100644 index 000000000..79ce55acc --- /dev/null +++ b/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/SessionItem.kt @@ -0,0 +1,58 @@ +package org.gdglille.devfest.android.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextDefaults +import androidx.glance.unit.ColorProvider + +@Composable +fun SessionItem( + title: String, + time: String, + room: String, + duration: String, + onClick: Action, + modifier: GlanceModifier = GlanceModifier, + containerColor: ColorProvider = GlanceTheme.colors.primaryContainer, + contentColor: ColorProvider = GlanceTheme.colors.onPrimaryContainer +) { + Column( + modifier = modifier + .background(containerColor) + .cornerRadius(8.dp) + .padding(8.dp) + .clickable(onClick) + ) { + Text( + text = title, + style = TextDefaults.defaultTextStyle.copy(color = contentColor), + modifier = GlanceModifier.padding(bottom = 4.dp) + ) + Row { + Tag( + resId = R.drawable.today, + text = time + ) + Tag( + resId = R.drawable.videocam, + text = room, + modifier = GlanceModifier.padding(start = 16.dp) + ) + Tag( + resId = R.drawable.schedule, + text = duration, + modifier = GlanceModifier.padding(start = 16.dp) + ) + } + } +} diff --git a/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/SessionList.kt b/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/SessionList.kt new file mode 100644 index 000000000..8e368440d --- /dev/null +++ b/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/SessionList.kt @@ -0,0 +1,37 @@ +package org.gdglille.devfest.android.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.action.Action +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import kotlinx.collections.immutable.ImmutableList +import kotlinx.datetime.LocalDateTime +import org.gdglille.devfest.extensions.formatHoursMinutes +import org.gdglille.devfest.models.ui.TalkItemUi + +@Composable +fun SessionList( + talks: ImmutableList, + onItemClick: (String) -> Action, + modifier: GlanceModifier = GlanceModifier +) { + LazyColumn(modifier = modifier) { + items(talks) { + Box(modifier = GlanceModifier.padding(8.dp)) { + SessionItem( + title = it.title, + time = LocalDateTime.parse(it.startTime).formatHoursMinutes(), + room = it.room, + duration = it.time, + modifier = GlanceModifier.fillMaxWidth(), + onClick = onItemClick(it.id) + ) + } + } + } +} diff --git a/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/Tag.kt b/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/Tag.kt new file mode 100644 index 000000000..5d8e6f613 --- /dev/null +++ b/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/Tag.kt @@ -0,0 +1,44 @@ +package org.gdglille.devfest.android.widgets.ui + +import androidx.annotation.DrawableRes +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.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.text.Text +import androidx.glance.text.TextDefaults +import androidx.glance.unit.ColorProvider + +@Composable +internal fun Tag( + @DrawableRes resId: Int, + text: String, + modifier: GlanceModifier = GlanceModifier, + containerColor: ColorProvider = GlanceTheme.colors.secondaryContainer, + contentColor: ColorProvider = GlanceTheme.colors.onSecondaryContainer +) { + Row( + modifier = modifier.background(containerColor), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + provider = ImageProvider(resId), + contentDescription = null, + colorFilter = ColorFilter.tint(contentColor), + modifier = GlanceModifier.size(16.dp) + ) + Text( + text = text, + modifier = GlanceModifier.padding(start = 4.dp), + style = TextDefaults.defaultTextStyle.copy(color = contentColor) + ) + } +} diff --git a/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/TopBar.kt b/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/TopBar.kt new file mode 100644 index 000000000..8b5f38968 --- /dev/null +++ b/widgets/widgets-ui/src/main/kotlin/org/gdglille/devfest/android/widgets/ui/TopBar.kt @@ -0,0 +1,30 @@ +package org.gdglille.devfest.android.widgets.ui + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.TitleBar + +@Composable +fun TopBar( + title: String, + @DrawableRes iconId: Int, + onClick: () -> Unit, + modifier: GlanceModifier = GlanceModifier +) { + TitleBar( + title = title, + startIcon = ImageProvider(iconId), + actions = { + CircleIconButton( + imageProvider = ImageProvider(R.drawable.refresh), + contentDescription = LocalContext.current.getString(R.string.widget_semantic_refresh), + onClick = onClick + ) + }, + modifier = modifier + ) +} diff --git a/widgets/widgets-ui/src/main/res/drawable/refresh.xml b/widgets/widgets-ui/src/main/res/drawable/refresh.xml new file mode 100644 index 000000000..84b267d9b --- /dev/null +++ b/widgets/widgets-ui/src/main/res/drawable/refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/widgets/widgets-ui/src/main/res/drawable/schedule.xml b/widgets/widgets-ui/src/main/res/drawable/schedule.xml new file mode 100644 index 000000000..5d4abed06 --- /dev/null +++ b/widgets/widgets-ui/src/main/res/drawable/schedule.xml @@ -0,0 +1,9 @@ + + + diff --git a/widgets/widgets-ui/src/main/res/drawable/today.xml b/widgets/widgets-ui/src/main/res/drawable/today.xml new file mode 100644 index 000000000..a4e83dc32 --- /dev/null +++ b/widgets/widgets-ui/src/main/res/drawable/today.xml @@ -0,0 +1,9 @@ + + + diff --git a/widgets/widgets-ui/src/main/res/drawable/videocam.xml b/widgets/widgets-ui/src/main/res/drawable/videocam.xml new file mode 100644 index 000000000..c07335fba --- /dev/null +++ b/widgets/widgets-ui/src/main/res/drawable/videocam.xml @@ -0,0 +1,10 @@ + + + diff --git a/widgets/widgets-ui/src/main/res/values/strings.xml b/widgets/widgets-ui/src/main/res/values/strings.xml new file mode 100644 index 000000000..b2e10b065 --- /dev/null +++ b/widgets/widgets-ui/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + No event + No event configured. Please select an event from the app. + Loading… + Refresh widget + \ No newline at end of file