diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 2c90731ff..58a27afd6 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -94,7 +94,7 @@ dependencies { implementation(libs.firebase.analytics) implementation(libs.firebase.crashlytics) - implementation(libs.hyperdrive.multiplatformx.api) + implementation(libs.decompose) implementation(libs.bundles.androidx.compose) diff --git a/android/src/main/java/co/touchlab/droidcon/android/MainActivity.kt b/android/src/main/java/co/touchlab/droidcon/android/MainActivity.kt index e4f64aa8c..3b446f35c 100644 --- a/android/src/main/java/co/touchlab/droidcon/android/MainActivity.kt +++ b/android/src/main/java/co/touchlab/droidcon/android/MainActivity.kt @@ -1,12 +1,5 @@ package co.touchlab.droidcon.android -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.ui.Modifier // import androidx.compose.animation.Crossfade // import androidx.compose.foundation.Image // import androidx.compose.foundation.layout.fillMaxSize @@ -19,33 +12,53 @@ import androidx.compose.ui.Modifier // import androidx.compose.ui.Modifier // import androidx.compose.ui.res.painterResource // import androidx.compose.ui.unit.dp +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import co.touchlab.droidcon.android.ui.theme.Colors +import co.touchlab.droidcon.android.util.DefaultUrlHandler import co.touchlab.droidcon.android.viewModel.MainViewModel import co.touchlab.droidcon.application.service.NotificationSchedulingService import co.touchlab.droidcon.domain.service.AnalyticsService import co.touchlab.droidcon.domain.service.SyncService +import co.touchlab.droidcon.ui.uiModule import co.touchlab.droidcon.ui.util.MainView -import co.touchlab.droidcon.viewmodel.ApplicationViewModel -import co.touchlab.droidcon.util.NavigationController +import co.touchlab.droidcon.util.UrlHandler +import co.touchlab.droidcon.viewmodel.ApplicationComponent +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.defaultComponentContext import com.google.accompanist.insets.ProvideWindowInsets -import kotlinx.coroutines.awaitCancellation -import org.brightify.hyperdrive.multiplatformx.LifecycleGraph import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.koin.core.context.loadKoinModules +import org.koin.core.context.unloadKoinModules +import org.koin.dsl.module class MainActivity: ComponentActivity(), KoinComponent { + private val modules = + module { + single { defaultComponentContext() } + single { DefaultUrlHandler(this@MainActivity) } + } + uiModule + + init { + loadKoinModules(modules) + } + private val notificationSchedulingService: NotificationSchedulingService by inject() private val syncService: SyncService by inject() private val analyticsService: AnalyticsService by inject() private val mainViewModel: MainViewModel by viewModels() - private val applicationViewModel: ApplicationViewModel by inject() - - private val root = LifecycleGraph.Root(this) + private val applicationComponent: ApplicationComponent by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -64,12 +77,10 @@ class MainActivity: ComponentActivity(), KoinComponent { WindowCompat.setDecorFitsSystemWindows(window, false) - root.addChild(applicationViewModel.lifecycle) - setContent { Box(modifier = Modifier.background(Colors.primary)) { ProvideWindowInsets { - MainView(viewModel = applicationViewModel) + MainView(component = applicationComponent) } } @@ -100,31 +111,11 @@ class MainActivity: ComponentActivity(), KoinComponent { // } } - - lifecycleScope.launchWhenResumed { - val cancelAttach = root.attach(lifecycleScope) - try { - awaitCancellation() - } finally { - cancelAttach.cancel() - } - } - } - - override fun onResume() { - super.onResume() - // mainViewModel.initializeFeedbackObserving() - applicationViewModel.onAppear() } override fun onDestroy() { - super.onDestroy() - root.removeChild(applicationViewModel.lifecycle) - } + unloadKoinModules(modules) - override fun onBackPressed() { - if (!NavigationController.root.handleBackPress()) { - super.onBackPressed() - } + super.onDestroy() } } diff --git a/android/src/main/java/co/touchlab/droidcon/android/MainApp.kt b/android/src/main/java/co/touchlab/droidcon/android/MainApp.kt index f63e4c737..2b8ea2eae 100644 --- a/android/src/main/java/co/touchlab/droidcon/android/MainApp.kt +++ b/android/src/main/java/co/touchlab/droidcon/android/MainApp.kt @@ -6,7 +6,6 @@ import android.content.Context import android.content.SharedPreferences import co.touchlab.droidcon.Constants import co.touchlab.droidcon.android.service.DateTimeFormatterViewService -import co.touchlab.droidcon.service.ParseUrlViewService import co.touchlab.droidcon.android.service.impl.AndroidAnalyticsService import co.touchlab.droidcon.android.service.impl.DefaultDateTimeFormatterViewService import co.touchlab.droidcon.android.service.impl.DefaultParseUrlViewService @@ -15,13 +14,15 @@ import co.touchlab.droidcon.application.service.NotificationSchedulingService import co.touchlab.droidcon.domain.service.AnalyticsService import co.touchlab.droidcon.domain.service.impl.ResourceReader import co.touchlab.droidcon.initKoin -import co.touchlab.droidcon.ui.uiModule +import co.touchlab.droidcon.service.ParseUrlViewService import co.touchlab.droidcon.util.ClasspathResourceReader +import co.touchlab.droidcon.util.DcDispatchers import com.google.firebase.analytics.ktx.analytics import com.google.firebase.ktx.Firebase import com.russhwolf.settings.AndroidSettings import com.russhwolf.settings.ExperimentalSettingsApi import com.russhwolf.settings.ObservableSettings +import kotlinx.coroutines.Dispatchers import org.koin.dsl.module @OptIn(ExperimentalSettingsApi::class) @@ -30,33 +31,37 @@ class MainApp: Application() { override fun onCreate() { super.onCreate() initKoin( - module { - single { this@MainApp } - single> { MainActivity::class.java } - single { - get().getSharedPreferences("DROIDCON_SETTINGS", Context.MODE_PRIVATE) - } - single { AndroidSettings(delegate = get()) } + listOf( + module { + single { this@MainApp } + single> { MainActivity::class.java } + single { + get().getSharedPreferences("DROIDCON_SETTINGS", Context.MODE_PRIVATE) + } + single { AndroidSettings(delegate = get()) } - single { - DefaultParseUrlViewService() - } + single { + DefaultParseUrlViewService() + } - single { - DefaultDateTimeFormatterViewService(conferenceTimeZone = Constants.conferenceTimeZone) - } - single { - ClasspathResourceReader() - } + single { + DefaultDateTimeFormatterViewService(conferenceTimeZone = Constants.conferenceTimeZone) + } + single { + ClasspathResourceReader() + } - single { - NotificationLocalizedStringFactory(context = get()) - } + single { + NotificationLocalizedStringFactory(context = get()) + } + + single { + AndroidAnalyticsService(firebaseAnalytics = Firebase.analytics) + } - single { - AndroidAnalyticsService(firebaseAnalytics = Firebase.analytics) + single { DcDispatchers() } } - } + uiModule + ) ) } } diff --git a/android/src/main/java/co/touchlab/droidcon/android/util/DefaultUrlHandler.kt b/android/src/main/java/co/touchlab/droidcon/android/util/DefaultUrlHandler.kt new file mode 100644 index 000000000..4ab01ad0d --- /dev/null +++ b/android/src/main/java/co/touchlab/droidcon/android/util/DefaultUrlHandler.kt @@ -0,0 +1,15 @@ +package co.touchlab.droidcon.android.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import co.touchlab.droidcon.util.UrlHandler + +class DefaultUrlHandler( + private val context: Context, +): UrlHandler { + + override fun openUrl(url: String) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } +} diff --git a/ios/Droidcon/Droidcon.xcodeproj/project.pbxproj b/ios/Droidcon/Droidcon.xcodeproj/project.pbxproj index 00be13009..4720695a0 100644 --- a/ios/Droidcon/Droidcon.xcodeproj/project.pbxproj +++ b/ios/Droidcon/Droidcon.xcodeproj/project.pbxproj @@ -20,18 +20,14 @@ 681C959D26C554E00011330B /* FeedbackDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 681C959C26C554E00011330B /* FeedbackDialog.swift */; }; 681C95A126C555D90011330B /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 681C95A026C555D90011330B /* VisualEffectView.swift */; }; 681C95A326C56B100011330B /* CustomOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 681C95A226C56B100011330B /* CustomOverlayView.swift */; }; - 684FAA7426B2A4D400673AFF /* ScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684FAA7326B2A4D400673AFF /* ScheduleView.swift */; }; 684FAA7726B2A4EA00673AFF /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684FAA7626B2A4E900673AFF /* SettingsView.swift */; }; - 684FAA7B26B2A55C00673AFF /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684FAA7A26B2A55C00673AFF /* SessionListView.swift */; }; 684FAA7E26B2A7B600673AFF /* RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684FAA7D26B2A7B600673AFF /* RoundedCorners.swift */; }; 684FAA8326B2B60300673AFF /* SessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684FAA8226B2B60300673AFF /* SessionDetailView.swift */; }; - 684FAA8626B2BD1300673AFF /* DaySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684FAA8526B2BD1300673AFF /* DaySelectionView.swift */; }; 684FAA8926B2C31800673AFF /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 684FAA8B26B2C31800673AFF /* Localizable.strings */; }; 6881CF6026BD3D22002541F0 /* SessionBlockItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6881CF5F26BD3D22002541F0 /* SessionBlockItemView.swift */; }; 6881CF6326BD60D4002541F0 /* SponsorListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6881CF6226BD60D4002541F0 /* SponsorListView.swift */; }; 6881CF6526BD6D75002541F0 /* SponsorGroupItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6881CF6426BD6D75002541F0 /* SponsorGroupItemView.swift */; }; 6881CF6926C29B81002541F0 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6881CF6826C29B81002541F0 /* MainView.swift */; }; - 68971CA426B313EC004B2763 /* BaseViewModel+ObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68971CA326B313EC004B2763 /* BaseViewModel+ObservableObject.swift */; }; 689DD2F726B40A9400A9B009 /* SwitchingNavigationLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 689DD2F626B40A9400A9B009 /* SwitchingNavigationLink.swift */; }; 689DD2F926B40B3800A9B009 /* SessionBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 689DD2F826B40B3800A9B009 /* SessionBlockView.swift */; }; 689DD2FB26B40F1800A9B009 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 689DD2FA26B40F1800A9B009 /* LazyView.swift */; }; @@ -40,17 +36,21 @@ 689DD30326B431C300A9B009 /* SpeakerListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 689DD30226B431C300A9B009 /* SpeakerListItemView.swift */; }; 689DD30526B438CA00A9B009 /* Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 689DD30426B438CA00A9B009 /* Avatar.swift */; }; 689DD30826B4447B00A9B009 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 689DD30726B4447B00A9B009 /* AboutView.swift */; }; - 68C86E9F26B31D6100008D15 /* LifecycleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68C86E9E26B31D6100008D15 /* LifecycleManager.swift */; }; 68DCBC6226C512DD0084C70D /* SponsorDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68DCBC6126C512DD0084C70D /* SponsorDetailView.swift */; }; 68DCBC6426C51E260084C70D /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68DCBC6326C51E260084C70D /* TextView.swift */; }; 711787A7E84A5FBD2D9A2E28 /* Pods_Droidcon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9EBC9F516E487D20266C9746 /* Pods_Droidcon.framework */; }; + 794BD24C28B26CCB00677F3A /* ObservableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794BD24B28B26CCB00677F3A /* ObservableValue.swift */; }; + 796A273F28B2B05D007796C1 /* TabChildView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796A273E28B2B05D007796C1 /* TabChildView.swift */; }; + 796A274128B2C5B1007796C1 /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796A274028B2C5B1007796C1 /* SessionListView.swift */; }; + 796A274328B2C633007796C1 /* SessionDaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796A274228B2C633007796C1 /* SessionDaysView.swift */; }; + 796A274B28B2D2AB007796C1 /* SessionDayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796A274A28B2D2AB007796C1 /* SessionDayView.swift */; }; + 796A274D28B2D9E5007796C1 /* ZeroCaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796A274C28B2D9E5007796C1 /* ZeroCaseView.swift */; }; 8404D80E26C64B9E00AE200F /* IOSAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8404D80D26C64B9E00AE200F /* IOSAnalyticsService.swift */; }; A357C7EE28AA7861004EF059 /* FilledButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A357C7ED28AA7860004EF059 /* FilledButtonStyle.swift */; }; A35DC2DF28AB6B2600C7B298 /* SwitchingRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35DC2DE28AB6B2600C7B298 /* SwitchingRootView.swift */; }; A35DC2E128AB6BF700C7B298 /* BackgroundCrashWorkaroundController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35DC2E028AB6BF700C7B298 /* BackgroundCrashWorkaroundController.swift */; }; A35DC2E328AB6C6F00C7B298 /* ComposeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35DC2E228AB6C6F00C7B298 /* ComposeController.swift */; }; A35DEF2228AA265C0072605A /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = A35DEF2128AA265C0072605A /* Settings.bundle */; }; - A35DEF2428AA26C80072605A /* SettingsBundleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35DEF2328AA26C80072605A /* SettingsBundleHelper.swift */; }; F1465F0123AA94BF0055F7C3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1465F0023AA94BF0055F7C3 /* AppDelegate.swift */; }; F1465F0A23AA94BF0055F7C3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F1465F0923AA94BF0055F7C3 /* Assets.xcassets */; }; /* End PBXBuildFile section */ @@ -71,18 +71,14 @@ 681C959C26C554E00011330B /* FeedbackDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackDialog.swift; sourceTree = ""; }; 681C95A026C555D90011330B /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 681C95A226C56B100011330B /* CustomOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomOverlayView.swift; sourceTree = ""; }; - 684FAA7326B2A4D400673AFF /* ScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleView.swift; sourceTree = ""; }; 684FAA7626B2A4E900673AFF /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 684FAA7A26B2A55C00673AFF /* SessionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListView.swift; sourceTree = ""; }; 684FAA7D26B2A7B600673AFF /* RoundedCorners.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedCorners.swift; sourceTree = ""; }; 684FAA8226B2B60300673AFF /* SessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDetailView.swift; sourceTree = ""; }; - 684FAA8526B2BD1300673AFF /* DaySelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaySelectionView.swift; sourceTree = ""; }; 684FAA8A26B2C31800673AFF /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 6881CF5F26BD3D22002541F0 /* SessionBlockItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionBlockItemView.swift; sourceTree = ""; }; 6881CF6226BD60D4002541F0 /* SponsorListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorListView.swift; sourceTree = ""; }; 6881CF6426BD6D75002541F0 /* SponsorGroupItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorGroupItemView.swift; sourceTree = ""; }; 6881CF6826C29B81002541F0 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; - 68971CA326B313EC004B2763 /* BaseViewModel+ObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseViewModel+ObservableObject.swift"; sourceTree = ""; }; 689DD2F626B40A9400A9B009 /* SwitchingNavigationLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchingNavigationLink.swift; sourceTree = ""; }; 689DD2F826B40B3800A9B009 /* SessionBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionBlockView.swift; sourceTree = ""; }; 689DD2FA26B40F1800A9B009 /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; @@ -91,9 +87,14 @@ 689DD30226B431C300A9B009 /* SpeakerListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakerListItemView.swift; sourceTree = ""; }; 689DD30426B438CA00A9B009 /* Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Avatar.swift; sourceTree = ""; }; 689DD30726B4447B00A9B009 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; - 68C86E9E26B31D6100008D15 /* LifecycleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifecycleManager.swift; sourceTree = ""; }; 68DCBC6126C512DD0084C70D /* SponsorDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorDetailView.swift; sourceTree = ""; }; 68DCBC6326C51E260084C70D /* TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextView.swift; sourceTree = ""; }; + 794BD24B28B26CCB00677F3A /* ObservableValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableValue.swift; sourceTree = ""; }; + 796A273E28B2B05D007796C1 /* TabChildView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabChildView.swift; sourceTree = ""; }; + 796A274028B2C5B1007796C1 /* SessionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListView.swift; sourceTree = ""; }; + 796A274228B2C633007796C1 /* SessionDaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDaysView.swift; sourceTree = ""; }; + 796A274A28B2D2AB007796C1 /* SessionDayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDayView.swift; sourceTree = ""; }; + 796A274C28B2D9E5007796C1 /* ZeroCaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZeroCaseView.swift; sourceTree = ""; }; 8404D80D26C64B9E00AE200F /* IOSAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSAnalyticsService.swift; sourceTree = ""; }; 9EBC9F516E487D20266C9746 /* Pods_Droidcon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Droidcon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A357C7ED28AA7860004EF059 /* FilledButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilledButtonStyle.swift; sourceTree = ""; }; @@ -101,7 +102,6 @@ A35DC2E028AB6BF700C7B298 /* BackgroundCrashWorkaroundController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundCrashWorkaroundController.swift; sourceTree = ""; }; A35DC2E228AB6C6F00C7B298 /* ComposeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeController.swift; sourceTree = ""; }; A35DEF2128AA265C0072605A /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; - A35DEF2328AA26C80072605A /* SettingsBundleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBundleHelper.swift; sourceTree = ""; }; A399EE7D791E7ABC935DC78D /* Pods-Droidcon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Droidcon.release.xcconfig"; path = "Pods/Target Support Files/Pods-Droidcon/Pods-Droidcon.release.xcconfig"; sourceTree = ""; }; F1465EFD23AA94BF0055F7C3 /* Droidcon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Droidcon.app; sourceTree = BUILT_PRODUCTS_DIR; }; F1465F0023AA94BF0055F7C3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -151,14 +151,6 @@ name = ios; sourceTree = ""; }; - 684FAA7226B2A4B400673AFF /* Schedule */ = { - isa = PBXGroup; - children = ( - 684FAA7326B2A4D400673AFF /* ScheduleView.swift */, - ); - path = Schedule; - sourceTree = ""; - }; 684FAA7526B2A4E200673AFF /* Settings */ = { isa = PBXGroup; children = ( @@ -175,12 +167,12 @@ 684FAA7D26B2A7B600673AFF /* RoundedCorners.swift */, 689DD2F626B40A9400A9B009 /* SwitchingNavigationLink.swift */, 689DD2FC26B40F8200A9B009 /* AssertionFailureView.swift */, - 68971CA326B313EC004B2763 /* BaseViewModel+ObservableObject.swift */, 689DD2FA26B40F1800A9B009 /* LazyView.swift */, - 68C86E9E26B31D6100008D15 /* LifecycleManager.swift */, 68DCBC6326C51E260084C70D /* TextView.swift */, 681C95A026C555D90011330B /* VisualEffectView.swift */, 681C95A226C56B100011330B /* CustomOverlayView.swift */, + 794BD24B28B26CCB00677F3A /* ObservableValue.swift */, + 796A274C28B2D9E5007796C1 /* ZeroCaseView.swift */, ); path = Utils; sourceTree = ""; @@ -188,10 +180,11 @@ 684FAA7926B2A55500673AFF /* Sessions */ = { isa = PBXGroup; children = ( - 684FAA7A26B2A55C00673AFF /* SessionListView.swift */, + 796A274028B2C5B1007796C1 /* SessionListView.swift */, + 796A274A28B2D2AB007796C1 /* SessionDayView.swift */, + 796A274228B2C633007796C1 /* SessionDaysView.swift */, 689DD2F826B40B3800A9B009 /* SessionBlockView.swift */, 6881CF5F26BD3D22002541F0 /* SessionBlockItemView.swift */, - 684FAA8526B2BD1300673AFF /* DaySelectionView.swift */, 689DD30926B4467800A9B009 /* Detail */, ); path = Sessions; @@ -287,15 +280,14 @@ 6881CF6826C29B81002541F0 /* MainView.swift */, 689DD30626B4392100A9B009 /* Common */, 684FAA7926B2A55500673AFF /* Sessions */, - 684FAA7226B2A4B400673AFF /* Schedule */, 6881CF6126BD607D002541F0 /* Sponsors */, 684FAA7526B2A4E200673AFF /* Settings */, 684FAA7826B2A51100673AFF /* Utils */, A35DEF2128AA265C0072605A /* Settings.bundle */, - A35DEF2328AA26C80072605A /* SettingsBundleHelper.swift */, A35DC2DE28AB6B2600C7B298 /* SwitchingRootView.swift */, A35DC2E028AB6BF700C7B298 /* BackgroundCrashWorkaroundController.swift */, A35DC2E228AB6C6F00C7B298 /* ComposeController.swift */, + 796A273E28B2B05D007796C1 /* TabChildView.swift */, ); path = Droidcon; sourceTree = ""; @@ -445,13 +437,12 @@ buildActionMask = 2147483647; files = ( 8404D80E26C64B9E00AE200F /* IOSAnalyticsService.swift in Sources */, + 794BD24C28B26CCB00677F3A /* ObservableValue.swift in Sources */, A35DC2DF28AB6B2600C7B298 /* SwitchingRootView.swift in Sources */, - 684FAA8626B2BD1300673AFF /* DaySelectionView.swift in Sources */, 6881CF6926C29B81002541F0 /* MainView.swift in Sources */, 681C95A326C56B100011330B /* CustomOverlayView.swift in Sources */, - 68C86E9F26B31D6100008D15 /* LifecycleManager.swift in Sources */, + 796A274D28B2D9E5007796C1 /* ZeroCaseView.swift in Sources */, 689DD2F726B40A9400A9B009 /* SwitchingNavigationLink.swift in Sources */, - 684FAA7B26B2A55C00673AFF /* SessionListView.swift in Sources */, 689DD2F926B40B3800A9B009 /* SessionBlockView.swift in Sources */, 684FAA7726B2A4EA00673AFF /* SettingsView.swift in Sources */, 689DD2FB26B40F1800A9B009 /* LazyView.swift in Sources */, @@ -459,25 +450,26 @@ 681C95A126C555D90011330B /* VisualEffectView.swift in Sources */, A35DC2E128AB6BF700C7B298 /* BackgroundCrashWorkaroundController.swift in Sources */, 1833221026B0CF5600D79482 /* DroidconApp.swift in Sources */, - 684FAA7426B2A4D400673AFF /* ScheduleView.swift in Sources */, 46B5284D249C5CF400A7725D /* Koin.swift in Sources */, + 796A274328B2C633007796C1 /* SessionDaysView.swift in Sources */, 68DCBC6226C512DD0084C70D /* SponsorDetailView.swift in Sources */, A357C7EE28AA7861004EF059 /* FilledButtonStyle.swift in Sources */, 684FAA8326B2B60300673AFF /* SessionDetailView.swift in Sources */, 681C959D26C554E00011330B /* FeedbackDialog.swift in Sources */, 68DCBC6426C51E260084C70D /* TextView.swift in Sources */, 689DD30126B4279400A9B009 /* SpeakerDetailView.swift in Sources */, + 796A274128B2C5B1007796C1 /* SessionListView.swift in Sources */, 689DD30326B431C300A9B009 /* SpeakerListItemView.swift in Sources */, A35DC2E328AB6C6F00C7B298 /* ComposeController.swift in Sources */, + 796A274B28B2D2AB007796C1 /* SessionDayView.swift in Sources */, 6881CF6526BD6D75002541F0 /* SponsorGroupItemView.swift in Sources */, 6881CF6326BD60D4002541F0 /* SponsorListView.swift in Sources */, 6881CF6026BD3D22002541F0 /* SessionBlockItemView.swift in Sources */, F1465F0123AA94BF0055F7C3 /* AppDelegate.swift in Sources */, - 68971CA426B313EC004B2763 /* BaseViewModel+ObservableObject.swift in Sources */, 689DD30826B4447B00A9B009 /* AboutView.swift in Sources */, 689DD2FD26B40F8200A9B009 /* AssertionFailureView.swift in Sources */, - A35DEF2428AA26C80072605A /* SettingsBundleHelper.swift in Sources */, 684FAA7E26B2A7B600673AFF /* RoundedCorners.swift in Sources */, + 796A273F28B2B05D007796C1 /* TabChildView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Droidcon/Droidcon/BackgroundCrashWorkaroundController.swift b/ios/Droidcon/Droidcon/BackgroundCrashWorkaroundController.swift index f2900d191..5e4ba2326 100644 --- a/ios/Droidcon/Droidcon/BackgroundCrashWorkaroundController.swift +++ b/ios/Droidcon/Droidcon/BackgroundCrashWorkaroundController.swift @@ -3,13 +3,13 @@ import DroidconKit class BackgroundCrashWorkaroundController: UIViewController { - let viewModel: ApplicationViewModel + let component: ApplicationComponent let composeController: UIViewController - init(viewModel: ApplicationViewModel) { - self.viewModel = viewModel + init(_ component: ApplicationComponent) { + self.component = component - composeController = ComposeRootControllerKt.getRootController(viewModel: viewModel) + composeController = ComposeRootControllerKt.getRootController(component: component) super.init(nibName: nil, bundle: nil) } diff --git a/ios/Droidcon/Droidcon/Common/FeedbackDialog.swift b/ios/Droidcon/Droidcon/Common/FeedbackDialog.swift index 0e2e32bfd..d73bf1c66 100644 --- a/ios/Droidcon/Droidcon/Common/FeedbackDialog.swift +++ b/ios/Droidcon/Droidcon/Common/FeedbackDialog.swift @@ -2,11 +2,20 @@ import SwiftUI import DroidconKit struct FeedbackDialog: View { + private var component: FeedbackDialogComponent + @ObservedObject - private(set) var viewModel: FeedbackDialogViewModel + private var observableModel: ObservableValue + + private var viewModel: FeedbackDialogComponent.Model { observableModel.value } @Environment(\.colorScheme) private var colorScheme + + init(_ component: FeedbackDialogComponent) { + self.component = component + observableModel = ObservableValue(component.model) + } var body: some View { ZStack { @@ -20,13 +29,13 @@ struct FeedbackDialog: View { .padding() HStack(spacing: 16) { - ForEach([FeedbackDialogViewModel.Rating.dissatisfied, FeedbackDialogViewModel.Rating.normal, FeedbackDialogViewModel.Rating.satisfied], id: \.self) { rating in - ratingButton(rating: rating, selectedRating: $viewModel.rating) + ForEach([FeedbackDialogComponent.Rating.dissatisfied, FeedbackDialogComponent.Rating.normal, FeedbackDialogComponent.Rating.satisfied], id: \.self) { rating in + ratingButton(rating: rating, isSelected: rating == viewModel.rating, onTapped: { component.setRating(rating: rating) }) } } .padding([.horizontal, .bottom], 8) - TextView($viewModel.comment, placeholder: "Feedback.Dialog.Opinion.Placeholder") + TextView(Binding(get: { viewModel.comment }, set: component.setComment), placeholder: "Feedback.Dialog.Opinion.Placeholder") .enableScrolling(true) .frame(maxHeight: 100) .padding(8) @@ -36,7 +45,7 @@ struct FeedbackDialog: View { Divider() VStack(spacing: 0) { - Button(action: viewModel.submitTapped) { + Button(action: component.submitTapped) { Text("Feedback.Dialog.Submit") .font(.system(size: 18)) .fontWeight(.medium) @@ -51,7 +60,7 @@ struct FeedbackDialog: View { Divider() if viewModel.showCloseAndDisableOption { - Button(action: viewModel.closeAndDisableTapped) { + Button(action: component.closeAndDisableTapped) { Text("Feedback.Dialog.CloseAndDisable") .font(.system(size: 18)) .fontWeight(.medium) @@ -64,7 +73,7 @@ struct FeedbackDialog: View { Divider() } - Button(action: viewModel.skipTapped) { + Button(action: component.skipTapped) { Text("Feedback.Dialog.Skip") .font(.system(size: 18)) .fontWeight(.semibold) @@ -82,7 +91,7 @@ struct FeedbackDialog: View { .padding(32) } - private func ratingButton(rating: FeedbackDialogViewModel.Rating, selectedRating: Binding) -> some View { + private func ratingButton(rating: FeedbackDialogComponent.Rating, isSelected: Bool, onTapped: @escaping () -> Void) -> some View { let imageName: String switch rating { case .dissatisfied: @@ -95,16 +104,12 @@ struct FeedbackDialog: View { fatalError("Unknown image for rating '\(rating)'.") } - let isSelected = selectedRating.wrappedValue == rating - return Image(imageName) .frame(width: 44, height: 44) .padding(4) .background((isSelected ? Color("Accent") : .clear).cornerRadius(4)) .foregroundColor(isSelected ? .white : .primary) - .onTapGesture { - selectedRating.wrappedValue = rating - } + .onTapGesture(perform: onTapped) } } diff --git a/ios/Droidcon/Droidcon/ComposeController.swift b/ios/Droidcon/Droidcon/ComposeController.swift index 222ec556a..9b545dda7 100644 --- a/ios/Droidcon/Droidcon/ComposeController.swift +++ b/ios/Droidcon/Droidcon/ComposeController.swift @@ -3,10 +3,10 @@ import DroidconKit struct ComposeController: UIViewControllerRepresentable { - let viewModel: ApplicationViewModel + let component: ApplicationComponent func makeUIViewController(context: Context) -> some UIViewController { - BackgroundCrashWorkaroundController(viewModel: viewModel) + BackgroundCrashWorkaroundController(component) } func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { diff --git a/ios/Droidcon/Droidcon/DroidconApp.swift b/ios/Droidcon/Droidcon/DroidconApp.swift index 75027b876..c755e4d1f 100644 --- a/ios/Droidcon/Droidcon/DroidconApp.swift +++ b/ios/Droidcon/Droidcon/DroidconApp.swift @@ -9,13 +9,13 @@ struct DroidconApp: App { init() { setupNavBarAppearance() setupTabBarAppearance() - - SettingsBundleHelper.initialize() } var body: some Scene { WindowGroup { - SwitchingRootView(viewModel: koin.applicationViewModel) + SwitchingRootView(koin.applicationComponent) + .onAppear { LifecycleExtKt.resume(koin.applicationComponentLifecycle) } + .onDisappear { LifecycleExtKt.stop(koin.applicationComponentLifecycle) } } } diff --git a/ios/Droidcon/Droidcon/MainView.swift b/ios/Droidcon/Droidcon/MainView.swift index e0cfdcea6..8dc150409 100644 --- a/ios/Droidcon/Droidcon/MainView.swift +++ b/ios/Droidcon/Droidcon/MainView.swift @@ -2,61 +2,64 @@ import SwiftUI import DroidconKit struct MainView: View { + private var component: ApplicationComponent @ObservedObject - private(set) var viewModel: ApplicationViewModel + private var observableTabStack: ObservableValue> + + @ObservedObject + private var observableFeedbackStack: ObservableValue> + + private var tabStack: ChildStack { observableTabStack.value } + private var feedbackStack: ChildStack { observableFeedbackStack.value } + init(_ component: ApplicationComponent) { + self.component = component + self.observableTabStack = ObservableValue(component.tabStack) + self.observableFeedbackStack = ObservableValue(component.feedbackStack) + } + var body: some View { - TabView(selection: $viewModel.selectedTab) { - ForEach(viewModel.tabs, id: \.self) { tab in - switch (tab) { - case ApplicationViewModel.Tab.schedule: - ScheduleView( - viewModel: viewModel.schedule, - navigationTitle: "Schedule.Title" - ) - .tabItem { - Image(systemName: "calendar") - Text("Schedule.TabItem.Title") - } - .tag(tab); - case ApplicationViewModel.Tab.myagenda: - ScheduleView( - viewModel: viewModel.agenda, - navigationTitle: "Agenda.Title" - ) - .tabItem { - Image(systemName: "clock") - Text("Agenda.TabItem.Title") - } - .tag(tab); - case ApplicationViewModel.Tab.sponsors: - SponsorListView( - viewModel: viewModel.sponsors, - navigationTitle: "Sponsors.Title" - ) - .tabItem { - Image(systemName: "flame") - Text("Sponsors.TabItem.Title") - } - .tag(tab); - case ApplicationViewModel.Tab.settings: - SettingsView( - viewModel: viewModel.settings - ) - .tabItem { - Image(systemName: "gearshape") - Text("Settings.TabItem.Title") - } - .tag(tab); - default: - fatalError("Unknown tab \(tab).") - } + VStack { + TabChildView(tabStack.active.instance) + .frame(maxHeight: .infinity) + + let tab = tabStack.active.instance.tab + + HStack(spacing: 16) { + TabItemView(text: "Schedule.TabItem.Title", systemImage: "calendar", isSelected: tab == .schedule, action: { component.selectTab(tab: .schedule) }) + + TabItemView(text: "Agenda.TabItem.Title", systemImage: "clock", isSelected: tab == .agenda, action: { component.selectTab(tab: .agenda) }) + + TabItemView(text: "Sponsors.TabItem.Title", systemImage: "flame", isSelected: tab == .sponsors, action: { component.selectTab(tab: .sponsors) }) + + TabItemView(text: "Settings.TabItem.Title", systemImage: "gearshape", isSelected: tab == .settings, action: { component.selectTab(tab: .settings) }) } + }.present(item: Binding(get: { feedbackStack.active.instance as? ApplicationComponentFeedbackChildFeedback }, set: { _,_ in })) { + FeedbackDialog($0.component) } - .accentColor(Color("Accent")) - .present(item: $viewModel.presentedFeedback) { viewModel in - FeedbackDialog(viewModel: viewModel) + } +} + +private struct TabItemView: View { + private(set) var text: LocalizedStringKey + private(set) var systemImage: String + private(set) var isSelected: Bool + private(set) var action: () -> Void + + var body: some View { + Button(action: action) { + Label(text, systemImage: systemImage) + }.labelStyle(VerticalLabelStyle()) + .opacity(isSelected ? 1 : 0.5) + } +} + +private struct VerticalLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .center, spacing: 8) { + configuration.icon + configuration.title } } } diff --git a/ios/Droidcon/Droidcon/Schedule/ScheduleView.swift b/ios/Droidcon/Droidcon/Schedule/ScheduleView.swift deleted file mode 100644 index f262cac9c..000000000 --- a/ios/Droidcon/Droidcon/Schedule/ScheduleView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import SwiftUI -import DroidconKit - -struct ScheduleView: View { - @ObservedObject - private(set) var viewModel: BaseSessionListViewModel - - private(set) var navigationTitle: LocalizedStringKey - - @State - private var shouldShowShrug: Bool = false - - var body: some View { - NavigationView { - VStack(spacing: 0) { - if let days = viewModel.days { - if !days.isEmpty { - DaySelectionView(viewModel: viewModel) - .padding() - .background( - Color("ElevatedHeaderBackground") - .shadow(color: Color("Shadow"), radius: 2, y: 1) - ) - // To overshadow the scroll view. - .zIndex(1) - - ScrollView { - if let selectedDay = viewModel.selectedDay { - SessionListView( - viewModel: selectedDay, - showAttendingIndicators: !viewModel.attendingOnly - ) - .padding(.top) - } - } - } else { - Spacer().frame(maxHeight: 128) - - Text(NSLocalizedString("Shrug", comment: "Empty list state")) - .font(.system(size: 72)) - .minimumScaleFactor(0.65) - .lineLimit(1) - .padding() - .opacity(shouldShowShrug ? 1 : 0) - .onAppear { - // Waiting for the next loop results in a nicer animation. - DispatchQueue.main.async { - withAnimation { - shouldShowShrug = true - } - } - } - } - } else { - Spacer().frame(maxHeight: 128) - - ProgressView() - .scaleEffect(x: 2, y: 2) - } - - SwitchingNavigationLink( - selection: $viewModel.presentedSessionDetail, - content: SessionDetailView.init(viewModel:) - ) - } - .frame(maxHeight: .infinity, alignment: .top) - .navigationTitle(navigationTitle) - .navigationBarTitleDisplayMode(.inline) - } - .navigationViewStyle(StackNavigationViewStyle()) - } -} - -struct ScheduleView_Previews: PreviewProvider { - static var previews: some View { - EmptyView() - } -} diff --git a/ios/Droidcon/Droidcon/Sessions/DaySelectionView.swift b/ios/Droidcon/Droidcon/Sessions/DaySelectionView.swift deleted file mode 100644 index 918a2741d..000000000 --- a/ios/Droidcon/Droidcon/Sessions/DaySelectionView.swift +++ /dev/null @@ -1,23 +0,0 @@ -import SwiftUI -import DroidconKit - -struct DaySelectionView: View { - @ObservedObject - private(set) var viewModel: BaseSessionListViewModel - - var body: some View { - Picker(viewModel.selectedDay?.day ?? "", selection: $viewModel.selectedDay) { - ForEach(viewModel.days ?? []) { day in - Text(day.day) - .tag(day as SessionDayViewModel?) - } - } - .pickerStyle(SegmentedPickerStyle()) - } -} - -struct DaySelectionView_Previews: PreviewProvider { - static var previews: some View { - EmptyView() - } -} diff --git a/ios/Droidcon/Droidcon/Sessions/Detail/SessionDetailView.swift b/ios/Droidcon/Droidcon/Sessions/Detail/SessionDetailView.swift index 460f4a7b8..fcf3dfd16 100644 --- a/ios/Droidcon/Droidcon/Sessions/Detail/SessionDetailView.swift +++ b/ios/Droidcon/Droidcon/Sessions/Detail/SessionDetailView.swift @@ -3,129 +3,136 @@ import DroidconKit struct SessionDetailView: View { private static let iconSize: CGFloat = 24 - + + private var component: SessionDetailComponent + @ObservedObject - private(set) var viewModel: SessionDetailViewModel - + private var observableModel: ObservableValue + + private var viewModel: SessionDetailComponent.Model { observableModel.value } + + init(_ component: SessionDetailComponent) { + self.component = component + self.observableModel = ObservableValue(component.model) + } + var body: some View { - ScrollView { - ZStack { - SwitchingNavigationLink( - selection: $viewModel.presentedSpeakerDetail, - content: SpeakerDetailView.init(viewModel:) - ) - - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text(viewModel.title) - .font(.title2) - - Text(viewModel.info) - .font(.footnote) - } - .padding(.horizontal, 8) - .frame(maxWidth: .infinity, alignment: .leading) - - if viewModel.state != .ended { - Button(action: viewModel.attendingTapped) { - if viewModel.isAttendingLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .black)) - } else { - Image(systemName: viewModel.isAttending ? "checkmark" : "plus") - .resizable() - .foregroundColor(.black) - } + NavigationView { + ScrollView { + ZStack { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.title) + .font(.title2) + + Text(viewModel.info) + .font(.footnote) } - .frame(width: 16, height: 16) - .padding(12) - .background(Color("AttendButton")) - .cornerRadius(.greatestFiniteMagnitude) - .shadow(color: Color("Shadow"), radius: 2) - .padding(.bottom, -36) - } - } - .frame(maxWidth: .infinity) - .padding() - .background( - Color("ElevatedHeaderBackground") - .shadow(color: Color("Shadow"), radius: 2, y: 1) - ) - - VStack(spacing: 16) { - if let sessionStateMessage = viewModel.state.flatMap(stateMessage(from:)) { - label( - Text(sessionStateMessage), - image: Image(systemName: "info.circle") - ) + .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - } - - if viewModel.showFeedbackOption { - Button(action: viewModel.writeFeedbackTapped) { - if viewModel.feedbackAlreadyWritten { - Text("Session.Detail.ChangeFeedback") - } else { - Text("Session.Detail.AddFeedback") + + if viewModel.state != .ended { + Button(action: component.attendingTapped) { + if viewModel.isAttendingLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } else { + Image(systemName: viewModel.isAttending ? "checkmark" : "plus") + .resizable() + .foregroundColor(.black) + } } + .frame(width: 16, height: 16) + .padding(12) + .background(Color("AttendButton")) + .cornerRadius(.greatestFiniteMagnitude) + .shadow(color: Color("Shadow"), radius: 2) + .padding(.bottom, -36) } - .buttonStyle(FilledButtonStyle()) - } - - if let abstract = viewModel.abstract { - label( - Text(abstract), - image: Image(systemName: "doc.text") - ) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) } - - VStack(spacing: 4) { - Section(header: VStack(spacing: 4) { - Text("Session.Detail.Speakers").font(.title2) - Divider() - }) { - ForEach(viewModel.speakers) { speaker in - SpeakerListItemView(viewModel: speaker) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .contentShape(Rectangle()) - .onTapGesture { - speaker.selected() - } + .frame(maxWidth: .infinity) + .padding() + .background( + Color("ElevatedHeaderBackground") + .shadow(color: Color("Shadow"), radius: 2, y: 1) + ) + + VStack(spacing: 16) { + if let sessionStateMessage = stateMessage(from: viewModel.state) { + label( + Text(sessionStateMessage), + image: Image(systemName: "info.circle") + ) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + } + + if viewModel.showFeedbackOption { + Button(action: component.writeFeedbackTapped) { + if viewModel.feedbackAlreadyWritten { + Text("Session.Detail.ChangeFeedback") + } else { + Text("Session.Detail.AddFeedback") + } + } + .buttonStyle(FilledButtonStyle()) + } + + if let abstract = viewModel.abstract { + label( + Text(abstract), + image: Image(systemName: "doc.text") + ) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + } + + VStack(spacing: 4) { + Section(header: VStack(spacing: 4) { + Text("Session.Detail.Speakers").font(.title2) + Divider() + }) { + ForEach(viewModel.speakers, id: \.self) { speaker in + SpeakerListItemView(bio: speaker.bio, avatarUrl: speaker.avatarUrl, info: speaker.info) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .contentShape(Rectangle()) + .onTapGesture { component.speakerTapped(speaker: speaker) } + } } } + .padding(4) + .padding(.top) } - .padding(4) - .padding(.top) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 32) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 32) } - } - .present(item: $viewModel.presentedFeedback) { viewModel in - FeedbackDialog(viewModel: viewModel) - } + }.navigationBarTitle(Text("Session.Detail.Title"), displayMode: .inline) + .navigationBarItems( + leading: Image(systemName: "arrow.backward") + .aspectRatio(contentMode: .fit) + .imageScale(.large) + .foregroundColor(.accentColor) + .onTapGesture(perform: component.backTapped) + ) } - .navigationTitle("Session.Detail.Title") } - + private func label(_ text: Text, image: Image) -> some View { return HStack(alignment: .firstTextBaseline) { image .frame(width: Self.iconSize, height: Self.iconSize) - + text .font(.callout) .padding(.leading, 8) .fixedSize(horizontal: false, vertical: true) } } - - private func stateMessage(from state: SessionDetailViewModel.SessionState) -> LocalizedStringKey? { + + private func stateMessage(from state: SessionDetailComponent.SessionState) -> LocalizedStringKey? { switch state { case .inconflict: return "Session.Detail.State.Conflict" @@ -141,7 +148,7 @@ struct SessionDetailView: View { struct SessionDetailView_Previews: PreviewProvider { static var previews: some View { -// SessionDetailView() + // SessionDetailView() EmptyView() } } diff --git a/ios/Droidcon/Droidcon/Sessions/Detail/Speaker/SpeakerDetailView.swift b/ios/Droidcon/Droidcon/Sessions/Detail/Speaker/SpeakerDetailView.swift index 8ca62d0a4..49b3f98dd 100644 --- a/ios/Droidcon/Droidcon/Sessions/Detail/Speaker/SpeakerDetailView.swift +++ b/ios/Droidcon/Droidcon/Sessions/Detail/Speaker/SpeakerDetailView.swift @@ -3,87 +3,94 @@ import DroidconKit struct SpeakerDetailView: View { private static let avatarSize: CGFloat = 48 - - @ObservedObject - private(set) var viewModel: SpeakerDetailViewModel - + + private(set) var component: SpeakerDetailComponent + var body: some View { - VStack(spacing: 0) { - ScrollView { - HStack(spacing: 16) { - if let avatarUrl = viewModel.avatarUrl.flatMap({ URL(string: $0.string) }) { - Avatar( - url: avatarUrl, - size: Self.avatarSize - ) - } else { - Image(systemName: "face.dashed") - .resizable() - .frame(width: Self.avatarSize, height: Self.avatarSize) + NavigationView { + VStack(spacing: 0) { + ScrollView { + HStack(spacing: 16) { + if let avatarUrl = component.avatarUrl.flatMap({ URL(string: $0.string) }) { + Avatar( + url: avatarUrl, + size: Self.avatarSize + ) + } else { + Image(systemName: "face.dashed") + .resizable() + .frame(width: Self.avatarSize, height: Self.avatarSize) + } + + VStack(alignment: .leading, spacing: 4) { + Text(component.name) + .font(.title2) + + if let position = component.position { + Text(position) + .font(.footnote) + } + } + .frame(maxWidth: .infinity, alignment: .leading) } - - VStack(alignment: .leading, spacing: 4) { - Text(viewModel.name) - .font(.title2) - - if let position = viewModel.position { - Text(position) - .font(.footnote) + .padding() + .background( + Color("ElevatedHeaderBackground") + .shadow(color: Color("Shadow"), radius: 2, y: 1) + ) + + VStack(spacing: 16) { + if let website = component.socials.website?.string, let websiteUrl = URL(string: website) { + largeImageLabel( + Link(website, destination: websiteUrl).font(.callout), + image: Image(systemName: "globe") + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let twitter = component.socials.twitter?.string, let twitterUrl = URL(string: twitter) { + largeImageLabel( + Link(twitter, destination: twitterUrl).font(.callout), + image: Image("twitter") + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let linkedIn = component.socials.linkedIn?.string, let linkedInUrl = URL(string: linkedIn) { + largeImageLabel( + Link(linkedIn, destination: linkedInUrl).font(.callout), + image: Image("linked-in") + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Show the divider only if there is at least 1 social link and a bio. + if !component.socials.isEmpty && !(component.bio.map { $0.isEmpty } ?? true) { + Divider() + } + + if let bio = component.bio { + label( + Text(bio).font(.callout), + image: Image(systemName: "doc.text") + ) + .frame(maxWidth: .infinity, alignment: .leading) } } .frame(maxWidth: .infinity, alignment: .leading) + .padding() } - .padding() - .background( - Color("ElevatedHeaderBackground") - .shadow(color: Color("Shadow"), radius: 2, y: 1) + }.navigationBarTitle(Text("Speaker.Detail.Title"), displayMode: .inline) + .navigationBarItems( + leading: Image(systemName: "arrow.backward") + .aspectRatio(contentMode: .fit) + .imageScale(.large) + .foregroundColor(.accentColor) + .onTapGesture(perform: component.backTapped) ) - - VStack(spacing: 16) { - if let website = viewModel.socials.website?.string, let websiteUrl = URL(string: website) { - largeImageLabel( - Link(website, destination: websiteUrl).font(.callout), - image: Image(systemName: "globe") - ) - .frame(maxWidth: .infinity, alignment: .leading) - } - - if let twitter = viewModel.socials.twitter?.string, let twitterUrl = URL(string: twitter) { - largeImageLabel( - Link(twitter, destination: twitterUrl).font(.callout), - image: Image("twitter") - ) - .frame(maxWidth: .infinity, alignment: .leading) - } - - if let linkedIn = viewModel.socials.linkedIn?.string, let linkedInUrl = URL(string: linkedIn) { - largeImageLabel( - Link(linkedIn, destination: linkedInUrl).font(.callout), - image: Image("linked-in") - ) - .frame(maxWidth: .infinity, alignment: .leading) - } - - // Show the divider only if there is at least 1 social link and a bio. - if !viewModel.socials.isEmpty && !(viewModel.bio.map { $0.isEmpty } ?? true) { - Divider() - } - - if let bio = viewModel.bio { - label( - Text(bio).font(.callout), - image: Image(systemName: "doc.text") - ) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - } } - .navigationTitle("Speaker.Detail.Title") } - + private func largeImageLabel(_ text: TEXT, image: Image) -> some View { return label( text, @@ -93,12 +100,12 @@ struct SpeakerDetailView: View { alignment: .top ) } - + private func label(_ text: TEXT, image: IMAGE, alignment: VerticalAlignment = .firstTextBaseline) -> some View { return HStack(alignment: alignment) { image .frame(width: 24, height: 24) - + text .padding(.leading, 8) .padding(.top, 4) @@ -108,7 +115,7 @@ struct SpeakerDetailView: View { struct SpeakerDetailView_Previews: PreviewProvider { static var previews: some View { -// SpeakerDetailView() + // SpeakerDetailView() EmptyView() } } diff --git a/ios/Droidcon/Droidcon/Sessions/Detail/SpeakerListItemView.swift b/ios/Droidcon/Droidcon/Sessions/Detail/SpeakerListItemView.swift index c4ea1c01a..596de0c1c 100644 --- a/ios/Droidcon/Droidcon/Sessions/Detail/SpeakerListItemView.swift +++ b/ios/Droidcon/Droidcon/Sessions/Detail/SpeakerListItemView.swift @@ -5,12 +5,13 @@ import DroidconKit struct SpeakerListItemView: View { private static let iconSize: CGFloat = 32 - @ObservedObject - private(set) var viewModel: SpeakerListItemViewModel + private(set) var bio: String? + private(set) var avatarUrl: Url? + private(set) var info: String var body: some View { - HStack(alignment: viewModel.bio == nil ? .center : .top) { - if let avatarUrl = viewModel.avatarUrl.flatMap({ URL(string: $0.string) }) { + HStack(alignment: bio == nil ? .center : .top) { + if let avatarUrl = avatarUrl.flatMap({ URL(string: $0.string) }) { Avatar(url: avatarUrl, size: Self.iconSize) } else { Image(systemName: "face.dashed") @@ -19,13 +20,13 @@ struct SpeakerListItemView: View { } VStack(alignment: .leading, spacing: 8) { - Text(viewModel.info) + Text(info) .font(.footnote) .lineLimit(2) .foregroundColor(.gray) .fixedSize(horizontal: false, vertical: true) - if let bio = viewModel.bio { + if let bio = bio { Text(bio) .font(.callout) .fixedSize(horizontal: false, vertical: true) diff --git a/ios/Droidcon/Droidcon/Sessions/SessionBlockItemView.swift b/ios/Droidcon/Droidcon/Sessions/SessionBlockItemView.swift index 55b6057a2..1f0e05087 100644 --- a/ios/Droidcon/Droidcon/Sessions/SessionBlockItemView.swift +++ b/ios/Droidcon/Droidcon/Sessions/SessionBlockItemView.swift @@ -2,13 +2,10 @@ import SwiftUI import DroidconKit struct SessionBlockItemView: View { - @ObservedObject - private(set) var viewModel: SessionListItemViewModel - - private(set) var showAttendingIndicators: Bool - + private(set) var viewModel: SessionDayComponent.ModelItem private(set) var isFirstInBlock: Bool private(set) var isLastInBlock: Bool + private(set) var onTapped: () -> Void var body: some View { VStack(spacing: 0) { @@ -71,9 +68,7 @@ struct SessionBlockItemView: View { .background(backgroundColor) } .contentShape(Rectangle()) - .onTapGesture { - viewModel.selected() - } + .onTapGesture(perform: onTapped) .zIndex(1) if isLastInBlock { @@ -84,9 +79,9 @@ struct SessionBlockItemView: View { } } - private func attendanceIndicator(for session: SessionListItemViewModel) -> Color { + private func attendanceIndicator(for session: SessionDayComponent.ModelItem) -> Color { // `guard` instead of `if` to sidestep the issue of SwiftUI not rendering the dot at all. - guard showAttendingIndicators && !session.isServiceSession && session.isAttending else { return Color.clear } + guard !session.isServiceSession && session.isAttending else { return Color.clear } let colorName: String if session.isInPast { diff --git a/ios/Droidcon/Droidcon/Sessions/SessionBlockView.swift b/ios/Droidcon/Droidcon/Sessions/SessionBlockView.swift index a632449db..af0797a45 100644 --- a/ios/Droidcon/Droidcon/Sessions/SessionBlockView.swift +++ b/ios/Droidcon/Droidcon/Sessions/SessionBlockView.swift @@ -4,10 +4,8 @@ import DroidconKit struct SessionBlockView: View { static let cornerRadius: CGFloat = 8 - @ObservedObject - private(set) var viewModel: SessionBlockViewModel - - private(set) var showAttendingIndicators: Bool + private(set) var viewModel: SessionDayComponent.ModelBlock + private(set) var onSessionTapped: (SessionDayComponent.ModelItem) -> Void var body: some View { ZStack { @@ -20,12 +18,12 @@ struct SessionBlockView: View { .frame(width: 80, alignment: .trailing) VStack(spacing: 0) { - ForEach(Array(viewModel.sessions.enumerated()), id: \.element) { index, session in + ForEach(Array(viewModel.items.enumerated()), id: \.element) { index, session in SessionBlockItemView( viewModel: session, - showAttendingIndicators: showAttendingIndicators, - isFirstInBlock: index == viewModel.sessions.startIndex, - isLastInBlock: index == viewModel.sessions.endIndex - 1 + isFirstInBlock: index == viewModel.items.startIndex, + isLastInBlock: index == viewModel.items.endIndex - 1, + onTapped: { onSessionTapped(session) } ) } } diff --git a/ios/Droidcon/Droidcon/Sessions/SessionDayView.swift b/ios/Droidcon/Droidcon/Sessions/SessionDayView.swift new file mode 100644 index 000000000..f5a09025c --- /dev/null +++ b/ios/Droidcon/Droidcon/Sessions/SessionDayView.swift @@ -0,0 +1,36 @@ +import SwiftUI +import DroidconKit + +struct SessionDayView: View { + private var component: SessionDayComponent + + @ObservedObject + private var observableModel: ObservableValue + + private var viewModel: SessionDayComponent.Model { observableModel.value } + + init(_ component: SessionDayComponent) { + self.component = component + self.observableModel = ObservableValue(component.model) + } + + var body: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.blocks, id: \.self) { sessionBlock in + SessionBlockView( + viewModel: sessionBlock, + onSessionTapped: component.itemSelected + ) + } + }.padding(.top, 16) + } + } +} + +struct SessionListView_Previews: PreviewProvider { + static var previews: some View { + // SessionListView() + EmptyView() + } +} diff --git a/ios/Droidcon/Droidcon/Sessions/SessionDaysView.swift b/ios/Droidcon/Droidcon/Sessions/SessionDaysView.swift new file mode 100644 index 000000000..86fcf430e --- /dev/null +++ b/ios/Droidcon/Droidcon/Sessions/SessionDaysView.swift @@ -0,0 +1,38 @@ +import SwiftUI +import DroidconKit + +struct SessionDaysView: View { + private let component: SessionDaysComponent + + @ObservedObject + private var observableStack: ObservableValue> + + private var stack: ChildStack { observableStack.value } + + init(_ component: SessionDaysComponent) { + self.component = component + self.observableStack = ObservableValue(component.stack) + } + + var body: some View { + VStack(spacing: 0) { + Picker("", selection: Binding(get: { stack.active.instance.date }, set: component.selectTab)) { + ForEach(component.days, id: \.self) { day in + Text(day.title) + .tag(day.date) + } + } + .pickerStyle(SegmentedPickerStyle()) + .padding() + .background( + Color("ElevatedHeaderBackground") + .shadow(color: Color("Shadow"), radius: 2, y: 1) + ) + // To overshadow the scroll view. + .zIndex(1) + + SessionDayView(stack.active.instance) + } + .frame(maxHeight: .infinity, alignment: .top) + } +} diff --git a/ios/Droidcon/Droidcon/Sessions/SessionListView.swift b/ios/Droidcon/Droidcon/Sessions/SessionListView.swift index be7f9cce3..a344c7fcc 100644 --- a/ios/Droidcon/Droidcon/Sessions/SessionListView.swift +++ b/ios/Droidcon/Droidcon/Sessions/SessionListView.swift @@ -2,26 +2,30 @@ import SwiftUI import DroidconKit struct SessionListView: View { + private var component: BaseSessionListComponent + private var navigationTitle: LocalizedStringKey + @ObservedObject - private(set) var viewModel: SessionDayViewModel - - private(set) var showAttendingIndicators: Bool - + private var observableStack: ObservableValue> + + private var stack: ChildStack { observableStack.value } + + init(component: BaseSessionListComponent, navigationTitle: LocalizedStringKey) { + self.component = component + self.navigationTitle = navigationTitle + self.observableStack = ObservableValue(component.stack) + } + var body: some View { - LazyVStack { - ForEach(viewModel.blocks) { sessionBlock in - SessionBlockView( - viewModel: sessionBlock, - showAttendingIndicators: showAttendingIndicators - ) - } + NavigationView { + VStack { + switch stack.active.instance { + case is BaseSessionListComponentChildLoading: EmptyView() + case let child as BaseSessionListComponentChildDays: SessionDaysView(child.component) + case is BaseSessionListComponentChildEmpty: ZeroCaseView() + default: EmptyView() + } + }.navigationBarTitle(Text(navigationTitle), displayMode: .inline) } } } - -struct SessionListView_Previews: PreviewProvider { - static var previews: some View { -// SessionListView() - EmptyView() - } -} diff --git a/ios/Droidcon/Droidcon/Settings/AboutView.swift b/ios/Droidcon/Droidcon/Settings/AboutView.swift index ab9e5c4fa..1e337faa4 100644 --- a/ios/Droidcon/Droidcon/Settings/AboutView.swift +++ b/ios/Droidcon/Droidcon/Settings/AboutView.swift @@ -4,8 +4,17 @@ import DroidconKit struct AboutView: View { private static let iconSize: CGFloat = 32 + private var component: AboutComponent + @ObservedObject - private(set) var viewModel: AboutViewModel + private var observableModel: ObservableValue + + private var viewModel: AboutComponent.Model { observableModel.value } + + init(_ component: AboutComponent) { + self.component = component + self.observableModel = ObservableValue(component.model) + } var body: some View { VStack(spacing: 16) { diff --git a/ios/Droidcon/Droidcon/Settings/SettingsView.swift b/ios/Droidcon/Droidcon/Settings/SettingsView.swift index c3eb06f82..d744895ba 100644 --- a/ios/Droidcon/Droidcon/Settings/SettingsView.swift +++ b/ios/Droidcon/Droidcon/Settings/SettingsView.swift @@ -2,15 +2,24 @@ import SwiftUI import DroidconKit struct SettingsView: View { + private var component: SettingsComponent + @ObservedObject - private(set) var viewModel: SettingsViewModel + private var observableModel: ObservableValue + + private var viewModel: SettingsComponent.Model { observableModel.value } + + init(_ component: SettingsComponent) { + self.component = component + self.observableModel = ObservableValue(component.model) + } var body: some View { NavigationView { ZStack { ScrollView { VStack(alignment: .leading, spacing: 0) { - Toggle(isOn: $viewModel.isFeedbackEnabled) { + Toggle(isOn: Binding(get: { viewModel.isFeedbackEnabled }, set: component.setFeedbackEnabled)) { Label("Settings.Feedback", systemImage: "ellipsis.bubble") } .padding(.vertical, 8) @@ -18,7 +27,7 @@ struct SettingsView: View { Divider().padding(.horizontal) - Toggle(isOn: $viewModel.isRemindersEnabled) { + Toggle(isOn: Binding(get: { viewModel.isRemindersEnabled }, set: component.setRemindersEnabled)) { Label("Settings.Reminders", systemImage: "calendar") } .padding(.vertical, 8) @@ -26,7 +35,7 @@ struct SettingsView: View { Divider().padding(.horizontal) - Toggle(isOn: $viewModel.useCompose) { + Toggle(isOn: Binding(get: { viewModel.useComposeForIos }, set: component.setUseComposeForIos)) { Label("Settings.Compose", systemImage: "doc.text.image") } .padding(.vertical, 8) @@ -34,7 +43,7 @@ struct SettingsView: View { Divider().padding(.horizontal) - AboutView(viewModel: viewModel.about) + AboutView(component.about) } } } diff --git a/ios/Droidcon/Droidcon/SettingsBundleHelper.swift b/ios/Droidcon/Droidcon/SettingsBundleHelper.swift deleted file mode 100644 index a47cd9f40..000000000 --- a/ios/Droidcon/Droidcon/SettingsBundleHelper.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -class SettingsBundleHelper { - - private static let UseComposeKey = "use_compose_preference" - - class func initialize() { - UserDefaults.standard.register(defaults: [UseComposeKey : true]) - } - - class func getUseComposeValue() -> Bool { - return UserDefaults.standard.bool(forKey: UseComposeKey) - } - - class func setUseComposeValue(newValue: Bool) { - UserDefaults.standard.set(newValue, forKey: UseComposeKey) - } -} diff --git a/ios/Droidcon/Droidcon/Sponsors/SponsorDetailView.swift b/ios/Droidcon/Droidcon/Sponsors/SponsorDetailView.swift index f812e3db6..d959fff22 100644 --- a/ios/Droidcon/Droidcon/Sponsors/SponsorDetailView.swift +++ b/ios/Droidcon/Droidcon/Sponsors/SponsorDetailView.swift @@ -4,108 +4,118 @@ import DroidconKit struct SponsorDetailView: View { private static let iconSize: CGFloat = 24 - + + private var component: SponsorDetailComponent + @ObservedObject - private(set) var viewModel: SponsorDetailViewModel - + private var observableModel: ObservableValue + + private var viewModel: SponsorDetailComponent.Model { observableModel.value } + + init(_ component: SponsorDetailComponent) { + self.component = component + self.observableModel = ObservableValue(component.model) + } + var body: some View { - ScrollView { - ZStack { - SwitchingNavigationLink( - selection: $viewModel.presentedSpeakerDetail, - content: SpeakerDetailView.init(viewModel:) - ) - - VStack(spacing: 0) { - HStack(spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text(viewModel.name) - .font(.title2) - - Text(viewModel.groupName) - .font(.footnote) - } - .padding(.horizontal, 8) - .frame(maxWidth: .infinity, alignment: .leading) - - if let imageUrl = URL(string: viewModel.imageUrl.string) { - KFImage(imageUrl) - .placeholder { - GeometryReader { geometry in - Text(viewModel.name) - .bold() - .lineLimit(1) - .minimumScaleFactor(0.65) - .padding(8) - .frame(maxWidth: .infinity) - .frame(height: geometry.size.width) - } - } - .resizable() - .scaledToFit() - .padding(4) - .background(Color.white) - .cornerRadius(.greatestFiniteMagnitude) - .shadow(color: Color("Shadow"), radius: 2, y: 1) - .frame(idealWidth: 128, idealHeight: 128) - } - } - .padding() - .background( - Color("ElevatedHeaderBackground") - .shadow(color: Color("Shadow"), radius: 2, y: 1) - ) - - VStack(spacing: 16) { - if let abstract = viewModel.abstract { - HStack(alignment: .firstTextBaseline) { - Image(systemName: "doc.text") - .frame(width: Self.iconSize, height: Self.iconSize) - - TextView(.constant(abstract)) - .isEditable(false) - .autoDetectDataTypes(.link) - .font(Font.callout) - .padding(.leading, 8) + NavigationView { + ScrollView { + ZStack { + VStack(spacing: 0) { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.name) + .font(.title2) + + Text(viewModel.groupName) + .font(.footnote) } + .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) + + if let imageUrl = URL(string: viewModel.imageUrl.string) { + KFImage(imageUrl) + .placeholder { + GeometryReader { geometry in + Text(viewModel.name) + .bold() + .lineLimit(1) + .minimumScaleFactor(0.65) + .padding(8) + .frame(maxWidth: .infinity) + .frame(height: geometry.size.width) + } + } + .resizable() + .scaledToFit() + .padding(4) + .background(Color.white) + .cornerRadius(.greatestFiniteMagnitude) + .shadow(color: Color("Shadow"), radius: 2, y: 1) + .frame(idealWidth: 128, idealHeight: 128) + } } - - if !viewModel.representatives.isEmpty { - VStack(spacing: 4) { - Section(header: VStack(spacing: 4) { - Text("Sponsor.Detail.Representatives").font(.title2) - Divider() - }) { - ForEach(viewModel.representatives) { representative in - SpeakerListItemView(viewModel: representative) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .contentShape(Rectangle()) - .onTapGesture { - representative.selected() - } + .padding() + .background( + Color("ElevatedHeaderBackground") + .shadow(color: Color("Shadow"), radius: 2, y: 1) + ) + + VStack(spacing: 16) { + if let abstract = viewModel.abstract { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "doc.text") + .frame(width: Self.iconSize, height: Self.iconSize) + + TextView(.constant(abstract)) + .isEditable(false) + .autoDetectDataTypes(.link) + .font(Font.callout) + .padding(.leading, 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + } + + if !viewModel.representatives.isEmpty { + VStack(spacing: 4) { + Section(header: VStack(spacing: 4) { + Text("Sponsor.Detail.Representatives").font(.title2) + Divider() + }) { + ForEach(viewModel.representatives, id: \.self) { profile in + SpeakerListItemView(bio: profile.bio, avatarUrl: profile.avatarUrl, info: profile.info) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .contentShape(Rectangle()) + .onTapGesture { component.representativeTapped(representative: profile) } + } } } + .padding(4) + .padding(.top) } - .padding(4) - .padding(.top) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 32) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 32) } - } + }.navigationBarTitle(Text("Sponsor.Detail.Title"), displayMode: .inline) + .navigationBarItems( + leading: Image(systemName: "arrow.backward") + .aspectRatio(contentMode: .fit) + .imageScale(.large) + .foregroundColor(.accentColor) + .onTapGesture(perform: component.backTapped) + ) } - .navigationTitle("Sponsor.Detail.Title") } - + private func label(_ text: Text, image: Image) -> some View { return HStack(alignment: .firstTextBaseline) { image .frame(width: Self.iconSize, height: Self.iconSize) - + text .font(.callout) .padding(.leading, 8) @@ -117,6 +127,6 @@ struct SponsorDetailView: View { struct SponsorDetailView_Previews: PreviewProvider { static var previews: some View { EmptyView() -// SponsorDetailView() + // SponsorDetailView() } } diff --git a/ios/Droidcon/Droidcon/Sponsors/SponsorGroupItemView.swift b/ios/Droidcon/Droidcon/Sponsors/SponsorGroupItemView.swift index fe7cef5e7..5734e0d65 100644 --- a/ios/Droidcon/Droidcon/Sponsors/SponsorGroupItemView.swift +++ b/ios/Droidcon/Droidcon/Sponsors/SponsorGroupItemView.swift @@ -3,8 +3,8 @@ import Kingfisher import DroidconKit struct SponsorGroupItemView: View { - @ObservedObject - private(set) var viewModel: SponsorGroupItemViewModel + private(set) var viewModel: SponsorListComponent.ModelSponsor + private(set) var onTapped: () -> Void var body: some View { ZStack(alignment: .center) { @@ -14,7 +14,7 @@ struct SponsorGroupItemView: View { .cornerRadius(.greatestFiniteMagnitude) .shadow(color: Color("Shadow"), radius: 2, y: 1) - if let imageUrl = URL(string: viewModel.imageUrl.string) { + if let imageUrl = viewModel.imageUrl.flatMap({ URL(string: $0) }) { KFImage(imageUrl) .placeholder { placeholder @@ -29,9 +29,7 @@ struct SponsorGroupItemView: View { } } .contentShape(Rectangle()) - .onTapGesture { - viewModel.selected() - } + .onTapGesture(perform: onTapped) } @ViewBuilder diff --git a/ios/Droidcon/Droidcon/Sponsors/SponsorListView.swift b/ios/Droidcon/Droidcon/Sponsors/SponsorListView.swift index 660bfc65e..59d0dd047 100644 --- a/ios/Droidcon/Droidcon/Sponsors/SponsorListView.swift +++ b/ios/Droidcon/Droidcon/Sponsors/SponsorListView.swift @@ -2,21 +2,23 @@ import SwiftUI import DroidconKit struct SponsorListView: View { + private var component: SponsorListComponent + @ObservedObject - private(set) var viewModel: SponsorListViewModel - - private(set) var navigationTitle: LocalizedStringKey + private var observableModel: ObservableValue + + private var viewModel: SponsorListComponent.Model { observableModel.value } + + init(_ component: SponsorListComponent) { + self.component = component + self.observableModel = ObservableValue(component.model) + } var body: some View { NavigationView { ScrollView { - SwitchingNavigationLink( - selection: $viewModel.presentedSponsorDetail, - content: SponsorDetailView.init(viewModel:) - ) - VStack(spacing: 20) { - ForEach(viewModel.sponsorGroups) { sponsorGroup in + ForEach(viewModel.groups, id: \.self) { sponsorGroup in VStack(spacing: 8) { Text(sponsorGroup.title) .font(.title) @@ -30,7 +32,7 @@ struct SponsorListView: View { spacing: 8 ) { ForEach(sponsorGroup.sponsors, id: \.self) { sponsor in - SponsorGroupItemView(viewModel: sponsor) + SponsorGroupItemView(viewModel: sponsor, onTapped: { component.sponsorTapped(sponsor: sponsor) }) } } } @@ -43,10 +45,8 @@ struct SponsorListView: View { } } .frame(maxHeight: .infinity, alignment: .top) - .navigationTitle(navigationTitle) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitle(Text("Sponsors.Title"), displayMode: .inline) } - .navigationViewStyle(StackNavigationViewStyle()) } } diff --git a/ios/Droidcon/Droidcon/SwitchingRootView.swift b/ios/Droidcon/Droidcon/SwitchingRootView.swift index 65738f636..ed70f61c6 100644 --- a/ios/Droidcon/Droidcon/SwitchingRootView.swift +++ b/ios/Droidcon/Droidcon/SwitchingRootView.swift @@ -3,40 +3,37 @@ import DroidconKit struct SwitchingRootView: View { + private let component: ApplicationComponent + @ObservedObject - var viewModel: ApplicationViewModel + private var observableModel: ObservableValue + + private var model: ApplicationComponent.Model { observableModel.value } private let userDefaultsPublisher = NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) - private let appActivePublisher = NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + init(_ component: ApplicationComponent) { + self.component = component + self.observableModel = ObservableValue(component.model) + } var body: some View { - Group { - if viewModel.useCompose { - ZStack { - Color("NavBar_Background") - .ignoresSafeArea() - - ComposeController(viewModel: viewModel) - } + VStack { + if model.useComposeForIos { + // Uncomment after verifying Compose for iOS with Decompose +// ZStack { +// Color("NavBar_Background") +// .ignoresSafeArea() +// +// ComposeController(component: component) +// } + + // Remove after verifyin Compose for iOS with Decompose + MainView(component) } else { - MainView(viewModel: viewModel) + MainView(component) } } - .attach(viewModel: viewModel) - .onAppear(perform: viewModel.onAppear) - .onReceive(userDefaultsPublisher) { _ in - let x = SettingsBundleHelper.getUseComposeValue() - viewModel.useCompose = x - print("Initial compose: \(x)") - } - .onChange(of: viewModel.useCompose) { newValue in - SettingsBundleHelper.setUseComposeValue(newValue: newValue) - } - .onReceive(appActivePublisher) { _ in - viewModel.onAppear() - } - } } diff --git a/ios/Droidcon/Droidcon/TabChildView.swift b/ios/Droidcon/Droidcon/TabChildView.swift new file mode 100644 index 000000000..f16dba22e --- /dev/null +++ b/ios/Droidcon/Droidcon/TabChildView.swift @@ -0,0 +1,29 @@ +import SwiftUI +import DroidconKit + +struct TabChildView: View { + private let component: TabComponent + + @ObservedObject + private var observableStack: ObservableValue> + + private var stack: ChildStack { observableStack.value } + + init(_ component: TabComponent) { + self.component = component + self.observableStack = ObservableValue(component.stack) + } + + var body: some View { + switch stack.active.instance { + case let child as TabComponentChildMainSchedule: SessionListView(component: child.component, navigationTitle: "Schedule.Title") + case let child as TabComponentChildMainAgenda: SessionListView(component: child.component, navigationTitle: "Agenda.Title") + case let child as TabComponentChildMainSponsors: SponsorListView(child.component) + case let child as TabComponentChildMainSettings: SettingsView(child.component) + case let child as TabComponentChildSession: SessionDetailView(child.component) + case let child as TabComponentChildSponsor: SponsorDetailView(child.component) + case let child as TabComponentChildSpeaker: SpeakerDetailView(component: child.component) + default: EmptyView() + } + } +} diff --git a/ios/Droidcon/Droidcon/Utils/BaseViewModel+ObservableObject.swift b/ios/Droidcon/Droidcon/Utils/BaseViewModel+ObservableObject.swift deleted file mode 100644 index 8fed6e02f..000000000 --- a/ios/Droidcon/Droidcon/Utils/BaseViewModel+ObservableObject.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Combine -import DroidconKit - -extension BaseViewModel: Combine.ObservableObject { - private static var objectWillChangePublisherKey: UInt8 = 0 - private static var objectWillChangeTokenKey: UInt8 = 0 - - public var objectWillChange: ObservableObjectPublisher { - if let publisher = objc_getAssociatedObject(self, &Self.objectWillChangePublisherKey) as? ObservableObjectPublisher { - return publisher - } - let publisher = ObjectWillChangePublisher() - objc_setAssociatedObject(self, &Self.objectWillChangePublisherKey, publisher, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) - if let previousToken = objc_getAssociatedObject(self, &Self.objectWillChangePublisherKey) as? CancellationToken { - previousToken.cancel() - } - let cancelationToken = changeTracking.addWillChangeObserver { - publisher.send() - } - objc_setAssociatedObject(self, &Self.objectWillChangeTokenKey, cancelationToken, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) - return publisher - } -} - -extension BaseViewModel: Identifiable {} diff --git a/ios/Droidcon/Droidcon/Utils/LifecycleManager.swift b/ios/Droidcon/Droidcon/Utils/LifecycleManager.swift deleted file mode 100644 index f98d68711..000000000 --- a/ios/Droidcon/Droidcon/Utils/LifecycleManager.swift +++ /dev/null @@ -1,58 +0,0 @@ -import SwiftUI -import DroidconKit - -class LifecycleManager: SwiftUI.ObservableObject { - - var managedViewModel: BaseViewModel? { - willSet { - managedViewModel?.lifecycle.removeFromParent() - } - didSet { - if let managedViewModel = managedViewModel { - root.addChild(child: managedViewModel.lifecycle) - } - } - } - - private let root = LifecycleGraph.Root(owner: "LifecycleManager") - private let cancelAttach: CancellationToken - - init() { - cancelAttach = root.attachToMainScope() - } - - deinit { - cancelAttach.cancel() - } -} - -struct ManagedLifecycle: ViewModifier { - - private let viewModel: BaseViewModel - - @StateObject - private var lifecycleManager = LifecycleManager() - - init(viewModel: BaseViewModel) { - self.viewModel = viewModel - } - - func body(content: Content) -> some View { - content - .onChange(of: viewModel) { vm in - lifecycleManager.managedViewModel = vm - } - .onAppear { - lifecycleManager.managedViewModel = viewModel - } - .onDisappear { - lifecycleManager.managedViewModel = nil - } - } -} - -extension View { - func attach(viewModel: BaseViewModel) -> some View { - self.modifier(ManagedLifecycle(viewModel: viewModel)) - } -} diff --git a/ios/Droidcon/Droidcon/Utils/ObservableValue.swift b/ios/Droidcon/Droidcon/Utils/ObservableValue.swift new file mode 100644 index 000000000..6db84f193 --- /dev/null +++ b/ios/Droidcon/Droidcon/Utils/ObservableValue.swift @@ -0,0 +1,22 @@ +import SwiftUI +import DroidconKit + +public class ObservableValue : SwiftUI.ObservableObject { + private let observableValue: Value + + @Published + var value: T + + private var observer: ((T) -> Void)? + + init(_ value: Value) { + observableValue = value + self.value = observableValue.value + observer = { [weak self] value in self?.value = value } + observableValue.subscribe(observer: observer!) + } + + deinit { + observableValue.unsubscribe(observer: self.observer!) + } +} diff --git a/ios/Droidcon/Droidcon/Utils/ZeroCaseView.swift b/ios/Droidcon/Droidcon/Utils/ZeroCaseView.swift new file mode 100644 index 000000000..d75143f9c --- /dev/null +++ b/ios/Droidcon/Droidcon/Utils/ZeroCaseView.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct ZeroCaseView: View { + + var body: some View { + VStack(alignment: .center) { + Text(NSLocalizedString("Shrug", comment: "Empty state")) + .font(.system(size: 72)) + .minimumScaleFactor(0.65) + .lineLimit(1) + .padding() + } + } +} diff --git a/ios/build.gradle.kts b/ios/build.gradle.kts index 975faf11e..91c5fbedb 100644 --- a/ios/build.gradle.kts +++ b/ios/build.gradle.kts @@ -40,6 +40,7 @@ kotlin { api(project(":shared-ui")) api(libs.kermit) + api(libs.decompose) } cocoapods { @@ -65,7 +66,7 @@ kotlin { binaries.withType { linkerOpts.add("-lsqlite3") export(libs.kermit) - export(libs.hyperdrive.multiplatformx.api) + export(libs.decompose) export(project(":shared")) export(project(":shared-ui")) } diff --git a/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/DependencyInjection.kt b/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/DependencyInjection.kt index 88f841d73..b6d81ed02 100644 --- a/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/DependencyInjection.kt +++ b/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/DependencyInjection.kt @@ -5,13 +5,20 @@ import co.touchlab.droidcon.domain.service.AnalyticsService import co.touchlab.droidcon.domain.service.impl.ResourceReader import co.touchlab.droidcon.initKoin import co.touchlab.droidcon.ios.service.DefaultParseUrlViewService +import co.touchlab.droidcon.ios.util.DefaultUrlHandler import co.touchlab.droidcon.ios.util.NotificationLocalizedStringFactory import co.touchlab.droidcon.ios.util.formatter.IOSDateFormatter import co.touchlab.droidcon.service.ParseUrlViewService import co.touchlab.droidcon.ui.uiModule import co.touchlab.droidcon.util.BundleResourceReader +import co.touchlab.droidcon.util.DcDispatchers +import co.touchlab.droidcon.util.UrlHandler import co.touchlab.droidcon.util.formatter.DateFormatter -import co.touchlab.droidcon.viewmodel.ApplicationViewModel +import co.touchlab.droidcon.viewmodel.ApplicationComponent +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.essenty.lifecycle.Lifecycle +import com.arkivanov.essenty.lifecycle.LifecycleRegistry import com.russhwolf.settings.AppleSettings import com.russhwolf.settings.ExperimentalSettingsApi import com.russhwolf.settings.ObservableSettings @@ -27,6 +34,11 @@ fun initKoinIos( analyticsService: AnalyticsService, ): KoinApplication = initKoin( module { + single { DcDispatchers() } + single { LifecycleRegistry() } + single { DefaultUrlHandler() } + single { DefaultComponentContext(lifecycle = get()) } + single { BundleProvider(bundle = NSBundle.mainBundle) } single { AppleSettings(delegate = userDefaults) } single { BundleResourceReader(bundle = get().bundle) } @@ -48,5 +60,8 @@ fun initKoinIos( // When not used, an error "KClass of Objective-C classes is not supported." is thrown. data class BundleProvider(val bundle: NSBundle) -val Koin.applicationViewModel: ApplicationViewModel +val Koin.applicationComponent: ApplicationComponent + get() = get() + +val Koin.applicationComponentLifecycle: LifecycleRegistry get() = get() diff --git a/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/util/DefaultUrlHandler.kt b/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/util/DefaultUrlHandler.kt new file mode 100644 index 000000000..08b6bc307 --- /dev/null +++ b/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/util/DefaultUrlHandler.kt @@ -0,0 +1,12 @@ +package co.touchlab.droidcon.ios.util + +import co.touchlab.droidcon.util.UrlHandler +import platform.Foundation.NSURL +import platform.UIKit.UIApplication + +class DefaultUrlHandler : UrlHandler { + + override fun openUrl(url: String) { + UIApplication.sharedApplication.openURL(NSURL(string = url)) + } +} diff --git a/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/util/LifecycleExt.kt b/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/util/LifecycleExt.kt new file mode 100644 index 000000000..9d020e306 --- /dev/null +++ b/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/util/LifecycleExt.kt @@ -0,0 +1,13 @@ +package co.touchlab.droidcon.ios.util + +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.resume as resumeLifecycle +import com.arkivanov.essenty.lifecycle.stop as stopLifecycle + +fun LifecycleRegistry.resume() { + resumeLifecycle() +} + +fun LifecycleRegistry.stop() { + stopLifecycle() +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 3a4c42ea7..e5c166b46 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -59,7 +59,7 @@ dependencyResolutionManagement { val statelyRef = version("stately", "1.2.1") val ktorRef = version("ktor", "2.0.0-beta-1") val multiplatformSettingsRef = version("multiplatformSettings", "0.8.1") - val hyperdriveRef = version("hyperdrive", "0.1.139") + val decomposeRef = version("decompose", "1.0.0-alpha-04-native-compose") val accompanistCoilRef = version("accompanistCoil", "0.15.0") val accompanistInsetsRef = version("accompanistInsets", "0.23.1") val accompanistNavigationAnimationRef = version("accompanistNavigationAnimation", "0.24.6-alpha") @@ -114,8 +114,8 @@ dependencyResolutionManagement { alias("accompanist-insets").to("com.google.accompanist", "accompanist-insets").versionRef(accompanistInsetsRef) alias("accompanist-navigationAnimation").to("com.google.accompanist", "accompanist-navigation-animation").versionRef(accompanistNavigationAnimationRef) - alias("hyperdrive-multiplatformx-api").to("org.brightify.hyperdrive", "multiplatformx-api").versionRef(hyperdriveRef) - alias("hyperdrive-multiplatformx-compose").to("org.brightify.hyperdrive", "multiplatformx-compose").versionRef(hyperdriveRef) + alias("decompose").to("com.arkivanov.decompose", "decompose").versionRef(decomposeRef) + alias("decompose-extensions-compose").to("com.arkivanov.decompose", "extensions-compose-jetbrains").versionRef(decomposeRef) alias("androidx-core").to("androidx.core", "core-ktx").versionRef(coreRef) diff --git a/shared-ui/build.gradle.kts b/shared-ui/build.gradle.kts index 494942b0d..192a20f0a 100644 --- a/shared-ui/build.gradle.kts +++ b/shared-ui/build.gradle.kts @@ -2,6 +2,7 @@ plugins { kotlin("multiplatform") kotlin("plugin.serialization") id("com.android.library") + id("kotlin-parcelize") id("org.jetbrains.compose") version "1.2.0-alpha01-dev755" } @@ -86,8 +87,8 @@ kotlin { implementation(compose.material) implementation(compose.runtime) - implementation(libs.hyperdrive.multiplatformx.api) - // implementation(libs.hyperdrive.multiplatformx.compose) + implementation(libs.decompose) + implementation(libs.decompose.extensions.compose) } } val commonTest by getting { diff --git a/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/settings/PlatformSpecificSettings.kt b/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/settings/PlatformSpecificSettings.kt index f90801aba..a4400d9df 100644 --- a/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/settings/PlatformSpecificSettings.kt +++ b/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/settings/PlatformSpecificSettings.kt @@ -1,9 +1,9 @@ package co.touchlab.droidcon.ui.settings import androidx.compose.runtime.Composable -import co.touchlab.droidcon.viewmodel.settings.SettingsViewModel +import co.touchlab.droidcon.viewmodel.settings.SettingsComponent @Composable -internal actual fun PlatformSpecificSettingsView(viewModel: SettingsViewModel) { +internal actual fun PlatformSpecificSettingsView(component: SettingsComponent) { // Add settings specific for Android here. } diff --git a/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/util/MainView.kt b/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/util/MainView.kt index 43609b076..7955c5f6e 100644 --- a/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/util/MainView.kt +++ b/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/util/MainView.kt @@ -4,9 +4,9 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import co.touchlab.droidcon.ui.MainComposeView -import co.touchlab.droidcon.viewmodel.ApplicationViewModel +import co.touchlab.droidcon.viewmodel.ApplicationComponent @Composable -fun MainView(viewModel: ApplicationViewModel) { - MainComposeView(viewModel = viewModel, modifier = Modifier.systemBarsPadding()) +fun MainView(component: ApplicationComponent) { + MainComposeView(component = component, modifier = Modifier.systemBarsPadding()) } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/decompose/InterfaceLock.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/decompose/InterfaceLock.kt new file mode 100644 index 000000000..525bc2e18 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/decompose/InterfaceLock.kt @@ -0,0 +1,37 @@ +package co.touchlab.droidcon.decompose + +import com.arkivanov.essenty.lifecycle.LifecycleOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +internal fun LifecycleOwner.interfaceLock(context: CoroutineContext): InterfaceLock = + DefaultInterfaceLock(coroutineScope(context)) + +internal interface InterfaceLock { + + fun runExclusively(work: suspend () -> Unit) +} + +private class DefaultInterfaceLock( + private val scope: CoroutineScope, +): InterfaceLock { + + private var lock = false + + override fun runExclusively(work: suspend () -> Unit) { + if (lock) { + return + } + + lock = true + + scope.launch { + try { + work() + } finally { + lock = false + } + } + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/decompose/Utils.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/decompose/Utils.kt new file mode 100644 index 000000000..47eeae360 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/decompose/Utils.kt @@ -0,0 +1,33 @@ +package co.touchlab.droidcon.decompose + +import com.arkivanov.essenty.lifecycle.LifecycleOwner +import com.arkivanov.essenty.lifecycle.doOnDestroy +import com.arkivanov.essenty.lifecycle.subscribe +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +internal fun LifecycleOwner.coroutineScope(context: CoroutineContext): CoroutineScope { + val scope = CoroutineScope(SupervisorJob() + context) + lifecycle.doOnDestroy(scope::cancel) + + return scope +} + +internal fun LifecycleOwner.whileStarted(context: CoroutineContext, block: suspend CoroutineScope.() -> Unit) { + var scope: CoroutineScope? = null + + lifecycle.subscribe( + onStart = { + val newScope = CoroutineScope(context) + scope = newScope + newScope.launch(block = block) + }, + onStop = { + scope?.cancel() + scope = null + } + ) +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/BottomNavigationView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/BottomNavigationView.kt index 1b9004bea..e692cbdd4 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/BottomNavigationView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/BottomNavigationView.kt @@ -1,6 +1,6 @@ package co.touchlab.droidcon.ui -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.BottomNavigation import androidx.compose.material.BottomNavigationItem @@ -16,51 +16,54 @@ import co.touchlab.droidcon.ui.icons.CalendarMonth import co.touchlab.droidcon.ui.icons.LocalFireDepartment import co.touchlab.droidcon.ui.icons.Schedule import co.touchlab.droidcon.ui.icons.Settings -import co.touchlab.droidcon.ui.session.SessionListView -import co.touchlab.droidcon.ui.settings.SettingsView -import co.touchlab.droidcon.ui.sponsors.SponsorsView -import co.touchlab.droidcon.ui.util.observeAsState -import co.touchlab.droidcon.viewmodel.ApplicationViewModel +import co.touchlab.droidcon.viewmodel.ApplicationComponent +import co.touchlab.droidcon.viewmodel.ApplicationComponent.FeedbackChild +import co.touchlab.droidcon.viewmodel.TabComponent +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState @Composable -internal fun BottomNavigationView(viewModel: ApplicationViewModel, modifier: Modifier = Modifier) { - val selectedTab by viewModel.observeSelectedTab.observeAsState() - +internal fun BottomNavigationView(component: ApplicationComponent, modifier: Modifier = Modifier) { Scaffold( modifier = modifier, bottomBar = { + val tabStack by component.tabStack.subscribeAsState() + BottomNavigation(elevation = 0.dp) { - viewModel.tabs.forEach { tab -> + TabComponent.Tab.values().forEach { tab -> val (title, icon) = when (tab) { - ApplicationViewModel.Tab.Schedule -> "Schedule" to Icons.Filled.CalendarMonth - ApplicationViewModel.Tab.MyAgenda -> "My Agenda" to Icons.Filled.Schedule - ApplicationViewModel.Tab.Sponsors -> "Sponsors" to Icons.Filled.LocalFireDepartment - ApplicationViewModel.Tab.Settings -> "Settings" to Icons.Filled.Settings + TabComponent.Tab.Schedule -> "Schedule" to Icons.Filled.CalendarMonth + TabComponent.Tab.Agenda -> "My Agenda" to Icons.Filled.Schedule + TabComponent.Tab.Sponsors -> "Sponsors" to Icons.Filled.LocalFireDepartment + TabComponent.Tab.Settings -> "Settings" to Icons.Filled.Settings } BottomNavigationItem( icon = { Icon(imageVector = icon, contentDescription = null) }, label = { Text(text = title) }, - selected = selectedTab == tab, - onClick = { - viewModel.selectedTab = tab - } + selected = tabStack.active.instance.tab == tab, + onClick = { component.selectTab(tab = tab) } ) } } } ) { innerPadding -> - Box(modifier = Modifier.padding(innerPadding)) { - when (selectedTab) { - ApplicationViewModel.Tab.Schedule -> SessionListView(viewModel.schedule) - ApplicationViewModel.Tab.MyAgenda -> SessionListView(viewModel.agenda) - ApplicationViewModel.Tab.Sponsors -> SponsorsView(viewModel.sponsors) - ApplicationViewModel.Tab.Settings -> SettingsView(viewModel.settings) - } + Children( + stack = component.tabStack, + modifier = Modifier.padding(innerPadding), + animation = stackAnimation(fade()), + ) { + TabView( + component = it.instance, + modifier = Modifier.fillMaxSize(), + ) } } - val feedback by viewModel.observePresentedFeedback.observeAsState() - feedback?.let { - FeedbackDialog(it) + val feedbackStack by component.feedbackStack.subscribeAsState() + when (val instance = feedbackStack.active.instance) { + is FeedbackChild.None -> Unit + is FeedbackChild.Feedback -> FeedbackDialog(instance.component) } } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/FeedbackDialog.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/FeedbackDialog.kt index 53e5d083e..99ee63048 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/FeedbackDialog.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/FeedbackDialog.kt @@ -1,6 +1,5 @@ package co.touchlab.droidcon.ui -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -33,11 +32,14 @@ import co.touchlab.droidcon.ui.icons.SentimentVeryDissatisfied import co.touchlab.droidcon.ui.icons.SentimentVerySatisfied import co.touchlab.droidcon.ui.theme.Dimensions import co.touchlab.droidcon.ui.util.Dialog -import co.touchlab.droidcon.ui.util.observeAsState -import co.touchlab.droidcon.viewmodel.FeedbackDialogViewModel +import co.touchlab.droidcon.viewmodel.FeedbackDialogComponent +import co.touchlab.droidcon.viewmodel.FeedbackDialogComponent.Rating +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState @Composable -internal fun FeedbackDialog(feedback: FeedbackDialogViewModel) { +internal fun FeedbackDialog(feedback: FeedbackDialogComponent) { + val model by feedback.model.subscribeAsState() + Dialog(dismiss = feedback::skipTapped) { Card(modifier = Modifier.padding(Dimensions.Padding.double), backgroundColor = MaterialTheme.colors.background) { Column( @@ -46,7 +48,7 @@ internal fun FeedbackDialog(feedback: FeedbackDialogViewModel) { .verticalScroll(rememberScrollState()), ) { Text( - text = "What did you think of ${feedback.sessionTitle}", + text = "What did you think of ${model.sessionTitle}", color = MaterialTheme.colors.onBackground, style = MaterialTheme.typography.subtitle1, overflow = TextOverflow.Ellipsis, @@ -56,13 +58,11 @@ internal fun FeedbackDialog(feedback: FeedbackDialogViewModel) { modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, ) { - val selected by feedback.observeRating.observeAsState() - - FeedbackDialogViewModel.Rating.values().forEach { rating -> + Rating.values().forEach { rating -> val image = when (rating) { - FeedbackDialogViewModel.Rating.Dissatisfied -> Icons.Default.SentimentVeryDissatisfied - FeedbackDialogViewModel.Rating.Normal -> Icons.Default.SentimentNeutral - FeedbackDialogViewModel.Rating.Satisfied -> Icons.Default.SentimentVerySatisfied + Rating.Dissatisfied -> Icons.Default.SentimentVeryDissatisfied + Rating.Normal -> Icons.Default.SentimentNeutral + Rating.Satisfied -> Icons.Default.SentimentVerySatisfied } Icon( imageVector = image, @@ -70,17 +70,16 @@ internal fun FeedbackDialog(feedback: FeedbackDialogViewModel) { .size(80.dp) .padding(Dimensions.Padding.default) .clip(CircleShape) - .clickable { feedback.rating = rating }, + .clickable { feedback.setRating(rating) }, contentDescription = rating.name, - tint = if (selected == rating) MaterialTheme.colors.primary else Color.Gray, + tint = if (model.rating == rating) MaterialTheme.colors.primary else Color.Gray, ) } } - val comment by feedback.observeComment.observeAsState() OutlinedTextField( - value = comment, - onValueChange = { feedback.comment = it }, + value = model.comment, + onValueChange = feedback::setComment, placeholder = { Text(text = "(Optional) Suggest improvement", style = MaterialTheme.typography.body1) }, @@ -98,7 +97,7 @@ internal fun FeedbackDialog(feedback: FeedbackDialogViewModel) { modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End, ) { - val isSubmitDisabled by feedback.observeIsSubmitDisabled.observeAsState() + val isSubmitDisabled = model.isSubmitDisabled TextButton(onClick = feedback::submitTapped, enabled = !isSubmitDisabled) { Text( text = "SUBMIT", diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/MainComposeView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/MainComposeView.kt index 975d5b386..05142188e 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/MainComposeView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/MainComposeView.kt @@ -3,11 +3,11 @@ package co.touchlab.droidcon.ui import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import co.touchlab.droidcon.ui.theme.DroidconTheme -import co.touchlab.droidcon.viewmodel.ApplicationViewModel +import co.touchlab.droidcon.viewmodel.ApplicationComponent @Composable -internal fun MainComposeView(viewModel: ApplicationViewModel, modifier: Modifier = Modifier) { +internal fun MainComposeView(component: ApplicationComponent, modifier: Modifier = Modifier) { DroidconTheme { - BottomNavigationView(viewModel = viewModel, modifier = modifier) + BottomNavigationView(component = component, modifier = modifier) } } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/TabView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/TabView.kt new file mode 100644 index 000000000..d52aedb0b --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/TabView.kt @@ -0,0 +1,35 @@ +package co.touchlab.droidcon.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import co.touchlab.droidcon.ui.session.SessionDetailView +import co.touchlab.droidcon.ui.session.SessionListView +import co.touchlab.droidcon.ui.session.SpeakerDetailView +import co.touchlab.droidcon.ui.settings.SettingsView +import co.touchlab.droidcon.ui.sponsors.SponsorDetailView +import co.touchlab.droidcon.ui.sponsors.SponsorsView +import co.touchlab.droidcon.viewmodel.TabComponent +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.plus +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.scale +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation + +@Composable +internal fun TabView(component: TabComponent, modifier: Modifier = Modifier) { + Children( + stack = component.stack, + modifier = modifier, + animation = stackAnimation(fade() + scale()), + ) { + when (val child = it.instance) { + is TabComponent.Child.Main.Schedule -> SessionListView(child.component) + is TabComponent.Child.Main.Agenda -> SessionListView(child.component) + is TabComponent.Child.Main.Sponsors -> SponsorsView(child.component) + is TabComponent.Child.Main.Settings -> SettingsView(child.component) + is TabComponent.Child.Session -> SessionDetailView(child.component) + is TabComponent.Child.Sponsor -> SponsorDetailView(child.component) + is TabComponent.Child.Speaker -> SpeakerDetailView(child.component) + } + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/UiModule.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/UiModule.kt index a92c904c3..96f972f3d 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/UiModule.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/UiModule.kt @@ -1,86 +1,87 @@ package co.touchlab.droidcon.ui import co.touchlab.droidcon.application.service.NotificationService -import co.touchlab.droidcon.viewmodel.ApplicationViewModel -import co.touchlab.droidcon.viewmodel.FeedbackDialogViewModel -import co.touchlab.droidcon.viewmodel.session.AgendaViewModel -import co.touchlab.droidcon.viewmodel.session.ScheduleViewModel -import co.touchlab.droidcon.viewmodel.session.SessionBlockViewModel -import co.touchlab.droidcon.viewmodel.session.SessionDayViewModel -import co.touchlab.droidcon.viewmodel.session.SessionDetailViewModel -import co.touchlab.droidcon.viewmodel.session.SessionListItemViewModel -import co.touchlab.droidcon.viewmodel.session.SpeakerDetailViewModel -import co.touchlab.droidcon.viewmodel.session.SpeakerListItemViewModel -import co.touchlab.droidcon.viewmodel.settings.AboutViewModel -import co.touchlab.droidcon.viewmodel.settings.SettingsViewModel -import co.touchlab.droidcon.viewmodel.sponsor.SponsorDetailViewModel -import co.touchlab.droidcon.viewmodel.sponsor.SponsorGroupItemViewModel -import co.touchlab.droidcon.viewmodel.sponsor.SponsorGroupViewModel -import co.touchlab.droidcon.viewmodel.sponsor.SponsorListViewModel +import co.touchlab.droidcon.viewmodel.ApplicationComponent +import co.touchlab.droidcon.viewmodel.FeedbackDialogComponent +import co.touchlab.droidcon.viewmodel.TabComponent +import co.touchlab.droidcon.viewmodel.session.AgendaComponent +import co.touchlab.droidcon.viewmodel.session.ScheduleComponent +import co.touchlab.droidcon.viewmodel.session.SessionDayComponent +import co.touchlab.droidcon.viewmodel.session.SessionDaysComponent +import co.touchlab.droidcon.viewmodel.session.SessionDetailComponent +import co.touchlab.droidcon.viewmodel.session.SpeakerDetailComponent +import co.touchlab.droidcon.viewmodel.settings.AboutComponent +import co.touchlab.droidcon.viewmodel.settings.SettingsComponent +import co.touchlab.droidcon.viewmodel.sponsor.SponsorDetailComponent +import co.touchlab.droidcon.viewmodel.sponsor.SponsorListComponent import org.koin.core.parameter.parametersOf import org.koin.dsl.module val uiModule = module { // MARK: View model factories. single { - ApplicationViewModel( - scheduleFactory = get(), - agendaFactory = get(), - sponsorsFactory = get(), - settingsFactory = get(), + ApplicationComponent( + componentContext = get(), + dispatchers = get(), + tabFactory = get(), feedbackDialogFactory = get(), syncService = get(), notificationSchedulingService = get(), feedbackService = get(), settingsGateway = get(), + urlHandler = get(), ) .also { get().setHandler(it) } } single { - ScheduleViewModel.Factory( - sessionGateway = get(), - sessionDayFactory = get(), + TabComponent.Factory( + scheduleFactory = get(), + agendaFactory = get(), + sponsorsFactory = get(), + settingsFactory = get(), sessionDetailFactory = get(), + sponsorDetailFactory = get(), + speakerDetailFactory = get(), + ) + } + + single { + ScheduleComponent.Factory( + dispatchers = get(), + sessionGateway = get(), + sessionDaysFactory = get(), dateTimeService = get(), ) } single { - AgendaViewModel.Factory( + AgendaComponent.Factory( + dispatchers = get(), sessionGateway = get(), - sessionDayFactory = get(), - sessionDetailFactory = get(), + sessionDaysFactory = get(), dateTimeService = get(), ) } - single { SessionBlockViewModel.Factory(sessionListItemFactory = get(), dateFormatter = get()) } - single { SessionDayViewModel.Factory(sessionBlockFactory = get(), dateFormatter = get(), dateTimeService = get()) } - single { SessionListItemViewModel.Factory(dateTimeService = get()) } + single { SessionDayComponent.Factory(dispatchers = get(), dateFormatter = get(), dateTimeService = get()) } + single { SessionDaysComponent.Factory(sessionDayFactory = get(), dateFormatter = get()) } single { - SessionDetailViewModel.Factory( + SessionDetailComponent.Factory( + dispatchers = get(), sessionGateway = get(), - speakerListItemFactory = get(), - speakerDetailFactory = get(), dateFormatter = get(), dateTimeService = get(), parseUrlViewService = get(), settingsGateway = get(), - feedbackDialogFactory = get(), - feedbackService = get(), ) } - single { SpeakerListItemViewModel.Factory() } - - single { SpeakerDetailViewModel.Factory(parseUrlViewService = get()) } + single { SpeakerDetailComponent.Factory(parseUrlViewService = get()) } - single { SponsorListViewModel.Factory(sponsorGateway = get(), sponsorGroupFactory = get(), sponsorDetailFactory = get()) } - single { SponsorGroupViewModel.Factory(sponsorGroupItemFactory = get()) } - single { SponsorGroupItemViewModel.Factory() } - single { SponsorDetailViewModel.Factory(sponsorGateway = get(), speakerListItemFactory = get(), speakerDetailFactory = get()) } + single { SponsorListComponent.Factory(dispatchers = get(), sponsorGateway = get()) } + single { SponsorDetailComponent.Factory(dispatchers = get(), sponsorGateway = get()) } - single { SettingsViewModel.Factory(settingsGateway = get(), aboutFactory = get()) } - single { AboutViewModel.Factory(aboutRepository = get(), parseUrlViewService = get()) } + single { SettingsComponent.Factory(dispatchers = get(), settingsGateway = get(), aboutFactory = get()) } + single { AboutComponent.Factory(dispatchers = get(), aboutRepository = get(), parseUrlViewService = get()) } - single { FeedbackDialogViewModel.Factory(sessionGateway = get(), get(parameters = { parametersOf("FeedbackDialogViewModel") })) } + single { FeedbackDialogComponent.Factory(dispatchers = get(), log = get(parameters = { parametersOf("FeedbackDialogComponent") })) } } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionBlockView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionBlockView.kt index 38e18b7ad..f42537626 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionBlockView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionBlockView.kt @@ -14,7 +14,6 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,15 +23,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import co.touchlab.droidcon.ui.theme.Colors import co.touchlab.droidcon.ui.theme.Dimensions -import co.touchlab.droidcon.ui.util.observeAsState -import co.touchlab.droidcon.viewmodel.session.SessionBlockViewModel +import co.touchlab.droidcon.viewmodel.session.SessionDayComponent @OptIn(ExperimentalMaterialApi::class) @Composable -internal fun SessionBlockView(sessionsBlock: SessionBlockViewModel) { +internal fun SessionBlockView(block: SessionDayComponent.Model.Block, onSessionClick: (SessionDayComponent.Model.Item) -> Unit) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopStart) { Text( - text = sessionsBlock.time, + text = block.time, modifier = Modifier .width(100.dp) .padding(Dimensions.Padding.half), @@ -40,26 +38,23 @@ internal fun SessionBlockView(sessionsBlock: SessionBlockViewModel) { ) Column(modifier = Modifier.padding(start = 72.dp)) { - sessionsBlock.sessions.forEach { session -> + block.items.forEach { session -> Row(verticalAlignment = Alignment.CenterVertically) { - val isInPast by session.observeIsInPast.observeAsState() val badgeColor = when { !session.isAttending -> Color.Transparent - isInPast -> Color.Gray + session.isInPast -> Color.Gray session.isInConflict -> Colors.orange else -> Colors.skyBlue } Box(modifier = Modifier.padding(Dimensions.Padding.default).size(8.dp).clip(CircleShape).background(badgeColor)) val backgroundColor = - if (isInPast) MaterialTheme.colors.surface else MaterialTheme.colors.background + if (session.isInPast) MaterialTheme.colors.surface else MaterialTheme.colors.background val isClickable = !session.isServiceSession Card( modifier = Modifier.weight(1f), backgroundColor = backgroundColor, - onClick = { - session.selected() - }, + onClick = { onSessionClick(session) }, elevation = 2.dp, enabled = isClickable, border = null, diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionDayView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionDayView.kt new file mode 100644 index 000000000..911eafd30 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionDayView.kt @@ -0,0 +1,34 @@ +package co.touchlab.droidcon.ui.session + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import co.touchlab.droidcon.ui.theme.Dimensions +import co.touchlab.droidcon.viewmodel.session.SessionDayComponent +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState + +@Composable +internal fun SessionDayView(day: SessionDayComponent, modifier: Modifier = Modifier) { + val model by day.model.subscribeAsState() + + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(vertical = Dimensions.Padding.quarter), + ) { + items(model.blocks) { block -> + Box( + modifier = Modifier.padding( + vertical = Dimensions.Padding.quarter, + horizontal = Dimensions.Padding.half, + ), + ) { + SessionBlockView(block = block, onSessionClick = day::itemSelected) + } + } + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionDaysView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionDaysView.kt new file mode 100644 index 000000000..257bed519 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionDaysView.kt @@ -0,0 +1,82 @@ +package co.touchlab.droidcon.ui.session + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import co.touchlab.droidcon.ui.theme.Dimensions +import co.touchlab.droidcon.viewmodel.session.SessionDayComponent +import co.touchlab.droidcon.viewmodel.session.SessionDaysComponent +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.Direction +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.StackAnimation +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.StackAnimator +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.isEnter +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.slide +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState +import kotlinx.datetime.LocalDate + +@Composable +internal fun SessionDaysView(days: SessionDaysComponent, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + val stack by days.stack.subscribeAsState() + val selectedTabIndex = days.days.indexOfFirst { it.date == stack.active.instance.date } + + TabRow(selectedTabIndex = selectedTabIndex) { + days.days.forEachIndexed { index, day -> + Tab(selected = selectedTabIndex == index, onClick = { days.selectTab(date = day.date) }) { + Text( + text = day.title, + modifier = Modifier.padding(Dimensions.Padding.default), + fontWeight = FontWeight.Bold, + ) + } + } + } + + Children( + stack = stack, + modifier = Modifier.fillMaxSize(), + animation = tabAnimation(indexOf = { date -> days.days.indexOfFirst { it.date == date } }), + ) { + SessionDayView(day = it.instance, modifier = Modifier.fillMaxSize()) + } + } +} + +@OptIn(ExperimentalDecomposeApi::class) +@Composable +private fun tabAnimation(indexOf: (LocalDate) -> Int): StackAnimation = + stackAnimation { child, otherChild, direction -> + val index = indexOf(child.instance.date) + val otherIndex = indexOf(otherChild.instance.date) + val anim = slide() + if ((index > otherIndex) == direction.isEnter) anim else anim.flipSide() + } + +@OptIn(ExperimentalDecomposeApi::class) +private fun StackAnimator.flipSide(): StackAnimator = + StackAnimator { direction, onFinished, content -> + invoke( + direction = direction.flipSide(), + onFinished = onFinished, + content = content, + ) + } + +@Suppress("OPT_IN_USAGE") +private fun Direction.flipSide(): Direction = + when (this) { + Direction.ENTER_FRONT -> Direction.ENTER_BACK + Direction.EXIT_FRONT -> Direction.EXIT_BACK + Direction.ENTER_BACK -> Direction.ENTER_FRONT + Direction.EXIT_BACK -> Direction.EXIT_FRONT + } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionDetailView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionDetailView.kt index 6b2d47989..87afa96e1 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionDetailView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionDetailView.kt @@ -39,10 +39,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import co.touchlab.droidcon.util.NavigationController -import co.touchlab.droidcon.util.NavigationStack import co.touchlab.droidcon.dto.WebLink -import co.touchlab.droidcon.ui.FeedbackDialog import co.touchlab.droidcon.ui.icons.Add import co.touchlab.droidcon.ui.icons.ArrowBack import co.touchlab.droidcon.ui.icons.Check @@ -51,115 +48,100 @@ import co.touchlab.droidcon.ui.icons.Info import co.touchlab.droidcon.ui.theme.Dimensions import co.touchlab.droidcon.ui.util.RemoteImage import co.touchlab.droidcon.ui.util.WebLinkText -import co.touchlab.droidcon.ui.util.observeAsState -import co.touchlab.droidcon.viewmodel.session.SessionDetailViewModel -import co.touchlab.droidcon.viewmodel.session.SpeakerListItemViewModel +import co.touchlab.droidcon.viewmodel.session.SessionDetailComponent +import co.touchlab.droidcon.viewmodel.session.SessionDetailComponent.Model +import co.touchlab.droidcon.viewmodel.session.SessionDetailComponent.SessionState +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState @Composable -internal fun SessionDetailView(viewModel: SessionDetailViewModel) { - NavigationStack(links = { - NavigationLink(viewModel.observePresentedSpeakerDetail) { - SpeakerDetailView(viewModel = it) - } - }) { - Scaffold( - topBar = { - TopAppBar( - title = { Text("Session") }, - elevation = 0.dp, - modifier = Modifier.shadow(AppBarDefaults.TopAppBarElevation), - navigationIcon = { - IconButton(onClick = { NavigationController.root.handleBackPress() }) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = "Back", - ) - } - } - ) - }, - ) { - val scrollState = rememberScrollState() - Column(modifier = Modifier.verticalScroll(scrollState)) { - val state by viewModel.observeState.observeAsState() - Box { - Column { - val title by viewModel.observeTitle.observeAsState() - val locationInfo by viewModel.observeInfo.observeAsState() - HeaderView(title, locationInfo) - } - if (state != SessionDetailViewModel.SessionState.Ended) { - val isAttending by viewModel.observeIsAttending.observeAsState() - FloatingActionButton( - onClick = viewModel::attendingTapped, - modifier = Modifier - .padding(top = 136.dp, start = Dimensions.Padding.default) - .size(44.dp), - ) { - val icon = if (isAttending) Icons.Default.Check else Icons.Default.Add - val description = if (isAttending) { - "Do not attend" - } else { - "Attend" - } - Icon(imageVector = icon, contentDescription = description) - } +internal fun SessionDetailView(component: SessionDetailComponent) { + val model by component.model.subscribeAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Session") }, + elevation = 0.dp, + modifier = Modifier.shadow(AppBarDefaults.TopAppBarElevation), + navigationIcon = { + IconButton(onClick = component::backTapped) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + ) } } - - val status = when (state) { - SessionDetailViewModel.SessionState.InConflict -> "This session is in conflict with another session in your schedule." - SessionDetailViewModel.SessionState.InProgress -> "This session is happening now." - SessionDetailViewModel.SessionState.Ended -> "This session has already ended." - null -> "This session hasn't started yet." + ) + }, + ) { + val scrollState = rememberScrollState() + Column(modifier = Modifier.verticalScroll(scrollState)) { + val state = model.state + Box { + Column { + HeaderView(model.title, model.info) } - InfoView(status) - - val showFeedbackOption by viewModel.observeShowFeedbackOption.observeAsState() - if (showFeedbackOption) { - Button( - onClick = viewModel::writeFeedbackTapped, + if (state != SessionState.Ended) { + val isAttending = model.isAttending + FloatingActionButton( + onClick = component::attendingTapped, modifier = Modifier - .padding(Dimensions.Padding.default) - .align(Alignment.CenterHorizontally), + .padding(top = 136.dp, start = Dimensions.Padding.default) + .size(44.dp), ) { - val feedbackAlreadyWritten by viewModel.observeFeedbackAlreadyWritten.observeAsState() - val text = if (feedbackAlreadyWritten) { - "Change your feedback" + val icon = if (isAttending) Icons.Default.Check else Icons.Default.Add + val description = if (isAttending) { + "Do not attend" } else { - "Add feedback" + "Attend" } - Text(text = text) + Icon(imageVector = icon, contentDescription = description) } } + } - val description by viewModel.observeAbstract.observeAsState() - val descriptionLinks by viewModel.observeAbstractLinks.observeAsState() - description?.let { - DescriptionView(it, descriptionLinks) + val status = when (state) { + SessionState.InConflict -> "This session is in conflict with another session in your schedule." + SessionState.InProgress -> "This session is happening now." + SessionState.Ended -> "This session has already ended." + SessionState.None -> "This session hasn't started yet." + } + InfoView(status) + + if (model.showFeedbackOption) { + Button( + onClick = component::writeFeedbackTapped, + modifier = Modifier + .padding(Dimensions.Padding.default) + .align(Alignment.CenterHorizontally), + ) { + val text = if (model.feedbackAlreadyWritten) { + "Change your feedback" + } else { + "Add feedback" + } + Text(text = text) } + } - Text( - text = "Speakers", - modifier = Modifier.fillMaxWidth().padding(Dimensions.Padding.default), - style = MaterialTheme.typography.h5, - textAlign = TextAlign.Center, - ) + model.abstract?.let { + DescriptionView(it, model.abstractLinks) + } - Divider() + Text( + text = "Speakers", + modifier = Modifier.fillMaxWidth().padding(Dimensions.Padding.default), + style = MaterialTheme.typography.h5, + textAlign = TextAlign.Center, + ) - val speakers by viewModel.observeSpeakers.observeAsState() - speakers.forEach { speaker -> - SpeakerView(speaker) - } + Divider() + + model.speakers.forEach { speaker -> + SpeakerView(speaker = speaker, onClick = { component.speakerTapped(speaker) }) } } } - - val feedback by viewModel.observePresentedFeedback.observeAsState() - feedback?.let { - FeedbackDialog(it) - } } @Composable @@ -244,11 +226,11 @@ private fun DescriptionView(description: String, links: List) { } @Composable -private fun SpeakerView(speaker: SpeakerListItemViewModel) { +private fun SpeakerView(speaker: Model.Speaker, onClick: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() - .clickable { speaker.selected() } + .clickable(onClick = onClick), ) { Row(verticalAlignment = Alignment.CenterVertically) { val imageUrl = speaker.avatarUrl?.string diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionListView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionListView.kt index f7be98daf..5405a9ed7 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionListView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SessionListView.kt @@ -1,116 +1,55 @@ package co.touchlab.droidcon.ui.session import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.AppBarDefaults import androidx.compose.material.Icon import androidx.compose.material.Scaffold -import androidx.compose.material.Tab -import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults -import androidx.compose.material.TabRowDefaults.tabIndicatorOffset import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.runtime.Composable -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.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import co.touchlab.droidcon.ui.icons.DateRange import co.touchlab.droidcon.ui.theme.Dimensions -import co.touchlab.droidcon.ui.util.observeAsState -import co.touchlab.droidcon.util.NavigationStack -import co.touchlab.droidcon.viewmodel.session.BaseSessionListViewModel -import co.touchlab.kermit.Logger +import co.touchlab.droidcon.viewmodel.session.BaseSessionListComponent +import co.touchlab.droidcon.viewmodel.session.BaseSessionListComponent.Child +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children @Composable -internal fun SessionListView(viewModel: BaseSessionListViewModel) { - var selectedTabIndex by rememberSaveable { mutableStateOf(0) } - - NavigationStack(links = { - NavigationLink(viewModel.observePresentedSessionDetail) { - SessionDetailView(viewModel = it) - } - }) { - Scaffold( - topBar = { - TopAppBar( - title = { Text("Droidcon Berlin 2022") }, - elevation = 0.dp, - modifier = Modifier.shadow(AppBarDefaults.TopAppBarElevation), - ) - }, +internal fun SessionListView(component: BaseSessionListComponent) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Droidcon Berlin 2022") }, + elevation = 0.dp, + modifier = Modifier.shadow(AppBarDefaults.TopAppBarElevation), + ) + }, + ) { paddingValues -> + Children( + stack = component.stack, + modifier = Modifier.padding(paddingValues), ) { - Column { - val days by viewModel.observeDays.observeAsState() - if (days?.isEmpty() != false) { - EmptyView() - } else { - TabRow( - selectedTabIndex = selectedTabIndex, - indicator = { tabPositions -> - if (tabPositions.indices.contains(selectedTabIndex)) { - TabRowDefaults.Indicator( - Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]) - ) - } else { - Logger.w("SessionList TabRow requested an indicator for selectedTabIndex: $selectedTabIndex, but only got ${tabPositions.count()} tabs.") - TabRowDefaults.Indicator() - } - } - ) { - days?.forEachIndexed { index, daySchedule -> - Tab(selected = selectedTabIndex == index, onClick = { selectedTabIndex = index }) { - Text( - text = daySchedule.day, - modifier = Modifier.padding(Dimensions.Padding.default), - fontWeight = FontWeight.Bold, - ) - } - } - } - days?.forEachIndexed { index, _ -> - val state = rememberLazyListState() - if (index == selectedTabIndex) { - LazyColumn(state = state, contentPadding = PaddingValues(vertical = Dimensions.Padding.quarter)) { - val daySchedule = days?.getOrNull(selectedTabIndex)?.blocks ?: emptyList() - items(daySchedule) { hourBlock -> - Box( - modifier = Modifier.padding( - vertical = Dimensions.Padding.quarter, - horizontal = Dimensions.Padding.half, - ), - ) { - SessionBlockView(hourBlock) - } - } - } - } - } - } + when (val child = it.instance) { + is Child.Loading -> EmptyView(text = "Loading...") + is Child.Days -> SessionDaysView(days = child.component, modifier = Modifier.fillMaxSize()) + is Child.Empty -> EmptyView(text = "Sessions could not be loaded.") } } } } @Composable -private fun EmptyView() { +private fun EmptyView(text: String) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, @@ -126,7 +65,7 @@ private fun EmptyView() { ) Text( - text = "Sessions could not be loaded.", + text = text, modifier = Modifier.padding(Dimensions.Padding.default), textAlign = TextAlign.Center, ) diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SpeakerDetailView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SpeakerDetailView.kt index 828e7d7ec..060689651 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SpeakerDetailView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/session/SpeakerDetailView.kt @@ -1,6 +1,5 @@ package co.touchlab.droidcon.ui.session -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio @@ -30,7 +29,6 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import co.touchlab.droidcon.util.NavigationController import co.touchlab.droidcon.composite.Url import co.touchlab.droidcon.dto.WebLink import co.touchlab.droidcon.ui.icons.ArrowBack @@ -40,10 +38,10 @@ import co.touchlab.droidcon.ui.theme.Dimensions import co.touchlab.droidcon.ui.util.LocalImage import co.touchlab.droidcon.ui.util.RemoteImage import co.touchlab.droidcon.ui.util.WebLinkText -import co.touchlab.droidcon.viewmodel.session.SpeakerDetailViewModel +import co.touchlab.droidcon.viewmodel.session.SpeakerDetailComponent @Composable -internal fun SpeakerDetailView(viewModel: SpeakerDetailViewModel) { +internal fun SpeakerDetailView(component: SpeakerDetailComponent) { Scaffold( topBar = { TopAppBar( @@ -51,7 +49,7 @@ internal fun SpeakerDetailView(viewModel: SpeakerDetailViewModel) { elevation = 0.dp, modifier = Modifier.shadow(AppBarDefaults.TopAppBarElevation), navigationIcon = { - IconButton(onClick = { NavigationController.root.handleBackPress() }) { + IconButton(onClick = component::backTapped) { Icon( imageVector = Icons.Default.ArrowBack, contentDescription = "Back", @@ -65,22 +63,22 @@ internal fun SpeakerDetailView(viewModel: SpeakerDetailViewModel) { Column( modifier = Modifier.verticalScroll(scrollState), ) { - HeaderView(viewModel.name, viewModel.position ?: "", viewModel.avatarUrl) + HeaderView(component.name, component.position ?: "", component.avatarUrl) - viewModel.socials.website?.let { + component.socials.website?.let { SocialView(WebLink.fromUrl(it), Icons.Default.Language) } - viewModel.socials.twitter?.let { + component.socials.twitter?.let { SocialView(WebLink.fromUrl(it), "twitter") } - viewModel.socials.linkedIn?.let { + component.socials.linkedIn?.let { SocialView(WebLink.fromUrl(it), "linkedin") } Divider() - viewModel.bio?.let { - BioView(it, viewModel.bioWebLinks) + component.bio?.let { + BioView(it, component.bioWebLinks) } } } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/AboutView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/AboutView.kt index bd4b2eb7d..d08118989 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/AboutView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/AboutView.kt @@ -16,29 +16,29 @@ import co.touchlab.droidcon.ui.icons.Info import co.touchlab.droidcon.ui.theme.Dimensions import co.touchlab.droidcon.ui.util.LocalImage import co.touchlab.droidcon.ui.util.WebLinkText -import co.touchlab.droidcon.ui.util.observeAsState -import co.touchlab.droidcon.viewmodel.settings.AboutItemViewModel -import co.touchlab.droidcon.viewmodel.settings.AboutViewModel +import co.touchlab.droidcon.viewmodel.settings.AboutComponent +import co.touchlab.droidcon.viewmodel.settings.AboutComponent.Model +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState @Composable -internal fun AboutView(viewModel: AboutViewModel) { - val items by viewModel.observeItemViewModels.observeAsState() - items.forEach { aboutItem -> +internal fun AboutView(component: AboutComponent) { + val model by component.model.subscribeAsState() + model.items.forEach { aboutItem -> AboutItemView(aboutItem) } } @Composable -private fun AboutItemView(viewModel: AboutItemViewModel) { +private fun AboutItemView(item: Model.Item) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { Icon( modifier = Modifier.padding(Dimensions.Padding.default), imageVector = Icons.Default.Info, - contentDescription = viewModel.title, + contentDescription = item.title, ) Column(modifier = Modifier.weight(1f)) { Text( - text = viewModel.title, + text = item.title, fontWeight = FontWeight.Bold, modifier = Modifier.padding( top = Dimensions.Padding.default, @@ -48,13 +48,13 @@ private fun AboutItemView(viewModel: AboutItemViewModel) { ) WebLinkText( - text = viewModel.detail, - links = viewModel.webLinks, + text = item.detail, + links = item.webLinks, modifier = Modifier.padding(end = Dimensions.Padding.default), ) LocalImage( - imageResourceName = viewModel.icon, + imageResourceName = item.icon, modifier = Modifier .fillMaxWidth() .padding( diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/PlatformSpecificSettings.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/PlatformSpecificSettings.kt index f84deb582..fbbea8b0c 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/PlatformSpecificSettings.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/PlatformSpecificSettings.kt @@ -1,7 +1,7 @@ package co.touchlab.droidcon.ui.settings import androidx.compose.runtime.Composable -import co.touchlab.droidcon.viewmodel.settings.SettingsViewModel +import co.touchlab.droidcon.viewmodel.settings.SettingsComponent @Composable -internal expect fun PlatformSpecificSettingsView(viewModel: SettingsViewModel) +internal expect fun PlatformSpecificSettingsView(component: SettingsComponent) diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/SettingsView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/SettingsView.kt index 01c75648f..64437dc35 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/SettingsView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/SettingsView.kt @@ -23,16 +23,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp -import co.touchlab.droidcon.ui.icons.Aod import co.touchlab.droidcon.ui.icons.MailOutline import co.touchlab.droidcon.ui.icons.Notifications import co.touchlab.droidcon.ui.theme.Dimensions -import co.touchlab.droidcon.ui.util.observeAsState -import co.touchlab.droidcon.viewmodel.settings.SettingsViewModel -import org.brightify.hyperdrive.multiplatformx.property.MutableObservableProperty +import co.touchlab.droidcon.viewmodel.settings.SettingsComponent +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState @Composable -internal fun SettingsView(viewModel: SettingsViewModel) { +internal fun SettingsView(component: SettingsComponent) { + val model by component.model.subscribeAsState() + Scaffold( topBar = { TopAppBar( @@ -47,7 +47,8 @@ internal fun SettingsView(viewModel: SettingsViewModel) { IconTextSwitchRow( text = "Enable feedback", image = Icons.Default.MailOutline, - checked = viewModel.observeIsFeedbackEnabled, + isChecked = model.isFeedbackEnabled, + onCheckedChange = component::setFeedbackEnabled, ) Divider() @@ -55,25 +56,25 @@ internal fun SettingsView(viewModel: SettingsViewModel) { IconTextSwitchRow( text = "Enable reminders", image = Icons.Default.Notifications, - checked = viewModel.observeIsRemindersEnabled, + isChecked = model.isRemindersEnabled, + onCheckedChange = component::setRemindersEnabled, ) Divider() - PlatformSpecificSettingsView(viewModel = viewModel) + PlatformSpecificSettingsView(component = component) - AboutView(viewModel.about) + AboutView(component.about) } } } @Composable -internal fun IconTextSwitchRow(text: String, image: ImageVector, checked: MutableObservableProperty) { - val isChecked by checked.observeAsState() +internal fun IconTextSwitchRow(text: String, image: ImageVector, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit) { Row( modifier = Modifier .fillMaxWidth() - .clickable { checked.value = !checked.value }, + .clickable { onCheckedChange(!isChecked) }, verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -88,7 +89,7 @@ internal fun IconTextSwitchRow(text: String, image: ImageVector, checked: Mutabl Switch( modifier = Modifier.padding(vertical = Dimensions.Padding.half, horizontal = 24.dp), checked = isChecked, - onCheckedChange = { checked.value = it }, + onCheckedChange = onCheckedChange, ) } } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/sponsors/SponsorDetailView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/sponsors/SponsorDetailView.kt index 26585e60f..610f2472f 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/sponsors/SponsorDetailView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/sponsors/SponsorDetailView.kt @@ -35,53 +35,45 @@ import co.touchlab.droidcon.composite.Url import co.touchlab.droidcon.ui.icons.ArrowBack import co.touchlab.droidcon.ui.icons.Description import co.touchlab.droidcon.ui.icons.Person -import co.touchlab.droidcon.ui.session.SpeakerDetailView import co.touchlab.droidcon.ui.theme.Dimensions import co.touchlab.droidcon.ui.util.RemoteImage -import co.touchlab.droidcon.ui.util.observeAsState -import co.touchlab.droidcon.util.NavigationController -import co.touchlab.droidcon.util.NavigationStack -import co.touchlab.droidcon.viewmodel.session.SpeakerListItemViewModel -import co.touchlab.droidcon.viewmodel.sponsor.SponsorDetailViewModel +import co.touchlab.droidcon.viewmodel.sponsor.SponsorDetailComponent +import co.touchlab.droidcon.viewmodel.sponsor.SponsorDetailComponent.Model +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState @Composable -internal fun SponsorDetailView(viewModel: SponsorDetailViewModel) { - NavigationStack(links = { - NavigationLink(viewModel.observePresentedSpeakerDetail) { - SpeakerDetailView(viewModel = it) - } - }) { - Scaffold(topBar = { - TopAppBar( - title = { Text("Sponsor") }, - elevation = 0.dp, - modifier = Modifier.shadow(AppBarDefaults.TopAppBarElevation), - navigationIcon = { - IconButton(onClick = { NavigationController.root.handleBackPress() }) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = "Back", - ) - } - } - ) - }) { paddingValues -> - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .padding(paddingValues) - .verticalScroll(scrollState), - ) { - HeaderView(name = viewModel.name, groupTitle = viewModel.groupName, imageUrl = viewModel.imageUrl) +internal fun SponsorDetailView(component: SponsorDetailComponent) { + val model by component.model.subscribeAsState() - viewModel.abstract?.let { - DescriptionView(description = it) + Scaffold(topBar = { + TopAppBar( + title = { Text("Sponsor") }, + elevation = 0.dp, + modifier = Modifier.shadow(AppBarDefaults.TopAppBarElevation), + navigationIcon = { + IconButton(onClick = component::backTapped) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + ) } + } + ) + }) { paddingValues -> + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .padding(paddingValues) + .verticalScroll(scrollState), + ) { + HeaderView(name = model.name, groupTitle = model.groupName, imageUrl = model.imageUrl) - val representatives by viewModel.observeRepresentatives.observeAsState() - representatives.forEach { - RepresentativeInfoView(profile = it) - } + model.abstract?.let { + DescriptionView(description = it) + } + + model.representatives.forEach { profile -> + RepresentativeInfoView(profile = profile, onClick = { component.representativeTapped(profile) }) } } } @@ -167,11 +159,11 @@ private fun DescriptionView(description: String) { } @Composable -private fun RepresentativeInfoView(profile: SpeakerListItemViewModel) { +private fun RepresentativeInfoView(profile: Model.Representative, onClick: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() - .clickable { profile.selected() }, + .clickable(onClick = onClick), ) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { val imageUrl = profile.avatarUrl diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/sponsors/SponsorsView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/sponsors/SponsorsView.kt index cc9dfecac..83da732ec 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/sponsors/SponsorsView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/sponsors/SponsorsView.kt @@ -32,60 +32,46 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import co.touchlab.droidcon.ui.icons.DateRange import co.touchlab.droidcon.ui.theme.Dimensions import co.touchlab.droidcon.ui.util.RemoteImage -import co.touchlab.droidcon.ui.util.observeAsState -import co.touchlab.droidcon.util.NavigationStack -import co.touchlab.droidcon.viewmodel.sponsor.SponsorGroupViewModel -import co.touchlab.droidcon.viewmodel.sponsor.SponsorListViewModel +import co.touchlab.droidcon.viewmodel.sponsor.SponsorListComponent +import co.touchlab.droidcon.viewmodel.sponsor.SponsorListComponent.Model +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import kotlin.math.min @Composable -internal fun SponsorsView(viewModel: SponsorListViewModel) { - NavigationStack(links = { - NavigationLink(viewModel.observePresentedSponsorDetail) { - SponsorDetailView(viewModel = it) - } - }) { - Scaffold( - topBar = { - TopAppBar( - title = { Text("Sponsors") }, - elevation = 0.dp, - modifier = Modifier.shadow(AppBarDefaults.TopAppBarElevation), - ) - }, - ) { - val uriHandler = LocalUriHandler.current +internal fun SponsorsView(component: SponsorListComponent) { + val model by component.model.subscribeAsState() - val sponsorGroups by viewModel.observeSponsorGroups.observeAsState() - Column { - if (sponsorGroups.isEmpty()) { - EmptyView() - } else { - LazyColumn(contentPadding = PaddingValues(vertical = Dimensions.Padding.quarter)) { - items(sponsorGroups) { sponsorGroup -> - SponsorGroupView(sponsorGroup) - } + Scaffold( + topBar = { + TopAppBar( + title = { Text("Sponsors") }, + elevation = 0.dp, + modifier = Modifier.shadow(AppBarDefaults.TopAppBarElevation), + ) + }, + ) { + Column { + if (model.groups.isEmpty()) { + EmptyView() + } else { + LazyColumn(contentPadding = PaddingValues(vertical = Dimensions.Padding.quarter)) { + items(model.groups) { sponsorGroup -> + SponsorGroupView(sponsorGroup = sponsorGroup, onSponsorClick = component::sponsorTapped) } } } - - val presentedUrl by viewModel.observePresentedUrl.observeAsState() - presentedUrl?.let { - uriHandler.openUri(it.string) - } } } } @Composable -private fun SponsorGroupView(sponsorGroup: SponsorGroupViewModel) { +private fun SponsorGroupView(sponsorGroup: Model.Group, onSponsorClick: (Model.Sponsor) -> Unit) { Surface( shape = MaterialTheme.shapes.medium, modifier = Modifier.padding(vertical = Dimensions.Padding.quarter, horizontal = Dimensions.Padding.half), @@ -106,7 +92,7 @@ private fun SponsorGroupView(sponsorGroup: SponsorGroupViewModel) { ) val columnCount = if (sponsorGroup.isProminent) 3 else 4 - val sponsors by sponsorGroup.observeSponsors.observeAsState() + val sponsors = sponsorGroup.sponsors repeat(sponsors.size / columnCount + if (sponsors.size % columnCount == 0) 0 else 1) { rowIndex -> Row(modifier = Modifier.padding(horizontal = Dimensions.Padding.half)) { @@ -120,12 +106,10 @@ private fun SponsorGroupView(sponsorGroup: SponsorGroupViewModel) { .padding(Dimensions.Padding.quarter) .shadow(2.dp, CircleShape) .background(Color.White) - .clickable { - sponsor.selected() - }, + .clickable { onSponsorClick(sponsor) }, contentAlignment = Alignment.Center, ) { - val imageUrl = sponsor.validImageUrl + val imageUrl = sponsor.imageUrl if (imageUrl != null) { RemoteImage( imageUrl = imageUrl, diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/util/ObserveAsState.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/util/ObserveAsState.kt deleted file mode 100644 index 54ed4de45..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/util/ObserveAsState.kt +++ /dev/null @@ -1,58 +0,0 @@ -package co.touchlab.droidcon.ui.util - -import androidx.compose.runtime.* -import org.brightify.hyperdrive.multiplatformx.ManageableViewModel -import org.brightify.hyperdrive.multiplatformx.ObservableObject -import org.brightify.hyperdrive.multiplatformx.property.ObservableProperty - -/** - * Observe a view model as its properties change to update the view. - * - * Equivalent to [ObservableProperty.observeAsState] for observing all changes in a view model. - */ -@Composable -internal fun T.observeAsState(): State { - val result = remember(this) { mutableStateOf(this, neverEqualPolicy()) } - val listener = remember(this) { - object: ObservableObject.ChangeTracking.Listener { - override fun onObjectDidChange() { - result.value = this@observeAsState - } - } - } - DisposableEffect(this) { - val token = changeTracking.addListener(listener) - result.value = this@observeAsState - - onDispose { - token.cancel() - } - } - return result -} - -/** - * Observe a view model property as it changes to update the view. - * - * Equivalent to [collectAsState] for [ObservableProperty]. - */ -@Composable -internal fun ObservableProperty.observeAsState(): State { - val result = remember(this) { mutableStateOf(value, neverEqualPolicy()) } - val listener = remember(this) { - object: ObservableProperty.Listener { - override fun valueDidChange(oldValue: T, newValue: T) { - result.value = newValue - } - } - } - DisposableEffect(this) { - val token = addListener(listener) - result.value = value - - onDispose { - token.cancel() - } - } - return result -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/util/DcDispatchers.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/util/DcDispatchers.kt new file mode 100644 index 000000000..b3ae059c5 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/util/DcDispatchers.kt @@ -0,0 +1,8 @@ +package co.touchlab.droidcon.util + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +class DcDispatchers( + val main: CoroutineDispatcher = Dispatchers.Main.immediate, +) diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/util/NavigationController.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/util/NavigationController.kt deleted file mode 100644 index abba07eae..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/util/NavigationController.kt +++ /dev/null @@ -1,299 +0,0 @@ -package co.touchlab.droidcon.util - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.with -import androidx.compose.material.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.RememberObserver -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.unit.Constraints -import co.touchlab.droidcon.ui.util.observeAsState -import org.brightify.hyperdrive.multiplatformx.BaseViewModel -import org.brightify.hyperdrive.multiplatformx.CancellationToken -import org.brightify.hyperdrive.multiplatformx.property.MutableObservableProperty -import org.brightify.hyperdrive.multiplatformx.property.ObservableProperty -import org.brightify.hyperdrive.multiplatformx.property.combine -import org.brightify.hyperdrive.multiplatformx.property.map -import org.brightify.hyperdrive.multiplatformx.property.neverEqualPolicy - -private val LocalNavigationController = staticCompositionLocalOf { - NavigationController.root -} - -private val LocalNavigationViewDimensions = staticCompositionLocalOf { - error("NavigationView hasn't been used.") -} - -class NavigationController : BaseViewModel() { - - private val stack = mutableListOf() - private var stackTracking: MutableList by published(stack, equalityPolicy = neverEqualPolicy()) - private val observeStack by observe(::stackTracking) - private var activeChild: NavigationController? = null - - companion object { - - val root = NavigationController() - } - - internal sealed class NavigationStackItem { - class BackPressHandler(val onBackPressed: BackPressHandlerScope.() -> Unit) : NavigationStackItem() { - - override fun toString(): String { - return "BackPress@${hashCode().toUInt().toString(16)}" - } - } - - class Push(val item: MutableObservableProperty, val content: @Composable (T) -> Unit) : NavigationStackItem() { - - override fun toString(): String { - return "Push(${item.value}@${item.hashCode().toUInt().toString(16)})@${hashCode().toUInt().toString(16)}" - } - } - } - - class BackPressHandlerScope { - - var isSkipped = false - private set - - fun skip() { - isSkipped = true - } - } - - fun handleBackPress(): Boolean { - val activeChild = activeChild - - return if (activeChild != null && activeChild.handleBackPress()) { - true - } else { - pop() - } - } - - private fun pop(defer: Int = 0): Boolean { - val currentIndex = stack.count() - 1 - defer - return when (val top = stack.getOrNull(currentIndex)) { - is NavigationStackItem.BackPressHandler -> { - val scope = BackPressHandlerScope() - top.onBackPressed(scope) - if (scope.isSkipped) { - pop(defer + 1) - } else { - true - } - } - is NavigationStackItem.Push<*> -> if (top.item.value != null) { - stack.removeAt(currentIndex) - top.item.value = null - true - } else { - pop(defer + 1) - } - null -> false - } - } - - fun branch(child: NavigationController): CancellationToken { - activeChild = child - return CancellationToken { - if (activeChild === child) { - activeChild = null - } - } - } - - @Composable - internal fun PushedStack(itemModifier: Modifier = Modifier) { - val currentStack by observeStack.observeAsState() - - var i = 0 - while (i < currentStack.count()) { - when (val item = currentStack[i++]) { - is NavigationStackItem.BackPressHandler -> continue - is NavigationStackItem.Push<*> -> PushedStackItem(item, itemModifier) - } - } - } - - @Composable - private fun PushedStackItem(item: NavigationStackItem.Push, itemModifier: Modifier) { - println("$item") - val itemValue by item.item.observeAsState() - - itemValue?.let { - Surface { - item.content(it) - } - } - } - - @Composable - internal fun Pushed(item: MutableObservableProperty, content: @Composable (T) -> Unit) { - val refTracking = remember { - val stackItem = NavigationStackItem.Push(item, content).also { - notifyingStackChange { - stack.add(it) - } - } - ReferenceTracking { - notifyingStackChange { - stack.remove(stackItem) - } - } - } - } - - @Composable - internal fun HandleBackPressEffect(onBackPressed: BackPressHandlerScope.() -> Unit) { - val refTracking = remember { - val stackItem = NavigationStackItem.BackPressHandler(onBackPressed).also { - stack.add(it) - } - ReferenceTracking { - stack.remove(stackItem) - } - } - } - - private inline fun notifyingStackChange(block: () -> T): T { - val result = block() - observeStack.value = stack - return result - } -} - -internal data class NavigationViewDimensions( - val constraints: Constraints, -) - -@Composable -internal fun rememberNavigationController(): NavigationController = remember { - NavigationController() -} - -private class ReferenceTracking(private val onDispose: () -> Unit): RememberObserver { - - private var refCount: Int = 0 - - override fun onAbandoned() { - onDispose() - } - - override fun onForgotten() { - refCount -= 1 - if (refCount <= 0) { - onDispose() - } - } - - override fun onRemembered() { - refCount += 1 - } -} - -@Composable -internal fun BackPressHandler(onBackPressed: NavigationController.BackPressHandlerScope.() -> Unit) { - val navigationController = LocalNavigationController.current - navigationController.HandleBackPressEffect(onBackPressed) -} - -internal interface NavigationStackScope { - - fun NavigationLink(item: MutableObservableProperty, content: @Composable (T) -> Unit) -} - -internal class NavigationLinkWrapper( - val index: Int, - private val value: T?, - private val reset: () -> Unit, - private val content: @Composable (T) -> Unit, -) { - - val body: (@Composable () -> Unit)? - get() = value?.let { value -> - @Composable { - BackPressHandler { - reset() - } - content(value) - } - } - - override fun equals(other: Any?): Boolean { - return (other as? NavigationLinkWrapper<*>)?.let { it.index == index && it.value == value } ?: false - } - - override fun hashCode(): Int { - return listOfNotNull(index, value).hashCode() - } -} - -@OptIn(ExperimentalAnimationApi::class) -@Composable -internal fun NavigationStack(vararg keys: Any?, links: NavigationStackScope.() -> Unit, content: @Composable () -> Unit) { - - val activeLinkComposables by remember(keys) { - val constructedLinks = mutableListOf>>() - val scope = object: NavigationStackScope { - override fun NavigationLink( - item: MutableObservableProperty, - content: @Composable (T) -> Unit, - ) { - constructedLinks.add( - item.map { - NavigationLinkWrapper(index = constructedLinks.size, value = it, reset = { item.value = null }, content) - } - ) - } - } - scope.links() - - combine(constructedLinks) - }.observeAsState() - - AnimatedContent( - targetState = activeLinkComposables, - transitionSpec = { - if (initialState.indexOfLast { it.body != null } < targetState.indexOfLast { it.body != null }) { - slideInHorizontally(initialOffsetX = { it }) with slideOutHorizontally(targetOffsetX = { -it }) - } else { - slideInHorizontally(initialOffsetX = { -it }) with slideOutHorizontally(targetOffsetX = { it }) - } - }, - contentAlignment = Alignment.BottomCenter, - ) { activeComposables -> - SubcomposeLayout(measurePolicy = { constraints -> - val layoutWidth = constraints.maxWidth - val layoutHeight = constraints.maxHeight - - val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) - - layout(layoutWidth, layoutHeight) { - val contentMeasurable = subcompose(-1, content) - - val linkMeasurables = activeComposables.mapNotNull { wrapper -> - wrapper.body?.let { subcompose(wrapper.index, it) } - } - - val activeMeasurables = linkMeasurables.lastOrNull() ?: contentMeasurable - - activeMeasurables.forEach { - it.measure(looseConstraints).place(x = 0, y = 0) - } - } - }) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/util/UrlHandler.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/util/UrlHandler.kt new file mode 100644 index 000000000..d26d16d5f --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/util/UrlHandler.kt @@ -0,0 +1,6 @@ +package co.touchlab.droidcon.util + +interface UrlHandler { + + fun openUrl(url: String) +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/ApplicationComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/ApplicationComponent.kt new file mode 100644 index 000000000..e7c8a5857 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/ApplicationComponent.kt @@ -0,0 +1,190 @@ +package co.touchlab.droidcon.viewmodel + +import co.touchlab.droidcon.application.gateway.SettingsGateway +import co.touchlab.droidcon.application.service.NotificationSchedulingService +import co.touchlab.droidcon.application.service.NotificationService +import co.touchlab.droidcon.decompose.whileStarted +import co.touchlab.droidcon.domain.entity.Session +import co.touchlab.droidcon.domain.service.FeedbackService +import co.touchlab.droidcon.domain.service.SyncService +import co.touchlab.droidcon.service.NotificationHandler +import co.touchlab.droidcon.util.DcDispatchers +import co.touchlab.droidcon.util.UrlHandler +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.navigate +import com.arkivanov.decompose.router.stack.replaceCurrent +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.reduce +import com.arkivanov.essenty.parcelable.Parcelable +import com.arkivanov.essenty.parcelable.Parcelize + +class ApplicationComponent( + componentContext: ComponentContext, + private val dispatchers: DcDispatchers, + private val tabFactory: TabComponent.Factory, + private val feedbackDialogFactory: FeedbackDialogComponent.Factory, + private val syncService: SyncService, + private val notificationSchedulingService: NotificationSchedulingService, + private val feedbackService: FeedbackService, + private val settingsGateway: SettingsGateway, + private val urlHandler: UrlHandler, +): ComponentContext by componentContext, NotificationHandler { + + private val navigation = StackNavigation() + + private val _tabStack = + childStack( + source = navigation, + initialConfiguration = TabConfig(TabComponent.Tab.Schedule), + key = "tabs", + childFactory = ::tabChild, + ) + + val tabStack: Value> get() = _tabStack + + private val feedbackNavigation = StackNavigation() + + private val _feedbackStack = childStack( + source = feedbackNavigation, + initialConfiguration = FeedbackConfig.None, + key = "feedback", + childFactory = ::feedbackChild, + ) + + val feedbackStack: Value> get() = _feedbackStack + + private val _model = MutableValue(Model(useComposeForIos = settingsGateway.settings().value.useComposeForIos)) + val model: Value get() = _model + + init { + whileStarted(dispatchers.main) { + notificationSchedulingService.runScheduling() + } + + whileStarted(dispatchers.main) { + syncService.runSynchronization() + } + + whileStarted(dispatchers.main) { + if (settingsGateway.settings().value.isFeedbackEnabled) { + presentNextFeedback() + } + } + + whileStarted(dispatchers.main) { + settingsGateway.settings().collect { settings -> + _model.reduce { it.copy(useComposeForIos = settings.useComposeForIos) } + } + } + } + + private fun tabChild(config: TabConfig, componentContext: ComponentContext): TabComponent = + tabFactory.create( + componentContext = componentContext, + tab = config.tab, + showFeedback = { feedbackNavigation.replaceCurrent(FeedbackConfig.FeedbackFromChild(it)) }, + showUrl = { urlHandler.openUrl(it.string) }, + ) + + fun selectTab(tab: TabComponent.Tab) { + navigation.navigate { stack -> + stack.filterNot { it.tab == tab } + TabConfig(tab) + } + } + + override fun notificationReceived(sessionId: String, notificationType: NotificationService.NotificationType) { + if (notificationType == NotificationService.NotificationType.Feedback) { + whileStarted(dispatchers.main) { + // We're not checking whether feedback is enabled, because the user opened a feedback notification. + presentNextFeedback() + } + } + } + + private fun feedbackChild(config: FeedbackConfig, componentContext: ComponentContext): FeedbackChild = + when (config) { + is FeedbackConfig.None -> FeedbackChild.None + is FeedbackConfig.FeedbackFromNotification -> FeedbackChild.Feedback(feedbackFromNotification(config, componentContext)) + is FeedbackConfig.FeedbackFromChild -> FeedbackChild.Feedback(feedbackFromChild(config, componentContext)) + } + + private fun feedbackFromNotification( + config: FeedbackConfig.FeedbackFromNotification, + componentContext: ComponentContext, + ): FeedbackDialogComponent = + feedbackDialogFactory.create( + componentContext = componentContext, + session = config.session, + submit = { + feedbackService.submit(config.session, it) + presentNextFeedback() + }, + closeAndDisable = { + settingsGateway.setFeedbackEnabled(false) + feedbackNavigation.replaceCurrent(FeedbackConfig.None) + }, + skip = { + feedbackService.skip(config.session) + presentNextFeedback() + } + ) + + private suspend fun presentNextFeedback() { + val nextSession = feedbackService.next() + + feedbackNavigation.replaceCurrent( + if (nextSession != null) { + FeedbackConfig.FeedbackFromNotification(nextSession) + } else { + FeedbackConfig.None + } + ) + } + + private fun feedbackFromChild( + config: FeedbackConfig.FeedbackFromChild, + componentContext: ComponentContext, + ): FeedbackDialogComponent = + feedbackDialogFactory.create( + componentContext = componentContext, + session = config.session, + submit = { + feedbackService.submit(config.session, it) + feedbackNavigation.replaceCurrent(FeedbackConfig.None) + }, + closeAndDisable = null, + skip = { + feedbackService.skip(config.session) + feedbackNavigation.replaceCurrent(FeedbackConfig.None) + } + ) + + data class Model( + val useComposeForIos: Boolean, + ) + + @Parcelize + private data class TabConfig(val tab: TabComponent.Tab): Parcelable + + sealed interface FeedbackChild { + object None: FeedbackChild + class Feedback(val component: FeedbackDialogComponent): FeedbackChild + } + + @Parcelize + private sealed interface FeedbackConfig: Parcelable { + + @Parcelize + object None: FeedbackConfig + + @Parcelize + data class FeedbackFromNotification(val session: Session): FeedbackConfig + + @Parcelize + data class FeedbackFromChild(val session: Session): FeedbackConfig + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/ApplicationViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/ApplicationViewModel.kt deleted file mode 100644 index e80073e78..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/ApplicationViewModel.kt +++ /dev/null @@ -1,100 +0,0 @@ -package co.touchlab.droidcon.viewmodel - -import co.touchlab.droidcon.application.gateway.SettingsGateway -import co.touchlab.droidcon.application.service.NotificationSchedulingService -import co.touchlab.droidcon.application.service.NotificationService -import co.touchlab.droidcon.domain.service.FeedbackService -import co.touchlab.droidcon.domain.service.SyncService -import co.touchlab.droidcon.viewmodel.session.AgendaViewModel -import co.touchlab.droidcon.viewmodel.session.ScheduleViewModel -import co.touchlab.droidcon.viewmodel.settings.SettingsViewModel -import co.touchlab.droidcon.viewmodel.sponsor.SponsorListViewModel -import co.touchlab.droidcon.service.NotificationHandler -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -class ApplicationViewModel( - scheduleFactory: ScheduleViewModel.Factory, - agendaFactory: AgendaViewModel.Factory, - sponsorsFactory: SponsorListViewModel.Factory, - settingsFactory: SettingsViewModel.Factory, - private val feedbackDialogFactory: FeedbackDialogViewModel.Factory, - private val syncService: SyncService, - private val notificationSchedulingService: NotificationSchedulingService, - private val feedbackService: FeedbackService, - private val settingsGateway: SettingsGateway, -): BaseViewModel(), NotificationHandler { - - val schedule by managed(scheduleFactory.create()) - val agenda by managed(agendaFactory.create()) - val sponsors by managed(sponsorsFactory.create()) - val settings by managed(settingsFactory.create()) - - var useCompose: Boolean by binding( - settingsGateway.settings(), - mapping = { it.useComposeForIos }, - set = { newValue -> - // TODO: Remove when `binding` supports suspend closures. - instanceLock.runExclusively { - settingsGateway.setUseComposeForIos(newValue) - } - } - ) - - var presentedFeedback: FeedbackDialogViewModel? by managed(null) - val observePresentedFeedback by observe(::presentedFeedback) - - val tabs = listOf(Tab.Schedule, Tab.MyAgenda, Tab.Sponsors, Tab.Settings) - var selectedTab: Tab by published(Tab.Schedule) - val observeSelectedTab by observe(::selectedTab) - - init { - lifecycle.whileAttached { - notificationSchedulingService.runScheduling() - } - - lifecycle.whileAttached { - syncService.runSynchronization() - } - } - - override fun notificationReceived(sessionId: String, notificationType: NotificationService.NotificationType) { - if (notificationType == NotificationService.NotificationType.Feedback) { - lifecycle.whileAttached { - // We're not checking whether feedback is enabled, because the user opened a feedback notification. - presentNextFeedback() - } - } - } - - fun onAppear() { - lifecycle.whileAttached { - if (settingsGateway.settings().value.isFeedbackEnabled) { - presentNextFeedback() - } - } - } - - private suspend fun presentNextFeedback() { - presentedFeedback = feedbackService.next()?.let { session -> - feedbackDialogFactory.create( - session, - submit = { feedback -> - feedbackService.submit(session, feedback) - presentNextFeedback() - }, - closeAndDisable = { - settingsGateway.setFeedbackEnabled(false) - presentedFeedback = null - }, - skip = { - feedbackService.skip(session) - presentNextFeedback() - }, - ) - } - } - - enum class Tab { - Schedule, MyAgenda, Sponsors, Settings; - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/FeedbackDialogComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/FeedbackDialogComponent.kt new file mode 100644 index 000000000..60be395e6 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/FeedbackDialogComponent.kt @@ -0,0 +1,101 @@ +package co.touchlab.droidcon.viewmodel + +import co.touchlab.droidcon.decompose.interfaceLock +import co.touchlab.droidcon.domain.entity.Session +import co.touchlab.droidcon.util.DcDispatchers +import co.touchlab.kermit.Logger +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.reduce + +class FeedbackDialogComponent( + componentContext: ComponentContext, + dispatchers: DcDispatchers, + session: Session, + private val log: Logger, + private val submit: suspend (Session.Feedback) -> Unit, + private val closeAndDisable: (suspend () -> Unit)?, + private val skip: suspend () -> Unit, +): ComponentContext by componentContext { + + private val instanceLock = interfaceLock(dispatchers.main) + + private val _model = + MutableValue( + Model( + sessionTitle = session.title, + rating = session.feedback?.rating?.let(::feedbackRatingToRating), + comment = session.feedback?.comment ?: "", + isSubmitDisabled = session.feedback != null, + showCloseAndDisableOption = closeAndDisable != null, + ) + ) + + val model: Value get() = _model + + fun setRating(rating: Rating) { + _model.reduce { it.copy(rating = rating) } + } + + fun setComment(comment: String) { + _model.reduce { it.copy(comment = comment) } + } + + fun submitTapped() = instanceLock.runExclusively { + val model = _model.value + model.rating?.let { + submit(Session.Feedback(it.entityValue, model.comment, false)) + } + } + + fun closeAndDisableTapped() = instanceLock.runExclusively { + closeAndDisable?.invoke() + } + + fun skipTapped() = instanceLock.runExclusively(skip::invoke) + + private fun feedbackRatingToRating(rating: Int): Rating? = + when (rating) { + Session.Feedback.Rating.DISSATISFIED -> Rating.Dissatisfied + Session.Feedback.Rating.NORMAL -> Rating.Normal + Session.Feedback.Rating.SATISFIED -> Rating.Satisfied + else -> { + log.w("Unknown feedback rating $rating.") + null + } + } + + data class Model( + val sessionTitle: String, + val rating: Rating?, + val comment: String, + val isSubmitDisabled: Boolean, + val showCloseAndDisableOption: Boolean, + ) + + enum class Rating { + Dissatisfied, Normal, Satisfied; + + val entityValue: Int + get() = when (this) { + Dissatisfied -> Session.Feedback.Rating.DISSATISFIED + Normal -> Session.Feedback.Rating.NORMAL + Satisfied -> Session.Feedback.Rating.SATISFIED + } + } + + class Factory( + private val dispatchers: DcDispatchers, + private val log: Logger, + ) { + + fun create( + componentContext: ComponentContext, + session: Session, + submit: suspend (Session.Feedback) -> Unit, + closeAndDisable: (suspend () -> Unit)?, + skip: suspend () -> Unit, + ) = FeedbackDialogComponent(componentContext, dispatchers, session, log, submit, closeAndDisable, skip) + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/FeedbackDialogViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/FeedbackDialogViewModel.kt deleted file mode 100644 index 65d238230..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/FeedbackDialogViewModel.kt +++ /dev/null @@ -1,76 +0,0 @@ -package co.touchlab.droidcon.viewmodel - -import co.touchlab.droidcon.domain.entity.Session -import co.touchlab.droidcon.domain.gateway.SessionGateway -import co.touchlab.kermit.Logger -import org.brightify.hyperdrive.multiplatformx.BaseViewModel -import org.brightify.hyperdrive.multiplatformx.property.map -import org.koin.core.parameter.parametersOf - -class FeedbackDialogViewModel( - private val sessionGateway: SessionGateway, - private val session: Session, - private val log: Logger, - private val submit: suspend (Session.Feedback) -> Unit, - private val closeAndDisable: (suspend () -> Unit)?, - private val skip: suspend () -> Unit, -): BaseViewModel() { - - val sessionTitle = session.title - var rating: Rating? by published(session.feedback?.rating?.let(::feedbackRatingToRating)) - val observeRating by observe(::rating) - var comment by published(session.feedback?.comment ?: "") - val observeComment by observe(::comment) - - val isSubmitDisabled by observeRating.map { it == null } - val observeIsSubmitDisabled by observe(::isSubmitDisabled) - - val showCloseAndDisableOption: Boolean = closeAndDisable != null - - fun submitTapped() = instanceLock.runExclusively { - rating?.let { - submit(Session.Feedback(it.entityValue, comment, false)) - } - } - - fun closeAndDisableTapped() = instanceLock.runExclusively { - closeAndDisable?.invoke() - } - - fun skipTapped() = instanceLock.runExclusively(skip::invoke) - - private fun feedbackRatingToRating(rating: Int): Rating? = - when (rating) { - Session.Feedback.Rating.DISSATISFIED -> Rating.Dissatisfied - Session.Feedback.Rating.NORMAL -> Rating.Normal - Session.Feedback.Rating.SATISFIED -> Rating.Satisfied - else -> { - log.w("Unknown feedback rating $rating.") - null - } - } - - enum class Rating { - Dissatisfied, Normal, Satisfied; - - val entityValue: Int - get() = when (this) { - Dissatisfied -> Session.Feedback.Rating.DISSATISFIED - Normal -> Session.Feedback.Rating.NORMAL - Satisfied -> Session.Feedback.Rating.SATISFIED - } - } - - class Factory( - private val sessionGateway: SessionGateway, - private val log: Logger, - ) { - - fun create( - session: Session, - submit: suspend (Session.Feedback) -> Unit, - closeAndDisable: (suspend () -> Unit)?, - skip: suspend () -> Unit, - ) = FeedbackDialogViewModel(sessionGateway, session, log, submit, closeAndDisable, skip) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/TabComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/TabComponent.kt new file mode 100644 index 000000000..79f0bd675 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/TabComponent.kt @@ -0,0 +1,189 @@ +package co.touchlab.droidcon.viewmodel + +import co.touchlab.droidcon.composite.Url +import co.touchlab.droidcon.domain.entity.Profile +import co.touchlab.droidcon.domain.entity.Session +import co.touchlab.droidcon.domain.entity.Sponsor +import co.touchlab.droidcon.viewmodel.session.AgendaComponent +import co.touchlab.droidcon.viewmodel.session.ScheduleComponent +import co.touchlab.droidcon.viewmodel.session.SessionDetailComponent +import co.touchlab.droidcon.viewmodel.session.SpeakerDetailComponent +import co.touchlab.droidcon.viewmodel.settings.SettingsComponent +import co.touchlab.droidcon.viewmodel.sponsor.SponsorDetailComponent +import co.touchlab.droidcon.viewmodel.sponsor.SponsorListComponent +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.push +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.parcelable.Parcelable +import com.arkivanov.essenty.parcelable.Parcelize + +class TabComponent( + componentContext: ComponentContext, + val tab: Tab, + private val scheduleFactory: ScheduleComponent.Factory, + private val agendaFactory: AgendaComponent.Factory, + private val sponsorsFactory: SponsorListComponent.Factory, + private val settingsFactory: SettingsComponent.Factory, + private val sessionDetailFactory: SessionDetailComponent.Factory, + private val sponsorDetailFactory: SponsorDetailComponent.Factory, + private val speakerDetailFactory: SpeakerDetailComponent.Factory, + private val showFeedback: (Session) -> Unit, + private val showUrl: (Url) -> Unit, +): ComponentContext by componentContext { + + private val navigation = StackNavigation() + + private val _stack = + childStack( + source = navigation, + initialConfiguration = Config.Main(tab = tab), + handleBackButton = true, + childFactory = ::child, + ) + + val stack: Value> get() = _stack + + private fun child(config: Config, componentContext: ComponentContext): Child = + when (config) { + is Config.Main -> mainChild(config, componentContext) + is Config.Session -> Child.Session(sessionDetailChild(config, componentContext)) + is Config.Sponsor -> Child.Sponsor(sponsorDetailChild(config, componentContext)) + is Config.Speaker -> Child.Speaker(speakerDetailChild(config)) + } + + private fun mainChild(config: Config.Main, componentContext: ComponentContext): Child = + when (config.tab) { + Tab.Schedule -> Child.Main.Schedule(scheduleChild(componentContext)) + Tab.Agenda -> Child.Main.Agenda(agendaChild(componentContext)) + Tab.Sponsors -> Child.Main.Sponsors(sponsorsChild(componentContext)) + Tab.Settings -> Child.Main.Settings(settingsChild(componentContext)) + } + + private fun scheduleChild(componentContext: ComponentContext): ScheduleComponent = + scheduleFactory.create( + componentContext = componentContext, + sessionSelected = ::showSession, + ) + + private fun agendaChild(componentContext: ComponentContext): AgendaComponent = + agendaFactory.create( + componentContext = componentContext, + sessionSelected = ::showSession, + ) + + private fun sponsorsChild(componentContext: ComponentContext): SponsorListComponent = + sponsorsFactory.create( + componentContext = componentContext, + sponsorSelected = ::showSponsor, + ) + + private fun settingsChild(componentContext: ComponentContext): SettingsComponent = + settingsFactory.create( + componentContext = componentContext, + ) + + private fun sessionDetailChild(config: Config.Session, componentContext: ComponentContext): SessionDetailComponent = + sessionDetailFactory.create( + componentContext = componentContext, + sessionId = config.sessionId, + speakerSelected = ::showSpeaker, + showFeedback = showFeedback, + backPressed = navigation::pop, + ) + + private fun sponsorDetailChild(config: Config.Sponsor, componentContext: ComponentContext): SponsorDetailComponent = + sponsorDetailFactory.create( + componentContext = componentContext, + sponsor = config.sponsor, + speakerSelected = { navigation.push(Config.Speaker(it)) }, + backPressed = navigation::pop, + ) + + private fun speakerDetailChild(config: Config.Speaker): SpeakerDetailComponent = + speakerDetailFactory.create( + profile = config.profile, + backPressed = navigation::pop, + ) + + private fun showSession(sessionId: Session.Id) { + navigation.push(Config.Session(sessionId = sessionId)) + } + + private fun showSponsor(sponsor: Sponsor) { + if (sponsor.hasDetail) { + navigation.push(Config.Sponsor(sponsor = sponsor)) + } else { + showUrl(sponsor.url) + } + } + + private fun showSpeaker(profile: Profile) { + navigation.push(Config.Speaker(profile = profile)) + } + + class Factory( + private val scheduleFactory: ScheduleComponent.Factory, + private val agendaFactory: AgendaComponent.Factory, + private val sponsorsFactory: SponsorListComponent.Factory, + private val settingsFactory: SettingsComponent.Factory, + private val sessionDetailFactory: SessionDetailComponent.Factory, + private val sponsorDetailFactory: SponsorDetailComponent.Factory, + private val speakerDetailFactory: SpeakerDetailComponent.Factory, + ) { + + fun create( + componentContext: ComponentContext, + tab: Tab, + showFeedback: (Session) -> Unit, + showUrl: (Url) -> Unit, + ): TabComponent = + TabComponent( + componentContext = componentContext, + tab = tab, + scheduleFactory = scheduleFactory, + agendaFactory = agendaFactory, + sponsorsFactory = sponsorsFactory, + settingsFactory = settingsFactory, + sessionDetailFactory = sessionDetailFactory, + sponsorDetailFactory = sponsorDetailFactory, + speakerDetailFactory = speakerDetailFactory, + showFeedback = showFeedback, + showUrl = showUrl, + ) + } + + sealed interface Child { + sealed interface Main: Child { + class Schedule(val component: ScheduleComponent): Main + class Agenda(val component: AgendaComponent): Main + class Sponsors(val component: SponsorListComponent): Main + class Settings(val component: SettingsComponent): Main + } + + class Session(val component: SessionDetailComponent): Child + class Sponsor(val component: SponsorDetailComponent): Child + class Speaker(val component: SpeakerDetailComponent): Child + } + + enum class Tab { + Schedule, Agenda, Sponsors, Settings; + } + + private sealed interface Config: Parcelable { + @Parcelize + data class Main(val tab: Tab): Config + + @Parcelize + data class Session(val sessionId: co.touchlab.droidcon.domain.entity.Session.Id): Config + + @Parcelize + data class Sponsor(val sponsor: co.touchlab.droidcon.domain.entity.Sponsor): Config + + @Parcelize + data class Speaker(val profile: Profile): Config + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/AgendaComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/AgendaComponent.kt new file mode 100644 index 000000000..5e864f0df --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/AgendaComponent.kt @@ -0,0 +1,39 @@ +package co.touchlab.droidcon.viewmodel.session + +import co.touchlab.droidcon.domain.entity.Session +import co.touchlab.droidcon.domain.gateway.SessionGateway +import co.touchlab.droidcon.domain.service.DateTimeService +import co.touchlab.droidcon.util.DcDispatchers +import com.arkivanov.decompose.ComponentContext +import kotlinx.coroutines.flow.first + +class AgendaComponent( + componentContext: ComponentContext, + dispatchers: DcDispatchers, + sessionGateway: SessionGateway, + sessionDaysFactory: SessionDaysComponent.Factory, + dateTimeService: DateTimeService, + sessionSelected: (Session.Id) -> Unit, +): BaseSessionListComponent( + componentContext = componentContext, + dispatchers = dispatchers, + showAttendingIndicators = false, + scheduleItems = { sessionGateway.observeAgenda().first() }, + sessionDaysFactory = sessionDaysFactory, + dateTimeService = dateTimeService, + sessionSelected = sessionSelected, +) { + + class Factory( + private val dispatchers: DcDispatchers, + private val sessionGateway: SessionGateway, + private val sessionDaysFactory: SessionDaysComponent.Factory, + private val dateTimeService: DateTimeService, + ) { + + fun create( + componentContext: ComponentContext, + sessionSelected: (Session.Id) -> Unit, + ) = AgendaComponent(componentContext, dispatchers, sessionGateway, sessionDaysFactory, dateTimeService, sessionSelected) + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/AgendaViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/AgendaViewModel.kt deleted file mode 100644 index e9799e0ae..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/AgendaViewModel.kt +++ /dev/null @@ -1,26 +0,0 @@ -package co.touchlab.droidcon.viewmodel.session - -import co.touchlab.droidcon.domain.gateway.SessionGateway -import co.touchlab.droidcon.domain.service.DateTimeService - -class AgendaViewModel( - sessionGateway: SessionGateway, - sessionDayFactory: SessionDayViewModel.Factory, - sessionDetailFactory: SessionDetailViewModel.Factory, - dateTimeService: DateTimeService, -): BaseSessionListViewModel( - sessionGateway, - sessionDayFactory, - sessionDetailFactory, - dateTimeService, - attendingOnly = true, -) { - class Factory( - private val sessionGateway: SessionGateway, - private val sessionDayFactory: SessionDayViewModel.Factory, - private val sessionDetailFactory: SessionDetailViewModel.Factory, - private val dateTimeService: DateTimeService, - ) { - fun create() = AgendaViewModel(sessionGateway, sessionDayFactory, sessionDetailFactory, dateTimeService) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/BaseSessionListComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/BaseSessionListComponent.kt new file mode 100644 index 000000000..327b44a49 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/BaseSessionListComponent.kt @@ -0,0 +1,76 @@ +package co.touchlab.droidcon.viewmodel.session + +import co.touchlab.droidcon.decompose.whileStarted +import co.touchlab.droidcon.domain.composite.ScheduleItem +import co.touchlab.droidcon.domain.entity.LocalDateParceler +import co.touchlab.droidcon.domain.entity.Session +import co.touchlab.droidcon.domain.service.DateTimeService +import co.touchlab.droidcon.domain.service.toConferenceDateTime +import co.touchlab.droidcon.util.DcDispatchers +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.replaceCurrent +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.parcelable.Parcelable +import com.arkivanov.essenty.parcelable.Parcelize +import com.arkivanov.essenty.parcelable.WriteWith +import kotlinx.datetime.LocalDate + +abstract class BaseSessionListComponent( + componentContext: ComponentContext, + dispatchers: DcDispatchers, + private val showAttendingIndicators: Boolean, + private val scheduleItems: suspend () -> List, + private val sessionDaysFactory: SessionDaysComponent.Factory, + private val dateTimeService: DateTimeService, + private val sessionSelected: (Session.Id) -> Unit, +): ComponentContext by componentContext { + + private val navigation = StackNavigation() + private val _stack = childStack(source = navigation, initialConfiguration = ChildConfig.Loading, childFactory = ::child) + val stack: Value> get() = _stack + + init { + whileStarted(dispatchers.main) { + val days = scheduleItems().map { it.session.startsAt.toConferenceDateTime(dateTimeService).date }.distinct() + navigation.replaceCurrent(if (days.isNotEmpty()) ChildConfig.Days(days) else ChildConfig.Empty) + } + } + + private fun child(config: ChildConfig, componentContext: ComponentContext): Child = + when (config) { + is ChildConfig.Loading -> Child.Loading + + is ChildConfig.Days -> + Child.Days( + sessionDaysFactory.create( + componentContext = componentContext, + days = config.days, + showAttendingIndicators = showAttendingIndicators, + scheduleItems = scheduleItems, + sessionSelected = sessionSelected, + ) + ) + + is ChildConfig.Empty -> Child.Empty + } + + sealed interface Child { + object Loading: Child + class Days(val component: SessionDaysComponent): Child + object Empty: Child + } + + private sealed interface ChildConfig: Parcelable { + @Parcelize + object Loading: ChildConfig + + @Parcelize + data class Days(val days: List<@WriteWith LocalDate>): ChildConfig + + @Parcelize + object Empty: ChildConfig + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/BaseSessionListViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/BaseSessionListViewModel.kt deleted file mode 100644 index 4c6a7ce9e..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/BaseSessionListViewModel.kt +++ /dev/null @@ -1,49 +0,0 @@ -package co.touchlab.droidcon.viewmodel.session - -import co.touchlab.droidcon.domain.gateway.SessionGateway -import co.touchlab.droidcon.domain.service.DateTimeService -import co.touchlab.droidcon.domain.service.toConferenceDateTime -import kotlinx.coroutines.flow.collect -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -abstract class BaseSessionListViewModel( - private val sessionGateway: SessionGateway, - private val sessionDayFactory: SessionDayViewModel.Factory, - private val sessionDetailFactory: SessionDetailViewModel.Factory, - private val dateTimeService: DateTimeService, - val attendingOnly: Boolean, -): BaseViewModel() { - - var days: List? by published(null) - private set - val observeDays by observe(::days) - - var selectedDay: SessionDayViewModel? by managed(null) - - var presentedSessionDetail: SessionDetailViewModel? by managed(null) - val observePresentedSessionDetail by observe(::presentedSessionDetail) - - override suspend fun whileAttached() { - val itemsFlow = if (attendingOnly) { - sessionGateway.observeAgenda() - } else { - sessionGateway.observeSchedule() - } - - itemsFlow - .collect { items -> - items - .groupBy { it.session.startsAt.toConferenceDateTime(dateTimeService).date } - .map { (date, items) -> - sessionDayFactory.create(date, items) { item -> - if (item.session.isServiceSession) { return@create } - presentedSessionDetail = sessionDetailFactory.create(item) - } - } - .also { newDays -> - days = newDays - selectedDay = newDays.firstOrNull { it.day == selectedDay?.day } ?: newDays.firstOrNull() - } - } - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/ScheduleComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/ScheduleComponent.kt new file mode 100644 index 000000000..72afe2473 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/ScheduleComponent.kt @@ -0,0 +1,39 @@ +package co.touchlab.droidcon.viewmodel.session + +import co.touchlab.droidcon.domain.entity.Session +import co.touchlab.droidcon.domain.gateway.SessionGateway +import co.touchlab.droidcon.domain.service.DateTimeService +import co.touchlab.droidcon.util.DcDispatchers +import com.arkivanov.decompose.ComponentContext +import kotlinx.coroutines.flow.first + +class ScheduleComponent( + componentContext: ComponentContext, + dispatchers: DcDispatchers, + sessionGateway: SessionGateway, + sessionDaysFactory: SessionDaysComponent.Factory, + dateTimeService: DateTimeService, + sessionSelected: (Session.Id) -> Unit, +): BaseSessionListComponent( + componentContext = componentContext, + dispatchers = dispatchers, + showAttendingIndicators = true, + scheduleItems = { sessionGateway.observeSchedule().first() }, + sessionDaysFactory = sessionDaysFactory, + dateTimeService = dateTimeService, + sessionSelected = sessionSelected, +) { + + class Factory( + private val dispatchers: DcDispatchers, + private val sessionGateway: SessionGateway, + private val sessionDaysFactory: SessionDaysComponent.Factory, + private val dateTimeService: DateTimeService, + ) { + + fun create( + componentContext: ComponentContext, + sessionSelected: (Session.Id) -> Unit, + ) = ScheduleComponent(componentContext, dispatchers, sessionGateway, sessionDaysFactory, dateTimeService, sessionSelected) + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/ScheduleViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/ScheduleViewModel.kt deleted file mode 100644 index 469ae1ddc..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/ScheduleViewModel.kt +++ /dev/null @@ -1,26 +0,0 @@ -package co.touchlab.droidcon.viewmodel.session - -import co.touchlab.droidcon.domain.gateway.SessionGateway -import co.touchlab.droidcon.domain.service.DateTimeService - -class ScheduleViewModel( - sessionGateway: SessionGateway, - sessionDayFactory: SessionDayViewModel.Factory, - sessionDetailFactory: SessionDetailViewModel.Factory, - dateTimeService: DateTimeService, -): BaseSessionListViewModel( - sessionGateway, - sessionDayFactory, - sessionDetailFactory, - dateTimeService, - attendingOnly = false, -) { - class Factory( - private val sessionGateway: SessionGateway, - private val sessionDayFactory: SessionDayViewModel.Factory, - private val sessionDetailFactory: SessionDetailViewModel.Factory, - private val dateTimeService: DateTimeService, - ) { - fun create() = ScheduleViewModel(sessionGateway, sessionDayFactory, sessionDetailFactory, dateTimeService) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionBlockViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionBlockViewModel.kt deleted file mode 100644 index 913698c51..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionBlockViewModel.kt +++ /dev/null @@ -1,34 +0,0 @@ -package co.touchlab.droidcon.viewmodel.session - -import co.touchlab.droidcon.domain.composite.ScheduleItem -import co.touchlab.droidcon.util.formatter.DateFormatter -import kotlinx.datetime.LocalDateTime -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -class SessionBlockViewModel( - sessionListItemFactory: SessionListItemViewModel.Factory, - dateFormatter: DateFormatter, - startsAt: LocalDateTime, - items: List, - onScheduleItemSelected: (ScheduleItem) -> Unit, -): BaseViewModel() { - val time: String = dateFormatter.timeOnly(startsAt) ?: "" - val sessions: List by managedList( - items.map { item -> - sessionListItemFactory.create(item, selected = { - onScheduleItemSelected(item) - }) - } - ) - - class Factory( - private val sessionListItemFactory: SessionListItemViewModel.Factory, - private val dateFormatter: DateFormatter, - ) { - fun create( - startsAt: LocalDateTime, - items: List, - onScheduleItemSelected: (ScheduleItem) -> Unit, - ) = SessionBlockViewModel(sessionListItemFactory, dateFormatter, startsAt, items, onScheduleItemSelected) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDayComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDayComponent.kt new file mode 100644 index 000000000..458d10637 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDayComponent.kt @@ -0,0 +1,140 @@ +package co.touchlab.droidcon.viewmodel.session + +import co.touchlab.droidcon.decompose.whileStarted +import co.touchlab.droidcon.domain.composite.ScheduleItem +import co.touchlab.droidcon.domain.entity.Session +import co.touchlab.droidcon.domain.service.DateTimeService +import co.touchlab.droidcon.domain.service.toConferenceDateTime +import co.touchlab.droidcon.util.DcDispatchers +import co.touchlab.droidcon.util.formatter.DateFormatter +import co.touchlab.droidcon.util.startOfMinute +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.reduce +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlin.time.Duration.Companion.seconds + +class SessionDayComponent( + componentContext: ComponentContext, + dispatchers: DcDispatchers, + private val dateFormatter: DateFormatter, + private val dateTimeService: DateTimeService, + val date: LocalDate, + private val showAttendingIndicators: Boolean, + private val scheduleItems: suspend () -> List, + private val sessionSelected: (Session.Id) -> Unit, +): ComponentContext by componentContext { + + private val _model = MutableValue(Model()) + val model: Value get() = _model + + init { + whileStarted(dispatchers.main) { + _model.reduce { it.copy(blocks = loadBlocks()) } + } + + whileStarted(dispatchers.main) { + while (coroutineContext.isActive) { + delay(10.seconds) + updateTimes() + } + } + } + + private suspend fun loadBlocks(): List = + scheduleItems() + .filter { it.session.startsAt.toConferenceDateTime(dateTimeService).date == date } + .groupBy { it.session.startsAt.toConferenceDateTime(dateTimeService).startOfMinute } + .map { (startsAt, items) -> block(startsAt, items) } + + private fun block(startsAt: LocalDateTime, items: List): Model.Block = + Model.Block( + time = dateFormatter.timeOnly(startsAt) ?: "", + items = items.map(::item), + ) + + private fun item(item: ScheduleItem): Model.Item = + Model.Item( + id = item.session.id, + title = item.session.title, + isServiceSession = item.session.isServiceSession, + isAttending = showAttendingIndicators && item.session.rsvp.isAttending, + isInConflict = item.isInConflict, + speakers = item.speakers.joinToString { it.fullName }, + room = item.room?.name, + isInPast = dateTimeService.now() > item.session.endsAt, + endsAt = item.session.endsAt, + ) + + private fun updateTimes() { + _model.reduce { model -> + model.copy( + blocks = model.blocks.map { block -> + block.copy( + items = block.items.map { item -> + item.copy(isInPast = dateTimeService.now() > item.endsAt) + } + ) + } + ) + } + } + + fun itemSelected(item: Model.Item) { + if (!item.isServiceSession) { + sessionSelected(item.id) + } + } + + data class Model( + val blocks: List = emptyList(), + ) { + + data class Block( + val time: String, + val items: List, + ) + + data class Item( + val id: Session.Id, + val title: String, + val isServiceSession: Boolean, + val isAttending: Boolean, + val isInConflict: Boolean, + val speakers: String, + val room: String?, + val isInPast: Boolean, + val endsAt: Instant, + ) + } + + class Factory( + private val dispatchers: DcDispatchers, + private val dateFormatter: DateFormatter, + private val dateTimeService: DateTimeService, + ) { + + fun create( + componentContext: ComponentContext, + date: LocalDate, + showAttendingIndicators: Boolean, + scheduleItems: suspend () -> List, + sessionSelected: (Session.Id) -> Unit, + ): SessionDayComponent = + SessionDayComponent( + componentContext, + dispatchers, + dateFormatter, + dateTimeService, + date, + showAttendingIndicators, + scheduleItems, + sessionSelected, + ) + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDayViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDayViewModel.kt deleted file mode 100644 index 8604ca382..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDayViewModel.kt +++ /dev/null @@ -1,41 +0,0 @@ -package co.touchlab.droidcon.viewmodel.session - -import co.touchlab.droidcon.domain.composite.ScheduleItem -import co.touchlab.droidcon.domain.service.DateTimeService -import co.touchlab.droidcon.domain.service.toConferenceDateTime -import co.touchlab.droidcon.util.formatter.DateFormatter -import co.touchlab.droidcon.util.startOfMinute -import kotlinx.datetime.LocalDate -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -class SessionDayViewModel( - sessionBlockFactory: SessionBlockViewModel.Factory, - dateFormatter: DateFormatter, - dateTimeService: DateTimeService, - date: LocalDate, - items: List, - onScheduleItemSelected: (ScheduleItem) -> Unit, -): BaseViewModel() { - - val day: String = dateFormatter.monthWithDay(date) ?: "" - val blocks: List by managedList( - items - .groupBy { it.session.startsAt.toConferenceDateTime(dateTimeService).startOfMinute } - .map { (startsAt, items) -> - sessionBlockFactory.create(startsAt, items, onScheduleItemSelected) - } - ) - - class Factory( - private val sessionBlockFactory: SessionBlockViewModel.Factory, - private val dateFormatter: DateFormatter, - private val dateTimeService: DateTimeService, - ) { - - fun create( - date: LocalDate, - items: List, - onScheduleItemSelected: (ScheduleItem) -> Unit, - ) = SessionDayViewModel(sessionBlockFactory, dateFormatter, dateTimeService, date, items, onScheduleItemSelected) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDaysComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDaysComponent.kt new file mode 100644 index 000000000..5455a216b --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDaysComponent.kt @@ -0,0 +1,77 @@ +package co.touchlab.droidcon.viewmodel.session + +import co.touchlab.droidcon.domain.composite.ScheduleItem +import co.touchlab.droidcon.domain.entity.LocalDateParceler +import co.touchlab.droidcon.domain.entity.Session +import co.touchlab.droidcon.util.formatter.DateFormatter +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.navigate +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.parcelable.Parcelable +import com.arkivanov.essenty.parcelable.Parcelize +import com.arkivanov.essenty.parcelable.WriteWith +import kotlinx.datetime.LocalDate + +class SessionDaysComponent( + componentContext: ComponentContext, + days: List, + private val showAttendingIndicators: Boolean, + dateFormatter: DateFormatter, + private val scheduleItems: suspend () -> List, + private val sessionSelected: (Session.Id) -> Unit, + private val sessionDayFactory: SessionDayComponent.Factory, +): ComponentContext by componentContext { + + private val navigation = StackNavigation() + private val _stack = childStack(source = navigation, initialConfiguration = DayConfig(days.first()), childFactory = ::day) + val stack: Value> get() = _stack + + val days: List = days.map { Day(date = it, title = dateFormatter.monthWithDay(it) ?: "") } + + private fun day(config: DayConfig, componentContext: ComponentContext): SessionDayComponent = + sessionDayFactory.create( + componentContext = componentContext, + date = config.date, + showAttendingIndicators = showAttendingIndicators, + scheduleItems = scheduleItems, + sessionSelected = sessionSelected, + ) + + fun selectTab(date: LocalDate) { + navigation.navigate { stack -> stack.filterNot { it.date == date } + DayConfig(date) } + } + + @Parcelize + private data class DayConfig(val date: @WriteWith LocalDate): Parcelable + + data class Day( + val date: LocalDate, + val title: String, + ) + + class Factory( + private val dateFormatter: DateFormatter, + private val sessionDayFactory: SessionDayComponent.Factory, + ) { + + fun create( + componentContext: ComponentContext, + days: List, + showAttendingIndicators: Boolean, + scheduleItems: suspend () -> List, + sessionSelected: (Session.Id) -> Unit, + ): SessionDaysComponent = + SessionDaysComponent( + componentContext, + days, + showAttendingIndicators, + dateFormatter, + scheduleItems, + sessionSelected, + sessionDayFactory, + ) + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDetailComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDetailComponent.kt new file mode 100644 index 000000000..2286c75df --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDetailComponent.kt @@ -0,0 +1,204 @@ +package co.touchlab.droidcon.viewmodel.session + +import co.touchlab.droidcon.application.composite.Settings +import co.touchlab.droidcon.application.gateway.SettingsGateway +import co.touchlab.droidcon.composite.Url +import co.touchlab.droidcon.decompose.interfaceLock +import co.touchlab.droidcon.decompose.whileStarted +import co.touchlab.droidcon.domain.composite.ScheduleItem +import co.touchlab.droidcon.domain.entity.Profile +import co.touchlab.droidcon.domain.entity.Session +import co.touchlab.droidcon.domain.gateway.SessionGateway +import co.touchlab.droidcon.domain.service.DateTimeService +import co.touchlab.droidcon.dto.WebLink +import co.touchlab.droidcon.service.ParseUrlViewService +import co.touchlab.droidcon.util.DcDispatchers +import co.touchlab.droidcon.util.formatter.DateFormatter +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.reduce +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.datetime.Instant + +// TODO: Make all properties observable when API is updated. +class SessionDetailComponent( + componentContext: ComponentContext, + dispatchers: DcDispatchers, + private val sessionGateway: SessionGateway, + settingsGateway: SettingsGateway, + private val dateFormatter: DateFormatter, + private val dateTimeService: DateTimeService, + private val parseUrlViewService: ParseUrlViewService, + sessionId: Session.Id, + private val speakerSelected: (Profile) -> Unit, + private val showFeedback: (Session) -> Unit, + private val backPressed: () -> Unit, +): ComponentContext by componentContext { + + private val instanceLock = interfaceLock(dispatchers.main) + private var item: ScheduleItem? = null + + private val _model = MutableValue(Model()) + val model: Value get() = _model + + private val isAttendingLoading = MutableStateFlow(false) + + init { + whileStarted(dispatchers.main) { + combine( + flow = sessionGateway.observeScheduleItem(sessionId).onEach { item = it }, + flow2 = flow { + while (true) { + emit(dateTimeService.now()) + delay(10_000) + } + }, + flow3 = settingsGateway.settings(), + flow4 = isAttendingLoading, + transform = ::model + ).collect { + _model.value = it + } + } + + whileStarted(dispatchers.main) { + sessionGateway.observeScheduleItem(sessionId) + .map { item -> + item.speakers.map { profile -> + Model.Speaker( + profile = profile, + avatarUrl = profile.profilePicture, + info = listOfNotNull(profile.fullName, profile.tagLine).joinToString(), + bio = profile.bio, + ) + } + } + .collect { speakers -> + _model.reduce { it.copy(speakers = speakers) } + } + } + } + + private fun model( + item: ScheduleItem, + time: Instant, + settings: Settings, + isAttendingLoading: Boolean, + ): Model { + val state = sessionState(item, time) + + return Model( + title = item.session.title, + info = sessionInfo(item), + state = state, + abstract = item.session.description, + abstractLinks = item.session.description?.let(parseUrlViewService::parse) ?: emptyList(), + isAttending = item.session.rsvp.isAttending, + isAttendingLoading = isAttendingLoading, + feedbackAlreadyWritten = item.session.feedback != null, + showFeedbackOption = settings.isFeedbackEnabled && state == SessionState.Ended, + ) + } + + private fun sessionInfo(item: ScheduleItem): String = + listOfNotNull( + item.room?.name, + with(dateTimeService) { + dateFormatter.timeOnlyInterval( + item.session.startsAt.toConferenceDateTime(), + item.session.endsAt.toConferenceDateTime(), + ) + }, + ).joinToString() + + private fun sessionState(item: ScheduleItem, time: Instant): SessionState = + when { + item.session.endsAt < time -> SessionState.Ended + item.session.startsAt < time -> SessionState.InProgress + item.isInConflict -> SessionState.InConflict + else -> SessionState.None + } + + fun attendingTapped() { + item?.session?.also { session -> + instanceLock.runExclusively { + isAttendingLoading.value = true + sessionGateway.setAttending(session, attending = !model.value.isAttending) + isAttendingLoading.value = false + } + } + } + + fun writeFeedbackTapped() { + item?.session?.also(showFeedback) + } + + fun backTapped() { + backPressed() + } + + fun speakerTapped(speaker: Model.Speaker) { + speakerSelected(speaker.profile) + } + + data class Model( + val title: String = "", + val info: String = "", + val state: SessionState = SessionState.None, + val abstract: String? = null, + val abstractLinks: List = emptyList(), + val isAttending: Boolean = false, + val isAttendingLoading: Boolean = false, + val feedbackAlreadyWritten: Boolean = false, + val showFeedbackOption: Boolean = false, + val speakers: List = emptyList(), + ) { + + data class Speaker( + val profile: Profile, + val avatarUrl: Url?, + val info: String, + val bio: String?, + ) + } + + enum class SessionState { + InConflict, InProgress, Ended, None + } + + class Factory( + private val dispatchers: DcDispatchers, + private val sessionGateway: SessionGateway, + private val settingsGateway: SettingsGateway, + private val dateFormatter: DateFormatter, + private val dateTimeService: DateTimeService, + private val parseUrlViewService: ParseUrlViewService, + ) { + + fun create( + componentContext: ComponentContext, + sessionId: Session.Id, + speakerSelected: (Profile) -> Unit, + showFeedback: (Session) -> Unit, + backPressed: () -> Unit, + ) = SessionDetailComponent( + componentContext, + dispatchers, + sessionGateway, + settingsGateway, + dateFormatter, + dateTimeService, + parseUrlViewService, + sessionId, + speakerSelected, + showFeedback, + backPressed, + ) + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDetailViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDetailViewModel.kt deleted file mode 100644 index deed19dc0..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionDetailViewModel.kt +++ /dev/null @@ -1,168 +0,0 @@ -package co.touchlab.droidcon.viewmodel.session - -import co.touchlab.droidcon.application.gateway.SettingsGateway -import co.touchlab.droidcon.domain.composite.ScheduleItem -import co.touchlab.droidcon.domain.gateway.SessionGateway -import co.touchlab.droidcon.domain.service.DateTimeService -import co.touchlab.droidcon.domain.service.FeedbackService -import co.touchlab.droidcon.dto.WebLink -import co.touchlab.droidcon.service.ParseUrlViewService -import co.touchlab.droidcon.util.formatter.DateFormatter -import co.touchlab.droidcon.viewmodel.FeedbackDialogViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.datetime.Instant -import org.brightify.hyperdrive.multiplatformx.BaseViewModel -import org.brightify.hyperdrive.multiplatformx.property.asFlow -import org.brightify.hyperdrive.multiplatformx.property.flatMapLatest -import org.brightify.hyperdrive.multiplatformx.property.identityEqualityPolicy -import org.brightify.hyperdrive.multiplatformx.property.map - -// TODO: Make all properties observable when API is updated. -class SessionDetailViewModel( - private val sessionGateway: SessionGateway, - private val settingsGateway: SettingsGateway, - private val speakerListItemFactory: SpeakerListItemViewModel.Factory, - private val speakerDetailFactory: SpeakerDetailViewModel.Factory, - private val feedbackDialogFactory: FeedbackDialogViewModel.Factory, - private val dateFormatter: DateFormatter, - private val dateTimeService: DateTimeService, - private val parseUrlViewService: ParseUrlViewService, - private val feedbackService: FeedbackService, - initialItem: ScheduleItem, -): BaseViewModel() { - - private val item by collected(initialItem, sessionGateway.observeScheduleItem(initialItem.session.id), identityEqualityPolicy()) - private val observeItem by observe(::item) - - private val time: Instant by collected(dateTimeService.now(), flow { - while (true) { - emit(dateTimeService.now()) - delay(10_000) - } - }) - private val observeTime by observe(::time) - - val title by observeItem.map { it.session.title } - val observeTitle by observe(::title) - val info by observeItem.map { - listOfNotNull( - it.room?.name, - with(dateTimeService) { - dateFormatter.timeOnlyInterval( - it.session.startsAt.toConferenceDateTime(), - it.session.endsAt.toConferenceDateTime(), - ) - }, - ).joinToString() - } - val observeInfo by observe(::info) - - val state: SessionState? by observeItem.flatMapLatest { item -> - observeTime.map { now -> - when { - item.session.endsAt < now -> SessionState.Ended - item.session.startsAt < now -> SessionState.InProgress - item.isInConflict -> SessionState.InConflict - else -> null - } - } - } - val observeState by observe(::state) - val abstract by observeItem.map { it.session.description } - val observeAbstract by observe(::abstract) - val abstractLinks: List by observeItem.map { it.session.description?.let(parseUrlViewService::parse) ?: emptyList() } - val observeAbstractLinks by observe(::abstractLinks) - - val speakers: List by managedList( - observeItem.map { - it.speakers.map { speaker -> - speakerListItemFactory.create(speaker, selected = { - presentedSpeakerDetail = speakerDetailFactory.create(speaker) - }) - } - } - ) - val observeSpeakers by observe(::speakers) - - val isAttending by observeItem.map { it.session.rsvp.isAttending } - val observeIsAttending by observe(::isAttending) - val isAttendingLoading by instanceLock.observeIsLocked - - var presentedSpeakerDetail: SpeakerDetailViewModel? by managed(null) - val observePresentedSpeakerDetail by observe(::presentedSpeakerDetail) - - var presentedFeedback: FeedbackDialogViewModel? by managed(null) - val observePresentedFeedback by observe(::presentedFeedback) - - val feedbackAlreadyWritten by observeItem.map { it.session.feedback != null } - val observeFeedbackAlreadyWritten by observe(::feedbackAlreadyWritten) - val showFeedbackOption by collected( - initialValue = false, - settingsGateway.settings() - .map { it.isFeedbackEnabled } - .combine(observeState.asFlow()) { feedbackEnabled, state -> - feedbackEnabled && state == SessionState.Ended - } - ) - val observeShowFeedbackOption by observe(::showFeedbackOption) - - fun attendingTapped() = instanceLock.runExclusively { - sessionGateway.setAttending(item.session, attending = !isAttending) - } - - fun writeFeedbackTapped() { - presentedFeedback = feedbackDialogFactory.create( - item.session, - submit = { feedback -> - feedbackService.submit(item.session, feedback) - presentedFeedback = null - }, - closeAndDisable = null, - skip = { - feedbackService.skip(item.session) - presentedFeedback = null - }, - ) - } - - private fun parseUrl(text: String): List { - val urlRegex = - "https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)".toRegex() - return urlRegex.findAll(text).map { WebLink(it.range, it.value) }.toList() - } - - enum class SessionState { - InConflict, InProgress, Ended - } - - class Factory( - private val sessionGateway: SessionGateway, - private val settingsGateway: SettingsGateway, - private val speakerListItemFactory: SpeakerListItemViewModel.Factory, - private val speakerDetailFactory: SpeakerDetailViewModel.Factory, - private val feedbackDialogFactory: FeedbackDialogViewModel.Factory, - private val dateFormatter: DateFormatter, - private val dateTimeService: DateTimeService, - private val parseUrlViewService: ParseUrlViewService, - private val feedbackService: FeedbackService, - ) { - - fun create( - item: ScheduleItem, - ) = SessionDetailViewModel( - sessionGateway, - settingsGateway, - speakerListItemFactory, - speakerDetailFactory, - feedbackDialogFactory, - dateFormatter, - dateTimeService, - parseUrlViewService, - feedbackService, - item, - ) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionListItemViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionListItemViewModel.kt deleted file mode 100644 index 943cbb0c3..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SessionListItemViewModel.kt +++ /dev/null @@ -1,38 +0,0 @@ -package co.touchlab.droidcon.viewmodel.session - -import co.touchlab.droidcon.domain.composite.ScheduleItem -import co.touchlab.droidcon.domain.service.DateTimeService -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -class SessionListItemViewModel( - dateTimeService: DateTimeService, - item: ScheduleItem, - val selected: () -> Unit, -): BaseViewModel() { - val title: String = item.session.title - val isServiceSession: Boolean = item.session.isServiceSession - val isAttending: Boolean = item.session.rsvp.isAttending - val isInConflict: Boolean = item.isInConflict - val speakers: String = item.speakers.joinToString { it.fullName } - val room: String? = item.room?.name - - val isInPast: Boolean by collected(dateTimeService.now() > item.session.endsAt, flow { - while (true) { - val isInPast = dateTimeService.now() > item.session.endsAt - emit(isInPast) - delay(10_000) - } - }) - val observeIsInPast by observe(::isInPast) - - class Factory( - private val dateTimeService: DateTimeService, - ) { - fun create( - item: ScheduleItem, - selected: () -> Unit, - ) = SessionListItemViewModel(dateTimeService, item, selected) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SpeakerDetailViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SpeakerDetailComponent.kt similarity index 77% rename from shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SpeakerDetailViewModel.kt rename to shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SpeakerDetailComponent.kt index 4c542f4b1..33c8074c8 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SpeakerDetailViewModel.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SpeakerDetailComponent.kt @@ -4,12 +4,12 @@ import co.touchlab.droidcon.composite.Url import co.touchlab.droidcon.domain.entity.Profile import co.touchlab.droidcon.dto.WebLink import co.touchlab.droidcon.service.ParseUrlViewService -import org.brightify.hyperdrive.multiplatformx.BaseViewModel -class SpeakerDetailViewModel( +class SpeakerDetailComponent( private val parseUrlViewService: ParseUrlViewService, profile: Profile, -): BaseViewModel() { + private val backPressed: () -> Unit, +) { val avatarUrl = profile.profilePicture @@ -25,6 +25,10 @@ class SpeakerDetailViewModel( val bio = profile.bio val bioWebLinks: List = bio?.let(parseUrlViewService::parse) ?: emptyList() + fun backTapped() { + backPressed() + } + data class Socials( val website: Url?, val twitter: Url?, @@ -40,6 +44,9 @@ class SpeakerDetailViewModel( class Factory(private val parseUrlViewService: ParseUrlViewService) { - fun create(profile: Profile) = SpeakerDetailViewModel(parseUrlViewService, profile) + fun create( + profile: Profile, + backPressed: () -> Unit, + ) = SpeakerDetailComponent(parseUrlViewService, profile, backPressed) } } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SpeakerListItemViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SpeakerListItemViewModel.kt deleted file mode 100644 index 3101d85c3..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/session/SpeakerListItemViewModel.kt +++ /dev/null @@ -1,14 +0,0 @@ -package co.touchlab.droidcon.viewmodel.session - -import co.touchlab.droidcon.domain.entity.Profile -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -class SpeakerListItemViewModel(profile: Profile, val selected: () -> Unit): BaseViewModel() { - val avatarUrl = profile.profilePicture - val info = listOfNotNull(profile.fullName, profile.tagLine).joinToString() - val bio = profile.bio - - class Factory { - fun create(profile: Profile, selected: () -> Unit) = SpeakerListItemViewModel(profile, selected) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/AboutComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/AboutComponent.kt new file mode 100644 index 000000000..18505b707 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/AboutComponent.kt @@ -0,0 +1,55 @@ +package co.touchlab.droidcon.viewmodel.settings + +import co.touchlab.droidcon.application.repository.AboutRepository +import co.touchlab.droidcon.decompose.whileStarted +import co.touchlab.droidcon.dto.WebLink +import co.touchlab.droidcon.service.ParseUrlViewService +import co.touchlab.droidcon.util.DcDispatchers +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.reduce + +class AboutComponent( + componentContext: ComponentContext, + dispatchers: DcDispatchers, + private val aboutRepository: AboutRepository, + private val parseUrlViewService: ParseUrlViewService, +): ComponentContext by componentContext { + + private val _model = MutableValue(Model()) + val model: Value get() = _model + + init { + whileStarted(dispatchers.main) { + val items = + aboutRepository + .getAboutItems() + .map { item -> Model.Item(item.title, item.detail, parseUrlViewService.parse(item.detail), item.icon) } + + _model.reduce { it.copy(items = items) } + } + } + + data class Model( + val items: List = emptyList(), + ) { + + data class Item( + val title: String, + val detail: String, + val webLinks: List, + val icon: String, + ) + } + + class Factory( + private val dispatchers: DcDispatchers, + private val aboutRepository: AboutRepository, + private val parseUrlViewService: ParseUrlViewService, + ) { + + fun create(componentContext: ComponentContext) = + AboutComponent(componentContext, dispatchers, aboutRepository, parseUrlViewService) + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/AboutItemViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/AboutItemViewModel.kt deleted file mode 100644 index 7172d7c76..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/AboutItemViewModel.kt +++ /dev/null @@ -1,11 +0,0 @@ -package co.touchlab.droidcon.viewmodel.settings - -import co.touchlab.droidcon.dto.WebLink -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -class AboutItemViewModel( - val title: String, - val detail: String, - val webLinks: List, - val icon: String, -): BaseViewModel() diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/AboutViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/AboutViewModel.kt deleted file mode 100644 index 3941e0648..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/AboutViewModel.kt +++ /dev/null @@ -1,31 +0,0 @@ -package co.touchlab.droidcon.viewmodel.settings - -import co.touchlab.droidcon.application.composite.AboutItem -import co.touchlab.droidcon.application.repository.AboutRepository -import co.touchlab.droidcon.service.ParseUrlViewService -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -class AboutViewModel( - private val aboutRepository: AboutRepository, - private val parseUrlViewService: ParseUrlViewService, -): BaseViewModel() { - - var items: List by published(emptyList()) - private set - - var itemViewModels: List by published(emptyList()) - val observeItemViewModels by observe(::itemViewModels) - - override suspend fun whileAttached() { - items = aboutRepository.getAboutItems() - itemViewModels = items.map { - val links = parseUrlViewService.parse(it.detail) - AboutItemViewModel(it.title, it.detail, links, it.icon) - } - } - - class Factory(private val aboutRepository: AboutRepository, private val parseUrlViewService: ParseUrlViewService) { - - fun create() = AboutViewModel(aboutRepository, parseUrlViewService) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/SettingsComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/SettingsComponent.kt new file mode 100644 index 000000000..638696c9b --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/SettingsComponent.kt @@ -0,0 +1,78 @@ +package co.touchlab.droidcon.viewmodel.settings + +import co.touchlab.droidcon.application.gateway.SettingsGateway +import co.touchlab.droidcon.decompose.interfaceLock +import co.touchlab.droidcon.decompose.whileStarted +import co.touchlab.droidcon.util.DcDispatchers +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.childContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.reduce + +class SettingsComponent( + componentContext: ComponentContext, + dispatchers: DcDispatchers, + private val settingsGateway: SettingsGateway, + aboutFactory: AboutComponent.Factory, +): ComponentContext by componentContext { + + private val instanceLock = interfaceLock(dispatchers.main) + + private val _model = MutableValue(Model()) + val model: Value get() = _model + + val about = aboutFactory.create(componentContext = childContext(key = "about")) + + init { + whileStarted(dispatchers.main) { + settingsGateway.settings().collect { + _model.value = + Model( + isFeedbackEnabled = it.isFeedbackEnabled, + isRemindersEnabled = it.isRemindersEnabled, + useComposeForIos = it.useComposeForIos + ) + } + } + } + + fun setFeedbackEnabled(enabled: Boolean) { + _model.reduce { it.copy(isFeedbackEnabled = enabled) } + + instanceLock.runExclusively { + settingsGateway.setFeedbackEnabled(enabled) + } + } + + fun setRemindersEnabled(enabled: Boolean) { + _model.reduce { it.copy(isRemindersEnabled = enabled) } + + instanceLock.runExclusively { + settingsGateway.setRemindersEnabled(enabled) + } + } + + fun setUseComposeForIos(enabled: Boolean) { + _model.reduce { it.copy(useComposeForIos = enabled) } + + instanceLock.runExclusively { + settingsGateway.setUseComposeForIos(enabled) + } + } + + data class Model( + val isFeedbackEnabled: Boolean = false, + val isRemindersEnabled: Boolean = false, + val useComposeForIos: Boolean = false, + ) + + class Factory( + private val dispatchers: DcDispatchers, + private val settingsGateway: SettingsGateway, + private val aboutFactory: AboutComponent.Factory, + ) { + + fun create(componentContext: ComponentContext) = SettingsComponent(componentContext, dispatchers, settingsGateway, aboutFactory) + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/SettingsViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/SettingsViewModel.kt deleted file mode 100644 index ad15bafb6..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/SettingsViewModel.kt +++ /dev/null @@ -1,56 +0,0 @@ -package co.touchlab.droidcon.viewmodel.settings - -import co.touchlab.droidcon.application.gateway.SettingsGateway -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -class SettingsViewModel( - settingsGateway: SettingsGateway, - private val aboutFactory: AboutViewModel.Factory, -): BaseViewModel() { - - var isFeedbackEnabled by binding( - settingsGateway.settings(), - mapping = { it.isFeedbackEnabled }, - set = { newValue -> - // TODO: Remove when `binding` supports suspend closures. - instanceLock.runExclusively { - settingsGateway.setFeedbackEnabled(newValue) - } - } - ) - val observeIsFeedbackEnabled by observe(::isFeedbackEnabled) - - var isRemindersEnabled by binding( - settingsGateway.settings(), - mapping = { it.isRemindersEnabled }, - set = { newValue -> - // TODO: Remove when `binding` supports suspend closures. - instanceLock.runExclusively { - settingsGateway.setRemindersEnabled(newValue) - } - } - ) - val observeIsRemindersEnabled by observe(::isRemindersEnabled) - - val about by managed(aboutFactory.create()) - - var useCompose: Boolean by binding( - settingsGateway.settings(), - mapping = { it.useComposeForIos }, - set = { newValue -> - // TODO: Remove when `binding` supports suspend closures. - instanceLock.runExclusively { - settingsGateway.setUseComposeForIos(newValue) - } - } - ) - val observeUseCompose by observe(::useCompose) - - class Factory( - private val settingsGateway: SettingsGateway, - private val aboutFactory: AboutViewModel.Factory, - ) { - - fun create() = SettingsViewModel(settingsGateway, aboutFactory) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorDetailComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorDetailComponent.kt new file mode 100644 index 000000000..c4122ba5e --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorDetailComponent.kt @@ -0,0 +1,93 @@ +package co.touchlab.droidcon.viewmodel.sponsor + +import co.touchlab.droidcon.composite.Url +import co.touchlab.droidcon.decompose.whileStarted +import co.touchlab.droidcon.domain.entity.Profile +import co.touchlab.droidcon.domain.entity.Sponsor +import co.touchlab.droidcon.domain.gateway.SponsorGateway +import co.touchlab.droidcon.util.DcDispatchers +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.reduce + +// TODO: Connect to a gateway. +class SponsorDetailComponent( + componentContext: ComponentContext, + dispatchers: DcDispatchers, + private val sponsorGateway: SponsorGateway, + private val sponsor: Sponsor, + private val speakerSelected: (Profile) -> Unit, + private val backPressed: () -> Unit, +): ComponentContext by componentContext { + + private val _model = + MutableValue( + Model( + name = sponsor.name, + groupName = sponsor.group, + imageUrl = sponsor.icon, + abstract = sponsor.description, + ) + ) + + val model: Value get() = _model + + init { + whileStarted(dispatchers.main) { + val representatives = + sponsorGateway + .getRepresentatives(sponsor.id) + .map { profile -> + Model.Representative( + profile = profile, + avatarUrl = profile.profilePicture, + info = listOfNotNull(profile.fullName, profile.tagLine).joinToString(), + bio = profile.bio, + ) + } + + _model.reduce { it.copy(representatives = representatives) } + } + } + + fun backTapped() { + backPressed() + } + + fun representativeTapped(representative: Model.Representative) { + speakerSelected(representative.profile) + } + + data class Model( + val name: String, + val groupName: String, + val imageUrl: Url, + val abstract: String?, + val representatives: List = emptyList(), + ) { + + data class Representative( + val profile: Profile, + val avatarUrl: Url?, + val info: String, + val bio: String?, + ) + } + + class Factory( + private val dispatchers: DcDispatchers, + private val sponsorGateway: SponsorGateway, + ) { + + fun create(componentContext: ComponentContext, sponsor: Sponsor, speakerSelected: (Profile) -> Unit, backPressed: () -> Unit) = + SponsorDetailComponent( + componentContext, + dispatchers, + sponsorGateway, + sponsor, + speakerSelected, + backPressed, + ) + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorDetailViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorDetailViewModel.kt deleted file mode 100644 index e4e8ff1c0..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorDetailViewModel.kt +++ /dev/null @@ -1,46 +0,0 @@ -package co.touchlab.droidcon.viewmodel.sponsor - -import co.touchlab.droidcon.domain.entity.Sponsor -import co.touchlab.droidcon.domain.gateway.SponsorGateway -import co.touchlab.droidcon.viewmodel.session.SpeakerDetailViewModel -import co.touchlab.droidcon.viewmodel.session.SpeakerListItemViewModel -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -// TODO: Connect to a gateway. -class SponsorDetailViewModel( - private val sponsorGateway: SponsorGateway, - private val speakerListItemFactory: SpeakerListItemViewModel.Factory, - private val speakerDetailFactory: SpeakerDetailViewModel.Factory, - private val sponsor: Sponsor, - val groupName: String, -): BaseViewModel() { - - val name = sponsor.name - val imageUrl = sponsor.icon - - val abstract = sponsor.description - - val representatives: List by managedList(emptyList()) - val observeRepresentatives by observe(::representatives) - - var presentedSpeakerDetail: SpeakerDetailViewModel? by managed(null) - val observePresentedSpeakerDetail by observe(::presentedSpeakerDetail) - - override suspend fun whileAttached() { - sponsorGateway.getRepresentatives(sponsor.id).map { speaker -> - speakerListItemFactory.create(speaker, selected = { - presentedSpeakerDetail = speakerDetailFactory.create(speaker) - }) - } - } - - class Factory( - private val sponsorGateway: SponsorGateway, - private val speakerListItemFactory: SpeakerListItemViewModel.Factory, - private val speakerDetailFactory: SpeakerDetailViewModel.Factory, - ) { - - fun create(sponsor: Sponsor, groupName: String) = - SponsorDetailViewModel(sponsorGateway, speakerListItemFactory, speakerDetailFactory, sponsor, groupName) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorGroupItemViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorGroupItemViewModel.kt deleted file mode 100644 index f119adef5..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorGroupItemViewModel.kt +++ /dev/null @@ -1,28 +0,0 @@ -package co.touchlab.droidcon.viewmodel.sponsor - -import co.touchlab.droidcon.domain.entity.Sponsor -import io.ktor.http.URLParserException -import io.ktor.http.Url -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -class SponsorGroupItemViewModel( - private val sponsor: Sponsor, - val selected: () -> Unit, -): BaseViewModel() { - - val name = sponsor.name - val imageUrl = sponsor.icon - - val validImageUrl: String? = - try { - Url(sponsor.icon.string).toString() - } catch (e: URLParserException) { - null - } - - class Factory { - - fun create(sponsor: Sponsor, selected: () -> Unit) = - SponsorGroupItemViewModel(sponsor, selected) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorGroupViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorGroupViewModel.kt deleted file mode 100644 index ffdaf3a9e..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorGroupViewModel.kt +++ /dev/null @@ -1,30 +0,0 @@ -package co.touchlab.droidcon.viewmodel.sponsor - -import co.touchlab.droidcon.domain.composite.SponsorGroupWithSponsors -import co.touchlab.droidcon.domain.entity.Sponsor -import co.touchlab.droidcon.domain.entity.SponsorGroup -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -class SponsorGroupViewModel( - sponsorGroupItemFactory: SponsorGroupItemViewModel.Factory, - sponsorGroup: SponsorGroupWithSponsors, - onSponsorSelected: (Sponsor) -> Unit -): BaseViewModel() { - val title = sponsorGroup.group.name - val isProminent = sponsorGroup.group.isProminent - val sponsors by managedList( - sponsorGroup.sponsors.map { sponsor -> - sponsorGroupItemFactory.create(sponsor, selected = { onSponsorSelected(sponsor) }) - } - ) - val observeSponsors by observe(::sponsors) - - class Factory( - private val sponsorGroupItemFactory: SponsorGroupItemViewModel.Factory, - ) { - fun create( - sponsorGroup: SponsorGroupWithSponsors, - onSponsorSelected: (Sponsor) -> Unit, - ) = SponsorGroupViewModel(sponsorGroupItemFactory, sponsorGroup, onSponsorSelected) - } -} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorListComponent.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorListComponent.kt new file mode 100644 index 000000000..75b94e7c1 --- /dev/null +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorListComponent.kt @@ -0,0 +1,86 @@ +package co.touchlab.droidcon.viewmodel.sponsor + +import co.touchlab.droidcon.decompose.whileStarted +import co.touchlab.droidcon.domain.composite.SponsorGroupWithSponsors +import co.touchlab.droidcon.domain.entity.Sponsor +import co.touchlab.droidcon.domain.gateway.SponsorGateway +import co.touchlab.droidcon.util.DcDispatchers +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.reduce +import io.ktor.http.URLParserException +import kotlinx.coroutines.flow.map + +class SponsorListComponent( + private val componentContext: ComponentContext, + dispatchers: DcDispatchers, + private val sponsorGateway: SponsorGateway, + private val sponsorSelected: (Sponsor) -> Unit, +): ComponentContext by componentContext { + + private val _model = MutableValue(Model()) + val model: Value get() = _model + + init { + whileStarted(dispatchers.main) { + sponsorGateway.observeSponsors() + .map { groups -> + groups + .sortedBy { it.group.displayPriority } + .map { it.toModel() } + } + .collect { groups -> + _model.reduce { it.copy(groups = groups) } + } + } + } + + private fun SponsorGroupWithSponsors.toModel(): Model.Group = + Model.Group( + title = group.name, + isProminent = group.isProminent, + sponsors = sponsors.map { it.toModel() }, + ) + + private fun Sponsor.toModel(): Model.Sponsor = + Model.Sponsor( + sponsor = this, + name = name, + imageUrl = try { + io.ktor.http.Url(icon.string).toString() + } catch (e: URLParserException) { + null + }, + ) + + fun sponsorTapped(sponsor: Model.Sponsor) { + sponsorSelected(sponsor.sponsor) + } + + data class Model( + val groups: List = emptyList(), + ) { + + data class Group( + val title: String, + val isProminent: Boolean, + val sponsors: List, + ) + + data class Sponsor( + val sponsor: co.touchlab.droidcon.domain.entity.Sponsor, + val name: String, + val imageUrl: String?, + ) + } + + class Factory( + private val dispatchers: DcDispatchers, + private val sponsorGateway: SponsorGateway, + ) { + + fun create(componentContext: ComponentContext, sponsorSelected: (Sponsor) -> Unit) = + SponsorListComponent(componentContext, dispatchers, sponsorGateway, sponsorSelected) + } +} diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorListViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorListViewModel.kt deleted file mode 100644 index 8d61693a7..000000000 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/sponsor/SponsorListViewModel.kt +++ /dev/null @@ -1,47 +0,0 @@ -package co.touchlab.droidcon.viewmodel.sponsor - -import co.touchlab.droidcon.composite.Url -import co.touchlab.droidcon.domain.gateway.SponsorGateway -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import org.brightify.hyperdrive.multiplatformx.BaseViewModel - -class SponsorListViewModel( - private val sponsorGateway: SponsorGateway, - private val sponsorGroupFactory: SponsorGroupViewModel.Factory, - private val sponsorDetailFactory: SponsorDetailViewModel.Factory, -): BaseViewModel() { - val sponsorGroups: List by managedList(emptyList(), - sponsorGateway.observeSponsors() - .map { sponsorGroups -> - sponsorGroups - .sortedBy { it.group.displayPriority } - .map { sponsorGroup -> - sponsorGroupFactory.create(sponsorGroup, onSponsorSelected = { sponsor -> - if (sponsor.hasDetail) { - presentedSponsorDetail = sponsorDetailFactory.create(sponsor, sponsorGroup.group.name) - } else { - // UIApplication.sharedApplication.openURL(NSURL(string = sponsor.url.string)) - presentedUrl = sponsor.url - } - }) - } - } - ) - val observeSponsorGroups by observe(::sponsorGroups) - - var presentedSponsorDetail: SponsorDetailViewModel? by managed(null) - val observePresentedSponsorDetail by observe(::presentedSponsorDetail) - - var presentedUrl: Url? by published(null) - val observePresentedUrl by observe(::presentedUrl) - - class Factory( - private val sponsorGateway: SponsorGateway, - private val sponsorGroupFactory: SponsorGroupViewModel.Factory, - private val sponsorDetailFactory: SponsorDetailViewModel.Factory, - ) { - - fun create() = SponsorListViewModel(sponsorGateway, sponsorGroupFactory, sponsorDetailFactory) - } -} diff --git a/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/ComposeRootController.kt b/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/ComposeRootController.kt index 1146e46af..7c4cbff2b 100644 --- a/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/ComposeRootController.kt +++ b/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/ComposeRootController.kt @@ -1,9 +1,8 @@ package co.touchlab.droidcon.ui import androidx.compose.ui.window.Application -import co.touchlab.droidcon.ui.MainComposeView -import co.touchlab.droidcon.viewmodel.ApplicationViewModel +import co.touchlab.droidcon.viewmodel.ApplicationComponent -fun getRootController(viewModel: ApplicationViewModel) = Application("MainComposeView") { - MainComposeView(viewModel) +fun getRootController(component: ApplicationComponent) = Application("MainComposeView") { + MainComposeView(component) } diff --git a/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/settings/PlatformSpecificSettings.kt b/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/settings/PlatformSpecificSettings.kt index 0709b3599..f6f521afd 100644 --- a/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/settings/PlatformSpecificSettings.kt +++ b/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/settings/PlatformSpecificSettings.kt @@ -3,15 +3,20 @@ package co.touchlab.droidcon.ui.settings import androidx.compose.material.Divider import androidx.compose.material.icons.Icons import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import co.touchlab.droidcon.ui.icons.Aod -import co.touchlab.droidcon.viewmodel.settings.SettingsViewModel +import co.touchlab.droidcon.viewmodel.settings.SettingsComponent +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState @Composable -internal actual fun PlatformSpecificSettingsView(viewModel: SettingsViewModel) { +internal actual fun PlatformSpecificSettingsView(component: SettingsComponent) { + val model by component.model.subscribeAsState() + IconTextSwitchRow( text = "Use compose for iOS", image = Icons.Default.Aod, - checked = viewModel.observeUseCompose, + isChecked = model.useComposeForIos, + onCheckedChange = component::setUseComposeForIos, ) Divider() diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 7ff00905b..f57856f6f 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -3,6 +3,7 @@ plugins { kotlin("plugin.serialization") id("com.android.library") id("com.squareup.sqldelight") + id("kotlin-parcelize") } android { @@ -71,6 +72,8 @@ kotlin { implementation(libs.stately.common) implementation(libs.koin.core) + + implementation(libs.decompose) } } val commonTest by getting { diff --git a/shared/src/androidMain/kotlin/co/touchlab/droidcon/domain/entity/Parcelers.kt b/shared/src/androidMain/kotlin/co/touchlab/droidcon/domain/entity/Parcelers.kt new file mode 100644 index 000000000..88d21b00c --- /dev/null +++ b/shared/src/androidMain/kotlin/co/touchlab/droidcon/domain/entity/Parcelers.kt @@ -0,0 +1,26 @@ +package co.touchlab.droidcon.domain.entity + +import android.os.Parcel +import com.arkivanov.essenty.parcelable.Parceler +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate + +actual object InstantParceler: Parceler { + + override fun create(parcel: Parcel): Instant = + Instant.fromEpochSeconds(parcel.readLong()) + + override fun Instant.write(parcel: Parcel, flags: Int) { + parcel.writeLong(epochSeconds) + } +} + +actual object LocalDateParceler: Parceler { + + override fun create(parcel: Parcel): LocalDate = + LocalDate.parse(parcel.readString()!!) + + override fun LocalDate.write(parcel: Parcel, flags: Int) { + parcel.writeString(toString()) + } +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/Koin.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/Koin.kt index 68732f2a7..7ceb8fcca 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/droidcon/Koin.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/Koin.kt @@ -110,7 +110,7 @@ private val coreModule = module { ) } single { - SqlDelightSessionRepository(dateTimeService = get(), sessionQueries = get().sessionQueries) + SqlDelightSessionRepository(sessionQueries = get().sessionQueries) } single { SqlDelightRoomRepository(roomQueries = get().roomQueries) diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/composite/Url.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/composite/Url.kt index f50f97f8d..b31409b88 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/droidcon/composite/Url.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/composite/Url.kt @@ -1,3 +1,7 @@ package co.touchlab.droidcon.composite -data class Url(val string: String) \ No newline at end of file +import com.arkivanov.essenty.parcelable.Parcelable +import com.arkivanov.essenty.parcelable.Parcelize + +@Parcelize +data class Url(val string: String): Parcelable diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Parcelers.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Parcelers.kt new file mode 100644 index 000000000..ad4874199 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Parcelers.kt @@ -0,0 +1,9 @@ +package co.touchlab.droidcon.domain.entity + +import com.arkivanov.essenty.parcelable.Parceler +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate + +expect object InstantParceler : Parceler + +expect object LocalDateParceler : Parceler diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Profile.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Profile.kt index 8e7a6e72c..a9e7618e7 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Profile.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Profile.kt @@ -1,8 +1,11 @@ package co.touchlab.droidcon.domain.entity import co.touchlab.droidcon.composite.Url +import com.arkivanov.essenty.parcelable.Parcelable +import com.arkivanov.essenty.parcelable.Parcelize // TODO: Add sponsors if desired. +@Parcelize class Profile( override val id: Id, val fullName: String, @@ -12,6 +15,8 @@ class Profile( val twitter: Url?, val linkedIn: Url?, val website: Url?, -): DomainEntity() { - data class Id(val value: String) -} \ No newline at end of file +): DomainEntity(), Parcelable { + + @Parcelize + data class Id(val value: String): Parcelable +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Room.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Room.kt index 2c6d83cec..68ba45172 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Room.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Room.kt @@ -1,8 +1,13 @@ package co.touchlab.droidcon.domain.entity +import com.arkivanov.essenty.parcelable.Parcelable +import com.arkivanov.essenty.parcelable.Parcelize + class Room( override val id: Id, val name: String, ): DomainEntity() { - data class Id(val value: Long) -} \ No newline at end of file + + @Parcelize + data class Id(val value: Long): Parcelable +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Session.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Session.kt index 150a9577b..aab0c624a 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Session.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Session.kt @@ -1,37 +1,41 @@ package co.touchlab.droidcon.domain.entity -import co.touchlab.droidcon.domain.service.DateTimeService +import com.arkivanov.essenty.parcelable.Parcelable +import com.arkivanov.essenty.parcelable.Parcelize +import com.arkivanov.essenty.parcelable.WriteWith import kotlinx.datetime.Instant +@Parcelize class Session( - private val dateTimeService: DateTimeService, override val id: Id, val title: String, val description: String?, - val startsAt: Instant, - val endsAt: Instant, + val startsAt: @WriteWith Instant, + val endsAt: @WriteWith Instant, val isServiceSession: Boolean, val room: Room.Id?, var rsvp: RSVP, var feedback: Feedback?, -): DomainEntity() { +): DomainEntity(), Parcelable { - val isAttendable: Boolean - get() = dateTimeService.now() < endsAt - - data class Id(val value: String) + @Parcelize + data class Id(val value: String): Parcelable + @Parcelize data class RSVP( val isAttending: Boolean, val isSent: Boolean, - ) + ): Parcelable + @Parcelize data class Feedback( val rating: Int, val comment: String, val isSent: Boolean, - ) { + ): Parcelable { + object Rating { + const val DISSATISFIED = 1 const val NORMAL = 2 const val SATISFIED = 3 diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Sponsor.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Sponsor.kt index fb652d01f..19573e375 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Sponsor.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/entity/Sponsor.kt @@ -1,14 +1,17 @@ package co.touchlab.droidcon.domain.entity import co.touchlab.droidcon.composite.Url +import com.arkivanov.essenty.parcelable.Parcelable +import com.arkivanov.essenty.parcelable.Parcelize +@Parcelize class Sponsor( override val id: Id, val hasDetail: Boolean, val description: String?, val icon: Url, val url: Url, -): DomainEntity() { +): DomainEntity(), Parcelable { val name: String get() = id.name @@ -16,5 +19,6 @@ class Sponsor( val group: String get() = id.group - data class Id(val name: String, val group: String) + @Parcelize + data class Id(val name: String, val group: String): Parcelable } diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/repository/impl/SqlDelightSessionRepository.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/repository/impl/SqlDelightSessionRepository.kt index bd3bfc4fe..76a7ad9dc 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/repository/impl/SqlDelightSessionRepository.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/repository/impl/SqlDelightSessionRepository.kt @@ -4,7 +4,6 @@ import co.touchlab.droidcon.db.SessionQueries import co.touchlab.droidcon.domain.entity.Room import co.touchlab.droidcon.domain.entity.Session import co.touchlab.droidcon.domain.repository.SessionRepository -import co.touchlab.droidcon.domain.service.DateTimeService import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.mapToList import com.squareup.sqldelight.runtime.coroutines.mapToOne @@ -14,7 +13,6 @@ import kotlinx.coroutines.flow.first import kotlinx.datetime.Instant class SqlDelightSessionRepository( - private val dateTimeService: DateTimeService, private val sessionQueries: SessionQueries, ): BaseRepository(), SessionRepository { override fun observe(id: Session.Id): Flow { @@ -98,7 +96,6 @@ class SqlDelightSessionRepository( feedbackComment: String?, feedbackSent: Long, ) = Session( - dateTimeService = dateTimeService, id = Session.Id(id), title = title, description = description, diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/impl/DefaultSyncService.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/impl/DefaultSyncService.kt index c99f06664..ba15e3178 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/impl/DefaultSyncService.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/impl/DefaultSyncService.kt @@ -224,7 +224,6 @@ class DefaultSyncService( val sessionsAndSpeakers = roomDtos.flatMap { room -> room.sessions.map { dto -> Session( - dateTimeService = dateTimeService, id = Session.Id(dto.id), title = dto.title, description = dto.description, diff --git a/shared/src/iosMain/kotlin/co/touchlab/droidcon/domain/entity/Parcelers.kt b/shared/src/iosMain/kotlin/co/touchlab/droidcon/domain/entity/Parcelers.kt new file mode 100644 index 000000000..015597731 --- /dev/null +++ b/shared/src/iosMain/kotlin/co/touchlab/droidcon/domain/entity/Parcelers.kt @@ -0,0 +1,9 @@ +package co.touchlab.droidcon.domain.entity + +import com.arkivanov.essenty.parcelable.Parceler +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate + +actual object InstantParceler : Parceler + +actual object LocalDateParceler : Parceler