diff --git a/.github/workflows/deploy_web_github_pages.yml b/.github/workflows/deploy_web_github_pages.yml index b711426b..ec038189 100644 --- a/.github/workflows/deploy_web_github_pages.yml +++ b/.github/workflows/deploy_web_github_pages.yml @@ -50,7 +50,10 @@ jobs: - name: Build Web distribution run: ./gradlew :composeApp:jsBrowserDistribution - + + - name: Copy index.html to 404.html + run: cp composeApp/build/dist/js/productionExecutable/index.html composeApp/build/dist/js/productionExecutable/404.html + - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index 9b7a6256..ba29c12f 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -2,19 +2,22 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import data.di.DataModule import di.AppModule +import domain.di.DomainModule import ivy.di.Di +import ivy.di.Di.register import ivy.di.SharedModule +import navigation.Navigation import org.jetbrains.compose.ui.tooling.preview.Preview -import ui.navigation.Navigation -import ui.screen.intro.IntroScreen import ui.theme.LearnTheme @Composable @Preview fun App() { var initialized by mutableStateOf(false) + val uriHandler = LocalUriHandler.current LaunchedEffect(Unit) { Di.init( @@ -22,8 +25,12 @@ fun App() { SharedModule, AppModule, DataModule, + DomainModule, ) ) + Di.appScope { + register { uriHandler } + } initialized = true } @@ -37,11 +44,6 @@ fun App() { @Composable private fun NavGraph() { val navigation = remember { Di.get() } - LaunchedEffect(navigation) { - // navigate to the initial screen - navigation.navigate(IntroScreen()) - } - Box(modifier = Modifier.fillMaxSize()) { navigation.NavHost() } diff --git a/composeApp/src/commonMain/kotlin/SystemNavigation.kt b/composeApp/src/commonMain/kotlin/SystemNavigation.kt deleted file mode 100644 index 19425ec8..00000000 --- a/composeApp/src/commonMain/kotlin/SystemNavigation.kt +++ /dev/null @@ -1,8 +0,0 @@ -import ui.navigation.Screen - -interface SystemNavigation { - fun navigateTo(screen: Screen) - fun navigateBack() -} - -expect fun systemNavigation(): SystemNavigation \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/data/di/DataModule.kt b/composeApp/src/commonMain/kotlin/data/di/DataModule.kt index 72013574..9dcad681 100644 --- a/composeApp/src/commonMain/kotlin/data/di/DataModule.kt +++ b/composeApp/src/commonMain/kotlin/data/di/DataModule.kt @@ -6,6 +6,7 @@ import data.LessonRepository import data.LessonRepositoryImpl import data.TopicsRepository import data.fake.FakeLessonRepository +import di.bindWithFake import ivy.di.Di import ivy.di.Di.register import ivy.di.autowire.autoWire @@ -25,14 +26,4 @@ object DataModule : Di.Module { } bindWithFake() } -} - -inline fun Di.Scope.bindWithFake() { - register { - if (Di.get().fakesEnabled) { - Di.get() - } else { - Di.get() - } - } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/di/AppModule.kt b/composeApp/src/commonMain/kotlin/di/AppModule.kt index 0f6d206f..d29de39c 100644 --- a/composeApp/src/commonMain/kotlin/di/AppModule.kt +++ b/composeApp/src/commonMain/kotlin/di/AppModule.kt @@ -1,10 +1,14 @@ package di +import AppConfiguration +import ivy.data.HerokuServerUrlProvider +import ivy.data.LocalServerUrlProvider +import ivy.data.ServerUrlProvider import ivy.di.Di import ivy.di.Di.register import ivy.di.autowire.autoWireSingleton -import systemNavigation -import ui.navigation.Navigation +import navigation.Navigation +import navigation.systemNavigation import util.DispatchersProvider import util.DispatchersProviderImpl @@ -14,7 +18,19 @@ object AppModule : Di.Module { Di.appScope { register { systemNavigation() } autoWireSingleton(::Navigation) + autoWireSingleton(::AppConfiguration) register { DispatchersProviderImpl() } + bindWithFake() + } + } +} + +inline fun Di.Scope.bindWithFake() { + register { + if (Di.get().fakesEnabled) { + Di.get() + } else { + Di.get() } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/domain/GoogleAuthenticationUseCase.kt b/composeApp/src/commonMain/kotlin/domain/GoogleAuthenticationUseCase.kt new file mode 100644 index 00000000..b98b398f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/domain/GoogleAuthenticationUseCase.kt @@ -0,0 +1,29 @@ +package domain + +import IvyConstants +import androidx.compose.ui.platform.UriHandler +import ivy.data.ServerUrlProvider + +class GoogleAuthenticationUseCaseImpl( + private val uriHandler: UriHandler, + private val serverUrlProvider: ServerUrlProvider, +) : GoogleAuthenticationUseCase { + override fun loginWithGoogle() { + val clientId = IvyConstants.GoogleClientId + val redirectUri = "${serverUrlProvider.serverUrl}${IvyConstants.GoogleAuthCallbackEndpoint}" + + val authUrl = """ + https://accounts.google.com/o/oauth2/v2/auth? + client_id=$clientId& + redirect_uri=$redirectUri& + response_type=code& + scope=email profile + """.trimIndent() + + uriHandler.openUri(authUrl) + } +} + +interface GoogleAuthenticationUseCase { + fun loginWithGoogle() +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/domain/di/DomainModule.kt b/composeApp/src/commonMain/kotlin/domain/di/DomainModule.kt new file mode 100644 index 00000000..17790f44 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/domain/di/DomainModule.kt @@ -0,0 +1,16 @@ +package domain.di + +import di.bindWithFake +import domain.GoogleAuthenticationUseCase +import domain.GoogleAuthenticationUseCaseImpl +import domain.fake.FakeGoogleAuthenticationUseCase +import ivy.di.Di +import ivy.di.autowire.autoWire + +object DomainModule : Di.Module { + override fun init() = Di.appScope { + autoWire(::GoogleAuthenticationUseCaseImpl) + autoWire(::FakeGoogleAuthenticationUseCase) + bindWithFake() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/domain/fake/FakeGoogleAuthenticationUseCase.kt b/composeApp/src/commonMain/kotlin/domain/fake/FakeGoogleAuthenticationUseCase.kt new file mode 100644 index 00000000..a78d5bec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/domain/fake/FakeGoogleAuthenticationUseCase.kt @@ -0,0 +1,13 @@ +package domain.fake + +import domain.GoogleAuthenticationUseCase +import navigation.Navigation +import ui.screen.home.HomeScreen + +class FakeGoogleAuthenticationUseCase( + private val navigation: Navigation +) : GoogleAuthenticationUseCase { + override fun loginWithGoogle() { + navigation.navigateTo(HomeScreen()) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/navigation/Navigation.kt b/composeApp/src/commonMain/kotlin/navigation/Navigation.kt new file mode 100644 index 00000000..0e259c3b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/navigation/Navigation.kt @@ -0,0 +1,34 @@ +package navigation + +import Platform +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import ui.screen.NotFoundPage + +class Navigation( + private val systemNavigation: SystemNavigation, + private val platform: Platform, +) { + @Composable + fun NavHost() { + val currentRoute by systemNavigation.currentRoute.collectAsState() + val screen = remember(currentRoute) { + Routing.resolve(currentRoute)?.also(Screen::initialize) + } + screen?.Content() ?: NotFoundPage(currentRoute) + } + + fun navigateTo(screen: Screen) { + systemNavigation.navigateTo(screen) + } + + fun replaceWith(screen: Screen) { + systemNavigation.replaceWith(screen) + } + + fun navigateBack() { + systemNavigation.navigateBack() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/navigation/Routing.kt b/composeApp/src/commonMain/kotlin/navigation/Routing.kt new file mode 100644 index 00000000..6409b832 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/navigation/Routing.kt @@ -0,0 +1,27 @@ +package navigation + +import arrow.core.Option +import ui.screen.course.CourseRouter +import ui.screen.home.HomeRouter +import ui.screen.intro.IntroRouter +import ui.screen.lesson.LessonRouter + +object Routing { + private val routers = setOf>( + IntroRouter, + HomeRouter, + LessonRouter, + CourseRouter, + ) + + fun resolve(route: Route): Screen? { + return routers.firstNotNullOfOrNull { + it.fromRoute(route).getOrNull() + } + } +} + +interface Router { + fun fromRoute(route: Route): Option + fun toRoute(screen: S): Route +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/navigation/Screen.kt b/composeApp/src/commonMain/kotlin/navigation/Screen.kt similarity index 71% rename from composeApp/src/commonMain/kotlin/ui/navigation/Screen.kt rename to composeApp/src/commonMain/kotlin/navigation/Screen.kt index b68439fb..fe3008a1 100644 --- a/composeApp/src/commonMain/kotlin/ui/navigation/Screen.kt +++ b/composeApp/src/commonMain/kotlin/navigation/Screen.kt @@ -1,4 +1,4 @@ -package ui.navigation +package navigation import androidx.compose.runtime.Composable import ivy.di.Di @@ -10,22 +10,28 @@ import kotlinx.coroutines.SupervisorJob abstract class Screen { - abstract val path: String - private lateinit var job: CompletableJob protected lateinit var screenScope: CoroutineScope - protected abstract fun onDi(): Di.Scope.() -> Unit + private var initialized = false + + abstract fun toRoute(): Route + + protected abstract fun Di.Scope.onDi() fun initialize() { + if (initialized) return + job = SupervisorJob() screenScope = CoroutineScope(Dispatchers.Main + job) - onDi().invoke(FeatureScope) + FeatureScope.onDi() + initialized = true } fun destroy() { job.cancel() Di.clear(FeatureScope) + initialized = false } diff --git a/composeApp/src/commonMain/kotlin/navigation/SystemNavigation.kt b/composeApp/src/commonMain/kotlin/navigation/SystemNavigation.kt new file mode 100644 index 00000000..dbcc8635 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/navigation/SystemNavigation.kt @@ -0,0 +1,26 @@ +package navigation + +import androidx.compose.runtime.Immutable +import arrow.core.None +import arrow.core.Option +import arrow.core.some +import kotlinx.coroutines.flow.StateFlow + +interface SystemNavigation { + val currentRoute: StateFlow + fun navigateTo(screen: Screen) + fun replaceWith(screen: Screen) + fun navigateBack() +} + +@Immutable +data class Route( + val path: String, + val params: Map = emptyMap(), +) { + operator fun get(key: String): Option { + return params[key]?.some() ?: None + } +} + +expect fun systemNavigation(): SystemNavigation \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/navigation/Navigation.kt b/composeApp/src/commonMain/kotlin/ui/navigation/Navigation.kt deleted file mode 100644 index 44446e92..00000000 --- a/composeApp/src/commonMain/kotlin/ui/navigation/Navigation.kt +++ /dev/null @@ -1,44 +0,0 @@ -package ui.navigation - -import SystemNavigation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.plus -import kotlinx.collections.immutable.toPersistentList - -class Navigation(private val systemNavigation: SystemNavigation) { - private var backstack by mutableStateOf(persistentListOf()) - - @Composable - fun NavHost() { - val currentScreen = remember(backstack) { backstack.lastOrNull() } - currentScreen?.Content() - } - - fun backstack(): List = backstack - - fun navigate(screen: Screen) { - backstack = backstack.plus(screen.also(Screen::initialize)) - systemNavigation.navigateTo(screen) - } - - fun backUntil(predicate: (Screen) -> Boolean) { - var lastScreen: Screen? - do { - lastScreen = back() - } while (lastScreen != null && !predicate(lastScreen)) - } - - fun back(): Screen? { - val lastScreen = backstack.lastOrNull() - backstack = backstack.dropLast(1).toPersistentList() - return lastScreen?.also { - it.destroy() -// systemNavigation.navigateBack() - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/screen/NotFoundPage.kt b/composeApp/src/commonMain/kotlin/ui/screen/NotFoundPage.kt new file mode 100644 index 00000000..48f05f66 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ui/screen/NotFoundPage.kt @@ -0,0 +1,34 @@ +package ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import navigation.Route + +@Composable +fun NotFoundPage( + route: Route, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Page not found :/", + style = MaterialTheme.typography.h2, + color = MaterialTheme.colors.error + ) + Spacer(Modifier.height(16.dp)) + Text( + text = "No routing found for: $route", + style = MaterialTheme.typography.body1 + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/screen/course/CourseScreen.kt b/composeApp/src/commonMain/kotlin/ui/screen/course/CourseScreen.kt index 095776b7..45133519 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/course/CourseScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/course/CourseScreen.kt @@ -1,21 +1,49 @@ package ui.screen.course import androidx.compose.runtime.Composable +import arrow.core.Option +import arrow.core.raise.option import ivy.di.Di import ivy.di.Di.register import ivy.di.autowire.autoWire import ivy.model.CourseId -import ui.navigation.Screen +import navigation.Route +import navigation.Router +import navigation.Screen import ui.screen.course.composable.CourseContent import ui.screen.course.mapper.CourseViewStateMapper +object CourseRouter : Router { + const val PATH = "course" + const val COURSE_ID = "course_id" + const val COURSE_NAME = "course_name" + + override fun fromRoute(route: Route): Option = option { + ensure(route.path == PATH) + CourseScreen( + courseId = route[COURSE_ID].bind().let(::CourseId), + courseName = route[COURSE_NAME].bind(), + ) + } + + override fun toRoute(screen: CourseScreen): Route { + return Route( + path = PATH, + params = mapOf( + COURSE_ID to screen.courseId.value, + COURSE_NAME to screen.courseName, + ) + ) + } +} + class CourseScreen( - private val courseId: CourseId, - private val courseName: String, + val courseId: CourseId, + val courseName: String, ) : Screen() { - override val path: String = "course" + override fun toRoute(): Route = CourseRouter.toRoute(this) - override fun onDi(): Di.Scope.() -> Unit = { + override fun Di.Scope.onDi() { autoWire(::CourseViewStateMapper) register { CourseViewModel( diff --git a/composeApp/src/commonMain/kotlin/ui/screen/course/CourseViewModel.kt b/composeApp/src/commonMain/kotlin/ui/screen/course/CourseViewModel.kt index 229c67d6..208109ec 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/course/CourseViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/course/CourseViewModel.kt @@ -7,8 +7,8 @@ import ivy.data.source.model.CourseResponse import ivy.model.CourseId import ivy.model.LessonId import kotlinx.collections.immutable.persistentListOf +import navigation.Navigation import ui.ComposeViewModel -import ui.navigation.Navigation import ui.screen.course.mapper.CourseViewStateMapper import ui.screen.lesson.LessonScreen @@ -45,11 +45,11 @@ class CourseViewModel( } private fun handleBackClick() { - navigation.back() + navigation.navigateBack() } private fun handleLessonClick(lesson: CourseItemViewState.Lesson) { - navigation.navigate( + navigation.navigateTo( LessonScreen( courseId = courseId, lessonId = LessonId(lesson.id), diff --git a/composeApp/src/commonMain/kotlin/ui/screen/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/ui/screen/home/HomeScreen.kt index ef383307..816e9c19 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/home/HomeScreen.kt @@ -1,16 +1,33 @@ package ui.screen.home import androidx.compose.runtime.Composable +import arrow.core.Option +import arrow.core.raise.option import ivy.di.Di import ivy.di.autowire.autoWire -import ui.navigation.Screen +import navigation.Route +import navigation.Router +import navigation.Screen import ui.screen.home.composable.HomeContent import ui.screen.home.mapper.HomeViewStateMapper +object HomeRouter : Router { + const val PATH = "home" + + override fun fromRoute(route: Route): Option = option { + ensure(route.path == PATH) + HomeScreen() + } + + override fun toRoute(screen: HomeScreen): Route { + return Route(path = PATH) + } +} + class HomeScreen : Screen() { - override val path: String = "home" + override fun toRoute(): Route = HomeRouter.toRoute(this) - override fun onDi(): Di.Scope.() -> Unit = { + override fun Di.Scope.onDi() { autoWire(::HomeViewStateMapper) autoWire(::HomeViewModel) } diff --git a/composeApp/src/commonMain/kotlin/ui/screen/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/ui/screen/home/HomeViewModel.kt index 88ec9258..e054e108 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/home/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/home/HomeViewModel.kt @@ -5,8 +5,8 @@ import arrow.core.identity import data.TopicsRepository import ivy.data.source.model.TopicsResponse import kotlinx.collections.immutable.persistentListOf +import navigation.Navigation import ui.ComposeViewModel -import ui.navigation.Navigation import ui.screen.course.CourseScreen import ui.screen.home.mapper.HomeViewStateMapper @@ -40,10 +40,10 @@ class HomeViewModel( } private fun handleBackClick() { - navigation.back() + navigation.navigateBack() } private fun handleCourseClick(event: HomeViewEvent.OnCourseClick) { - navigation.navigate(CourseScreen(courseId = event.id, courseName = event.name)) + navigation.navigateTo(CourseScreen(courseId = event.id, courseName = event.name)) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroScreen.kt b/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroScreen.kt index 4ad144ae..7ff9bd70 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroScreen.kt @@ -1,15 +1,32 @@ package ui.screen.intro import androidx.compose.runtime.Composable +import arrow.core.Option +import arrow.core.raise.option import ivy.di.Di import ivy.di.autowire.autoWire -import ui.navigation.Screen +import navigation.Route +import navigation.Router +import navigation.Screen import ui.screen.intro.composable.IntroContent +object IntroRouter : Router { + const val PATH = "" + + override fun fromRoute(route: Route): Option = option { + ensure(route.path == PATH) + IntroScreen() + } + + override fun toRoute(screen: IntroScreen): Route { + return Route(PATH) + } +} + class IntroScreen : Screen() { - override val path: String = "intro" + override fun toRoute(): Route = IntroRouter.toRoute(this) - override fun onDi(): Di.Scope.() -> Unit = { + override fun Di.Scope.onDi() { autoWire(::IntroViewModel) } diff --git a/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroViewModel.kt b/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroViewModel.kt index 689616a0..b652cf5a 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroViewModel.kt @@ -1,15 +1,27 @@ package ui.screen.intro +import IvyConstants +import Platform import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import domain.GoogleAuthenticationUseCase +import navigation.Navigation import ui.ComposeViewModel -import ui.navigation.Navigation import ui.screen.home.HomeScreen class IntroViewModel( - private val navigation: Navigation + private val navigation: Navigation, + private val platform: Platform, + private val googleAuthenticationUseCase: GoogleAuthenticationUseCase, ) : ComposeViewModel { @Composable override fun viewState(): IntroViewState { + LaunchedEffect(Unit) { + platform.getUrlParam(IvyConstants.SessionTokenParam) + ?.let { sessionToken -> + navigation.navigateTo(HomeScreen()) + } + } return IntroViewState() } @@ -20,6 +32,6 @@ class IntroViewModel( } private fun handleContinueClick() { - navigation.navigate(HomeScreen()) + googleAuthenticationUseCase.loginWithGoogle() } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/screen/intro/composable/IntroContent.kt b/composeApp/src/commonMain/kotlin/ui/screen/intro/composable/IntroContent.kt index 39b7eb76..740b6bfd 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/intro/composable/IntroContent.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/intro/composable/IntroContent.kt @@ -54,7 +54,7 @@ private fun ContinueButton( ) { CtaButton( modifier = modifier, - text = "LET'S GO!", + text = "Continue with Google", onClick = onClick ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/screen/lesson/LessonScreen.kt b/composeApp/src/commonMain/kotlin/ui/screen/lesson/LessonScreen.kt index edb7ae9a..614f50c9 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/lesson/LessonScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/lesson/LessonScreen.kt @@ -1,25 +1,57 @@ package ui.screen.lesson import androidx.compose.runtime.Composable +import arrow.core.Option +import arrow.core.raise.option import ivy.di.Di import ivy.di.Di.register import ivy.di.autowire.autoWire import ivy.model.CourseId import ivy.model.LessonId -import ui.navigation.Screen +import navigation.Route +import navigation.Router +import navigation.Screen import ui.screen.lesson.composable.LessonContent import ui.screen.lesson.handler.* import ui.screen.lesson.mapper.LessonTreeManager import ui.screen.lesson.mapper.LessonViewStateMapper +object LessonRouter : Router { + const val PATH = "lesson" + const val COURSE_ID = "course_id" + const val LESSON_ID = "lesson_id" + const val LESSON_NAME = "lesson_name" + + override fun fromRoute(route: Route): Option = option { + ensure(route.path == PATH) + LessonScreen( + courseId = route[COURSE_ID].bind().let(::CourseId), + lessonId = route[LESSON_ID].bind().let(::LessonId), + lessonName = route[LESSON_NAME].bind() + ) + } + + override fun toRoute(screen: LessonScreen): Route { + return Route( + path = PATH, + params = mapOf( + COURSE_ID to screen.courseId.value, + LESSON_ID to screen.lessonId.value, + LESSON_NAME to screen.lessonName, + ) + ) + } + +} + class LessonScreen( - private val courseId: CourseId, - private val lessonId: LessonId, - private val lessonName: String + val courseId: CourseId, + val lessonId: LessonId, + val lessonName: String ) : Screen() { - override val path: String = "lesson" + override fun toRoute(): Route = LessonRouter.toRoute(this) - override fun onDi(): Di.Scope.() -> Unit = { + override fun Di.Scope.onDi() { autoWire(::LessonTreeManager) autoWire(::LessonViewStateMapper) autoWire(::OnBackClickEventHandler) diff --git a/composeApp/src/commonMain/kotlin/ui/screen/lesson/handler/OnBackClickEventHandler.kt b/composeApp/src/commonMain/kotlin/ui/screen/lesson/handler/OnBackClickEventHandler.kt index 83a7ff8c..97ce99ce 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/lesson/handler/OnBackClickEventHandler.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/lesson/handler/OnBackClickEventHandler.kt @@ -1,7 +1,7 @@ package ui.screen.lesson.handler +import navigation.Navigation import ui.EventHandler -import ui.navigation.Navigation import ui.screen.lesson.LessonViewEvent import ui.screen.lesson.LessonViewModel import ui.screen.lesson.LessonVmContext @@ -14,6 +14,6 @@ class OnBackClickEventHandler( override suspend fun LessonVmContext.handleEvent( event: LessonViewEvent.OnBackClick ) { - navigation.back() + navigation.navigateBack() } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/screen/lesson/handler/OnFinishClickEventHandler.kt b/composeApp/src/commonMain/kotlin/ui/screen/lesson/handler/OnFinishClickEventHandler.kt index 748a47e8..87061d78 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/lesson/handler/OnFinishClickEventHandler.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/lesson/handler/OnFinishClickEventHandler.kt @@ -2,8 +2,8 @@ package ui.screen.lesson.handler import Platform import ivy.content.SoundsUrls +import navigation.Navigation import ui.EventHandler -import ui.navigation.Navigation import ui.screen.lesson.LessonViewEvent import ui.screen.lesson.LessonViewModel.LocalState import ui.screen.lesson.LessonVmContext @@ -18,7 +18,7 @@ class OnFinishClickEventHandler( override suspend fun LessonVmContext.handleEvent( event: LessonViewEvent.OnFinishClick ) { - navigation.back() + navigation.navigateBack() platform.playSound(SoundsUrls.CompleteLesson) } } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/SystemNavigation.desktop.kt b/composeApp/src/desktopMain/kotlin/navigation/SystemNavigation.desktop.kt similarity index 65% rename from composeApp/src/desktopMain/kotlin/SystemNavigation.desktop.kt rename to composeApp/src/desktopMain/kotlin/navigation/SystemNavigation.desktop.kt index 7d7650d4..6900a160 100644 --- a/composeApp/src/desktopMain/kotlin/SystemNavigation.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/navigation/SystemNavigation.desktop.kt @@ -1,8 +1,9 @@ -import ui.navigation.Screen +package navigation class DesktopSystemNavigation : SystemNavigation { override fun navigateTo(screen: Screen) {} override fun navigateBack() {} + override fun setupUrlChangeListener(onUrlChange: (String, Map) -> Unit) {} } actual fun systemNavigation(): SystemNavigation = DesktopSystemNavigation() \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/SystemNavigation.ios.kt b/composeApp/src/iosMain/kotlin/navigation/SystemNavigation.ios.kt similarity index 64% rename from composeApp/src/iosMain/kotlin/SystemNavigation.ios.kt rename to composeApp/src/iosMain/kotlin/navigation/SystemNavigation.ios.kt index cc37df57..f9eb50b1 100644 --- a/composeApp/src/iosMain/kotlin/SystemNavigation.ios.kt +++ b/composeApp/src/iosMain/kotlin/navigation/SystemNavigation.ios.kt @@ -1,9 +1,10 @@ -import ui.navigation.Screen +package navigation class IOSSystemNavigation : SystemNavigation { override fun navigateTo(screen: Screen) {} override fun navigateBack() {} + override fun setupUrlChangeListener(onUrlChange: (String, Map) -> Unit) {} } actual fun systemNavigation(): SystemNavigation = IOSSystemNavigation() \ No newline at end of file diff --git a/composeApp/src/jsMain/kotlin/SystemNavigation.js.kt b/composeApp/src/jsMain/kotlin/SystemNavigation.js.kt deleted file mode 100644 index 3dc4e209..00000000 --- a/composeApp/src/jsMain/kotlin/SystemNavigation.js.kt +++ /dev/null @@ -1,20 +0,0 @@ -import kotlinx.browser.window -import ui.navigation.Screen - -class WebSystemNavigation : SystemNavigation { - override fun navigateTo(screen: Screen) { - // TODO: Temporary workaround until we support deep links - val path = "" // "/${screen.path}" - - val stateObject = js("({})") - // TODO: Temporary workaround until we support deep links - stateObject.screen = "" //screen.path - window.history.pushState(stateObject, "", path) - } - - override fun navigateBack() { - window.history.back() - } -} - -actual fun systemNavigation(): SystemNavigation = WebSystemNavigation() \ No newline at end of file diff --git a/composeApp/src/jsMain/kotlin/main.js.kt b/composeApp/src/jsMain/kotlin/main.js.kt index f8587807..ecff61b8 100644 --- a/composeApp/src/jsMain/kotlin/main.js.kt +++ b/composeApp/src/jsMain/kotlin/main.js.kt @@ -1,9 +1,6 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.CanvasBasedWindow -import ivy.di.Di -import kotlinx.browser.window import org.jetbrains.skiko.wasm.onWasmReady -import ui.navigation.Navigation @OptIn(ExperimentalComposeUiApi::class) fun main() { @@ -12,14 +9,4 @@ fun main() { App() } } - setupBackNavigationHandler() } - -fun setupBackNavigationHandler() { - window.addEventListener("popstate", { - val navigation = Di.get() - if (navigation.backstack().size > 1) { - navigation.back() - } - }) -} \ No newline at end of file diff --git a/composeApp/src/jsMain/kotlin/navigation/SystemNavigation.js.kt b/composeApp/src/jsMain/kotlin/navigation/SystemNavigation.js.kt new file mode 100644 index 00000000..b9f0a8ee --- /dev/null +++ b/composeApp/src/jsMain/kotlin/navigation/SystemNavigation.js.kt @@ -0,0 +1,76 @@ +package navigation + +import kotlinx.browser.window +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class WebSystemNavigation : SystemNavigation { + + // Initialize with the current route + private val _routeChange = MutableStateFlow(getCurrentRouteInfo()) + override val currentRoute: StateFlow = _routeChange + + init { + setupRouteChangeListener() + } + + + private fun setupRouteChangeListener() { + // Listen for back/forward navigation using addEventListener + window.addEventListener("popstate", { + emitCurrentRoute() + }) + // Emit the initial route on startup is already handled by initializing _routeChange + } + + override fun navigateTo(screen: Screen) { + window.history.pushState(js("({})"), "", screen.toFullPath()) + emitCurrentRoute() // Update the route immediately + } + + override fun replaceWith(screen: Screen) { + window.history.replaceState(js("({})"), "", screen.toFullPath()) + } + + private fun Screen.toFullPath(): String { + val route = toRoute() + val params = buildString { + for ((key, value) in route.params) { + append(if (isEmpty()) '?' else '&') + append("$key=$value") + } + } + return "${route.path}$params" + } + + override fun navigateBack() { + window.history.back() // Let the browser handle back navigation + // No need to call emitCurrentRoute() here; the listener will handle it + } + + private fun emitCurrentRoute() { + val routeInfo = getCurrentRouteInfo() + _routeChange.value = routeInfo // Update the StateFlow with the new route + } + + private fun getCurrentRouteInfo(): Route { + val route = window.location.pathname.trimStart('/') + val params = parseParams(window.location.search) + return Route(route, params) + } + + private fun parseParams(query: String): Map { + if (query.isEmpty() || query == "?") return emptyMap() + + return query.trimStart('?') + .split("&") + .mapNotNull { + val parts = it.split("=") + if (parts.size == 2) parts[0] to parts[1] else null + } + .toMap() + } +} + + +actual fun systemNavigation(): SystemNavigation = WebSystemNavigation() \ No newline at end of file diff --git a/composeApp/webpack.config.d/historyApiFallback.js b/composeApp/webpack.config.d/historyApiFallback.js new file mode 100644 index 00000000..625e82e4 --- /dev/null +++ b/composeApp/webpack.config.d/historyApiFallback.js @@ -0,0 +1,3 @@ +if (config.devServer) { + config.devServer.historyApiFallback = true; +} \ No newline at end of file diff --git a/scripts/runServer.sh b/scripts/runServer.sh index c4a2464b..88dd3cd0 100755 --- a/scripts/runServer.sh +++ b/scripts/runServer.sh @@ -19,7 +19,7 @@ fi PORT=8081 # Use lsof to check if the port is in use -PID=$(sudo lsof -ti:$PORT) +PID=$(lsof -ti:$PORT) # If a PID exists, kill the process if [ ! -z "$PID" ]; then diff --git a/server/src/main/kotlin/ivy/learn/LearnServer.kt b/server/src/main/kotlin/ivy/learn/LearnServer.kt index 118c4a8a..bb1e8b47 100644 --- a/server/src/main/kotlin/ivy/learn/LearnServer.kt +++ b/server/src/main/kotlin/ivy/learn/LearnServer.kt @@ -10,7 +10,9 @@ import io.ktor.server.plugins.cors.routing.* import io.ktor.server.routing.* import ivy.di.Di import ivy.di.Di.register +import ivy.di.autowire.autoWire import ivy.learn.api.* +import ivy.learn.api.common.Api import ivy.learn.data.database.Database import kotlinx.serialization.json.Json @@ -18,22 +20,24 @@ class LearnServer( private val database: Database, private val configurationProvider: ServerConfigurationProvider, ) { - private val apis by lazy { + private val apis: Set by lazy { setOf( Di.get(), Di.get(), Di.get(), Di.get(), Di.get(), + Di.get(), ) } private fun injectDependencies() = Di.appScope { - register { AnalyticsApi() } - register { LessonsApi(Di.get()) } - register { StatusApi() } - register { TopicsApi(Di.get(), Di.get()) } - register { CoursesApi(Di.get(), Di.get()) } + autoWire(::AnalyticsApi) + autoWire(::LessonsApi) + autoWire(::StatusApi) + autoWire(::TopicsApi) + autoWire(::CoursesApi) + autoWire(::GoogleAuthenticationApi) } fun init(ktorApp: Application): Either = either { diff --git a/server/src/main/kotlin/ivy/learn/api/GoogleAuthenticationApi.kt b/server/src/main/kotlin/ivy/learn/api/GoogleAuthenticationApi.kt new file mode 100644 index 00000000..cfb07a06 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/api/GoogleAuthenticationApi.kt @@ -0,0 +1,37 @@ +package ivy.learn.api + +import IvyConstants +import arrow.core.raise.ensureNotNull +import io.ktor.server.response.* +import io.ktor.server.routing.* +import ivy.IvyUrls +import ivy.learn.api.common.Api +import ivy.learn.api.common.getEndpointBase +import ivy.learn.api.common.model.ServerError.BadRequest +import java.util.* + +class GoogleAuthenticationApi : Api { + override fun Routing.endpoints() { + googleAuthCallback() + } + + private fun Routing.googleAuthCallback() { + get(IvyConstants.GoogleAuthCallbackEndpoint) { + call.parameters[""] + } + getEndpointBase(IvyConstants.GoogleAuthCallbackEndpoint) { call -> + val googleAuthCode = call.parameters["code"]?.let(::GoogleAuthorizationCode) + ensureNotNull(googleAuthCode) { + BadRequest("Google authorization code is required as 'code' parameter.") + } + // TODO: 1. Validate authorization code + // TODO: 2. Created session token + val sessionToken = UUID.randomUUID().toString() + val frontEndUrl = IvyUrls.debugFrontEnd + call.respondRedirect("${frontEndUrl}?${IvyConstants.SessionTokenParam}=$sessionToken") + } + } + + @JvmInline + value class GoogleAuthorizationCode(val value: String) +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/api/common/ApiUtils.kt b/server/src/main/kotlin/ivy/learn/api/common/ApiUtils.kt index a765506f..c3ecaa34 100644 --- a/server/src/main/kotlin/ivy/learn/api/common/ApiUtils.kt +++ b/server/src/main/kotlin/ivy/learn/api/common/ApiUtils.kt @@ -30,5 +30,25 @@ inline fun Routing.getEndpoint( } } +@IvyServerDsl +inline fun Routing.getEndpointBase( + path: String, + crossinline handler: suspend Raise.(RoutingCall) -> Unit +) { + get(path) { + either { + handler(call) + }.onLeft { error -> + call.respond( + status = when (error) { + is ServerError.BadRequest -> HttpStatusCode.BadRequest + is ServerError.Unknown -> HttpStatusCode.InternalServerError + }, + message = ServerErrorResponse(error.msg) + ) + } + } +} + @DslMarker annotation class IvyServerDsl \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/IvyConstants.kt b/shared/src/commonMain/kotlin/IvyConstants.kt new file mode 100644 index 00000000..6f5d229b --- /dev/null +++ b/shared/src/commonMain/kotlin/IvyConstants.kt @@ -0,0 +1,5 @@ +object IvyConstants { + val GoogleClientId = "717609528266-tt1p4jhsg8glp4qlj9td5db1racvqlal.apps.googleusercontent.com" + val GoogleAuthCallbackEndpoint = "/auth/google/callback" + val SessionTokenParam = "sessionToken" +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/Platform.kt b/shared/src/commonMain/kotlin/Platform.kt index 22829c54..f7ea1a81 100644 --- a/shared/src/commonMain/kotlin/Platform.kt +++ b/shared/src/commonMain/kotlin/Platform.kt @@ -5,6 +5,7 @@ interface Platform { fun log(level: LogLevel, msg: String) fun httpClient(config: HttpClientConfig<*>.() -> Unit = {}): HttpClient fun playSound(soundUrl: String) + fun getUrlParam(key: String): String? } enum class LogLevel { diff --git a/shared/src/commonMain/kotlin/ivy/IvyUrls.kt b/shared/src/commonMain/kotlin/ivy/IvyUrls.kt index 552780a4..2486fd2c 100644 --- a/shared/src/commonMain/kotlin/ivy/IvyUrls.kt +++ b/shared/src/commonMain/kotlin/ivy/IvyUrls.kt @@ -3,4 +3,6 @@ package ivy object IvyUrls { const val tos = "https://github.com/Ivy-Apps/legal/blob/main/ivy-learn-tos.md" const val privacy = "https://github.com/Ivy-Apps/legal/blob/main/ivy-learn-privacy.md" + const val frontEnd = "https://ivylearn.app" + const val debugFrontEnd = "http://localhost:8080/" } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/ivy/data/ServerUrlProvider.kt b/shared/src/commonMain/kotlin/ivy/data/ServerUrlProvider.kt index 38045c78..35eaf108 100644 --- a/shared/src/commonMain/kotlin/ivy/data/ServerUrlProvider.kt +++ b/shared/src/commonMain/kotlin/ivy/data/ServerUrlProvider.kt @@ -5,7 +5,7 @@ interface ServerUrlProvider { } class HerokuServerUrlProvider : ServerUrlProvider { - override val serverUrl: String = "https://ivy-learn-0c3c19230895.herokuapp.com" + override val serverUrl: String = "https://api.ivylearn.app" } class LocalServerUrlProvider : ServerUrlProvider { diff --git a/shared/src/commonMain/kotlin/ivy/di/SharedModule.kt b/shared/src/commonMain/kotlin/ivy/di/SharedModule.kt index ada2dc22..7421e7a0 100644 --- a/shared/src/commonMain/kotlin/ivy/di/SharedModule.kt +++ b/shared/src/commonMain/kotlin/ivy/di/SharedModule.kt @@ -7,13 +7,14 @@ import io.ktor.client.plugins.logging.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import ivy.data.HerokuServerUrlProvider +import ivy.data.LocalServerUrlProvider import ivy.data.LottieAnimationLoader -import ivy.data.ServerUrlProvider import ivy.data.source.CoursesDataSource import ivy.data.source.LessonDataSource import ivy.data.source.TopicsDataSource import ivy.di.Di.register import ivy.di.Di.singleton +import ivy.di.autowire.autoWire import ivy.model.* import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -29,7 +30,8 @@ object SharedModule : Di.Module { singleton { platform() } json() ktorClient() - register { HerokuServerUrlProvider() } + autoWire(::HerokuServerUrlProvider) + autoWire(::LocalServerUrlProvider) register { LessonDataSource(Di.get(), Di.get()) } register { TopicsDataSource(Di.get(), Di.get()) } register { CoursesDataSource(Di.get(), Di.get()) } diff --git a/shared/src/iosMain/kotlin/Platform.ios.kt b/shared/src/iosMain/kotlin/Platform.ios.kt index 432fc062..fb00f1d4 100644 --- a/shared/src/iosMain/kotlin/Platform.ios.kt +++ b/shared/src/iosMain/kotlin/Platform.ios.kt @@ -18,6 +18,10 @@ class IOSPlatform : Platform { override fun playSound(soundUrl: String) { // TODO: Implement } + + override fun getUrlParam(key: String): String? { + return null + } } diff --git a/shared/src/jsMain/kotlin/Platform.js.kt b/shared/src/jsMain/kotlin/Platform.js.kt index da69aa20..f923a4c0 100644 --- a/shared/src/jsMain/kotlin/Platform.js.kt +++ b/shared/src/jsMain/kotlin/Platform.js.kt @@ -1,6 +1,8 @@ import io.ktor.client.* import io.ktor.client.engine.js.* +import kotlinx.browser.window import org.w3c.dom.Audio +import org.w3c.dom.url.URLSearchParams class JsPlatform : Platform { override val name: String = "Web with Kotlin/JS" @@ -19,6 +21,11 @@ class JsPlatform : Platform { val audio = Audio(soundUrl) audio.play() } + + override fun getUrlParam(key: String): String? { + val urlParams = URLSearchParams(window.location.search) + return urlParams.get(key) + } } actual fun platform(): Platform = JsPlatform() \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/Platform.jvm.kt b/shared/src/jvmMain/kotlin/Platform.jvm.kt index 96a35c7e..36bf7a0d 100644 --- a/shared/src/jvmMain/kotlin/Platform.jvm.kt +++ b/shared/src/jvmMain/kotlin/Platform.jvm.kt @@ -17,6 +17,10 @@ class JVMPlatform: Platform { override fun playSound(soundUrl: String) { // TODO: Implement } + + override fun getUrlParam(key: String): String? { + return null + } } actual fun platform(): Platform = JVMPlatform() \ No newline at end of file