Files
Charis 2709fa4a3e feat: pre-compile-time partials (#34028)
Partials are currently defined via MDX includes. This PR switches to pre-compile-time partials, which have a new syntax:

```
<$Partial path="path/to/file.mdx" />
```

## Rationale

This produces two improvements:

1. Partial substitution can occur in pipelines that don't use MDX compilation. For example, we can now do partial substitution before building the search index, so partial content will also be indexed.
2. After the App Router migration, the MDXProviders should've been deprecated, but were kept around for the sole reason of making partials work, and leading to us shipping unnecessary client-side code. We get a minor decrease in overall client bundle size (5.74 MB to 5.6 MB) by getting rid of the Providers.

## Breaking changes

Besides the change to partial syntax, the arguments are also less powerful than before because we are doing string substitution and don't have the full power of JS. Defining string variables is still possible (documented in the Contributing guide), and since that's all we actually do in practice, this shouldn't be too cumbersome. There is always the escape hatch of making a custom component for more complex content reuse cases.
2025-03-18 10:37:39 -04:00

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) - 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.
![manage-product-cover](/docs/img/guides/kotlin/manage-product-cover.png)
<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" />
## Building the app
### Create new Android project
Open Android Studio > New Project > Base Activity (Jetpack Compose).
![Android Studio new project](/docs/img/guides/kotlin/android-studio-new-project.png)
### 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_ANON_KEY=YOUR_SUPABASE_ANON_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_ANON_KEY", "\"${properties.getProperty("SUPABASE_ANON_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_ANON_KEY
```
### Set up Supabase dependencies
![Gradle dependencies](/docs/img/guides/kotlin/gradle-dependencies.png)
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_ANON_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)
}
}
```