diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bridges/common/KMPApiBridge.kt b/android/app/src/main/kotlin/io/rebble/cobble/bridges/common/KMPApiBridge.kt index ff78aa50..2c74257a 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/bridges/common/KMPApiBridge.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/bridges/common/KMPApiBridge.kt @@ -37,4 +37,12 @@ class KMPApiBridge @Inject constructor( activity.startActivity(intent) } } + override fun openStoreView() { + activity?.let { + Timber.d("Opening store view") + val intent = Intent(activity.context, MainActivity::class.java) + intent.putExtra("navigationPath", Routes.Home.STORE_WATCHFACES) + activity.startActivity(intent) + } + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/pigeons/Pigeons.java b/android/app/src/main/kotlin/io/rebble/cobble/pigeons/Pigeons.java index 5f43eab1..47ffac5e 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/pigeons/Pigeons.java +++ b/android/app/src/main/kotlin/io/rebble/cobble/pigeons/Pigeons.java @@ -4,22 +4,21 @@ package io.rebble.cobble.pigeons; import android.util.Log; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - +import io.flutter.plugin.common.BasicMessageChannel; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MessageCodec; +import io.flutter.plugin.common.StandardMessageCodec; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; -import io.flutter.plugin.common.BasicMessageChannel; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.MessageCodec; -import io.flutter.plugin.common.StandardMessageCodec; - /** Generated class from Pigeon. */ @SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression", "serial"}) public class Pigeons { @@ -5458,6 +5457,8 @@ public interface KMPApi { void openLockerView(); + void openStoreView(); + /** The codec used by KMPApi. */ static @NonNull MessageCodec getCodec() { return KMPApiCodec.INSTANCE; @@ -5500,6 +5501,28 @@ static void setup(@NonNull BinaryMessenger binaryMessenger, @Nullable KMPApi api api.openLockerView(); wrapped.add(0, null); } + catch (Throwable exception) { + ArrayList wrappedError = wrapError(exception); + wrapped = wrappedError; + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.KMPApi.openStoreView", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList(); + try { + api.openStoreView(); + wrapped.add(0, null); + } catch (Throwable exception) { ArrayList wrappedError = wrapError(exception); wrapped = wrappedError; diff --git a/android/shared/build.gradle.kts b/android/shared/build.gradle.kts index 9bd868e8..e06ca68e 100644 --- a/android/shared/build.gradle.kts +++ b/android/shared/build.gradle.kts @@ -71,6 +71,7 @@ kotlin { implementation(libs.compose.viewmodel) implementation(libs.compose.components.resources) implementation(libs.compose.components.reorderable) + api("io.github.kevinnzou:compose-webview-multiplatform:1.9.40") } androidMain.dependencies { implementation(libs.ktor.client.okhttp) diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/AuthClient.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/AuthClient.kt index 20d7bbf0..15bf3790 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/AuthClient.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/AuthClient.kt @@ -30,4 +30,4 @@ class AuthClient( return res.body() ?: error("Failed to deserialize account") } -} \ No newline at end of file +} diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/RWS.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/RWS.kt index a79ed420..9dabc975 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/RWS.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/RWS.kt @@ -17,6 +17,8 @@ object RWS: KoinComponent { private val token: StateFlow by inject(named("currentToken")) private val scope = CoroutineScope(Dispatchers.Default) + val currentTokenFlow: StateFlow get() = token + val appstoreClientFlow = token.map { it.tokenOrNull?.let { t -> AppstoreClient("https://appstore-api.$domainSuffix/api", t) } }.stateIn(scope, SharingStarted.Eagerly, null) @@ -32,4 +34,4 @@ object RWS: KoinComponent { get() = authClientFlow.value val timelineClient: TimelineClient? get() = timelineClientFlow.value -} \ No newline at end of file +} diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/store/JsFrame.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/store/JsFrame.kt new file mode 100644 index 00000000..cc82ea67 --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/store/JsFrame.kt @@ -0,0 +1,58 @@ +package io.rebble.cobble.shared.domain.store + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class JsFrame( + val methodName: String, + val callbackId: Int, + val data: T, +) + +@Serializable +data class LoadAppToDeviceAndLocker( + val id: String, + val uuid: String, + val title: String, + @SerialName("list_image") + val listImage: String, + @SerialName("icon_image") + val iconImage: String, + @SerialName("screenshot_image") + val screenshotImage: String, + val type: String, + @SerialName("pbw_file") + val pbwFile: String, + val links: AppLinks, +) + +@Serializable +data class AppLinks( + val add: String, + val remove: String, + val share: String, + @SerialName("add_flag") + val addFlag: String? = null, + @SerialName("add_heart") + val addHeart: String? = null, + @SerialName("remove_flag") + val removeFlag: String? = null, + @SerialName("remove_heart") + val removeHeart: String? = null, +) + +@Serializable +data class SetNavBarTitle( + val title: String, + val browserTitle: String? = null, + @SerialName("show_search") + val showSearch: Boolean = true, + @SerialName("show_share") + val showShare: Boolean? = null, +) + +@Serializable +data class OpenURL( + val url: String, +) diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/store/PebbleBridge.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/store/PebbleBridge.kt new file mode 100644 index 00000000..49dd0992 --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/store/PebbleBridge.kt @@ -0,0 +1,88 @@ +package io.rebble.cobble.shared.domain.store + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class PebbleBridge { + companion object { + internal val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + internal val className = "PebbleBridge" + + fun searchRequest( + query: String, + section: String, + ): String { + val request = PebbleBridgeSearchRequest(query = query, section = section) + return createRequest(request) + } + + fun navigateRequest(url: String): String { + val request = PebbleBridgeNavigateRequest(url = url) + return createRequest(request) + } + + fun refreshRequest(): String { + val request = PebbleBridgeRefreshRequest() + return createRequest(request) + } + + fun lockerResponse( + callbackId: Int, + addedToLocker: Boolean, + ): String { + val response = PebbleBridgeLockerResponse(addedToLocker) + return createResponse(callbackId, response) + } + + private inline fun createRequest(request: T): String { + val requestJson = json.encodeToString(request) + return "$className.handleRequest($requestJson)" + } + + private inline fun createResponse( + callbackId: Int, + response: T, + ): String { + val responseData = PebbleBridgeResponse(callbackId, response) + val responseJson = json.encodeToString(responseData) + return "$className.handleResponse($responseJson)" + } + } +} + +@Serializable +data class PebbleBridgeResponse( + val callbackId: Int, + val data: T, +) + +@Serializable +data class PebbleBridgeLockerResponse( + @SerialName("added_to_locker") + val addedToLocker: Boolean, +) + +@Serializable +data class PebbleBridgeSearchRequest( + val methodName: String = "search", + val query: String, + val section: String, +) + +@Serializable +data class PebbleBridgeNavigateRequest( + val methodName: String = "navigate", + val url: String, +) + +@Serializable +data class PebbleBridgeRefreshRequest( + val methodName: String = "refresh", +) diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/nav/Routes.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/nav/Routes.kt index bf37c617..5d057088 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/nav/Routes.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/nav/Routes.kt @@ -5,6 +5,8 @@ object Routes { object Home { const val LOCKER_APPS = "locker_apps" const val LOCKER_WATCHFACES = "locker_watchfaces" + const val STORE_APPS = "store_apps" + const val STORE_WATCHFACES = "store_watchfaces" const val TEST_PAGE = "test_page" } -} \ No newline at end of file +} diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/MainView.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/MainView.kt index 668eb1ee..8e663e21 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/MainView.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/MainView.kt @@ -20,6 +20,7 @@ import io.rebble.cobble.shared.ui.view.dialogs.AppInstallDialog import io.rebble.cobble.shared.ui.view.home.HomePage import io.rebble.cobble.shared.ui.view.home.HomeScaffold import io.rebble.cobble.shared.ui.view.home.locker.LockerTabs +import io.rebble.cobble.shared.ui.view.home.store.StoreTabs import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json.Default.decodeFromString import org.koin.compose.KoinContext @@ -43,6 +44,12 @@ fun MainView(navController: NavHostController = rememberNavController()) { composable(Routes.Home.LOCKER_APPS) { HomeScaffold(HomePage.Locker(LockerTabs.Apps), onNavChange = navController::navigate) } + composable(Routes.Home.STORE_WATCHFACES) { + HomeScaffold(HomePage.Store(StoreTabs.Watchfaces), onNavChange = navController::navigate) + } + composable(Routes.Home.STORE_APPS) { + HomeScaffold(HomePage.Store(StoreTabs.Apps), onNavChange = navController::navigate) + } composable(Routes.Home.TEST_PAGE) { HomeScaffold(HomePage.TestPage, onNavChange = navController::navigate) } @@ -60,4 +67,4 @@ fun MainView(navController: NavHostController = rememberNavController()) { } } } -} \ No newline at end of file +} diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/HomeScaffold.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/HomeScaffold.kt index 2cf47dca..94f96b10 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/HomeScaffold.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/HomeScaffold.kt @@ -15,10 +15,13 @@ import io.rebble.cobble.shared.ui.common.RebbleIcons import io.rebble.cobble.shared.ui.nav.Routes import io.rebble.cobble.shared.ui.view.home.locker.Locker import io.rebble.cobble.shared.ui.view.home.locker.LockerTabs +import io.rebble.cobble.shared.ui.view.home.store.Store +import io.rebble.cobble.shared.ui.view.home.store.StoreTabs import kotlinx.coroutines.launch open class HomePage { class Locker(val tab: LockerTabs) : HomePage() + class Store(val tab: StoreTabs) : HomePage() object TestPage : HomePage() } @@ -26,7 +29,6 @@ open class HomePage { fun HomeScaffold(page: HomePage, onNavChange: (String) -> Unit) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - val searchingState = remember { mutableStateOf(false) } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, /*topBar = { @@ -51,6 +53,12 @@ fun HomeScaffold(page: HomePage, onNavChange: (String) -> Unit) { icon = { RebbleIcons.locker() }, label = { Text("Locker") } ) + NavigationBarItem( + selected = page is HomePage.Store, + onClick = { onNavChange(Routes.Home.STORE_WATCHFACES) }, + icon = { RebbleIcons.rebbleStore() }, + label = { Text("Store") } + ) } }, floatingActionButton = { @@ -60,10 +68,14 @@ fun HomeScaffold(page: HomePage, onNavChange: (String) -> Unit) { modifier = Modifier .padding(16.dp), onClick = { - searchingState.value = true + if (page.tab == LockerTabs.Watchfaces) { + onNavChange(Routes.Home.STORE_WATCHFACES) + } else { + onNavChange(Routes.Home.STORE_APPS) + } }, content = { - RebbleIcons.search() + RebbleIcons.plusAdd() }, ) } @@ -73,7 +85,12 @@ fun HomeScaffold(page: HomePage, onNavChange: (String) -> Unit) { Box(modifier = Modifier.padding(innerPadding)) { when (page) { is HomePage.Locker -> { - Locker(searchingState, page.tab, onTabChanged = { + Locker(page.tab, onTabChanged = { + onNavChange(it.navRoute) + }) + } + is HomePage.Store -> { + Store(page.tab, onTabChanged = { onNavChange(it.navRoute) }) } @@ -87,4 +104,4 @@ fun HomeScaffold(page: HomePage, onNavChange: (String) -> Unit) { } } } -} \ No newline at end of file +} diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/Locker.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/Locker.kt index 837992bb..2a128195 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/Locker.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/Locker.kt @@ -24,12 +24,13 @@ enum class LockerTabs(val label: String, val navRoute: String) { } @Composable -fun Locker(searchingState: MutableState, page: LockerTabs, lockerDao: LockerDao = getKoin().get(), viewModel: LockerViewModel = viewModel { LockerViewModel(lockerDao) }, onTabChanged: (LockerTabs) -> Unit) { +fun Locker(page: LockerTabs, lockerDao: LockerDao = getKoin().get(), viewModel: LockerViewModel = viewModel { LockerViewModel(lockerDao) }, onTabChanged: (LockerTabs) -> Unit) { val entriesState: LockerViewModel.LockerEntriesState by viewModel.entriesState.collectAsState() val modalSheetState by viewModel.modalSheetState.collectAsState() val watchIsConnected by viewModel.watchIsConnected.collectAsState() val searchQuery: String? by viewModel.searchQuery.collectAsState() val focusRequester = remember { FocusRequester() } + val searchingState = remember { mutableStateOf(false) } val (searching, setSearching) = searchingState Column { @@ -95,4 +96,4 @@ fun Locker(searchingState: MutableState, page: LockerTabs, lockerDao: L val sheetViewModel = (modalSheetState as LockerViewModel.ModalSheetState.Open).viewModel LockerItemSheet(onDismissRequest = { viewModel.closeModalSheet() }, watchIsConnected = watchIsConnected, viewModel = sheetViewModel) } -} \ No newline at end of file +} diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/store/Store.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/store/Store.kt new file mode 100644 index 00000000..7fdaed0c --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/store/Store.kt @@ -0,0 +1,202 @@ +package io.rebble.cobble.shared.ui.view.home.store + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import io.rebble.cobble.shared.api.RWS +import io.rebble.cobble.shared.domain.state.ConnectionState +import io.rebble.cobble.shared.domain.state.CurrentToken +import io.rebble.cobble.shared.domain.store.* +import io.rebble.cobble.shared.ui.common.RebbleIcons +import io.rebble.cobble.shared.ui.nav.Routes +import io.rebble.cobble.shared.ui.viewmodel.StoreViewModel +import com.multiplatform.webview.cookie.Cookie +import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge +import com.multiplatform.webview.web.WebView +import com.multiplatform.webview.web.rememberWebViewNavigator +import com.multiplatform.webview.web.rememberWebViewState +import kotlinx.coroutines.flow.collectLatest + +enum class StoreTabs(val label: String, val navRoute: String) { + Watchfaces("Watchfaces", Routes.Home.STORE_WATCHFACES), + Apps("Apps", Routes.Home.STORE_APPS), +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Store(page: StoreTabs, viewModel: StoreViewModel = viewModel { StoreViewModel() }, onTabChanged: (StoreTabs) -> Unit) { + LaunchedEffect(page) { + viewModel.setCurrentTab(page) + } + + val watchIsConnected by viewModel.watchIsConnected.collectAsState() + val watchConnectionState by viewModel.watchConnectionState.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + val searching by viewModel.searchingState.collectAsState(initial = false) + val searchButton by viewModel.searchButton.collectAsState(initial = true) + val expanded by viewModel.expandedDropdown.collectAsState(initial = false) + val initialUrl by viewModel.initialUrl.collectAsState(initial = null) + val pageTitle by viewModel.pageTitle.collectAsState(initial = "Rebble Store") + + val tokenState by RWS.currentTokenFlow.collectAsState(initial = CurrentToken.LoggedOut) + + val focusRequester = remember { FocusRequester() } + + if (initialUrl == null || watchConnectionState !is ConnectionState.Connected) { + return + } + + val state = rememberWebViewState(url = initialUrl!!) + val jsBridge = rememberWebViewJsBridge() + val navigator = rememberWebViewNavigator( + requestInterceptor = viewModel.createRequestInterceptor() + ) + + val uriHandler = LocalUriHandler.current + + LaunchedEffect(tokenState) { + when (tokenState) { + is CurrentToken.LoggedIn -> { + val token = (tokenState as CurrentToken.LoggedIn).token + state.cookieManager.removeAllCookies() + state.cookieManager.setCookie( + "https://apps.rebble.io", + Cookie( + name = "access_token", + value = token, + domain = "apps.rebble.io" + ) + ) + } + else -> { + state.cookieManager.removeAllCookies() + } + } + } + + LaunchedEffect(Unit) { + viewModel.resultFlow.collectLatest { result -> + val addedToLocker = result is StoreViewModel.LockerResult.Success + val jsResponse = PebbleBridge.lockerResponse(callbackId = result.callbackId, addedToLocker) + navigator.evaluateJavaScript(jsResponse) + } + } + + LaunchedEffect(Unit) { + viewModel.openUrlEvent.collect { url -> + uriHandler.openUri(url) + } + } + + Column { + if (searching) { + SearchBar( + windowInsets = WindowInsets(0, 0, 0, 0), + query = searchQuery ?: "", + onQueryChange = { newQuery -> viewModel.searchQuery.value = newQuery }, + onSearch = { viewModel.performSearch(navigator) }, + active = false, + onActiveChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .focusRequester(focusRequester) + .onGloballyPositioned { + focusRequester.requestFocus() + }, + leadingIcon = { + IconButton( + onClick = { viewModel.setSearching(false) }, + content = { + RebbleIcons.xClose() + } + ) + }, + trailingIcon = { + IconButton( + onClick = { viewModel.performSearch(navigator) } + ) { + RebbleIcons.caretRight() + } + }, + content = {} + ) + } else { + CenterAlignedTopAppBar( + windowInsets = WindowInsets(0, 0, 0, 0), + modifier = Modifier + .onGloballyPositioned { coordinates -> + println("TopAppBar height: ${coordinates.size.height}") + }, + navigationIcon = { + IconButton( + onClick = { navigator.navigateBack() }, + enabled = navigator.canGoBack, + content = { RebbleIcons.caretLeft() } + ) + }, + + title = { + TextButton(onClick = { viewModel.toggleDropdown() }) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + pageTitle ?: "Rebble Store", + style = MaterialTheme.typography.bodyLarge + ) + Text(page.label) + } + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = "Dropdown" + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { viewModel.closeDropdown() } + ) { + StoreTabs.entries.forEach { tab -> + DropdownMenuItem( + text = { Text(tab.label) }, + onClick = { + onTabChanged(tab) + viewModel.closeDropdown() + } + ) + } + } + }, + + actions = { + if (searchButton) { + IconButton( + onClick = { viewModel.setSearching(true) }, + content = { RebbleIcons.search() } + ) + } + } + ) + } + + WebView( + state = state, + modifier = Modifier.fillMaxSize(), + navigator = navigator, + webViewJsBridge = jsBridge + ) + } +} \ No newline at end of file diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/StoreViewModel.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/StoreViewModel.kt new file mode 100644 index 00000000..2100a8d7 --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/StoreViewModel.kt @@ -0,0 +1,276 @@ +package io.rebble.cobble.shared.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.multiplatform.webview.cookie.Cookie +import com.multiplatform.webview.request.RequestInterceptor +import com.multiplatform.webview.request.WebRequest +import com.multiplatform.webview.request.WebRequestInterceptResult +import com.multiplatform.webview.web.WebViewNavigator +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.parametersOf +import io.rebble.cobble.shared.api.RWS +import io.rebble.cobble.shared.domain.state.ConnectionState +import io.rebble.cobble.shared.domain.state.ConnectionStateManager +import io.rebble.cobble.shared.domain.state.CurrentToken +import io.rebble.cobble.shared.domain.store.* +import io.rebble.cobble.shared.ui.view.home.store.StoreTabs +import io.rebble.libpebblecommon.metadata.WatchHardwarePlatform +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +class StoreViewModel : ViewModel() { + val watchIsConnected = ConnectionStateManager.isConnected + val watchConnectionState = ConnectionStateManager.connectionState + val searchQuery = MutableStateFlow(null) + val resultFlow = MutableSharedFlow() + + private val _searchingState = MutableStateFlow(false) + val searchingState = _searchingState.asStateFlow() + + private val _searchButton = MutableStateFlow(false) + val searchButton = _searchButton.asStateFlow() + + private val _expandedDropdown = MutableStateFlow(false) + val expandedDropdown = _expandedDropdown.asStateFlow() + + private val _initialUrl = MutableStateFlow(null) + val initialUrl = _initialUrl.asStateFlow() + + // Stores current page title from WebView + private val _pageTitle = MutableStateFlow("Rebble store") + val pageTitle = _pageTitle.asStateFlow() + + // Store current tab + private val _currentTab = MutableStateFlow(StoreTabs.Watchfaces) + val currentTab = _currentTab.asStateFlow() + + val openUrlEvent = MutableSharedFlow() + + sealed class LockerResult( + open val callbackId: Int + ) { + data class Success( + override val callbackId: Int + ) : LockerResult(callbackId) + + data class Failure( + val errorMessage: String, + override val callbackId: Int + ) : LockerResult(callbackId) + } + + fun handleSetNavBarTitle(paramData: JsFrame) { + _pageTitle.value = paramData.data.browserTitle ?: paramData.data.title + _searchButton.value = paramData.data.showSearch + } + + fun handleOpenURL(paramData: JsFrame) { + viewModelScope.launch { + openUrlEvent.emit(paramData.data.url) + } + } + + fun performSearch(navigator: WebViewNavigator) { + println("hello?") + val jsRequest = PebbleBridge.navigateRequest(url = baseUrl(search = searchQuery.value)) + println(jsRequest) + navigator.evaluateJavaScript(jsRequest) + _searchingState.value = false + } + + fun saveItemToLocker( + uuid: String, + callbackId: Int + ) { + viewModelScope.launch { + RWS.appstoreClientFlow.collect { appstoreClient -> + if (appstoreClient != null) { + val result = + try { + appstoreClient.addToLocker(uuid) + LockerResult.Success(callbackId) + } catch (e: Exception) { + LockerResult.Failure( + errorMessage = "Failed to add item: ${e.localizedMessage}", + callbackId + ) + } + resultFlow.emit(result) + } else { + resultFlow.emit( + LockerResult.Failure( + errorMessage = "AppstoreClient is not available", + callbackId + ) + ) + } + } + } + } + + fun setSearching(searching: Boolean) { + _searchingState.value = searching + if (!searching) { + searchQuery.value = null + } + } + + fun toggleDropdown() { + _expandedDropdown.value = !_expandedDropdown.value + } + + fun closeDropdown() { + _expandedDropdown.value = false + } + + fun setCurrentTab(tab: StoreTabs) { + _currentTab.value = tab + generateInitialUrl() + } + + fun generateInitialUrl() { + _initialUrl.value = baseUrl() + } + + fun baseUrl(search: String? = null): String { + var url = "https://apps.rebble.io/en_US/" // TODO: Use an actual locale + + if (search != null) { + url += "search/" + } + url += + when (_currentTab.value) { + StoreTabs.Watchfaces -> "watchfaces/" + StoreTabs.Apps -> "watchapps/" + } + + if (search != null) { + url += "1?query=$search" + } + + url = + URLBuilder(url) + .apply { + parameters.appendAll( + parametersOf( + "native" to listOf("true"), + "inApp" to listOf("true"), + "platform" to listOf("android"), + "app_version" to listOf("0.0.1"), + "release_id" to listOf("100") + ) + ) + }.buildString() + + val currentState = watchConnectionState.value + if (currentState is ConnectionState.Connected) { + val watchDevice = currentState.watch + val metadata = watchDevice.metadata.value!! + + val pebbleColor = watchDevice.modelId.value + val pebbleHardware = + WatchHardwarePlatform + .fromProtocolNumber( + metadata.running.hardwarePlatform.get() + )?.watchType + ?.codename + val pebbleId = metadata.serial.get() + + url = + URLBuilder(url) + .apply { + parameters.appendAll( + parametersOf( + "pebble_color" to listOf(pebbleColor.toString()), + "hardware" to listOf(pebbleHardware ?: ""), + "pid" to listOf(pebbleId) + ) + ) + }.buildString() + } + return url + } + + // Used to initialize cookies for the WebView + suspend fun prepareTokenCookie(): Cookie? { + var cookie: Cookie? = null + // Collect the current token state from the flow + RWS.currentTokenFlow.collect { tokenState -> + when (tokenState) { + is CurrentToken.LoggedIn -> { + cookie = + Cookie( + name = "access_token", + value = tokenState.token, + domain = "apps.rebble.io" + ) + return@collect // Break out of collection after getting value + } + else -> {} + } + } + return cookie + } + + // Create a request interceptor for WebView + fun createRequestInterceptor(): RequestInterceptor { + return object : RequestInterceptor { + override fun onInterceptUrlRequest( + request: WebRequest, + navigator: WebViewNavigator + ): WebRequestInterceptResult { + println(request.url) + if (request.url.startsWith("pebble-method-call-js-frame")) { + val fullUrl = request.url.replaceRange(30, 30, "?") + val url = Url(fullUrl) + val parametersArgs = url.parameters.get("args")!! + println(parametersArgs) + when (url.parameters.get("method")) { + "setNavBarTitle" -> { + val paramData = + Json.decodeFromString>( + parametersArgs + ) + handleSetNavBarTitle(paramData) + } + "openURL" -> { + val paramData = Json.decodeFromString>(parametersArgs) + handleOpenURL(paramData) + } + "loadAppToDeviceAndLocker" -> { + val paramData = + Json + .decodeFromString>( + parametersArgs + ) + saveItemToLocker( + uuid = paramData.data.uuid, + callbackId = paramData.callbackId + ) + } + /* + // TODO: + "setVisibleApp" -> { + val paramData = Json.decodeFromString>(parametersArgs) + println(paramData) + } + */ + } + return WebRequestInterceptResult.Reject + } + + return WebRequestInterceptResult.Allow + } + } + } + + init { + // Generate initial URL when VM is created + generateInitialUrl() + } +} \ No newline at end of file diff --git a/ios/Runner/Pigeon/Pigeons.h b/ios/Runner/Pigeon/Pigeons.h index ad7fc703..697ca058 100644 --- a/ios/Runner/Pigeon/Pigeons.h +++ b/ios/Runner/Pigeon/Pigeons.h @@ -622,6 +622,7 @@ NSObject *KMPApiGetCodec(void); @protocol KMPApi - (void)updateTokenToken:(StringWrapper *)token error:(FlutterError *_Nullable *_Nonnull)error; - (void)openLockerViewWithError:(FlutterError *_Nullable *_Nonnull)error; +- (void)openStoreViewWithError:(FlutterError *_Nullable *_Nonnull)error; @end extern void KMPApiSetup(id binaryMessenger, NSObject *_Nullable api); diff --git a/ios/Runner/Pigeon/Pigeons.m b/ios/Runner/Pigeon/Pigeons.m index 2b94a967..0a4c1a16 100644 --- a/ios/Runner/Pigeon/Pigeons.m +++ b/ios/Runner/Pigeon/Pigeons.m @@ -3661,4 +3661,21 @@ void KMPApiSetup(id binaryMessenger, NSObject *a [channel setMessageHandler:nil]; } } + { + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:@"dev.flutter.pigeon.KMPApi.openStoreView" + binaryMessenger:binaryMessenger + codec:KMPApiGetCodec()]; + if (api) { + NSCAssert([api respondsToSelector:@selector(openStoreViewWithError:)], @"KMPApi api (%@) doesn't respond to @selector(openStoreViewWithError:)", api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + FlutterError *error; + [api openStoreViewWithError:&error]; + callback(wrapResult(nil, error)); + }]; + } else { + [channel setMessageHandler:nil]; + } + } } diff --git a/lib/infrastructure/pigeons/pigeons.g.dart b/lib/infrastructure/pigeons/pigeons.g.dart index 4c1d30ab..44d66fc6 100644 --- a/lib/infrastructure/pigeons/pigeons.g.dart +++ b/lib/infrastructure/pigeons/pigeons.g.dart @@ -3593,4 +3593,26 @@ class KMPApi { return; } } + + Future openStoreView() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.KMPApi.openStoreView', codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send(null) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } } diff --git a/lib/ui/home/home_page.dart b/lib/ui/home/home_page.dart index 522a4774..7b479e9f 100644 --- a/lib/ui/home/home_page.dart +++ b/lib/ui/home/home_page.dart @@ -55,7 +55,12 @@ class HomePage extends HookConsumerWidget implements CobbleScreen { onSelect: () => KMPApi().openLockerView(), child: PlaceholderScreen(), ), - _TabConfig(tr.homePage.store, RebbleIcons.rebble_store, child: StoreTab()), + _TabConfig( + tr.homePage.store, + RebbleIcons.rebble_store, + onSelect: () => KMPApi().openStoreView(), + child: PlaceholderScreen(), + ), _TabConfig(tr.homePage.watches, RebbleIcons.devices, child: MyWatchesTab()), _TabConfig(tr.homePage.settings, RebbleIcons.settings, child: Settings()), ]; diff --git a/pigeons/pigeons.dart b/pigeons/pigeons.dart index c0024a63..82b23042 100644 --- a/pigeons/pigeons.dart +++ b/pigeons/pigeons.dart @@ -550,4 +550,5 @@ abstract class KeepUnusedHack { abstract class KMPApi { void updateToken(StringWrapper token); void openLockerView(); -} + void openStoreView(); +} \ No newline at end of file