mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## Problem The Docs E2E link checker found broken links throughout docs, starting with `phone-login.mdx` pointing to `/docs/guides/cli/config` (404). Old links like `/docs/guides/cli/config` still work on the live site because `supabase.com` has redirects set up for them, but these links break on the docs preview site, which is what the E2E check tests against. These issues look clean on the live site, and I didn't catch them in my first pass because I was testing production instead of the preview. The E2E check only tests the ~20 pages a given PR happens to touch, so fixing the pages it flagged kept exposing more of the same problem one page at a time as each fix pulled in a new file. To stop chasing this incrementally, I cross-referenced every `/docs/guides/*` and `/docs/reference/*` redirect source in `apps/www/lib/redirects.js` against actual usage across all of `apps/docs`, and verified each candidate against the live preview. ## Solution Rather than updating the Docs E2E link checker, this PR resolves the links. **Why:** we own these docs, so keeping the links clean without redirects is keeping the house maintained. See [Broken Window Theory](https://blog.codinghorror.com/the-broken-window-theory/). Updated every link still using an old path to point straight at the current page instead of relying on a redirect. This covers old links like: - `/docs/guides/cli/config` → `/docs/guides/local-development/cli/config` - `/docs/guides/cli/getting-started` → `/docs/guides/local-development/cli/getting-started` - `/docs/guides/cli/local-development` → `/docs/guides/local-development/database-migrations` - `/docs/guides/cli/managing-environments` → `/docs/guides/deployment/managing-environments` - `/docs/guides/cli/seeding-your-database` → `/docs/guides/local-development/seeding-your-database` - bare `/docs/guides/cli` → `/docs/guides/local-development` - `/docs/guides/platform/compute-add-ons` → `/docs/guides/platform/compute-and-disk` - `/docs/guides/platform/shared-responsibility-model` → `/docs/guides/deployment/shared-responsibility-model` - `/docs/guides/database` → `/docs/guides/database/overview` - `/docs/reference/javascript`, `/docs/reference/dart`, `/docs/reference/kotlin`, `/docs/reference/python`, `/docs/reference/csharp` → their `/introduction` pages (the redirect's own destination, `/start`, turned out to be dead even on production — a separate bug in `redirects.js` I didn't touch here) - and about 35 more of the same pattern, listed in the commit messages Also fixed a handful of dead heading anchors found along the way (links that resolve to the right page but point at a `#section` that got renamed or moved), including the original `#bigquery` anchor and a few in `connecting-to-postgres.mdx` where content moved to its own dedicated page. Left alone on purpose: - `content/guides/cli.mdx` — this page has no route in the docs app at all (no `app/guides/cli/` directory), so it 404s even in production before the `www` redirect ever fires. Fixing its internal link wouldn't change that; it needs an actual routing/content decision, not a link fix. - A few candidates that already resolve fine as-is (`pg_partman`, bare `/docs/reference/api`, bare `/docs/reference/cli`) — confirmed via curl, left untouched. ## Manual testing 1. Confirmed every new link target actually exists by checking the destination file/page and matching heading anchors. 2. Cross-referenced every `/docs/guides/*` and `/docs/reference/*` redirect source in `apps/www/lib/redirects.js` against real usage in `apps/docs`, and curl-verified each old path (404) and new path (200) against the live PR preview before fixing it. 3. Ran the Docs E2E link checker locally against changed pages. 4. Spot-checked the original broken link from CI (`/docs/guides/cli/config`) to confirm it now points to a working page.
1389 lines
44 KiB
Plaintext
1389 lines
44 KiB
Plaintext
---
|
|
title: 'Build a Product Management Android App with Jetpack Compose'
|
|
description: 'Learn how to use Supabase in your Android Kotlin App.'
|
|
---
|
|
|
|
This tutorial demonstrates how to build a basic product management app. The app demonstrates management operations, photo upload, account creation and authentication using:
|
|
|
|
- [Supabase Database](/docs/guides/database/overview) - a Postgres database for storing your user data and [Row Level Security](/docs/guides/auth#row-level-security) so data is protected and users can only access their own information.
|
|
- [Supabase Auth](/docs/guides/auth) - users log in through magic links sent to their email (without having to set up a password).
|
|
- [Supabase Storage](/docs/guides/storage) - users can upload a profile photo.
|
|
|
|

|
|
|
|
<Admonition type="note">
|
|
|
|
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/hieuwu/product-sample-supabase-kt).
|
|
|
|
</Admonition>
|
|
|
|
<$Partial path="kotlin_project_setup.mdx" variables={{ "framework": "androidkotlin", "tab": "mobiles" }} />
|
|
|
|
## Building the app
|
|
|
|
### Create new Android project
|
|
|
|
Open Android Studio > New Project > Base Activity (Jetpack Compose).
|
|
|
|

|
|
|
|
### Set up API key and secret securely
|
|
|
|
#### Create local environment secret
|
|
|
|
Create or edit the `local.properties` file at the root (same level as `build.gradle`) of your project.
|
|
|
|
> **Note**: Do not commit this file to your source control, for example, by adding it to your `.gitignore` file!
|
|
|
|
```kotlin
|
|
SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY
|
|
SUPABASE_URL=YOUR_SUPABASE_URL
|
|
```
|
|
|
|
#### Read and set value to `BuildConfig`
|
|
|
|
In your `build.gradle` (app) file, create a `Properties` object and read the values from your `local.properties` file by calling the `buildConfigField` method:
|
|
|
|
```kotlin
|
|
defaultConfig {
|
|
applicationId "com.example.manageproducts"
|
|
minSdkVersion 22
|
|
targetSdkVersion 33
|
|
versionCode 5
|
|
versionName "1.0"
|
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
|
|
// Set value part
|
|
Properties properties = new Properties()
|
|
properties.load(project.rootProject.file("local.properties").newDataInputStream())
|
|
buildConfigField("String", "SUPABASE_PUBLISHABLE_KEY", "\"${properties.getProperty("SUPABASE_PUBLISHABLE_KEY")}\"")
|
|
buildConfigField("String", "SECRET", "\"${properties.getProperty("SECRET")}\"")
|
|
buildConfigField("String", "SUPABASE_URL", "\"${properties.getProperty("SUPABASE_URL")}\"")
|
|
}
|
|
```
|
|
|
|
#### Use value from `BuildConfig`
|
|
|
|
Read the value from `BuildConfig`:
|
|
|
|
```kotlin
|
|
val url = BuildConfig.SUPABASE_URL
|
|
val apiKey = BuildConfig.SUPABASE_PUBLISHABLE_KEY
|
|
```
|
|
|
|
### Set up Supabase dependencies
|
|
|
|

