Build a Product Management Android App with Jetpack Compose
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 - a Postgres database for storing your user data and Row Level Security so data is protected and users can only access their own information.
- Supabase Auth - users log in through magic links sent to their email (without having to set up a password).
- Supabase Storage - users can upload a profile photo.
note
If you get stuck while working through this guide, refer to the full example on GitHub.
Project setup#
Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase and then creating a "schema" inside the database.
Create a project#
- Create a new project in the Supabase Dashboard.
- Enter your project details.
- Wait for the new database to launch.
Set up the database schema#
Now we are going to set up the database schema. You can just copy/paste the SQL from below and run it yourself.
_32-- Create a table for public profiles_32_32create table_32 public.products (_32 id uuid not null default gen_random_uuid (),_32 name text not null,_32 price real not null,_32 image text null,_32 constraint products_pkey primary key (id)_32 ) tablespace pg_default;_32_32-- Set up Storage!_32insert into storage.buckets (id, name)_32 values ('Product Image', 'Product Image');_32_32-- Set up access controls for storage._32-- See https://supabase.com/docs/guides/storage#policy-examples for more details._32CREATE POLICY "Enable read access for all users" ON "storage"."objects"_32AS PERMISSIVE FOR SELECT_32TO public_32USING (true)_32_32CREATE POLICY "Enable insert for all users" ON "storage"."objects"_32AS PERMISSIVE FOR INSERT_32TO authenticated, anon_32WITH CHECK (true)_32_32CREATE POLICY "Enable update for all users" ON "storage"."objects"_32AS PERMISSIVE FOR UPDATE_32TO public_32USING (true)_32WITH CHECK (true)
Get the API Keys#
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
We just need to get the Project URL and anon
key from the API settings.
- Go to the API Settings page in the Dashboard.
- Find your Project
URL
,anon
, andservice_role
keys on this page.
Set up Google Authentication#
From the Google Console, create a new project and add OAuth2 credentials.
In your Supabase Auth settings enable Google as a provider and set the required credentials as outlined in the auth docs.
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, e.g. by adding it to your
.gitignore
file!
_10SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY_10SUPABASE_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:
_15defaultConfig {_15 applicationId "com.example.manageproducts"_15 minSdkVersion 22_15 targetSdkVersion 33_15 versionCode 5_15 versionName "1.0"_15 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"_15_15 // Set value part_15 Properties properties = new Properties()_15 properties.load(project.rootProject.file("local.properties").newDataInputStream())_15 buildConfigField("String", "SUPABASE_ANON_KEY", "\"${properties.getProperty("SUPABASE_ANON_KEY")}\"")_15 buildConfigField("String", "SECRET", "\"${properties.getProperty("SECRET")}\"")_15 buildConfigField("String", "SUPABASE_URL", "\"${properties.getProperty("SUPABASE_URL")}\"")_15}
Use value from BuildConfig
Read the value from BuildConfig
:
_10val url = BuildConfig.SUPABASE_URL_10val apiKey = BuildConfig.SUPABASE_ANON_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.
_10implementation "io.github.jan-tennert.supabase:postgrest-kt:$supabase_version"_10implementation "io.github.jan-tennert.supabase:storage-kt:$supabase_version"_10implementation "io.github.jan-tennert.supabase:gotrue-kt:$supabase_version"_10implementation "io.ktor:ktor-client-android:$ktor_version"_10implementation "io.ktor:ktor-client-core:$ktor_version"_10implementation "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.
_10plugins {_10 ..._10 id 'org.jetbrains.kotlin.plugin.serialization' version '$kotlin_version'_10 ..._10}
Set up Hilt for dependency injection#
In the build.gradle
(app) file, add the following:
_10implementation "com.google.dagger:hilt-android:$hilt_version"_10annotationProcessor "com.google.dagger:hilt-compiler:$hilt_version"_10implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
Create a new ManageProductApplication.kt
class extending Application with @HiltAndroidApp
annotation:
_10// ManageProductApplication.kt_10@HiltAndroidApp_10class ManageProductApplication: Application()
Open the AndroidManifest.xml
file, update name property of Application tag:
_10<application_10..._10 android:name=".ManageProductApplication"_10..._10</application>
Add annotation for the MainActivity
:
_10@AndroidEntryPoint_10class MainActivity : ComponentActivity() {_10}
Provide Supabase instances with Hilt#
To make the app easier to test, create a SupabaseMdule.kt
file as follows:
_41@InstallIn(SingletonComponent::class)_41@Module_41object SupabaseModule {_41_41 @Provides_41 @Singleton_41 fun provideSupabaseClient(): SupabaseClient {_41 return createSupabaseClient(_41 supabaseUrl = BuildConfig.SUPABASE_URL,_41 supabaseKey = BuildConfig.SUPABASE_ANON_KEY_41 ) {_41 install(Postgrest)_41 install(GoTrue) {_41 flowType = FlowType.PKCE_41 scheme = "app"_41 host = "supabase.com"_41 }_41 install(Storage)_41 }_41 }_41_41 @Provides_41 @Singleton_41 fun provideSupabaseDatabase(client: SupabaseClient): Postgrest {_41 return client.postgrest_41 }_41_41 @Provides_41 @Singleton_41 fun provideSupabaseGoTrue(client: SupabaseClient): GoTrue {_41 return client.gotrue_41 }_41_41_41 @Provides_41 @Singleton_41 fun provideSupabaseStorage(client: SupabaseClient): Storage {_41 return client.storage_41 }_41_41}
Create a data transfer object#
Create a ProductDto.kt
class and use annotations to parse data from Supabase:
_15@Serializable_15data class ProductDto(_15_15 @SerialName("name")_15 val name: String,_15_15 @SerialName("price")_15 val price: Double,_15_15 @SerialName("image")_15 val image: String?,_15_15 @SerialName("id")_15 val id: String,_15)
Create a Domain object in Product.kt
expose the data in your view:
_10data class Product(_10 val id: String,_10 val name: String,_10 val price: Double,_10 val image: String?_10)
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:
_10interface ProductRepository {_10 suspend fun createProduct(product: Product): Boolean_10 suspend fun getProducts(): List<ProductDto>?_10 suspend fun getProduct(id: String): ProductDto_10 suspend fun deleteProduct(id: String)_10 suspend fun updateProduct(_10 id: String, name: String, price: Double, imageName: String, imageFile: ByteArray_10 )_10}
_83class ProductRepositoryImpl @Inject constructor(_83 private val postgrest: Postgrest,_83 private val storage: Storage,_83) : ProductRepository {_83 override suspend fun createProduct(product: Product): Boolean {_83 return try {_83 withContext(Dispatchers.IO) {_83 val productDto = ProductDto(_83 name = product.name,_83 price = product.price,_83 )_83 postgrest["products"].insert(productDto)_83 true_83 }_83 true_83 } catch (e: java.lang.Exception) {_83 throw e_83 }_83 }_83_83 override suspend fun getProducts(): List<ProductDto>? {_83 return withContext(Dispatchers.IO) {_83 val result = postgrest["products"]_83 .select().decodeList<ProductDto>()_83 result_83 }_83 }_83_83_83 override suspend fun getProduct(id: String): ProductDto {_83 return withContext(Dispatchers.IO) {_83 postgrest["products"].select {_83 eq("id", id)_83 }.decodeSingle<ProductDto>()_83 }_83 }_83_83 override suspend fun deleteProduct(id: String) {_83 return withContext(Dispatchers.IO) {_83 postgrest["products"].delete {_83 eq("id", id)_83 }_83 }_83 }_83_83 override suspend fun updateProduct(_83 id: String,_83 name: String,_83 price: Double,_83 imageName: String,_83 imageFile: ByteArray_83 ) {_83 withContext(Dispatchers.IO) {_83 if (imageFile.isNotEmpty()) {_83 val imageUrl =_83 storage["Product%20Image"].upload(_83 path = "$imageName.png",_83 data = imageFile,_83 upsert = true_83 )_83 postgrest["products"].update({_83 set("name", name)_83 set("price", price)_83 set("image", buildImageUrl(imageFileName = imageUrl))_83 }) {_83 eq("id", id)_83 }_83 } else {_83 postgrest["products"].update({_83 set("name", name)_83 set("price", price)_83 }) {_83 eq("id", id)_83 }_83 }_83 }_83 }_83_83 // Because I named the bucket as "Product Image" so when it turns to an url, it is "%20"_83 // For better approach, you should create your bucket name without space symbol_83 private fun buildImageUrl(imageFileName: String) =_83 "${BuildConfig.SUPABASE_URL}/storage/v1/object/public/${imageFileName}".replace(" ", "%20")_83}
Create the Authentication Repository:
_10interface AuthenticationRepository {_10 suspend fun signIn(email: String, password: String): Boolean_10 suspend fun signUp(email: String, password: String): Boolean_10 suspend fun signInWithGoogle(): Boolean_10}
_36class AuthenticationRepositoryImpl @Inject constructor(_36 private val goTrue: GoTrue_36) : AuthenticationRepository {_36 override suspend fun signIn(email: String, password: String): Boolean {_36 return try {_36 goTrue.loginWith(Email) {_36 this.email = email_36 this.password = password_36 }_36 true_36 } catch (e: Exception) {_36 false_36 }_36 }_36_36 override suspend fun signUp(email: String, password: String): Boolean {_36 return try {_36 goTrue.signUpWith(Email) {_36 this.email = email_36 this.password = password_36 }_36 true_36 } catch (e: Exception) {_36 false_36 }_36 }_36_36 override suspend fun signInWithGoogle(): Boolean {_36 return try {_36 goTrue.loginWith(Google)_36 true_36 } catch (e: Exception) {_36 false_36 }_36 }_36}
Implement screens#
Create a ProductListViewModel
:
_45@HiltViewModel_45class ProductListViewModel @Inject constructor(_45private val productRepository: ProductRepository,_45) : ViewModel() {_45_45 private val _productList = MutableStateFlow<List<Product>?>(listOf())_45 val productList: Flow<List<Product>?> = _productList_45_45_45 private val _isLoading = MutableStateFlow(false)_45 val isLoading: Flow<Boolean> = _isLoading_45_45 init {_45 getProducts()_45 }_45_45 fun getProducts() {_45 viewModelScope.launch {_45 val products = productRepository.getProducts()_45 _productList.emit(products?.map { it -> it.asDomainModel() })_45 }_45 }_45_45 fun removeItem(product: Product) {_45 viewModelScope.launch {_45 val newList = mutableListOf<Product>().apply { _productList.value?.let { addAll(it) } }_45 newList.remove(product)_45 _productList.emit(newList.toList())_45 // Call api to remove_45 productRepository.deleteProduct(id = product.id)_45 // Then fetch again_45 getProducts()_45 }_45 }_45_45 private fun ProductDto.asDomainModel(): Product {_45 return Product(_45 id = this.id,_45 name = this.name,_45 price = this.price,_45 image = this.image_45 )_45 }_45_45}
Create the ProductListScreen.kt
:
_113@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)_113@Composable_113fun ProductListScreen(_113 modifier: Modifier = Modifier,_113 navController: NavController,_113 viewModel: ProductListViewModel = hiltViewModel(),_113) {_113 val isLoading by viewModel.isLoading.collectAsState(initial = false)_113 val swipeRefreshState = rememberSwipeRefreshState(isRefreshing = isLoading)_113 SwipeRefresh(state = swipeRefreshState, onRefresh = { viewModel.getProducts() }) {_113 Scaffold(_113 topBar = {_113 TopAppBar(_113 backgroundColor = MaterialTheme.colorScheme.primary,_113 title = {_113 Text(_113 text = stringResource(R.string.product_list_text_screen_title),_113 color = MaterialTheme.colorScheme.onPrimary,_113 )_113 },_113 )_113 },_113 floatingActionButton = {_113 AddProductButton(onClick = { navController.navigate(AddProductDestination.route) })_113 }_113 ) { padding ->_113 val productList = viewModel.productList.collectAsState(initial = listOf()).value_113 if (!productList.isNullOrEmpty()) {_113 LazyColumn(_113 modifier = modifier.padding(padding),_113 contentPadding = PaddingValues(5.dp)_113 ) {_113 itemsIndexed(_113 items = productList,_113 key = { _, product -> product.name }) { _, item ->_113 val state = rememberDismissState(_113 confirmStateChange = {_113 if (it == DismissValue.DismissedToStart) {_113 // Handle item removed_113 viewModel.removeItem(item)_113 }_113 true_113 }_113 )_113 SwipeToDismiss(_113 state = state,_113 background = {_113 val color by animateColorAsState(_113 targetValue = when (state.dismissDirection) {_113 DismissDirection.StartToEnd -> MaterialTheme.colorScheme.primary_113 DismissDirection.EndToStart -> MaterialTheme.colorScheme.primary.copy(_113 alpha = 0.2f_113 )_113 null -> Color.Transparent_113 }_113 )_113 Box(_113 modifier = modifier_113 .fillMaxSize()_113 .background(color = color)_113 .padding(16.dp),_113 ) {_113 Icon(_113 imageVector = Icons.Filled.Delete,_113 contentDescription = null,_113 tint = MaterialTheme.colorScheme.primary,_113 modifier = modifier.align(Alignment.CenterEnd)_113 )_113 }_113_113 },_113 dismissContent = {_113 ProductListItem(_113 product = item,_113 modifier = modifier,_113 onClick = {_113 navController.navigate(_113 ProductDetailsDestination.createRouteWithParam(_113 item.id_113 )_113 )_113 },_113 )_113 },_113 directions = setOf(DismissDirection.EndToStart),_113 )_113 }_113 }_113 } else {_113 Text("Product list is empty!")_113 }_113_113 }_113 }_113}_113_113@Composable_113private fun AddProductButton(_113 modifier: Modifier = Modifier,_113 onClick: () -> Unit,_113) {_113 FloatingActionButton(_113 modifier = modifier,_113 onClick = onClick,_113 containerColor = MaterialTheme.colorScheme.primary,_113 contentColor = MaterialTheme.colorScheme.onPrimary_113 ) {_113 Icon(_113 imageVector = Icons.Filled.Add,_113 contentDescription = null,_113 )_113 }_113}
Create the ProductDetailsViewModel.kt
:
_68_68@HiltViewModel_68class ProductDetailsViewModel @Inject constructor(_68 private val productRepository: ProductRepository,_68 savedStateHandle: SavedStateHandle,_68 ) : ViewModel() {_68_68 private val _product = MutableStateFlow<Product?>(null)_68 val product: Flow<Product?> = _product_68_68 private val _name = MutableStateFlow("")_68 val name: Flow<String> = _name_68_68 private val _price = MutableStateFlow(0.0)_68 val price: Flow<Double> = _price_68_68 private val _imageUrl = MutableStateFlow("")_68 val imageUrl: Flow<String> = _imageUrl_68_68 init {_68 val productId = savedStateHandle.get<String>(ProductDetailsDestination.productId)_68 productId?.let {_68 getProduct(productId = it)_68 }_68 }_68_68 private fun getProduct(productId: String) {_68 viewModelScope.launch {_68 val result = productRepository.getProduct(productId).asDomainModel()_68 _product.emit(result)_68 _name.emit(result.name)_68 _price.emit(result.price)_68 }_68 }_68_68 fun onNameChange(name: String) {_68 _name.value = name_68 }_68_68 fun onPriceChange(price: Double) {_68 _price.value = price_68 }_68_68 fun onSaveProduct(image: ByteArray) {_68 viewModelScope.launch {_68 productRepository.updateProduct(_68 id = _product.value?.id,_68 price = _price.value,_68 name = _name.value,_68 imageFile = image,_68 imageName = "image_${_product.value.id}",_68 )_68 }_68 }_68_68 fun onImageChange(url: String) {_68 _imageUrl.value = url_68 }_68_68 private fun ProductDto.asDomainModel(): Product {_68 return Product(_68 id = this.id,_68 name = this.name,_68 price = this.price,_68 image = this.image_68 )_68 }_68}
Create the ProductDetailsScreen.kt
:
_167@OptIn(ExperimentalCoilApi::class)_167@SuppressLint("UnusedMaterialScaffoldPaddingParameter")_167@Composable_167fun ProductDetailsScreen(_167 modifier: Modifier = Modifier,_167 viewModel: ProductDetailsViewModel = hiltViewModel(),_167 navController: NavController,_167 productId: String?,_167) {_167 val snackBarHostState = remember { SnackbarHostState() }_167 val coroutineScope = rememberCoroutineScope()_167_167 Scaffold(_167 snackbarHost = { SnackbarHost(snackBarHostState) },_167 topBar = {_167 TopAppBar(_167 navigationIcon = {_167 IconButton(onClick = {_167 navController.navigateUp()_167 }) {_167 Icon(_167 imageVector = Icons.Filled.ArrowBack,_167 contentDescription = null,_167 tint = MaterialTheme.colorScheme.onPrimary_167 )_167 }_167 },_167 backgroundColor = MaterialTheme.colorScheme.primary,_167 title = {_167 Text(_167 text = stringResource(R.string.product_details_text_screen_title),_167 color = MaterialTheme.colorScheme.onPrimary,_167 )_167 },_167 )_167 }_167 ) {_167 val name = viewModel.name.collectAsState(initial = "")_167 val price = viewModel.price.collectAsState(initial = 0.0)_167 var imageUrl = Uri.parse(viewModel.imageUrl.collectAsState(initial = null).value)_167 val contentResolver = LocalContext.current.contentResolver_167_167 Column(_167 modifier = modifier_167 .padding(16.dp)_167 .fillMaxSize()_167 ) {_167 val galleryLauncher =_167 rememberLauncherForActivityResult(ActivityResultContracts.GetContent())_167 { uri ->_167 uri?.let {_167 if (it.toString() != imageUrl.toString()) {_167 viewModel.onImageChange(it.toString())_167 }_167 }_167 }_167_167 Image(_167 painter = rememberImagePainter(imageUrl),_167 contentScale = ContentScale.Fit,_167 contentDescription = null,_167 modifier = Modifier_167 .padding(16.dp, 8.dp)_167 .size(100.dp)_167 .align(Alignment.CenterHorizontally)_167 )_167 IconButton(modifier = modifier.align(alignment = Alignment.CenterHorizontally),_167 onClick = {_167 galleryLauncher.launch("image/*")_167 }) {_167 Icon(_167 imageVector = Icons.Filled.Edit,_167 contentDescription = null,_167 tint = MaterialTheme.colorScheme.primary_167 )_167 }_167 OutlinedTextField(_167 label = {_167 Text(_167 text = "Product name",_167 color = MaterialTheme.colorScheme.primary,_167 style = MaterialTheme.typography.titleMedium_167 )_167 },_167 maxLines = 2,_167 shape = RoundedCornerShape(32),_167 modifier = modifier.fillMaxWidth(),_167 value = name.value,_167 onValueChange = {_167 viewModel.onNameChange(it)_167 },_167 )_167 Spacer(modifier = modifier.height(12.dp))_167 OutlinedTextField(_167 label = {_167 Text(_167 text = "Product price",_167 color = MaterialTheme.colorScheme.primary,_167 style = MaterialTheme.typography.titleMedium_167 )_167 },_167 maxLines = 2,_167 shape = RoundedCornerShape(32),_167 modifier = modifier.fillMaxWidth(),_167 value = price.value.toString(),_167 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),_167 onValueChange = {_167 viewModel.onPriceChange(it.toDouble())_167 },_167 )_167 Spacer(modifier = modifier.weight(1f))_167 Button(_167 modifier = modifier.fillMaxWidth(),_167 onClick = {_167 if (imageUrl.host?.contains("supabase") == true) {_167 viewModel.onSaveProduct(image = byteArrayOf())_167 } else {_167 val image = uriToByteArray(contentResolver, imageUrl)_167 viewModel.onSaveProduct(image = image)_167 }_167 coroutineScope.launch {_167 snackBarHostState.showSnackbar(_167 message = "Product updated successfully !",_167 duration = SnackbarDuration.Short_167 )_167 }_167 }) {_167 Text(text = "Save changes")_167 }_167 Spacer(modifier = modifier.height(12.dp))_167 OutlinedButton(_167 modifier = modifier_167 .fillMaxWidth(),_167 onClick = {_167 navController.navigateUp()_167 }) {_167 Text(text = "Cancel")_167 }_167_167 }_167_167 }_167}_167_167_167private fun getBytes(inputStream: InputStream): ByteArray {_167 val byteBuffer = ByteArrayOutputStream()_167 val bufferSize = 1024_167 val buffer = ByteArray(bufferSize)_167 var len = 0_167 while (inputStream.read(buffer).also { len = it } != -1) {_167 byteBuffer.write(buffer, 0, len)_167 }_167 return byteBuffer.toByteArray()_167}_167_167_167private fun uriToByteArray(contentResolver: ContentResolver, uri: Uri): ByteArray {_167 if (uri == Uri.EMPTY) {_167 return byteArrayOf()_167 }_167 val inputStream = contentResolver.openInputStream(uri)_167 if (inputStream != null) {_167 return getBytes(inputStream)_167 }_167 return byteArrayOf()_167}
Create a AddProductScreen:
_54@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")_54@OptIn(ExperimentalMaterial3Api::class)_54@Composable_54fun AddProductScreen(_54 modifier: Modifier = Modifier,_54 navController: NavController,_54 viewModel: AddProductViewModel = hiltViewModel(),_54) {_54 Scaffold(_54 topBar = {_54 TopAppBar(_54 navigationIcon = {_54 IconButton(onClick = {_54 navController.navigateUp()_54 }) {_54 Icon(_54 imageVector = Icons.Filled.ArrowBack,_54 contentDescription = null,_54 tint = MaterialTheme.colorScheme.onPrimary_54 )_54 }_54 },_54 backgroundColor = MaterialTheme.colorScheme.primary,_54 title = {_54 Text(_54 text = stringResource(R.string.add_product_text_screen_title),_54 color = MaterialTheme.colorScheme.onPrimary,_54 )_54 },_54 )_54 }_54 ) { padding ->_54 val navigateAddProductSuccess =_54 viewModel.navigateAddProductSuccess.collectAsState(initial = null).value_54 val isLoading =_54 viewModel.isLoading.collectAsState(initial = null).value_54 if (isLoading == true) {_54 LoadingScreen(message = "Adding Product",_54 onCancelSelected = {_54 navController.navigateUp()_54 })_54 } else {_54 SuccessScreen(_54 message = "Product added",_54 onMoreAction = {_54 viewModel.onAddMoreProductSelected()_54 },_54 onNavigateBack = {_54 navController.navigateUp()_54 })_54 }_54_54 }_54}
Create the AddProductViewModel.kt
:
_27@HiltViewModel_27class AddProductViewModel @Inject constructor(_27 private val productRepository: ProductRepository,_27) : ViewModel() {_27_27 private val _isLoading = MutableStateFlow(false)_27 val isLoading: Flow<Boolean> = _isLoading_27_27 private val _showSuccessMessage = MutableStateFlow(false)_27 val showSuccessMessage: Flow<Boolean> = _showSuccessMessage_27_27 fun onCreateProduct(name: String, price: Double) {_27 if (name.isEmpty() || price <= 0) return_27 viewModelScope.launch {_27 _isLoading.value = true_27 val product = Product(_27 id = UUID.randomUUID().toString(),_27 name = name,_27 price = price,_27 )_27 productRepository.createProduct(product = product)_27 _isLoading.value = false_27 _showSuccessMessage.emit(true)_27_27 }_27 }_27}
Create a SignUpViewModel
:
_28@HiltViewModel_28class SignUpViewModel @Inject constructor(_28 private val authenticationRepository: AuthenticationRepository_28) : ViewModel() {_28_28 private val _email = MutableStateFlow("")_28 val email: Flow<String> = _email_28_28 private val _password = MutableStateFlow("")_28 val password = _password_28_28 fun onEmailChange(email: String) {_28 _email.value = email_28 }_28_28 fun onPasswordChange(password: String) {_28 _password.value = password_28 }_28_28 fun onSignUp() {_28 viewModelScope.launch {_28 authenticationRepository.signUp(_28 email = _email.value,_28 password = _password.value_28 )_28 }_28 }_28}
Create the SignUpScreen.kt
:
_93@Composable_93fun SignUpScreen(_93 modifier: Modifier = Modifier,_93 navController: NavController,_93 viewModel: SignUpViewModel = hiltViewModel()_93) {_93 val snackBarHostState = remember { SnackbarHostState() }_93 val coroutineScope = rememberCoroutineScope()_93 Scaffold(_93 snackbarHost = { androidx.compose.material.SnackbarHost(snackBarHostState) },_93 topBar = {_93 TopAppBar(_93 navigationIcon = {_93 IconButton(onClick = {_93 navController.navigateUp()_93 }) {_93 Icon(_93 imageVector = Icons.Filled.ArrowBack,_93 contentDescription = null,_93 tint = MaterialTheme.colorScheme.onPrimary_93 )_93 }_93 },_93 backgroundColor = MaterialTheme.colorScheme.primary,_93 title = {_93 Text(_93 text = "Sign Up",_93 color = MaterialTheme.colorScheme.onPrimary,_93 )_93 },_93 )_93 }_93 ) { paddingValues ->_93 Column(_93 modifier = modifier_93 .padding(paddingValues)_93 .padding(20.dp)_93 ) {_93 val email = viewModel.email.collectAsState(initial = "")_93 val password = viewModel.password.collectAsState()_93 OutlinedTextField(_93 label = {_93 Text(_93 text = "Email",_93 color = MaterialTheme.colorScheme.primary,_93 style = MaterialTheme.typography.titleMedium_93 )_93 },_93 maxLines = 1,_93 shape = RoundedCornerShape(32),_93 modifier = modifier.fillMaxWidth(),_93 value = email.value,_93 onValueChange = {_93 viewModel.onEmailChange(it)_93 },_93 )_93 OutlinedTextField(_93 label = {_93 Text(_93 text = "Password",_93 color = MaterialTheme.colorScheme.primary,_93 style = MaterialTheme.typography.titleMedium_93 )_93 },_93 maxLines = 1,_93 shape = RoundedCornerShape(32),_93 modifier = modifier_93 .fillMaxWidth()_93 .padding(top = 12.dp),_93 value = password.value,_93 onValueChange = {_93 viewModel.onPasswordChange(it)_93 },_93 )_93 val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current_93 Button(modifier = modifier_93 .fillMaxWidth()_93 .padding(top = 12.dp),_93 onClick = {_93 localSoftwareKeyboardController?.hide()_93 viewModel.onSignUp()_93 coroutineScope.launch {_93 snackBarHostState.showSnackbar(_93 message = "Create account successfully. Sign in now!",_93 duration = SnackbarDuration.Long_93 )_93 }_93 }) {_93 Text("Sign up")_93 }_93 }_93 }_93}
Create a SignInViewModel
:
_35@HiltViewModel_35class SignInViewModel @Inject constructor(_35 private val authenticationRepository: AuthenticationRepository_35) : ViewModel() {_35_35 private val _email = MutableStateFlow("")_35 val email: Flow<String> = _email_35_35 private val _password = MutableStateFlow("")_35 val password = _password_35_35 fun onEmailChange(email: String) {_35 _email.value = email_35 }_35_35 fun onPasswordChange(password: String) {_35 _password.value = password_35 }_35_35 fun onLogin() {_35 viewModelScope.launch {_35 authenticationRepository.signIn(_35 email = _email.value,_35 password = _password.value_35 )_35 }_35 }_35_35 fun onGoogleSignIn() {_35 viewModelScope.launch {_35 authenticationRepository.signInWithGoogle()_35 }_35 }_35_35}
Create the SignInScreen.kt
:
_110@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)_110@Composable_110fun SignInScreen(_110 modifier: Modifier = Modifier,_110 navController: NavController,_110 viewModel: SignInViewModel = hiltViewModel()_110) {_110 val snackBarHostState = remember { SnackbarHostState() }_110 val coroutineScope = rememberCoroutineScope()_110 Scaffold(_110 snackbarHost = { androidx.compose.material.SnackbarHost(snackBarHostState) },_110 topBar = {_110 TopAppBar(_110 navigationIcon = {_110 IconButton(onClick = {_110 navController.navigateUp()_110 }) {_110 Icon(_110 imageVector = Icons.Filled.ArrowBack,_110 contentDescription = null,_110 tint = MaterialTheme.colorScheme.onPrimary_110 )_110 }_110 },_110 backgroundColor = MaterialTheme.colorScheme.primary,_110 title = {_110 Text(_110 text = "Login",_110 color = MaterialTheme.colorScheme.onPrimary,_110 )_110 },_110 )_110 }_110 ) { paddingValues ->_110 Column(_110 modifier = modifier_110 .padding(paddingValues)_110 .padding(20.dp)_110 ) {_110 val email = viewModel.email.collectAsState(initial = "")_110 val password = viewModel.password.collectAsState()_110 androidx.compose.material.OutlinedTextField(_110 label = {_110 Text(_110 text = "Email",_110 color = MaterialTheme.colorScheme.primary,_110 style = MaterialTheme.typography.titleMedium_110 )_110 },_110 maxLines = 1,_110 shape = RoundedCornerShape(32),_110 modifier = modifier.fillMaxWidth(),_110 value = email.value,_110 onValueChange = {_110 viewModel.onEmailChange(it)_110 },_110 )_110 androidx.compose.material.OutlinedTextField(_110 label = {_110 Text(_110 text = "Password",_110 color = MaterialTheme.colorScheme.primary,_110 style = MaterialTheme.typography.titleMedium_110 )_110 },_110 maxLines = 1,_110 shape = RoundedCornerShape(32),_110 modifier = modifier_110 .fillMaxWidth()_110 .padding(top = 12.dp),_110 value = password.value,_110 onValueChange = {_110 viewModel.onPasswordChange(it)_110 },_110 )_110 val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current_110 Button(modifier = modifier_110 .fillMaxWidth()_110 .padding(top = 12.dp),_110 onClick = {_110 localSoftwareKeyboardController?.hide()_110 viewModel.onGoogleSignIn()_110 }) {_110 Text("Sign in with Google")_110 }_110 Button(modifier = modifier_110 .fillMaxWidth()_110 .padding(top = 12.dp),_110 onClick = {_110 localSoftwareKeyboardController?.hide()_110 viewModel.onSignIn()_110 coroutineScope.launch {_110 snackBarHostState.showSnackbar(_110 message = "Sign in successfully !",_110 duration = SnackbarDuration.Long_110 )_110 }_110 }) {_110 Text("Sign in")_110 }_110 OutlinedButton(modifier = modifier_110 .fillMaxWidth()_110 .padding(top = 12.dp), onClick = {_110 navController.navigate(SignUpDestination.route)_110 }) {_110 Text("Sign up")_110 }_110 }_110 }_110}
Create the success screen#
Implement a "Sign In" success screen. Create new "Empty Activity" and in AndroidManifest.xml
, add a deep link, make sure scheme
and host
are the same as the ones set in the GoTrue instance.
_40<?xml version="1.0" encoding="utf-8"?>_40<manifest xmlns:android="http://schemas.android.com/apk/res/android"_40 xmlns:tools="http://schemas.android.com/tools">_40 <uses-permission android:name="android.permission.INTERNET" />_40 <application_40 android:name=".ManageProductApplication"_40 android:allowBackup="true"_40 android:dataExtractionRules="@xml/data_extraction_rules"_40 android:enableOnBackInvokedCallback="true"_40 android:fullBackupContent="@xml/backup_rules"_40 android:icon="@mipmap/ic_launcher"_40 android:label="@string/app_name"_40 android:supportsRtl="true"_40 android:theme="@style/Theme.ManageProducts"_40 tools:targetApi="31">_40 <activity_40 android:name=".DeepLinkHandlerActivity"_40 android:exported="true"_40 android:theme="@style/Theme.ManageProducts" >_40 <intent-filter android:autoVerify="true">_40 <action android:name="android.intent.action.VIEW" />_40 <category android:name="android.intent.category.DEFAULT" />_40 <category android:name="android.intent.category.BROWSABLE" />_40 <data_40 android:host="supabase.com"_40 android:scheme="app" />_40 </intent-filter>_40 </activity>_40 <activity_40 android:name=".MainActivity"_40 android:exported="true"_40 android:label="@string/app_name"_40 android:theme="@style/Theme.ManageProducts">_40 <intent-filter>_40 <action android:name="android.intent.action.MAIN" />_40 <category android:name="android.intent.category.LAUNCHER" />_40 </intent-filter>_40 </activity>_40 </application>_40</manifest>
_51@AndroidEntryPoint_51class DeepLinkHandlerActivity : ComponentActivity() {_51_51 @Inject_51 lateinit var supabaseClient: SupabaseClient_51_51 private lateinit var callback: (String, String) -> Unit_51_51 override fun onCreate(savedInstanceState: Bundle?) {_51 super.onCreate(savedInstanceState)_51 supabaseClient.handleDeeplinks(intent = intent,_51 onSessionSuccess = { userSession ->_51 Log.d("LOGIN", "Log in successfully with user info: ${userSession.user}")_51 userSession.user?.apply {_51 callback(email ?: "", createdAt.toString())_51 }_51 })_51 setContent {_51 val navController = rememberNavController()_51 val emailState = remember { mutableStateOf("") }_51 val createdAtState = remember { mutableStateOf("") }_51 LaunchedEffect(Unit) {_51 callback = { email, created ->_51 emailState.value = email_51 createdAtState.value = created_51 }_51 }_51 ManageProductsTheme {_51 Surface(_51 modifier = Modifier.fillMaxSize(),_51 color = MaterialTheme.colorScheme.background_51 ) {_51 SignInSuccessScreen(_51 modifier = Modifier.padding(20.dp),_51 navController = navController,_51 email = emailState.value,_51 createdAt = createdAtState.value,_51 onClick = { navigateToMainApp() }_51 )_51 }_51 }_51 }_51 }_51_51 private fun navigateToMainApp() {_51 val intent = Intent(this, MainActivity::class.java).apply {_51 flags = Intent.FLAG_ACTIVITY_CLEAR_TOP_51 }_51 startActivity(intent)_51 }_51}