diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7beb525..cbb08ef 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,14 @@ + + + + + + + + diff --git a/app/src/main/java/com/angrypodo/wisp/navigation/Routes.kt b/app/src/main/java/com/angrypodo/wisp/navigation/Routes.kt index fd49259..85e2284 100644 --- a/app/src/main/java/com/angrypodo/wisp/navigation/Routes.kt +++ b/app/src/main/java/com/angrypodo/wisp/navigation/Routes.kt @@ -8,13 +8,20 @@ import kotlinx.serialization.Serializable data object Home @Serializable -@Wisp("product/{productId}") -data class ProductDetail(val productId: String) +@Wisp("product") +data class ProductDetail( + val productId: Int, + val showReviews: Boolean = false +) @Serializable @Wisp("settings") data object Settings +@Serializable +@Wisp("user") +data class UserRoute(val userId: Int) + @Serializable @Wisp("splash") data object Splash diff --git a/app/src/main/java/com/angrypodo/wisp/ui/main/MainActivity.kt b/app/src/main/java/com/angrypodo/wisp/ui/main/MainActivity.kt index 1d0e134..0d72656 100644 --- a/app/src/main/java/com/angrypodo/wisp/ui/main/MainActivity.kt +++ b/app/src/main/java/com/angrypodo/wisp/ui/main/MainActivity.kt @@ -18,11 +18,13 @@ import com.angrypodo.wisp.navigation.Home import com.angrypodo.wisp.navigation.ProductDetail import com.angrypodo.wisp.navigation.Settings import com.angrypodo.wisp.navigation.Splash +import com.angrypodo.wisp.navigation.UserRoute import com.angrypodo.wisp.runtime.navigateTo import com.angrypodo.wisp.ui.screens.HomeScreen import com.angrypodo.wisp.ui.screens.ProductDetailScreen import com.angrypodo.wisp.ui.screens.SettingsScreen import com.angrypodo.wisp.ui.screens.SplashScreen +import com.angrypodo.wisp.ui.screens.UserScreen import com.angrypodo.wisp.ui.theme.WispTheme class MainActivity : ComponentActivity() { @@ -57,18 +59,29 @@ private fun WispNavHost(deepLinkUri: Uri?) { composable { HomeScreen( onNavigateToProduct = { - val uri = "app://wisp?stack=product/123|settings".toUri() + val uri = "app://wisp/product/settings?productId=123&showReviews=true".toUri() navController.navigateTo(uri) }, onNavigateToSettings = { - val uri = "app://wisp?stack=settings".toUri() + val uri = "app://wisp/settings".toUri() + navController.navigateTo(uri) + }, + onNavigateToMultiStack = { + val uri = "app://wisp/product/user?productId=123&userId=99".toUri() navController.navigateTo(uri) } ) } composable { backStackEntry -> val productDetail: ProductDetail = backStackEntry.toRoute() - ProductDetailScreen(productId = productDetail.productId) + ProductDetailScreen( + productId = productDetail.productId, + showReviews = productDetail.showReviews + ) + } + composable { backStackEntry -> + val userRoute: UserRoute = backStackEntry.toRoute() + UserScreen(userId = userRoute.userId) } composable { SettingsScreen() diff --git a/app/src/main/java/com/angrypodo/wisp/ui/screens/SampleScreens.kt b/app/src/main/java/com/angrypodo/wisp/ui/screens/SampleScreens.kt index 8edfc91..09611a2 100644 --- a/app/src/main/java/com/angrypodo/wisp/ui/screens/SampleScreens.kt +++ b/app/src/main/java/com/angrypodo/wisp/ui/screens/SampleScreens.kt @@ -20,7 +20,11 @@ import com.angrypodo.wisp.runtime.Wisp import kotlinx.coroutines.delay @Composable -fun HomeScreen(onNavigateToProduct: () -> Unit, onNavigateToSettings: () -> Unit) { +fun HomeScreen( + onNavigateToProduct: () -> Unit, + onNavigateToSettings: () -> Unit, + onNavigateToMultiStack: () -> Unit +) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, @@ -29,17 +33,21 @@ fun HomeScreen(onNavigateToProduct: () -> Unit, onNavigateToSettings: () -> Unit Text(text = "Home Screen", fontSize = 24.sp) Spacer(modifier = Modifier.height(24.dp)) Button(onClick = onNavigateToProduct) { - Text(text = "Go to Product 123 -> Settings (Deep Link)") + Text(text = "Go to Product 123 -> Settings") } Spacer(modifier = Modifier.height(16.dp)) Button(onClick = onNavigateToSettings) { - Text(text = "Go to Settings (Single Deep Link)") + Text(text = "Go to Settings") + } + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onNavigateToMultiStack) { + Text(text = "Product(123) -> User(99)") } } } @Composable -fun ProductDetailScreen(productId: String) { +fun ProductDetailScreen(productId: Int, showReviews: Boolean) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, @@ -48,6 +56,7 @@ fun ProductDetailScreen(productId: String) { Text(text = "Product Detail Screen", fontSize = 24.sp) Spacer(modifier = Modifier.height(16.dp)) Text(text = "Product ID: $productId", fontSize = 20.sp) + Text(text = "Show Reviews: $showReviews", fontSize = 16.sp) } } @@ -62,6 +71,19 @@ fun SettingsScreen() { } } +@Composable +fun UserScreen(userId: Int) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "User Screen", fontSize = 24.sp) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "User ID: $userId", fontSize = 20.sp) + } +} + @Composable fun SplashScreen( navController: NavController, @@ -83,10 +105,8 @@ fun SplashScreen( } } else { // Deep link found. - // The first route is "splash", which we are already on. - // We navigate to the rest of the stack. - val routesToNavigate = routes.drop(1) - wisp.navigateTo(navController, routesToNavigate) + // Navigate to the parsed stack. + wisp.navigateTo(navController, routes) } } diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt index e5e8bc9..4e9a6a7 100644 --- a/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt @@ -25,7 +25,7 @@ internal class WispRegistryGenerator { val factoriesProperty = buildFactoriesProperty(routes) - val registryObject = TypeSpec.objectBuilder(registryClassName) + val registryObject = TypeSpec.classBuilder(registryClassName) .addSuperinterface(WispClassName.WISP_MODULE_REGISTRY) // Changed interface .addModifiers(KModifier.PUBLIC) .addProperty(factoriesProperty) diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt index ca147c8..3369b68 100644 --- a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt @@ -24,16 +24,30 @@ class Wisp( */ fun resolveRoutes(uri: Uri): List { val paths = parser.parse(uri) + val queryParams = getQueryParams(uri) + return paths.map { path -> - matchAndCreate(path) ?: throw WispError.UnknownPath(path) + matchAndCreate(path, queryParams) ?: throw WispError.UnknownPath(path) + } + } + + private fun getQueryParams(uri: Uri): Map { + val params = mutableMapOf() + uri.queryParameterNames.forEach { key -> + uri.getQueryParameter(key)?.let { value -> + params[key] = value + } } + return params } - private fun matchAndCreate(path: String): Any? { + private fun matchAndCreate(path: String, queryParams: Map): Any? { for ((pattern, factory) in mergedRoutes) { - val params = WispUriMatcher.match(path, pattern) - if (params != null) { - return factory.create(params) + val pathVariables = WispUriMatcher.match(path, pattern) + if (pathVariables != null) { + // Path Variable과 Query Parameter 병합 (Query Param 우선순위 낮음) + val combinedParams = queryParams + pathVariables + return factory.create(combinedParams) } } return null @@ -71,7 +85,9 @@ class Wisp( @JvmStatic @Synchronized - fun initialize() { + fun initialize( + parser: WispUriParser = DefaultWispUriParser() + ) { if (instance == null) { val aggregatedRoutes = mutableMapOf() val loader = ServiceLoader.load(WispModuleRegistry::class.java) @@ -80,7 +96,7 @@ class Wisp( aggregatedRoutes.putAll(registry.getRoutes()) } - instance = Wisp(aggregatedRoutes) + instance = Wisp(aggregatedRoutes, parser) } } diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/parser/DefaultWispUriParser.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/parser/DefaultWispUriParser.kt index 0bf677b..0f25dae 100644 --- a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/parser/DefaultWispUriParser.kt +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/parser/DefaultWispUriParser.kt @@ -1,26 +1,28 @@ package com.angrypodo.wisp.runtime.parser import android.net.Uri -import com.angrypodo.wisp.runtime.WispError -import java.net.URLDecoder -import java.nio.charset.StandardCharsets -private const val STACK = "stack" +/** + * 기본 URI 파서 구현체입니다. + * URI의 경로(Path)를 지정된 구분자([delimiter])로 분리하여 라우트 경로 리스트를 생성합니다. + * + * 예: "myapp://home/product" (delimiter="/") -> ["home", "product"] + */ +class DefaultWispUriParser( + private val delimiter: String = "/" +) : WispUriParser { -class DefaultWispUriParser : WispUriParser { override fun parse(uri: Uri): List { - val encodedStack = uri.getQueryParameter(STACK) + val path = uri.path ?: return emptyList() - if (encodedStack.isNullOrBlank()) { - throw WispError.ParsingFailed(uri.toString(), "Missing 'stack' query parameter") + // 경로가 구분자로 시작하면 제거 (예: "/home" -> "home") + val trimmedPath = if (path.startsWith(delimiter)) { + path.substring(delimiter.length) + } else { + path } - return try { - val decodedStack = URLDecoder.decode(encodedStack, StandardCharsets.UTF_8.name()) - - decodedStack.split("|").filter { it.isNotBlank() } - } catch (e: Exception) { - throw WispError.ParsingFailed(uri.toString(), e.message ?: "Unknown decoding error") - } + return trimmedPath.split(delimiter) + .filter { it.isNotEmpty() } } }