|
|
|
|
In the `build.gradle` (app) file, add these dependencies then press "Sync now." Replace the dependency version placeholders `$supabase_version` and `$ktor_version` with their respective latest versions.
|
|
|
|
```kotlin
|
|
implementation "io.github.jan-tennert.supabase:postgrest-kt:$supabase_version"
|
|
implementation "io.github.jan-tennert.supabase:storage-kt:$supabase_version"
|
|
implementation "io.github.jan-tennert.supabase:auth-kt:$supabase_version"
|
|
implementation "io.ktor:ktor-client-android:$ktor_version"
|
|
implementation "io.ktor:ktor-client-core:$ktor_version"
|
|
implementation "io.ktor:ktor-utils:$ktor_version"
|
|
```
|
|
|
|
Also in the `build.gradle` (app) file, add the plugin for serialization. The version of this plugin should be the same as your Kotlin version.
|
|
|
|
```kotlin
|
|
plugins {
|
|
...
|
|
id 'org.jetbrains.kotlin.plugin.serialization' version '$kotlin_version'
|
|
...
|
|
}
|
|
```
|
|
|
|
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
|
|
|
### Set up Hilt for dependency injection
|
|
|
|
In the `build.gradle` (app) file, add the following:
|
|
|
|
```kotlin
|
|
implementation "com.google.dagger:hilt-android:$hilt_version"
|
|
annotationProcessor "com.google.dagger:hilt-compiler:$hilt_version"
|
|
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
|
|
```
|
|
|
|
Create a new `ManageProductApplication.kt` class extending Application with `@HiltAndroidApp` annotation:
|
|
|
|
```kotlin
|
|
// ManageProductApplication.kt
|
|
@HiltAndroidApp
|
|
class ManageProductApplication: Application()
|
|
```
|
|
|
|
Open the `AndroidManifest.xml` file, update name property of Application tag:
|
|
|
|
```xml
|
|
<application
|
|
...
|
|
android:name=".ManageProductApplication"
|
|
...
|
|
</application>
|
|
|
|
```
|
|
|
|
Create the `MainActivity`:
|
|
|
|
```kotlin
|
|
@AndroidEntryPoint
|
|
class MainActivity : ComponentActivity() {
|
|
//This will come later
|
|
}
|
|
```
|
|
|
|
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
|
|
|
### Provide Supabase instances with Hilt
|
|
|
|
To make the app easier to test, create a `SupabaseModule.kt` file as follows:
|
|
|
|
```kotlin
|
|
@InstallIn(SingletonComponent::class)
|
|
@Module
|
|
object SupabaseModule {
|
|
|
|
@Provides
|
|
@Singleton
|
|
fun provideSupabaseClient(): SupabaseClient {
|
|
return createSupabaseClient(
|
|
supabaseUrl = BuildConfig.SUPABASE_URL,
|
|
supabaseKey = BuildConfig.SUPABASE_PUBLISHABLE_KEY
|
|
) {
|
|
install(Postgrest)
|
|
install(Auth) {
|
|
flowType = FlowType.PKCE
|
|
scheme = "app"
|
|
host = "supabase.com"
|
|
}
|
|
install(Storage)
|
|
}
|
|
}
|
|
|
|
@Provides
|
|
@Singleton
|
|
fun provideSupabaseDatabase(client: SupabaseClient): Postgrest {
|
|
return client.postgrest
|
|
}
|
|
|
|
@Provides
|
|
@Singleton
|
|
fun provideSupabaseAuth(client: SupabaseClient): Auth {
|
|
return client.auth
|
|
}
|
|
|
|
|
|
@Provides
|
|
@Singleton
|
|
fun provideSupabaseStorage(client: SupabaseClient): Storage {
|
|
return client.storage
|
|
}
|
|
|
|
}
|
|
```
|
|
|
|
### Create a data transfer object
|
|
|
|
Create a `ProductDto.kt` class and use annotations to parse data from Supabase:
|
|
|
|
```kotlin
|
|
@Serializable
|
|
data class ProductDto(
|
|
|
|
@SerialName("name")
|
|
val name: String,
|
|
|
|
@SerialName("price")
|
|
val price: Double,
|
|
|
|
@SerialName("image")
|
|
val image: String?,
|
|
|
|
@SerialName("id")
|
|
val id: String,
|
|
)
|
|
```
|
|
|
|
Create a Domain object in `Product.kt` expose the data in your view:
|
|
|
|
```kotlin
|
|
data class Product(
|
|
val id: String,
|
|
val name: String,
|
|
val price: Double,
|
|
val image: String?
|
|
)
|
|
```
|
|
|
|
### Implement repositories
|
|
|
|
Create a `ProductRepository` interface and its implementation named `ProductRepositoryImpl`. This holds the logic to interact with data sources from Supabase. Do the same with the `AuthenticationRepository`.
|
|
|
|
Create the Product Repository:
|
|
|
|
```kotlin
|
|
interface ProductRepository {
|
|
suspend fun createProduct(product: Product): Boolean
|
|
suspend fun getProducts(): List<ProductDto>?
|
|
suspend fun getProduct(id: String): ProductDto
|
|
suspend fun deleteProduct(id: String)
|
|
suspend fun updateProduct(
|
|
id: String, name: String, price: Double, imageName: String, imageFile: ByteArray
|
|
)
|
|
}
|
|
```
|
|
|
|
```kotlin
|
|
class ProductRepositoryImpl @Inject constructor(
|
|
private val postgrest: Postgrest,
|
|
private val storage: Storage,
|
|
) : ProductRepository {
|
|
override suspend fun createProduct(product: Product): Boolean {
|
|
return try {
|
|
withContext(Dispatchers.IO) {
|
|
val productDto = ProductDto(
|
|
name = product.name,
|
|
price = product.price,
|
|
)
|
|
postgrest.from("products").insert(productDto)
|
|
true
|
|
}
|
|
true
|
|
} catch (e: java.lang.Exception) {
|
|
throw e
|
|
}
|
|
}
|
|
|
|
override suspend fun getProducts(): List<ProductDto>? {
|
|
return withContext(Dispatchers.IO) {
|
|
val result = postgrest.from("products")
|
|
.select().decodeList<ProductDto>()
|
|
result
|
|
}
|
|
}
|
|
|
|
|
|
override suspend fun getProduct(id: String): ProductDto {
|
|
return withContext(Dispatchers.IO) {
|
|
postgrest.from("products").select {
|
|
filter {
|
|
eq("id", id)
|
|
}
|
|
}.decodeSingle<ProductDto>()
|
|
}
|
|
}
|
|
|
|
override suspend fun deleteProduct(id: String) {
|
|
return withContext(Dispatchers.IO) {
|
|
postgrest.from("products").delete {
|
|
filter {
|
|
eq("id", id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override suspend fun updateProduct(
|
|
id: String,
|
|
name: String,
|
|
price: Double,
|
|
imageName: String,
|
|
imageFile: ByteArray
|
|
) {
|
|
withContext(Dispatchers.IO) {
|
|
if (imageFile.isNotEmpty()) {
|
|
val imageUrl =
|
|
storage.from("Product%20Image").upload(
|
|
path = "$imageName.png",
|
|
data = imageFile,
|
|
upsert = true
|
|
)
|
|
postgrest.from("products").update({
|
|
set("name", name)
|
|
set("price", price)
|
|
set("image", buildImageUrl(imageFileName = imageUrl))
|
|
}) {
|
|
filter {
|
|
eq("id", id)
|
|
}
|
|
}
|
|
} else {
|
|
postgrest.from("products").update({
|
|
set("name", name)
|
|
set("price", price)
|
|
}) {
|
|
filter {
|
|
eq("id", id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Because I named the bucket as "Product Image" so when it turns to an url, it is "%20"
|
|
// For better approach, you should create your bucket name without space symbol
|
|
private fun buildImageUrl(imageFileName: String) =
|
|
"${BuildConfig.SUPABASE_URL}/storage/v1/object/public/${imageFileName}".replace(" ", "%20")
|
|
}
|
|
```
|
|
|
|
Create the Authentication Repository:
|
|
|
|
```kotlin
|
|
interface AuthenticationRepository {
|
|
suspend fun signIn(email: String, password: String): Boolean
|
|
suspend fun signUp(email: String, password: String): Boolean
|
|
suspend fun signInWithGoogle(): Boolean
|
|
}
|
|
```
|
|
|
|
```kotlin
|
|
class AuthenticationRepositoryImpl @Inject constructor(
|
|
private val auth: Auth
|
|
) : AuthenticationRepository {
|
|
override suspend fun signIn(email: String, password: String): Boolean {
|
|
return try {
|
|
auth.signInWith(Email) {
|
|
this.email = email
|
|
this.password = password
|
|
}
|
|
true
|
|
} catch (e: Exception) {
|
|
false
|
|
}
|
|
}
|
|
|
|
override suspend fun signUp(email: String, password: String): Boolean {
|
|
return try {
|
|
auth.signUpWith(Email) {
|
|
this.email = email
|
|
this.password = password
|
|
}
|
|
true
|
|
} catch (e: Exception) {
|
|
false
|
|
}
|
|
}
|
|
|
|
override suspend fun signInWithGoogle(): Boolean {
|
|
return try {
|
|
auth.signInWith(Google)
|
|
true
|
|
} catch (e: Exception) {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Implement screens
|
|
|
|
To navigate screens, use the AndroidX navigation library. For routes, implement a `Destination` interface:
|
|
|
|
```kotlin
|
|
|
|
interface Destination {
|
|
val route: String
|
|
val title: String
|
|
}
|
|
|
|
|
|
object ProductListDestination : Destination {
|
|
override val route = "product_list"
|
|
override val title = "Product List"
|
|
}
|
|
|
|
object ProductDetailsDestination : Destination {
|
|
override val route = "product_details"
|
|
override val title = "Product Details"
|
|
const val productId = "product_id"
|
|
val arguments = listOf(navArgument(name = productId) {
|
|
type = NavType.StringType
|
|
})
|
|
fun createRouteWithParam(productId: String) = "$route/${productId}"
|
|
}
|
|
|
|
object AddProductDestination : Destination {
|
|
override val route = "add_product"
|
|
override val title = "Add Product"
|
|
}
|
|
|
|
object AuthenticationDestination: Destination {
|
|
override val route = "authentication"
|
|
override val title = "Authentication"
|
|
}
|
|
|
|
object SignUpDestination: Destination {
|
|
override val route = "signup"
|
|
override val title = "Sign Up"
|
|
}
|
|
```
|
|
|
|
This will help later for navigating between screens.
|
|
|
|
Create a `ProductListViewModel`:
|
|
|
|
```kotlin
|
|
@HiltViewModel
|
|
class ProductListViewModel @Inject constructor(
|
|
private val productRepository: ProductRepository,
|
|
) : ViewModel() {
|
|
|
|
private val _productList = MutableStateFlow<List<Product>?>(listOf())
|
|
val productList: Flow<List<Product>?> = _productList
|
|
|
|
|
|
private val _isLoading = MutableStateFlow(false)
|
|
val isLoading: Flow<Boolean> = _isLoading
|
|
|
|
init {
|
|
getProducts()
|
|
}
|
|
|
|
fun getProducts() {
|
|
viewModelScope.launch {
|
|
val products = productRepository.getProducts()
|
|
_productList.emit(products?.map { it -> it.asDomainModel() })
|
|
}
|
|
}
|
|
|
|
fun removeItem(product: Product) {
|
|
viewModelScope.launch {
|
|
val newList = mutableListOf<Product>().apply { _productList.value?.let { addAll(it) } }
|
|
newList.remove(product)
|
|
_productList.emit(newList.toList())
|
|
// Call api to remove
|
|
productRepository.deleteProduct(id = product.id)
|
|
// Then fetch again
|
|
getProducts()
|
|
}
|
|
}
|
|
|
|
private fun ProductDto.asDomainModel(): Product {
|
|
return Product(
|
|
id = this.id,
|
|
name = this.name,
|
|
price = this.price,
|
|
image = this.image
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
```
|
|
|
|
Create the `ProductListScreen.kt`:
|
|
|
|
```kotlin
|
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
|
@Composable
|
|
fun ProductListScreen(
|
|
modifier: Modifier = Modifier,
|
|
navController: NavController,
|
|
viewModel: ProductListViewModel = hiltViewModel(),
|
|
) {
|
|
val isLoading by viewModel.isLoading.collectAsState(initial = false)
|
|
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing = isLoading)
|
|
SwipeRefresh(state = swipeRefreshState, onRefresh = { viewModel.getProducts() }) {
|
|
Scaffold(
|
|
topBar = {
|
|
TopAppBar(
|
|
backgroundColor = MaterialTheme.colorScheme.primary,
|
|
title = {
|
|
Text(
|
|
text = stringResource(R.string.product_list_text_screen_title),
|
|
color = MaterialTheme.colorScheme.onPrimary,
|
|
)
|
|
},
|
|
)
|
|
},
|
|
floatingActionButton = {
|
|
AddProductButton(onClick = { navController.navigate(AddProductDestination.route) })
|
|
}
|
|
) { padding ->
|
|
val productList = viewModel.productList.collectAsState(initial = listOf()).value
|
|
if (!productList.isNullOrEmpty()) {
|
|
LazyColumn(
|
|
modifier = modifier.padding(padding),
|
|
contentPadding = PaddingValues(5.dp)
|
|
) {
|
|
itemsIndexed(
|
|
items = productList,
|
|
key = { _, product -> product.name }) { _, item ->
|
|
val state = rememberDismissState(
|
|
confirmStateChange = {
|
|
if (it == DismissValue.DismissedToStart) {
|
|
// Handle item removed
|
|
viewModel.removeItem(item)
|
|
}
|
|
true
|
|
}
|
|
)
|
|
SwipeToDismiss(
|
|
state = state,
|
|
background = {
|
|
val color by animateColorAsState(
|
|
targetValue = when (state.dismissDirection) {
|
|
DismissDirection.StartToEnd -> MaterialTheme.colorScheme.primary
|
|
DismissDirection.EndToStart -> MaterialTheme.colorScheme.primary.copy(
|
|
alpha = 0.2f
|
|
)
|
|
null -> Color.Transparent
|
|
}
|
|
)
|
|
Box(
|
|
modifier = modifier
|
|
.fillMaxSize()
|
|
.background(color = color)
|
|
.padding(16.dp),
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Filled.Delete,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.primary,
|
|
modifier = modifier.align(Alignment.CenterEnd)
|
|
)
|
|
}
|
|
|
|
},
|
|
dismissContent = {
|
|
ProductListItem(
|
|
product = item,
|
|
modifier = modifier,
|
|
onClick = {
|
|
navController.navigate(
|
|
ProductDetailsDestination.createRouteWithParam(
|
|
item.id
|
|
)
|
|
)
|
|
},
|
|
)
|
|
},
|
|
directions = setOf(DismissDirection.EndToStart),
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
Text("Product list is empty!")
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun AddProductButton(
|
|
modifier: Modifier = Modifier,
|
|
onClick: () -> Unit,
|
|
) {
|
|
FloatingActionButton(
|
|
modifier = modifier,
|
|
onClick = onClick,
|
|
containerColor = MaterialTheme.colorScheme.primary,
|
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Filled.Add,
|
|
contentDescription = null,
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
Create the `ProductDetailsViewModel.kt`:
|
|
|
|
```kotlin
|
|
|
|
@HiltViewModel
|
|
class ProductDetailsViewModel @Inject constructor(
|
|
private val productRepository: ProductRepository,
|
|
savedStateHandle: SavedStateHandle,
|
|
) : ViewModel() {
|
|
|
|
private val _product = MutableStateFlow<Product?>(null)
|
|
val product: Flow<Product?> = _product
|
|
|
|
private val _name = MutableStateFlow("")
|
|
val name: Flow<String> = _name
|
|
|
|
private val _price = MutableStateFlow(0.0)
|
|
val price: Flow<Double> = _price
|
|
|
|
private val _imageUrl = MutableStateFlow("")
|
|
val imageUrl: Flow<String> = _imageUrl
|
|
|
|
init {
|
|
val productId = savedStateHandle.get<String>(ProductDetailsDestination.productId)
|
|
productId?.let {
|
|
getProduct(productId = it)
|
|
}
|
|
}
|
|
|
|
private fun getProduct(productId: String) {
|
|
viewModelScope.launch {
|
|
val result = productRepository.getProduct(productId).asDomainModel()
|
|
_product.emit(result)
|
|
_name.emit(result.name)
|
|
_price.emit(result.price)
|
|
}
|
|
}
|
|
|
|
fun onNameChange(name: String) {
|
|
_name.value = name
|
|
}
|
|
|
|
fun onPriceChange(price: Double) {
|
|
_price.value = price
|
|
}
|
|
|
|
fun onSaveProduct(image: ByteArray) {
|
|
viewModelScope.launch {
|
|
productRepository.updateProduct(
|
|
id = _product.value?.id,
|
|
price = _price.value,
|
|
name = _name.value,
|
|
imageFile = image,
|
|
imageName = "image_${_product.value.id}",
|
|
)
|
|
}
|
|
}
|
|
|
|
fun onImageChange(url: String) {
|
|
_imageUrl.value = url
|
|
}
|
|
|
|
private fun ProductDto.asDomainModel(): Product {
|
|
return Product(
|
|
id = this.id,
|
|
name = this.name,
|
|
price = this.price,
|
|
image = this.image
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
Create the `ProductDetailsScreen.kt`:
|
|
|
|
```kotlin
|
|
@OptIn(ExperimentalCoilApi::class)
|
|
@SuppressLint("UnusedMaterialScaffoldPaddingParameter")
|
|
@Composable
|
|
fun ProductDetailsScreen(
|
|
modifier: Modifier = Modifier,
|
|
viewModel: ProductDetailsViewModel = hiltViewModel(),
|
|
navController: NavController,
|
|
productId: String?,
|
|
) {
|
|
val snackBarHostState = remember { SnackbarHostState() }
|
|
val coroutineScope = rememberCoroutineScope()
|
|
|
|
Scaffold(
|
|
snackbarHost = { SnackbarHost(snackBarHostState) },
|
|
topBar = {
|
|
TopAppBar(
|
|
navigationIcon = {
|
|
IconButton(onClick = {
|
|
navController.navigateUp()
|
|
}) {
|
|
Icon(
|
|
imageVector = Icons.Filled.ArrowBack,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.onPrimary
|
|
)
|
|
}
|
|
},
|
|
backgroundColor = MaterialTheme.colorScheme.primary,
|
|
title = {
|
|
Text(
|
|
text = stringResource(R.string.product_details_text_screen_title),
|
|
color = MaterialTheme.colorScheme.onPrimary,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
) {
|
|
val name = viewModel.name.collectAsState(initial = "")
|
|
val price = viewModel.price.collectAsState(initial = 0.0)
|
|
var imageUrl = Uri.parse(viewModel.imageUrl.collectAsState(initial = null).value)
|
|
val contentResolver = LocalContext.current.contentResolver
|
|
|
|
Column(
|
|
modifier = modifier
|
|
.padding(16.dp)
|
|
.fillMaxSize()
|
|
) {
|
|
val galleryLauncher =
|
|
rememberLauncherForActivityResult(ActivityResultContracts.GetContent())
|
|
{ uri ->
|
|
uri?.let {
|
|
if (it.toString() != imageUrl.toString()) {
|
|
viewModel.onImageChange(it.toString())
|
|
}
|
|
}
|
|
}
|
|
|
|
Image(
|
|
painter = rememberImagePainter(imageUrl),
|
|
contentScale = ContentScale.Fit,
|
|
contentDescription = null,
|
|
modifier = Modifier
|
|
.padding(16.dp, 8.dp)
|
|
.size(100.dp)
|
|
.align(Alignment.CenterHorizontally)
|
|
)
|
|
IconButton(modifier = modifier.align(alignment = Alignment.CenterHorizontally),
|
|
onClick = {
|
|
galleryLauncher.launch("image/*")
|
|
}) {
|
|
Icon(
|
|
imageVector = Icons.Filled.Edit,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.primary
|
|
)
|
|
}
|
|
OutlinedTextField(
|
|
label = {
|
|
Text(
|
|
text = "Product name",
|
|
color = MaterialTheme.colorScheme.primary,
|
|
style = MaterialTheme.typography.titleMedium
|
|
)
|
|
},
|
|
maxLines = 2,
|
|
shape = RoundedCornerShape(32),
|
|
modifier = modifier.fillMaxWidth(),
|
|
value = name.value,
|
|
onValueChange = {
|
|
viewModel.onNameChange(it)
|
|
},
|
|
)
|
|
Spacer(modifier = modifier.height(12.dp))
|
|
OutlinedTextField(
|
|
label = {
|
|
Text(
|
|
text = "Product price",
|
|
color = MaterialTheme.colorScheme.primary,
|
|
style = MaterialTheme.typography.titleMedium
|
|
)
|
|
},
|
|
maxLines = 2,
|
|
shape = RoundedCornerShape(32),
|
|
modifier = modifier.fillMaxWidth(),
|
|
value = price.value.toString(),
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
onValueChange = {
|
|
viewModel.onPriceChange(it.toDouble())
|
|
},
|
|
)
|
|
Spacer(modifier = modifier.weight(1f))
|
|
Button(
|
|
modifier = modifier.fillMaxWidth(),
|
|
onClick = {
|
|
if (imageUrl.host?.contains("supabase") == true) {
|
|
viewModel.onSaveProduct(image = byteArrayOf())
|
|
} else {
|
|
val image = uriToByteArray(contentResolver, imageUrl)
|
|
viewModel.onSaveProduct(image = image)
|
|
}
|
|
coroutineScope.launch {
|
|
snackBarHostState.showSnackbar(
|
|
message = "Product updated successfully !",
|
|
duration = SnackbarDuration.Short
|
|
)
|
|
}
|
|
}) {
|
|
Text(text = "Save changes")
|
|
}
|
|
Spacer(modifier = modifier.height(12.dp))
|
|
OutlinedButton(
|
|
modifier = modifier
|
|
.fillMaxWidth(),
|
|
onClick = {
|
|
navController.navigateUp()
|
|
}) {
|
|
Text(text = "Cancel")
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
|
|
private fun getBytes(inputStream: InputStream): ByteArray {
|
|
val byteBuffer = ByteArrayOutputStream()
|
|
val bufferSize = 1024
|
|
val buffer = ByteArray(bufferSize)
|
|
var len = 0
|
|
while (inputStream.read(buffer).also { len = it } != -1) {
|
|
byteBuffer.write(buffer, 0, len)
|
|
}
|
|
return byteBuffer.toByteArray()
|
|
}
|
|
|
|
|
|
private fun uriToByteArray(contentResolver: ContentResolver, uri: Uri): ByteArray {
|
|
if (uri == Uri.EMPTY) {
|
|
return byteArrayOf()
|
|
}
|
|
val inputStream = contentResolver.openInputStream(uri)
|
|
if (inputStream != null) {
|
|
return getBytes(inputStream)
|
|
}
|
|
return byteArrayOf()
|
|
}
|
|
```
|
|
|
|
Create a `AddProductScreen`:
|
|
|
|
```kotlin
|
|
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun AddProductScreen(
|
|
modifier: Modifier = Modifier,
|
|
navController: NavController,
|
|
viewModel: AddProductViewModel = hiltViewModel(),
|
|
) {
|
|
Scaffold(
|
|
topBar = {
|
|
TopAppBar(
|
|
navigationIcon = {
|
|
IconButton(onClick = {
|
|
navController.navigateUp()
|
|
}) {
|
|
Icon(
|
|
imageVector = Icons.Filled.ArrowBack,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.onPrimary
|
|
)
|
|
}
|
|
},
|
|
backgroundColor = MaterialTheme.colorScheme.primary,
|
|
title = {
|
|
Text(
|
|
text = stringResource(R.string.add_product_text_screen_title),
|
|
color = MaterialTheme.colorScheme.onPrimary,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
) { padding ->
|
|
val navigateAddProductSuccess =
|
|
viewModel.navigateAddProductSuccess.collectAsState(initial = null).value
|
|
val isLoading =
|
|
viewModel.isLoading.collectAsState(initial = null).value
|
|
if (isLoading == true) {
|
|
LoadingScreen(message = "Adding Product",
|
|
onCancelSelected = {
|
|
navController.navigateUp()
|
|
})
|
|
} else {
|
|
SuccessScreen(
|
|
message = "Product added",
|
|
onMoreAction = {
|
|
viewModel.onAddMoreProductSelected()
|
|
},
|
|
onNavigateBack = {
|
|
navController.navigateUp()
|
|
})
|
|
}
|
|
|
|
}
|
|
}
|
|
```
|
|
|
|
Create the `AddProductViewModel.kt`:
|
|
|
|
```kotlin
|
|
@HiltViewModel
|
|
class AddProductViewModel @Inject constructor(
|
|
private val productRepository: ProductRepository,
|
|
) : ViewModel() {
|
|
|
|
private val _isLoading = MutableStateFlow(false)
|
|
val isLoading: Flow<Boolean> = _isLoading
|
|
|
|
private val _showSuccessMessage = MutableStateFlow(false)
|
|
val showSuccessMessage: Flow<Boolean> = _showSuccessMessage
|
|
|
|
fun onCreateProduct(name: String, price: Double) {
|
|
if (name.isEmpty() || price <= 0) return
|
|
viewModelScope.launch {
|
|
_isLoading.value = true
|
|
val product = Product(
|
|
id = UUID.randomUUID().toString(),
|
|
name = name,
|
|
price = price,
|
|
)
|
|
productRepository.createProduct(product = product)
|
|
_isLoading.value = false
|
|
_showSuccessMessage.emit(true)
|
|
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Create a `SignUpViewModel`:
|
|
|
|
```kotlin
|
|
@HiltViewModel
|
|
class SignUpViewModel @Inject constructor(
|
|
private val authenticationRepository: AuthenticationRepository
|
|
) : ViewModel() {
|
|
|
|
private val _email = MutableStateFlow("")
|
|
val email: Flow<String> = _email
|
|
|
|
private val _password = MutableStateFlow("")
|
|
val password = _password
|
|
|
|
fun onEmailChange(email: String) {
|
|
_email.value = email
|
|
}
|
|
|
|
fun onPasswordChange(password: String) {
|
|
_password.value = password
|
|
}
|
|
|
|
fun onSignUp() {
|
|
viewModelScope.launch {
|
|
authenticationRepository.signUp(
|
|
email = _email.value,
|
|
password = _password.value
|
|
)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Create the `SignUpScreen.kt`:
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun SignUpScreen(
|
|
modifier: Modifier = Modifier,
|
|
navController: NavController,
|
|
viewModel: SignUpViewModel = hiltViewModel()
|
|
) {
|
|
val snackBarHostState = remember { SnackbarHostState() }
|
|
val coroutineScope = rememberCoroutineScope()
|
|
Scaffold(
|
|
snackbarHost = { androidx.compose.material.SnackbarHost(snackBarHostState) },
|
|
topBar = {
|
|
TopAppBar(
|
|
navigationIcon = {
|
|
IconButton(onClick = {
|
|
navController.navigateUp()
|
|
}) {
|
|
Icon(
|
|
imageVector = Icons.Filled.ArrowBack,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.onPrimary
|
|
)
|
|
}
|
|
},
|
|
backgroundColor = MaterialTheme.colorScheme.primary,
|
|
title = {
|
|
Text(
|
|
text = "Sign Up",
|
|
color = MaterialTheme.colorScheme.onPrimary,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
) { paddingValues ->
|
|
Column(
|
|
modifier = modifier
|
|
.padding(paddingValues)
|
|
.padding(20.dp)
|
|
) {
|
|
val email = viewModel.email.collectAsState(initial = "")
|
|
val password = viewModel.password.collectAsState()
|
|
OutlinedTextField(
|
|
label = {
|
|
Text(
|
|
text = "Email",
|
|
color = MaterialTheme.colorScheme.primary,
|
|
style = MaterialTheme.typography.titleMedium
|
|
)
|
|
},
|
|
maxLines = 1,
|
|
shape = RoundedCornerShape(32),
|
|
modifier = modifier.fillMaxWidth(),
|
|
value = email.value,
|
|
onValueChange = {
|
|
viewModel.onEmailChange(it)
|
|
},
|
|
)
|
|
OutlinedTextField(
|
|
label = {
|
|
Text(
|
|
text = "Password",
|
|
color = MaterialTheme.colorScheme.primary,
|
|
style = MaterialTheme.typography.titleMedium
|
|
)
|
|
},
|
|
maxLines = 1,
|
|
shape = RoundedCornerShape(32),
|
|
modifier = modifier
|
|
.fillMaxWidth()
|
|
.padding(top = 12.dp),
|
|
value = password.value,
|
|
onValueChange = {
|
|
viewModel.onPasswordChange(it)
|
|
},
|
|
)
|
|
val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current
|
|
Button(modifier = modifier
|
|
.fillMaxWidth()
|
|
.padding(top = 12.dp),
|
|
onClick = {
|
|
localSoftwareKeyboardController?.hide()
|
|
viewModel.onSignUp()
|
|
coroutineScope.launch {
|
|
snackBarHostState.showSnackbar(
|
|
message = "Create account successfully. Sign in now!",
|
|
duration = SnackbarDuration.Long
|
|
)
|
|
}
|
|
}) {
|
|
Text("Sign up")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Create a `SignInViewModel`:
|
|
|
|
```kotlin
|
|
@HiltViewModel
|
|
class SignInViewModel @Inject constructor(
|
|
private val authenticationRepository: AuthenticationRepository
|
|
) : ViewModel() {
|
|
|
|
private val _email = MutableStateFlow("")
|
|
val email: Flow<String> = _email
|
|
|
|
private val _password = MutableStateFlow("")
|
|
val password = _password
|
|
|
|
fun onEmailChange(email: String) {
|
|
_email.value = email
|
|
}
|
|
|
|
fun onPasswordChange(password: String) {
|
|
_password.value = password
|
|
}
|
|
|
|
fun onSignIn() {
|
|
viewModelScope.launch {
|
|
authenticationRepository.signIn(
|
|
email = _email.value,
|
|
password = _password.value
|
|
)
|
|
}
|
|
}
|
|
|
|
fun onGoogleSignIn() {
|
|
viewModelScope.launch {
|
|
authenticationRepository.signInWithGoogle()
|
|
}
|
|
}
|
|
|
|
}
|
|
```
|
|
|
|
Create the `SignInScreen.kt`:
|
|
|
|
```kotlin
|
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
|
@Composable
|
|
fun SignInScreen(
|
|
modifier: Modifier = Modifier,
|
|
navController: NavController,
|
|
viewModel: SignInViewModel = hiltViewModel()
|
|
) {
|
|
val snackBarHostState = remember { SnackbarHostState() }
|
|
val coroutineScope = rememberCoroutineScope()
|
|
Scaffold(
|
|
snackbarHost = { androidx.compose.material.SnackbarHost(snackBarHostState) },
|
|
topBar = {
|
|
TopAppBar(
|
|
navigationIcon = {
|
|
IconButton(onClick = {
|
|
navController.navigateUp()
|
|
}) {
|
|
Icon(
|
|
imageVector = Icons.Filled.ArrowBack,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.onPrimary
|
|
)
|
|
}
|
|
},
|
|
backgroundColor = MaterialTheme.colorScheme.primary,
|
|
title = {
|
|
Text(
|
|
text = "Login",
|
|
color = MaterialTheme.colorScheme.onPrimary,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
) { paddingValues ->
|
|
Column(
|
|
modifier = modifier
|
|
.padding(paddingValues)
|
|
.padding(20.dp)
|
|
) {
|
|
val email = viewModel.email.collectAsState(initial = "")
|
|
val password = viewModel.password.collectAsState()
|
|
androidx.compose.material.OutlinedTextField(
|
|
label = {
|
|
Text(
|
|
text = "Email",
|
|
color = MaterialTheme.colorScheme.primary,
|
|
style = MaterialTheme.typography.titleMedium
|
|
)
|
|
},
|
|
maxLines = 1,
|
|
shape = RoundedCornerShape(32),
|
|
modifier = modifier.fillMaxWidth(),
|
|
value = email.value,
|
|
onValueChange = {
|
|
viewModel.onEmailChange(it)
|
|
},
|
|
)
|
|
androidx.compose.material.OutlinedTextField(
|
|
label = {
|
|
Text(
|
|
text = "Password",
|
|
color = MaterialTheme.colorScheme.primary,
|
|
style = MaterialTheme.typography.titleMedium
|
|
)
|
|
},
|
|
maxLines = 1,
|
|
shape = RoundedCornerShape(32),
|
|
modifier = modifier
|
|
.fillMaxWidth()
|
|
.padding(top = 12.dp),
|
|
value = password.value,
|
|
onValueChange = {
|
|
viewModel.onPasswordChange(it)
|
|
},
|
|
)
|
|
val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current
|
|
Button(modifier = modifier
|
|
.fillMaxWidth()
|
|
.padding(top = 12.dp),
|
|
onClick = {
|
|
localSoftwareKeyboardController?.hide()
|
|
viewModel.onGoogleSignIn()
|
|
}) {
|
|
Text("Sign in with Google")
|
|
}
|
|
Button(modifier = modifier
|
|
.fillMaxWidth()
|
|
.padding(top = 12.dp),
|
|
onClick = {
|
|
localSoftwareKeyboardController?.hide()
|
|
viewModel.onSignIn()
|
|
coroutineScope.launch {
|
|
snackBarHostState.showSnackbar(
|
|
message = "Sign in successfully !",
|
|
duration = SnackbarDuration.Long
|
|
)
|
|
}
|
|
}) {
|
|
Text("Sign in")
|
|
}
|
|
OutlinedButton(modifier = modifier
|
|
.fillMaxWidth()
|
|
.padding(top = 12.dp), onClick = {
|
|
navController.navigate(SignUpDestination.route)
|
|
}) {
|
|
Text("Sign up")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Implement the `MainActivity`
|
|
|
|
In the `MainActivity` you created earlier, show your newly created screens:
|
|
|
|
```kotlin
|
|
@AndroidEntryPoint
|
|
class MainActivity : ComponentActivity() {
|
|
@Inject
|
|
lateinit var supabaseClient: SupabaseClient
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
setContent {
|
|
ManageProductsTheme {
|
|
// A surface container using the 'background' color from the theme
|
|
val navController = rememberNavController()
|
|
val currentBackStack by navController.currentBackStackEntryAsState()
|
|
val currentDestination = currentBackStack?.destination
|
|
Scaffold { innerPadding ->
|
|
NavHost(
|
|
navController,
|
|
startDestination = ProductListDestination.route,
|
|
Modifier.padding(innerPadding)
|
|
) {
|
|
composable(ProductListDestination.route) {
|
|
ProductListScreen(
|
|
navController = navController
|
|
)
|
|
}
|
|
|
|
composable(AuthenticationDestination.route) {
|
|
SignInScreen(
|
|
navController = navController
|
|
)
|
|
}
|
|
|
|
composable(SignUpDestination.route) {
|
|
SignUpScreen(
|
|
navController = navController
|
|
)
|
|
}
|
|
|
|
composable(AddProductDestination.route) {
|
|
AddProductScreen(
|
|
navController = navController
|
|
)
|
|
}
|
|
|
|
composable(
|
|
route = "${ProductDetailsDestination.route}/{${ProductDetailsDestination.productId}}",
|
|
arguments = ProductDetailsDestination.arguments
|
|
) { navBackStackEntry ->
|
|
val productId =
|
|
navBackStackEntry.arguments?.getString(ProductDetailsDestination.productId)
|
|
ProductDetailsScreen(
|
|
productId = productId,
|
|
navController = navController,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Create the success screen
|
|
|
|
To handle OAuth and OTP signins, create a new activity to handle the deep link you set in `AndroidManifest.xml`:
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
xmlns:tools="http://schemas.android.com/tools">
|
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
<application
|
|
android:name=".ManageProductApplication"
|
|
android:allowBackup="true"
|
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
|
android:enableOnBackInvokedCallback="true"
|
|
android:fullBackupContent="@xml/backup_rules"
|
|
android:icon="@mipmap/ic_launcher"
|
|
android:label="@string/app_name"
|
|
android:supportsRtl="true"
|
|
android:theme="@style/Theme.ManageProducts"
|
|
tools:targetApi="31">
|
|
<activity
|
|
android:name=".DeepLinkHandlerActivity"
|
|
android:exported="true"
|
|
android:theme="@style/Theme.ManageProducts" >
|
|
<intent-filter android:autoVerify="true">
|
|
<action android:name="android.intent.action.VIEW" />
|
|
<category android:name="android.intent.category.DEFAULT" />
|
|
<category android:name="android.intent.category.BROWSABLE" />
|
|
<data
|
|
android:host="supabase.com"
|
|
android:scheme="app" />
|
|
</intent-filter>
|
|
</activity>
|
|
<activity
|
|
android:name=".MainActivity"
|
|
android:exported="true"
|
|
android:label="@string/app_name"
|
|
android:theme="@style/Theme.ManageProducts">
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.MAIN" />
|
|
<category android:name="android.intent.category.LAUNCHER" />
|
|
</intent-filter>
|
|
</activity>
|
|
</application>
|
|
</manifest>
|
|
```
|
|
|
|
Then create the `DeepLinkHandlerActivity`:
|
|
|
|
```kotlin
|
|
@AndroidEntryPoint
|
|
class DeepLinkHandlerActivity : ComponentActivity() {
|
|
|
|
@Inject
|
|
lateinit var supabaseClient: SupabaseClient
|
|
|
|
private lateinit var callback: (String, String) -> Unit
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
supabaseClient.handleDeeplinks(intent = intent,
|
|
onSessionSuccess = { userSession ->
|
|
Log.d("LOGIN", "Log in successfully with user info: ${userSession.user}")
|
|
userSession.user?.apply {
|
|
callback(email ?: "", createdAt.toString())
|
|
}
|
|
})
|
|
setContent {
|
|
val navController = rememberNavController()
|
|
val emailState = remember { mutableStateOf("") }
|
|
val createdAtState = remember { mutableStateOf("") }
|
|
LaunchedEffect(Unit) {
|
|
callback = { email, created ->
|
|
emailState.value = email
|
|
createdAtState.value = created
|
|
}
|
|
}
|
|
ManageProductsTheme {
|
|
Surface(
|
|
modifier = Modifier.fillMaxSize(),
|
|
color = MaterialTheme.colorScheme.background
|
|
) {
|
|
SignInSuccessScreen(
|
|
modifier = Modifier.padding(20.dp),
|
|
navController = navController,
|
|
email = emailState.value,
|
|
createdAt = createdAtState.value,
|
|
onClick = { navigateToMainApp() }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun navigateToMainApp() {
|
|
val intent = Intent(this, MainActivity::class.java).apply {
|
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
}
|
|
startActivity(intent)
|
|
}
|
|
}
|
|
```
|