diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index b26993f9..1ce089b8 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -20,6 +20,7 @@ datastore = "1.1.1" uuidVersion = "0.8.4" compose-lib = "1.6.11" compose-nav = "2.7.0-alpha07" +compose-viewmodel = "2.8.0" compose-material3 = "1.3.0" reorderable = "2.3.3" @@ -80,6 +81,7 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-li compose-material = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-material3" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-lib" } compose-navigation = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "compose-nav" } +compose-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "compose-viewmodel" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-lib" } compose-components-reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } diff --git a/android/shared/build.gradle.kts b/android/shared/build.gradle.kts index 24c413c6..130841a9 100644 --- a/android/shared/build.gradle.kts +++ b/android/shared/build.gradle.kts @@ -61,11 +61,12 @@ kotlin { implementation(libs.ktor.client.core) implementation(libs.ktor.client.contentnegotiation) implementation(libs.ktor.serialization.json) - implementation(libs.compose.runtime) - implementation(libs.compose.ui) - implementation(libs.compose.material) - implementation(libs.compose.foundation) + implementation(compose.runtime) + implementation(compose.ui) + implementation(compose.material3) + implementation(compose.foundation) implementation(libs.compose.navigation) + implementation(libs.compose.viewmodel) implementation(libs.compose.components.resources) implementation(libs.compose.components.reorderable) } 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 46a4df1e..9d7bcaa5 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 @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import io.rebble.cobble.shared.ui.common.RebbleIcons import io.rebble.cobble.shared.ui.view.home.locker.Locker @@ -13,9 +14,8 @@ enum class HomePages { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun HomeScaffold(page: HomePages, modifier: Modifier = Modifier, topBarWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0)) { +fun HomeScaffold(page: HomePages) { Scaffold( - modifier = modifier, topBar = { TopAppBar( windowInsets = WindowInsets.statusBars, @@ -34,10 +34,12 @@ fun HomeScaffold(page: HomePages, modifier: Modifier = Modifier, topBarWindowIns ) } }, - ) { - when (page) { - HomePages.Locker -> { - Locker() + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + when (page) { + HomePages.Locker -> { + Locker() + } } } } 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 47a4da20..229a4f42 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 @@ -1,19 +1,15 @@ package io.rebble.cobble.shared.ui.view.home.locker -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.* import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.* import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import io.rebble.cobble.shared.database.dao.LockerDao import io.rebble.cobble.shared.database.entity.SyncedLockerEntryWithPlatforms +import io.rebble.cobble.shared.ui.viewmodel.LockerViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch @@ -25,21 +21,13 @@ enum class LockerTabs(val label: String) { } @Composable -fun Locker(lockerDao: LockerDao = getKoin().get()) { - val scope = rememberCoroutineScope() - val (entries, setEntries) = remember { mutableStateOf?>(null) } +fun Locker(lockerDao: LockerDao = getKoin().get(), viewModel: LockerViewModel = viewModel { LockerViewModel(lockerDao) }) { + val entriesState: LockerViewModel.LockerEntriesState by viewModel.entriesState.collectAsState() val tab = remember { mutableStateOf(LockerTabs.Apps) } - scope.launch(Dispatchers.IO) { - val entries = lockerDao.getAllEntries() - setEntries(entries) - } - Column { - Surface( - modifier = Modifier.fillMaxWidth().height(100.dp) - ) { - Row { + Surface { + Row(modifier = Modifier.fillMaxWidth().height(64.dp)) { LockerTabs.entries.forEachIndexed { index, it -> NavigationBarItem( selected = tab.value == it, @@ -50,17 +38,17 @@ fun Locker(lockerDao: LockerDao = getKoin().get()) { } } - entries?.let { + if (entriesState is LockerViewModel.LockerEntriesState.Loaded) { when (tab.value) { LockerTabs.Apps -> { - LockerAppList(it.filter { it.entry.type == "watchapp" }) + LockerAppList(viewModel) } LockerTabs.Watchfaces -> { - TODO() + LockerWatchfaceList(viewModel) } } - } ?: run { + } else { CircularProgressIndicator(modifier = Modifier.align(CenterHorizontally)) } } diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/LockerAppList.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/LockerAppList.kt index 02693635..0a68be49 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/LockerAppList.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/LockerAppList.kt @@ -7,31 +7,33 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.IconButton -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import io.rebble.cobble.shared.database.dao.LockerDao import io.rebble.cobble.shared.database.entity.SyncedLockerEntryWithPlatforms import io.rebble.cobble.shared.jobs.LockerSyncJob import io.rebble.cobble.shared.ui.common.RebbleIcons +import io.rebble.cobble.shared.ui.viewmodel.LockerViewModel +import kotlinx.coroutines.Job import org.koin.compose.getKoin import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @OptIn(ExperimentalFoundationApi::class) @Composable -fun LockerAppList(entries: List) { +fun LockerAppList(viewModel: LockerViewModel) { val lazyListState = rememberLazyListState() val koin = getKoin() - val lockerDao: LockerDao = koin.get() + val entriesState by viewModel.entriesState.collectAsState() + val entries = ((entriesState as? LockerViewModel.LockerEntriesState.Loaded)?.entries ?: emptyList()).filter { it.entry.type == "watchapp" } val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to -> val entry = entries.first { it.entry.id == from.key } val nwList = entries.toMutableList() nwList.remove(entry) nwList.add(to.index, entry) - nwList.forEachIndexed { i, e -> - lockerDao.updateOrder(e.entry.id, i+1) - } + viewModel.updateOrder(nwList) LockerSyncJob.schedule(koin.get()) } LazyColumn(state = lazyListState, modifier = Modifier.fillMaxSize()) { diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/LockerWatchfaceItem.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/LockerWatchfaceItem.kt new file mode 100644 index 00000000..a8c51685 --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/LockerWatchfaceItem.kt @@ -0,0 +1,28 @@ +package io.rebble.cobble.shared.ui.view.home.locker + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import io.rebble.cobble.shared.database.entity.SyncedLockerEntry +import io.rebble.cobble.shared.database.entity.SyncedLockerEntryWithPlatforms +import io.rebble.cobble.shared.ui.common.RebbleIcons +import io.rebble.cobble.shared.ui.viewmodel.LockerWatchfaceItemViewModel + +@Composable +fun LockerWatchfaceItem(entry: SyncedLockerEntryWithPlatforms) { + val viewModel: LockerWatchfaceItemViewModel = viewModel { LockerWatchfaceItemViewModel(entry) } + Surface(tonalElevation = 1.dp) { + Column(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.size(width = 92.dp, height = if (viewModel.circleWatchface) 92.dp else 108.dp)) { RebbleIcons.unknownApp() } + Text(viewModel.title) + Text(viewModel.developerName) + } + } +} \ No newline at end of file diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/LockerWatchfaceList.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/LockerWatchfaceList.kt index c35ce0bb..2034c253 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/LockerWatchfaceList.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/home/locker/LockerWatchfaceList.kt @@ -1,9 +1,29 @@ package io.rebble.cobble.shared.ui.view.home.locker +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import io.rebble.cobble.shared.ui.viewmodel.LockerViewModel @Composable -fun LockerWatchfaceList() { - Text("Watchface list") +fun LockerWatchfaceList(viewModel: LockerViewModel) { + val entriesState: LockerViewModel.LockerEntriesState by viewModel.entriesState.collectAsState() + val entries = ((entriesState as? LockerViewModel.LockerEntriesState.Loaded)?.entries ?: emptyList()).filter { it.entry.type == "watchface" } + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + items(entries.size) { i -> + LockerWatchfaceItem(entries[i]) + } + } } \ No newline at end of file diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/LockerViewModel.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/LockerViewModel.kt new file mode 100644 index 00000000..cd251542 --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/LockerViewModel.kt @@ -0,0 +1,44 @@ +package io.rebble.cobble.shared.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.rebble.cobble.shared.database.dao.LockerDao +import io.rebble.cobble.shared.database.entity.SyncedLockerEntryWithPlatforms +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class LockerViewModel(private val lockerDao: LockerDao): ViewModel() { + open class LockerEntriesState { + object Loading : LockerEntriesState() + data class Loaded(val entries: List) : LockerEntriesState() + } + + private val _entriesState: MutableStateFlow = MutableStateFlow(LockerEntriesState.Loading) + val entriesState = _entriesState.asStateFlow() + private var mutex = Mutex() + private var lastJob: Job? = null + + init { + viewModelScope.launch(Dispatchers.IO) { + _entriesState.value = LockerEntriesState.Loaded(lockerDao.getAllEntries()) + } + } + + suspend fun updateOrder(entries: List) { + lastJob?.cancel() + lastJob = viewModelScope.launch(Dispatchers.IO) { + mutex.withLock { + entries.forEachIndexed { i, e -> + lockerDao.updateOrder(e.entry.id, i+1) + } + } + } + _entriesState.value = LockerEntriesState.Loaded(entries) + } +} \ No newline at end of file diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/LockerWatchfaceItemViewModel.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/LockerWatchfaceItemViewModel.kt new file mode 100644 index 00000000..01a36fe7 --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/LockerWatchfaceItemViewModel.kt @@ -0,0 +1,30 @@ +package io.rebble.cobble.shared.ui.viewmodel + +import androidx.lifecycle.ViewModel +import io.rebble.cobble.shared.database.entity.SyncedLockerEntry +import io.rebble.cobble.shared.database.entity.SyncedLockerEntryWithPlatforms +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.jetbrains.skia.Image + +class LockerWatchfaceItemViewModel(val entry: SyncedLockerEntryWithPlatforms): ViewModel() { + open class ImageState { + object Loading : ImageState() + data class Loaded(val image: Image) : ImageState() + } + private var _imageState = MutableStateFlow(ImageState.Loading) + val imageState = _imageState.asStateFlow() + + val title: String + get() = entry.entry.title + + val developerName: String + get() = entry.entry.developerName + + val circleWatchface: Boolean + get() = entry.platforms.any { it.name == "chalk" } && entry.platforms.size == 1 //TODO: also display when chalk connected + + init { + //TODO: Load image + } +} \ No newline at end of file diff --git a/ios/Runner/Pigeon/Pigeons.h b/ios/Runner/Pigeon/Pigeons.h index 2e31ef38..bfd38c4d 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; @end extern void KMPApiSetup(id binaryMessenger, NSObject *_Nullable api); diff --git a/ios/Runner/Pigeon/Pigeons.m b/ios/Runner/Pigeon/Pigeons.m index 79e4d751..7e68de0b 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.openLockerView" + binaryMessenger:binaryMessenger + codec:KMPApiGetCodec()]; + if (api) { + NSCAssert([api respondsToSelector:@selector(openLockerViewWithError:)], @"KMPApi api (%@) doesn't respond to @selector(openLockerViewWithError:)", api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + FlutterError *error; + [api openLockerViewWithError:&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 2aab637f..5c586dfd 100644 --- a/lib/infrastructure/pigeons/pigeons.g.dart +++ b/lib/infrastructure/pigeons/pigeons.g.dart @@ -3593,4 +3593,26 @@ class KMPApi { return; } } + + Future openLockerView() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.KMPApi.openLockerView', 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 d00c9426..522a4774 100644 --- a/lib/ui/home/home_page.dart +++ b/lib/ui/home/home_page.dart @@ -1,4 +1,5 @@ import 'package:cobble/domain/connection/connection_state_provider.dart'; +import 'package:cobble/infrastructure/pigeons/pigeons.g.dart'; import 'package:cobble/localization/localization.dart'; import 'package:cobble/ui/home/tabs/locker_tab.dart'; import 'package:cobble/ui/home/tabs/store_tab.dart'; @@ -8,6 +9,7 @@ import 'package:cobble/ui/router/cobble_navigator.dart'; import 'package:cobble/ui/router/cobble_scaffold.dart'; import 'package:cobble/ui/router/cobble_screen.dart'; import 'package:cobble/ui/router/uri_navigator.dart'; +import 'package:cobble/ui/screens/placeholder_screen.dart'; import 'package:cobble/ui/screens/settings.dart'; import 'package:cobble/ui/screens/update_prompt.dart'; import 'package:flutter/cupertino.dart'; @@ -19,33 +21,43 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../common/icons/fonts/rebble_icons.dart'; class _TabConfig { - final CobbleScreen child; + final CobbleScreen? child; + final Function? onSelect; final String label; final IconData icon; final GlobalKey key; - _TabConfig(this.child, this.label, this.icon) + _TabConfig(this.label, this.icon, {this.onSelect, this.child}) : key = GlobalKey(); } class HomePage extends HookConsumerWidget implements CobbleScreen { final _config = [ // Only visible when in debug mode - ... kDebugMode ? [_TabConfig( - TestTab(), - tr.homePage.testing, - RebbleIcons.send_to_watch_checked, - )] : [], + ...kDebugMode + ? [ + _TabConfig( + tr.homePage.testing, + RebbleIcons.send_to_watch_checked, + child: TestTab(), + ) + ] + : [], // // TODO: Health not yet implemented // _TabConfig( // HealthTab(), // tr.homePage.health, // RebbleIcons.health_journal, // ), - _TabConfig(LockerTab(), tr.homePage.locker, RebbleIcons.locker), - _TabConfig(StoreTab(), tr.homePage.store, RebbleIcons.rebble_store), - _TabConfig(MyWatchesTab(), tr.homePage.watches, RebbleIcons.devices), - _TabConfig(Settings(), tr.homePage.settings, RebbleIcons.settings), + _TabConfig( + tr.homePage.locker, + RebbleIcons.locker, + onSelect: () => KMPApi().openLockerView(), + child: PlaceholderScreen(), + ), + _TabConfig(tr.homePage.store, RebbleIcons.rebble_store, child: StoreTab()), + _TabConfig(tr.homePage.watches, RebbleIcons.devices, child: MyWatchesTab()), + _TabConfig(tr.homePage.settings, RebbleIcons.settings, child: Settings()), ]; HomePage({super.key}); @@ -55,11 +67,12 @@ class HomePage extends HookConsumerWidget implements CobbleScreen { useUriNavigator(context); final index = useState(0); - + final connectionState = ref.watch(connectionStateProvider); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((Duration duration) { - if (connectionState.currentConnectedWatch?.runningFirmware.isRecovery == true) { + if (connectionState.currentConnectedWatch?.runningFirmware.isRecovery == + true) { context.push(UpdatePrompt( confirmOnSuccess: true, onSuccess: (screenContext) { @@ -71,12 +84,20 @@ class HomePage extends HookConsumerWidget implements CobbleScreen { return null; }, [connectionState]); + useEffect(() { + if (_config[index.value].onSelect != null) { + _config[index.value].onSelect(); + } + return null; + }); + return WillPopScope( onWillPop: () async { /// Ask currently active child Navigator to pop. If child Navigator has /// nothing to pop it will return `false`, allowing root navigator to /// pop itself, closing the app. - final popped = (await _config[index.value].key.currentState?.maybePop())!; + final popped = + (await _config[index.value].key.currentState?.maybePop())!; return popped == false; }, child: CobbleScaffold.page( @@ -87,25 +108,25 @@ class HomePage extends HookConsumerWidget implements CobbleScreen { items: _config .map( (tab) => BottomNavigationBarItem( - icon: Icon(tab.icon), - label: tab.label, - backgroundColor: Theme.of(context).colorScheme.surface, - ), - ) + icon: Icon(tab.icon), + label: tab.label, + backgroundColor: Theme.of(context).colorScheme.surface, + ), + ) .toList(), ), child: IndexedStack( + index: index.value, children: _config .map( (tab) => Navigator( - key: tab.key, - onGenerateInitialRoutes: (navigator, initialRoute) => [ + key: tab.key, + onGenerateInitialRoutes: (navigator, initialRoute) => [ CupertinoPageRoute(builder: (_) => tab.child), ], ), ) .toList(), - index: index.value, ), ), ); diff --git a/pigeons/pigeons.dart b/pigeons/pigeons.dart index e0ce2f76..c6f689ff 100644 --- a/pigeons/pigeons.dart +++ b/pigeons/pigeons.dart @@ -550,4 +550,5 @@ abstract class KeepUnusedHack { @HostApi() abstract class KMPApi { void updateToken(StringWrapper token); + void openLockerView(); }