* docs: indicate publishable key instead of anon in many examples * replace your-anon-key to string indicating publishable or anon * fix your_... * apply suggestion from @ChrisChinchilla Co-authored-by: Chris Chinchilla <chris@chrischinchilla.com> * Update keys in code examples * Prettier fix * Update apps/docs/content/guides/functions/schedule-functions.mdx --------- Co-authored-by: Chris Chinchilla <chris@chrischinchilla.com>
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) - 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" />
|
|
|
|
## 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)
|
|
}
|
|
}
|
|
```
|