使用 Jetpack Compose 构建产品管理 Android 应用
本教程演示如何构建一个基本的产品管理应用。该应用演示了管理操作、照片上传、账户创建和身份验证,使用
- Supabase 数据库 - 一个用于存储用户数据的 Postgres 数据库,以及 行级别安全,以保护数据并确保用户只能访问他们自己的信息。
- Supabase Auth - 用户通过发送到他们电子邮件的魔术链接登录(无需设置密码)。
- Supabase Storage - 用户可以上传个人资料照片。

如果在完成本教程的过程中遇到问题,请参考 GitHub 上的完整示例。
项目设置#
在开始构建之前,我们将设置数据库和 API。这就像在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”。
创建项目#
- 在 Supabase 控制面板中创建一个新项目。
- 输入您的项目详细信息。
- 等待新的数据库启动。
设置数据库 schema#
现在我们将设置数据库 schema。您可以直接复制/粘贴下面的 SQL 并自行运行。
1-- Create a table for public profiles23create table4 public.products (5 id uuid not null default gen_random_uuid (),6 name text not null,7 price real not null,8 image text null,9 constraint products_pkey primary key (id)10 ) tablespace pg_default;1112-- Set up Storage!13insert into storage.buckets (id, name)14 values ('Product Image', 'Product Image');1516-- Set up access controls for storage.17-- See https://supabase.org.cn/docs/guides/storage/security/access-control#policy-examples for more details.18CREATE POLICY "Enable read access for all users" ON "storage"."objects"19AS PERMISSIVE FOR SELECT20TO public21USING (true)2223CREATE POLICY "Enable insert for all users" ON "storage"."objects"24AS PERMISSIVE FOR INSERT25TO authenticated, anon26WITH CHECK (true)2728CREATE POLICY "Enable update for all users" ON "storage"."objects"29AS PERMISSIVE FOR UPDATE30TO public31USING (true)32WITH CHECK (true)获取 API 详细信息#
现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。
为此,您需要从 项目的 Connect 对话框 获取项目 URL 和密钥。
API 密钥的更改
Supabase 正在更改密钥的工作方式,以提高项目安全性和开发人员体验。您可以 阅读完整的公告,但在过渡期间,您可以使用当前的 anon 和 service_role 密钥以及新的可发布密钥,格式为 sb_publishable_xxx,它将取代旧的密钥。
在大多数情况下,您可以从 项目的 Connect 对话框 获取正确的密钥,但如果您需要特定的密钥,可以在 项目的设置页面中的 API 密钥部分 找到所有密钥
- 对于旧版密钥,从 旧版 API 密钥 选项卡中复制
anon密钥用于客户端操作,并复制service_role密钥用于服务器端操作。 - 对于新密钥,打开 API 密钥 选项卡,如果您还没有可发布密钥,请单击 创建新的 API 密钥,并复制 可发布密钥 部分中的值。
阅读 API 密钥文档 以全面了解所有密钥类型及其用途。
设置 Google 身份验证#
从 Google Console,创建一个新项目并添加 OAuth2 凭据。

在您的 Supabase Auth 设置 中启用 Google 作为提供商,并按照 身份验证文档 中概述的要求设置所需的凭据。
构建应用#
创建新的 Android 项目#
打开 Android Studio > 新项目 > 基本活动(Jetpack Compose)。

安全地设置 API 密钥和密钥#
创建本地环境密钥#
在项目的根目录(与 build.gradle 同级)中创建或编辑 local.properties 文件。
注意:不要将此文件提交到您的源代码控制,例如,通过将其添加到您的
.gitignore文件中!
1SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY2SUPABASE_URL=YOUR_SUPABASE_URL读取并设置 BuildConfig 的值#
在您的 build.gradle (app) 文件中,创建一个 Properties 对象,并通过调用 buildConfigField 方法从您的 local.properties 文件中读取值
1defaultConfig {2 applicationId "com.example.manageproducts"3 minSdkVersion 224 targetSdkVersion 335 versionCode 56 versionName "1.0"7 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"89 // Set value part10 Properties properties = new Properties()11 properties.load(project.rootProject.file("local.properties").newDataInputStream())12 buildConfigField("String", "SUPABASE_PUBLISHABLE_KEY", "\"${properties.getProperty("SUPABASE_PUBLISHABLE_KEY")}\"")13 buildConfigField("String", "SECRET", "\"${properties.getProperty("SECRET")}\"")14 buildConfigField("String", "SUPABASE_URL", "\"${properties.getProperty("SUPABASE_URL")}\"")15}使用 BuildConfig 中的值#
从 BuildConfig 读取值
1val url = BuildConfig.SUPABASE_URL2val apiKey = BuildConfig.SUPABASE_PUBLISHABLE_KEY设置 Supabase 依赖项#

在 build.gradle (app) 文件中,添加这些依赖项,然后按“立即同步”。将依赖项版本占位符 $supabase_version 和 $ktor_version 替换为各自的最新版本。
1implementation "io.github.jan-tennert.supabase:postgrest-kt:$supabase_version"2implementation "io.github.jan-tennert.supabase:storage-kt:$supabase_version"3implementation "io.github.jan-tennert.supabase:auth-kt:$supabase_version"4implementation "io.ktor:ktor-client-android:$ktor_version"5implementation "io.ktor:ktor-client-core:$ktor_version"6implementation "io.ktor:ktor-utils:$ktor_version"同样在 build.gradle (app) 文件中,添加序列化的插件。此插件的版本应与您的 Kotlin 版本相同。
1plugins {2 ...3 id 'org.jetbrains.kotlin.plugin.serialization' version '$kotlin_version'4 ...5}设置 Hilt 进行依赖注入#
在 build.gradle (app) 文件中,添加以下内容
1implementation "com.google.dagger:hilt-android:$hilt_version"2annotationProcessor "com.google.dagger:hilt-compiler:$hilt_version"3implementation("androidx.hilt:hilt-navigation-compose:1.0.0")创建一个新的 ManageProductApplication.kt 类,扩展 Application 并使用 @HiltAndroidApp 注解
1// ManageProductApplication.kt2@HiltAndroidApp3class ManageProductApplication: Application()打开 AndroidManifest.xml 文件,更新 Application 标签的 name 属性
1<application2...3 android:name=".ManageProductApplication"4...5</application>创建 MainActivity
1@AndroidEntryPoint2class MainActivity : ComponentActivity() {3 //This will come later4}使用 Hilt 提供 Supabase 实例#
为了使应用程序更易于测试,创建一个 SupabaseModule.kt 文件,如下所示
1@InstallIn(SingletonComponent::class)2@Module3object SupabaseModule {45 @Provides6 @Singleton7 fun provideSupabaseClient(): SupabaseClient {8 return createSupabaseClient(9 supabaseUrl = BuildConfig.SUPABASE_URL,10 supabaseKey = BuildConfig.SUPABASE_PUBLISHABLE_KEY11 ) {12 install(Postgrest)13 install(Auth) {14 flowType = FlowType.PKCE15 scheme = "app"16 host = "supabase.com"17 }18 install(Storage)19 }20 }2122 @Provides23 @Singleton24 fun provideSupabaseDatabase(client: SupabaseClient): Postgrest {25 return client.postgrest26 }2728 @Provides29 @Singleton30 fun provideSupabaseAuth(client: SupabaseClient): Auth {31 return client.auth32 }333435 @Provides36 @Singleton37 fun provideSupabaseStorage(client: SupabaseClient): Storage {38 return client.storage39 }4041}创建一个数据传输对象#
创建一个 ProductDto.kt 类,并使用注解来解析来自 Supabase 的数据
1@Serializable2data class ProductDto(34 @SerialName("name")5 val name: String,67 @SerialName("price")8 val price: Double,910 @SerialName("image")11 val image: String?,1213 @SerialName("id")14 val id: String,15)在 Product.kt 中创建一个 Domain 对象,在您的视图中公开数据
1data class Product(2 val id: String,3 val name: String,4 val price: Double,5 val image: String?6)实现仓库#
创建一个 ProductRepository 接口及其实现,命名为 ProductRepositoryImpl。这包含与 Supabase 数据源交互的逻辑。对 AuthenticationRepository 执行相同的操作。
创建产品仓库
1interface ProductRepository {2 suspend fun createProduct(product: Product): Boolean3 suspend fun getProducts(): List<ProductDto>?4 suspend fun getProduct(id: String): ProductDto5 suspend fun deleteProduct(id: String)6 suspend fun updateProduct(7 id: String, name: String, price: Double, imageName: String, imageFile: ByteArray8 )9}1class ProductRepositoryImpl @Inject constructor(2 private val postgrest: Postgrest,3 private val storage: Storage,4) : ProductRepository {5 override suspend fun createProduct(product: Product): Boolean {6 return try {7 withContext(Dispatchers.IO) {8 val productDto = ProductDto(9 name = product.name,10 price = product.price,11 )12 postgrest.from("products").insert(productDto)13 true14 }15 true16 } catch (e: java.lang.Exception) {17 throw e18 }19 }2021 override suspend fun getProducts(): List<ProductDto>? {22 return withContext(Dispatchers.IO) {23 val result = postgrest.from("products")24 .select().decodeList<ProductDto>()25 result26 }27 }282930 override suspend fun getProduct(id: String): ProductDto {31 return withContext(Dispatchers.IO) {32 postgrest.from("products").select {33 filter {34 eq("id", id)35 }36 }.decodeSingle<ProductDto>()37 }38 }3940 override suspend fun deleteProduct(id: String) {41 return withContext(Dispatchers.IO) {42 postgrest.from("products").delete {43 filter {44 eq("id", id)45 }46 }47 }48 }4950 override suspend fun updateProduct(51 id: String,52 name: String,53 price: Double,54 imageName: String,55 imageFile: ByteArray56 ) {57 withContext(Dispatchers.IO) {58 if (imageFile.isNotEmpty()) {59 val imageUrl =60 storage.from("Product%20Image").upload(61 path = "$imageName.png",62 data = imageFile,63 upsert = true64 )65 postgrest.from("products").update({66 set("name", name)67 set("price", price)68 set("image", buildImageUrl(imageFileName = imageUrl))69 }) {70 filter {71 eq("id", id)72 }73 }74 } else {75 postgrest.from("products").update({76 set("name", name)77 set("price", price)78 }) {79 filter {80 eq("id", id)81 }82 }83 }84 }85 }8687 // Because I named the bucket as "Product Image" so when it turns to an url, it is "%20"88 // For better approach, you should create your bucket name without space symbol89 private fun buildImageUrl(imageFileName: String) =90 "${BuildConfig.SUPABASE_URL}/storage/v1/object/public/${imageFileName}".replace(" ", "%20")91}创建身份验证仓库
1interface AuthenticationRepository {2 suspend fun signIn(email: String, password: String): Boolean3 suspend fun signUp(email: String, password: String): Boolean4 suspend fun signInWithGoogle(): Boolean5}1class AuthenticationRepositoryImpl @Inject constructor(2 private val auth: Auth3) : AuthenticationRepository {4 override suspend fun signIn(email: String, password: String): Boolean {5 return try {6 auth.signInWith(Email) {7 this.email = email8 this.password = password9 }10 true11 } catch (e: Exception) {12 false13 }14 }1516 override suspend fun signUp(email: String, password: String): Boolean {17 return try {18 auth.signUpWith(Email) {19 this.email = email20 this.password = password21 }22 true23 } catch (e: Exception) {24 false25 }26 }2728 override suspend fun signInWithGoogle(): Boolean {29 return try {30 auth.signInWith(Google)31 true32 } catch (e: Exception) {33 false34 }35 }36}实现屏幕#
要导航屏幕,请使用 AndroidX 导航库。对于路由,实现一个 Destination 接口
1interface Destination {2 val route: String3 val title: String4}567object ProductListDestination : Destination {8 override val route = "product_list"9 override val title = "Product List"10}1112object ProductDetailsDestination : Destination {13 override val route = "product_details"14 override val title = "Product Details"15 const val productId = "product_id"16 val arguments = listOf(navArgument(name = productId) {17 type = NavType.StringType18 })19 fun createRouteWithParam(productId: String) = "$route/${productId}"20}2122object AddProductDestination : Destination {23 override val route = "add_product"24 override val title = "Add Product"25}2627object AuthenticationDestination: Destination {28 override val route = "authentication"29 override val title = "Authentication"30}3132object SignUpDestination: Destination {33 override val route = "signup"34 override val title = "Sign Up"35}这将在以后在屏幕之间导航时提供帮助。
创建一个 ProductListViewModel
1@HiltViewModel2class ProductListViewModel @Inject constructor(3private val productRepository: ProductRepository,4) : ViewModel() {56 private val _productList = MutableStateFlow<List<Product>?>(listOf())7 val productList: Flow<List<Product>?> = _productList8910 private val _isLoading = MutableStateFlow(false)11 val isLoading: Flow<Boolean> = _isLoading1213 init {14 getProducts()15 }1617 fun getProducts() {18 viewModelScope.launch {19 val products = productRepository.getProducts()20 _productList.emit(products?.map { it -> it.asDomainModel() })21 }22 }2324 fun removeItem(product: Product) {25 viewModelScope.launch {26 val newList = mutableListOf<Product>().apply { _productList.value?.let { addAll(it) } }27 newList.remove(product)28 _productList.emit(newList.toList())29 // Call api to remove30 productRepository.deleteProduct(id = product.id)31 // Then fetch again32 getProducts()33 }34 }3536 private fun ProductDto.asDomainModel(): Product {37 return Product(38 id = this.id,39 name = this.name,40 price = this.price,41 image = this.image42 )43 }4445}创建 ProductListScreen.kt
1@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)2@Composable3fun ProductListScreen(4 modifier: Modifier = Modifier,5 navController: NavController,6 viewModel: ProductListViewModel = hiltViewModel(),7) {8 val isLoading by viewModel.isLoading.collectAsState(initial = false)9 val swipeRefreshState = rememberSwipeRefreshState(isRefreshing = isLoading)10 SwipeRefresh(state = swipeRefreshState, onRefresh = { viewModel.getProducts() }) {11 Scaffold(12 topBar = {13 TopAppBar(14 backgroundColor = MaterialTheme.colorScheme.primary,15 title = {16 Text(17 text = stringResource(R.string.product_list_text_screen_title),18 color = MaterialTheme.colorScheme.onPrimary,19 )20 },21 )22 },23 floatingActionButton = {24 AddProductButton(onClick = { navController.navigate(AddProductDestination.route) })25 }26 ) { padding ->27 val productList = viewModel.productList.collectAsState(initial = listOf()).value28 if (!productList.isNullOrEmpty()) {29 LazyColumn(30 modifier = modifier.padding(padding),31 contentPadding = PaddingValues(5.dp)32 ) {33 itemsIndexed(34 items = productList,35 key = { _, product -> product.name }) { _, item ->36 val state = rememberDismissState(37 confirmStateChange = {38 if (it == DismissValue.DismissedToStart) {39 // Handle item removed40 viewModel.removeItem(item)41 }42 true43 }44 )45 SwipeToDismiss(46 state = state,47 background = {48 val color by animateColorAsState(49 targetValue = when (state.dismissDirection) {50 DismissDirection.StartToEnd -> MaterialTheme.colorScheme.primary51 DismissDirection.EndToStart -> MaterialTheme.colorScheme.primary.copy(52 alpha = 0.2f53 )54 null -> Color.Transparent55 }56 )57 Box(58 modifier = modifier59 .fillMaxSize()60 .background(color = color)61 .padding(16.dp),62 ) {63 Icon(64 imageVector = Icons.Filled.Delete,65 contentDescription = null,66 tint = MaterialTheme.colorScheme.primary,67 modifier = modifier.align(Alignment.CenterEnd)68 )69 }7071 },72 dismissContent = {73 ProductListItem(74 product = item,75 modifier = modifier,76 onClick = {77 navController.navigate(78 ProductDetailsDestination.createRouteWithParam(79 item.id80 )81 )82 },83 )84 },85 directions = setOf(DismissDirection.EndToStart),86 )87 }88 }89 } else {90 Text("Product list is empty!")91 }9293 }94 }95}9697@Composable98private fun AddProductButton(99 modifier: Modifier = Modifier,100 onClick: () -> Unit,101) {102 FloatingActionButton(103 modifier = modifier,104 onClick = onClick,105 containerColor = MaterialTheme.colorScheme.primary,106 contentColor = MaterialTheme.colorScheme.onPrimary107 ) {108 Icon(109 imageVector = Icons.Filled.Add,110 contentDescription = null,111 )112 }113}创建 ProductDetailsViewModel.kt
1@HiltViewModel2class ProductDetailsViewModel @Inject constructor(3 private val productRepository: ProductRepository,4 savedStateHandle: SavedStateHandle,5 ) : ViewModel() {67 private val _product = MutableStateFlow<Product?>(null)8 val product: Flow<Product?> = _product910 private val _name = MutableStateFlow("")11 val name: Flow<String> = _name1213 private val _price = MutableStateFlow(0.0)14 val price: Flow<Double> = _price1516 private val _imageUrl = MutableStateFlow("")17 val imageUrl: Flow<String> = _imageUrl1819 init {20 val productId = savedStateHandle.get<String>(ProductDetailsDestination.productId)21 productId?.let {22 getProduct(productId = it)23 }24 }2526 private fun getProduct(productId: String) {27 viewModelScope.launch {28 val result = productRepository.getProduct(productId).asDomainModel()29 _product.emit(result)30 _name.emit(result.name)31 _price.emit(result.price)32 }33 }3435 fun onNameChange(name: String) {36 _name.value = name37 }3839 fun onPriceChange(price: Double) {40 _price.value = price41 }4243 fun onSaveProduct(image: ByteArray) {44 viewModelScope.launch {45 productRepository.updateProduct(46 id = _product.value?.id,47 price = _price.value,48 name = _name.value,49 imageFile = image,50 imageName = "image_${_product.value.id}",51 )52 }53 }5455 fun onImageChange(url: String) {56 _imageUrl.value = url57 }5859 private fun ProductDto.asDomainModel(): Product {60 return Product(61 id = this.id,62 name = this.name,63 price = this.price,64 image = this.image65 )66 }67}创建 ProductDetailsScreen.kt
1@OptIn(ExperimentalCoilApi::class)2@SuppressLint("UnusedMaterialScaffoldPaddingParameter")3@Composable4fun ProductDetailsScreen(5 modifier: Modifier = Modifier,6 viewModel: ProductDetailsViewModel = hiltViewModel(),7 navController: NavController,8 productId: String?,9) {10 val snackBarHostState = remember { SnackbarHostState() }11 val coroutineScope = rememberCoroutineScope()1213 Scaffold(14 snackbarHost = { SnackbarHost(snackBarHostState) },15 topBar = {16 TopAppBar(17 navigationIcon = {18 IconButton(onClick = {19 navController.navigateUp()20 }) {21 Icon(22 imageVector = Icons.Filled.ArrowBack,23 contentDescription = null,24 tint = MaterialTheme.colorScheme.onPrimary25 )26 }27 },28 backgroundColor = MaterialTheme.colorScheme.primary,29 title = {30 Text(31 text = stringResource(R.string.product_details_text_screen_title),32 color = MaterialTheme.colorScheme.onPrimary,33 )34 },35 )36 }37 ) {38 val name = viewModel.name.collectAsState(initial = "")39 val price = viewModel.price.collectAsState(initial = 0.0)40 var imageUrl = Uri.parse(viewModel.imageUrl.collectAsState(initial = null).value)41 val contentResolver = LocalContext.current.contentResolver4243 Column(44 modifier = modifier45 .padding(16.dp)46 .fillMaxSize()47 ) {48 val galleryLauncher =49 rememberLauncherForActivityResult(ActivityResultContracts.GetContent())50 { uri ->51 uri?.let {52 if (it.toString() != imageUrl.toString()) {53 viewModel.onImageChange(it.toString())54 }55 }56 }5758 Image(59 painter = rememberImagePainter(imageUrl),60 contentScale = ContentScale.Fit,61 contentDescription = null,62 modifier = Modifier63 .padding(16.dp, 8.dp)64 .size(100.dp)65 .align(Alignment.CenterHorizontally)66 )67 IconButton(modifier = modifier.align(alignment = Alignment.CenterHorizontally),68 onClick = {69 galleryLauncher.launch("image/*")70 }) {71 Icon(72 imageVector = Icons.Filled.Edit,73 contentDescription = null,74 tint = MaterialTheme.colorScheme.primary75 )76 }77 OutlinedTextField(78 label = {79 Text(80 text = "Product name",81 color = MaterialTheme.colorScheme.primary,82 style = MaterialTheme.typography.titleMedium83 )84 },85 maxLines = 2,86 shape = RoundedCornerShape(32),87 modifier = modifier.fillMaxWidth(),88 value = name.value,89 onValueChange = {90 viewModel.onNameChange(it)91 },92 )93 Spacer(modifier = modifier.height(12.dp))94 OutlinedTextField(95 label = {96 Text(97 text = "Product price",98 color = MaterialTheme.colorScheme.primary,99 style = MaterialTheme.typography.titleMedium100 )101 },102 maxLines = 2,103 shape = RoundedCornerShape(32),104 modifier = modifier.fillMaxWidth(),105 value = price.value.toString(),106 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),107 onValueChange = {108 viewModel.onPriceChange(it.toDouble())109 },110 )111 Spacer(modifier = modifier.weight(1f))112 Button(113 modifier = modifier.fillMaxWidth(),114 onClick = {115 if (imageUrl.host?.contains("supabase") == true) {116 viewModel.onSaveProduct(image = byteArrayOf())117 } else {118 val image = uriToByteArray(contentResolver, imageUrl)119 viewModel.onSaveProduct(image = image)120 }121 coroutineScope.launch {122 snackBarHostState.showSnackbar(123 message = "Product updated successfully !",124 duration = SnackbarDuration.Short125 )126 }127 }) {128 Text(text = "Save changes")129 }130 Spacer(modifier = modifier.height(12.dp))131 OutlinedButton(132 modifier = modifier133 .fillMaxWidth(),134 onClick = {135 navController.navigateUp()136 }) {137 Text(text = "Cancel")138 }139140 }141142 }143}144145146private fun getBytes(inputStream: InputStream): ByteArray {147 val byteBuffer = ByteArrayOutputStream()148 val bufferSize = 1024149 val buffer = ByteArray(bufferSize)150 var len = 0151 while (inputStream.read(buffer).also { len = it } != -1) {152 byteBuffer.write(buffer, 0, len)153 }154 return byteBuffer.toByteArray()155}156157158private fun uriToByteArray(contentResolver: ContentResolver, uri: Uri): ByteArray {159 if (uri == Uri.EMPTY) {160 return byteArrayOf()161 }162 val inputStream = contentResolver.openInputStream(uri)163 if (inputStream != null) {164 return getBytes(inputStream)165 }166 return byteArrayOf()167}创建一个 AddProductScreen
1@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")2@OptIn(ExperimentalMaterial3Api::class)3@Composable4fun AddProductScreen(5 modifier: Modifier = Modifier,6 navController: NavController,7 viewModel: AddProductViewModel = hiltViewModel(),8) {9 Scaffold(10 topBar = {11 TopAppBar(12 navigationIcon = {13 IconButton(onClick = {14 navController.navigateUp()15 }) {16 Icon(17 imageVector = Icons.Filled.ArrowBack,18 contentDescription = null,19 tint = MaterialTheme.colorScheme.onPrimary20 )21 }22 },23 backgroundColor = MaterialTheme.colorScheme.primary,24 title = {25 Text(26 text = stringResource(R.string.add_product_text_screen_title),27 color = MaterialTheme.colorScheme.onPrimary,28 )29 },30 )31 }32 ) { padding ->33 val navigateAddProductSuccess =34 viewModel.navigateAddProductSuccess.collectAsState(initial = null).value35 val isLoading =36 viewModel.isLoading.collectAsState(initial = null).value37 if (isLoading == true) {38 LoadingScreen(message = "Adding Product",39 onCancelSelected = {40 navController.navigateUp()41 })42 } else {43 SuccessScreen(44 message = "Product added",45 onMoreAction = {46 viewModel.onAddMoreProductSelected()47 },48 onNavigateBack = {49 navController.navigateUp()50 })51 }5253 }54}创建 AddProductViewModel.kt
1@HiltViewModel2class AddProductViewModel @Inject constructor(3 private val productRepository: ProductRepository,4) : ViewModel() {56 private val _isLoading = MutableStateFlow(false)7 val isLoading: Flow<Boolean> = _isLoading89 private val _showSuccessMessage = MutableStateFlow(false)10 val showSuccessMessage: Flow<Boolean> = _showSuccessMessage1112 fun onCreateProduct(name: String, price: Double) {13 if (name.isEmpty() || price <= 0) return14 viewModelScope.launch {15 _isLoading.value = true16 val product = Product(17 id = UUID.randomUUID().toString(),18 name = name,19 price = price,20 )21 productRepository.createProduct(product = product)22 _isLoading.value = false23 _showSuccessMessage.emit(true)2425 }26 }27}创建一个 SignUpViewModel
1@HiltViewModel2class SignUpViewModel @Inject constructor(3 private val authenticationRepository: AuthenticationRepository4) : ViewModel() {56 private val _email = MutableStateFlow("")7 val email: Flow<String> = _email89 private val _password = MutableStateFlow("")10 val password = _password1112 fun onEmailChange(email: String) {13 _email.value = email14 }1516 fun onPasswordChange(password: String) {17 _password.value = password18 }1920 fun onSignUp() {21 viewModelScope.launch {22 authenticationRepository.signUp(23 email = _email.value,24 password = _password.value25 )26 }27 }28}创建 SignUpScreen.kt
1@Composable2fun SignUpScreen(3 modifier: Modifier = Modifier,4 navController: NavController,5 viewModel: SignUpViewModel = hiltViewModel()6) {7 val snackBarHostState = remember { SnackbarHostState() }8 val coroutineScope = rememberCoroutineScope()9 Scaffold(10 snackbarHost = { androidx.compose.material.SnackbarHost(snackBarHostState) },11 topBar = {12 TopAppBar(13 navigationIcon = {14 IconButton(onClick = {15 navController.navigateUp()16 }) {17 Icon(18 imageVector = Icons.Filled.ArrowBack,19 contentDescription = null,20 tint = MaterialTheme.colorScheme.onPrimary21 )22 }23 },24 backgroundColor = MaterialTheme.colorScheme.primary,25 title = {26 Text(27 text = "Sign Up",28 color = MaterialTheme.colorScheme.onPrimary,29 )30 },31 )32 }33 ) { paddingValues ->34 Column(35 modifier = modifier36 .padding(paddingValues)37 .padding(20.dp)38 ) {39 val email = viewModel.email.collectAsState(initial = "")40 val password = viewModel.password.collectAsState()41 OutlinedTextField(42 label = {43 Text(44 text = "Email",45 color = MaterialTheme.colorScheme.primary,46 style = MaterialTheme.typography.titleMedium47 )48 },49 maxLines = 1,50 shape = RoundedCornerShape(32),51 modifier = modifier.fillMaxWidth(),52 value = email.value,53 onValueChange = {54 viewModel.onEmailChange(it)55 },56 )57 OutlinedTextField(58 label = {59 Text(60 text = "Password",61 color = MaterialTheme.colorScheme.primary,62 style = MaterialTheme.typography.titleMedium63 )64 },65 maxLines = 1,66 shape = RoundedCornerShape(32),67 modifier = modifier68 .fillMaxWidth()69 .padding(top = 12.dp),70 value = password.value,71 onValueChange = {72 viewModel.onPasswordChange(it)73 },74 )75 val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current76 Button(modifier = modifier77 .fillMaxWidth()78 .padding(top = 12.dp),79 onClick = {80 localSoftwareKeyboardController?.hide()81 viewModel.onSignUp()82 coroutineScope.launch {83 snackBarHostState.showSnackbar(84 message = "Create account successfully. Sign in now!",85 duration = SnackbarDuration.Long86 )87 }88 }) {89 Text("Sign up")90 }91 }92 }93}创建一个 SignInViewModel
1@HiltViewModel2class SignInViewModel @Inject constructor(3 private val authenticationRepository: AuthenticationRepository4) : ViewModel() {56 private val _email = MutableStateFlow("")7 val email: Flow<String> = _email89 private val _password = MutableStateFlow("")10 val password = _password1112 fun onEmailChange(email: String) {13 _email.value = email14 }1516 fun onPasswordChange(password: String) {17 _password.value = password18 }1920 fun onSignIn() {21 viewModelScope.launch {22 authenticationRepository.signIn(23 email = _email.value,24 password = _password.value25 )26 }27 }2829 fun onGoogleSignIn() {30 viewModelScope.launch {31 authenticationRepository.signInWithGoogle()32 }33 }3435}创建 SignInScreen.kt
1@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)2@Composable3fun SignInScreen(4 modifier: Modifier = Modifier,5 navController: NavController,6 viewModel: SignInViewModel = hiltViewModel()7) {8 val snackBarHostState = remember { SnackbarHostState() }9 val coroutineScope = rememberCoroutineScope()10 Scaffold(11 snackbarHost = { androidx.compose.material.SnackbarHost(snackBarHostState) },12 topBar = {13 TopAppBar(14 navigationIcon = {15 IconButton(onClick = {16 navController.navigateUp()17 }) {18 Icon(19 imageVector = Icons.Filled.ArrowBack,20 contentDescription = null,21 tint = MaterialTheme.colorScheme.onPrimary22 )23 }24 },25 backgroundColor = MaterialTheme.colorScheme.primary,26 title = {27 Text(28 text = "Login",29 color = MaterialTheme.colorScheme.onPrimary,30 )31 },32 )33 }34 ) { paddingValues ->35 Column(36 modifier = modifier37 .padding(paddingValues)38 .padding(20.dp)39 ) {40 val email = viewModel.email.collectAsState(initial = "")41 val password = viewModel.password.collectAsState()42 androidx.compose.material.OutlinedTextField(43 label = {44 Text(45 text = "Email",46 color = MaterialTheme.colorScheme.primary,47 style = MaterialTheme.typography.titleMedium48 )49 },50 maxLines = 1,51 shape = RoundedCornerShape(32),52 modifier = modifier.fillMaxWidth(),53 value = email.value,54 onValueChange = {55 viewModel.onEmailChange(it)56 },57 )58 androidx.compose.material.OutlinedTextField(59 label = {60 Text(61 text = "Password",62 color = MaterialTheme.colorScheme.primary,63 style = MaterialTheme.typography.titleMedium64 )65 },66 maxLines = 1,67 shape = RoundedCornerShape(32),68 modifier = modifier69 .fillMaxWidth()70 .padding(top = 12.dp),71 value = password.value,72 onValueChange = {73 viewModel.onPasswordChange(it)74 },75 )76 val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current77 Button(modifier = modifier78 .fillMaxWidth()79 .padding(top = 12.dp),80 onClick = {81 localSoftwareKeyboardController?.hide()82 viewModel.onGoogleSignIn()83 }) {84 Text("Sign in with Google")85 }86 Button(modifier = modifier87 .fillMaxWidth()88 .padding(top = 12.dp),89 onClick = {90 localSoftwareKeyboardController?.hide()91 viewModel.onSignIn()92 coroutineScope.launch {93 snackBarHostState.showSnackbar(94 message = "Sign in successfully !",95 duration = SnackbarDuration.Long96 )97 }98 }) {99 Text("Sign in")100 }101 OutlinedButton(modifier = modifier102 .fillMaxWidth()103 .padding(top = 12.dp), onClick = {104 navController.navigate(SignUpDestination.route)105 }) {106 Text("Sign up")107 }108 }109 }110}实现 MainActivity#
在您之前创建的 MainActivity 中,显示您新创建的屏幕
1@AndroidEntryPoint2class MainActivity : ComponentActivity() {3 @Inject4 lateinit var supabaseClient: SupabaseClient56 @OptIn(ExperimentalMaterial3Api::class)7 override fun onCreate(savedInstanceState: Bundle?) {8 super.onCreate(savedInstanceState)9 setContent {10 ManageProductsTheme {11 // A surface container using the 'background' color from the theme12 val navController = rememberNavController()13 val currentBackStack by navController.currentBackStackEntryAsState()14 val currentDestination = currentBackStack?.destination15 Scaffold { innerPadding ->16 NavHost(17 navController,18 startDestination = ProductListDestination.route,19 Modifier.padding(innerPadding)20 ) {21 composable(ProductListDestination.route) {22 ProductListScreen(23 navController = navController24 )25 }2627 composable(AuthenticationDestination.route) {28 SignInScreen(29 navController = navController30 )31 }3233 composable(SignUpDestination.route) {34 SignUpScreen(35 navController = navController36 )37 }3839 composable(AddProductDestination.route) {40 AddProductScreen(41 navController = navController42 )43 }4445 composable(46 route = "${ProductDetailsDestination.route}/{${ProductDetailsDestination.productId}}",47 arguments = ProductDetailsDestination.arguments48 ) { navBackStackEntry ->49 val productId =50 navBackStackEntry.arguments?.getString(ProductDetailsDestination.productId)51 ProductDetailsScreen(52 productId = productId,53 navController = navController,54 )55 }56 }57 }58 }59 }60 }61}创建成功屏幕#
为了处理 OAuth 和 OTP 登录,创建一个新的 Activity 来处理您在 AndroidManifest.xml 中设置的深层链接
1<?xml version="1.0" encoding="utf-8"?>2<manifest xmlns:android="http://schemas.android.com/apk/res/android"3 xmlns:tools="http://schemas.android.com/tools">4 <uses-permission android:name="android.permission.INTERNET" />5 <application6 android:name=".ManageProductApplication"7 android:allowBackup="true"8 android:dataExtractionRules="@xml/data_extraction_rules"9 android:enableOnBackInvokedCallback="true"10 android:fullBackupContent="@xml/backup_rules"11 android:icon="@mipmap/ic_launcher"12 android:label="@string/app_name"13 android:supportsRtl="true"14 android:theme="@style/Theme.ManageProducts"15 tools:targetApi="31">16 <activity17 android:name=".DeepLinkHandlerActivity"18 android:exported="true"19 android:theme="@style/Theme.ManageProducts" >20 <intent-filter android:autoVerify="true">21 <action android:name="android.intent.action.VIEW" />22 <category android:name="android.intent.category.DEFAULT" />23 <category android:name="android.intent.category.BROWSABLE" />24 <data25 android:host="supabase.com"26 android:scheme="app" />27 </intent-filter>28 </activity>29 <activity30 android:name=".MainActivity"31 android:exported="true"32 android:label="@string/app_name"33 android:theme="@style/Theme.ManageProducts">34 <intent-filter>35 <action android:name="android.intent.action.MAIN" />36 <category android:name="android.intent.category.LAUNCHER" />37 </intent-filter>38 </activity>39 </application>40</manifest>然后创建 DeepLinkHandlerActivity
1@AndroidEntryPoint2class DeepLinkHandlerActivity : ComponentActivity() {34 @Inject5 lateinit var supabaseClient: SupabaseClient67 private lateinit var callback: (String, String) -> Unit89 override fun onCreate(savedInstanceState: Bundle?) {10 super.onCreate(savedInstanceState)11 supabaseClient.handleDeeplinks(intent = intent,12 onSessionSuccess = { userSession ->13 Log.d("LOGIN", "Log in successfully with user info: ${userSession.user}")14 userSession.user?.apply {15 callback(email ?: "", createdAt.toString())16 }17 })18 setContent {19 val navController = rememberNavController()20 val emailState = remember { mutableStateOf("") }21 val createdAtState = remember { mutableStateOf("") }22 LaunchedEffect(Unit) {23 callback = { email, created ->24 emailState.value = email25 createdAtState.value = created26 }27 }28 ManageProductsTheme {29 Surface(30 modifier = Modifier.fillMaxSize(),31 color = MaterialTheme.colorScheme.background32 ) {33 SignInSuccessScreen(34 modifier = Modifier.padding(20.dp),35 navController = navController,36 email = emailState.value,37 createdAt = createdAtState.value,38 onClick = { navigateToMainApp() }39 )40 }41 }42 }43 }4445 private fun navigateToMainApp() {46 val intent = Intent(this, MainActivity::class.java).apply {47 flags = Intent.FLAG_ACTIVITY_CLEAR_TOP48 }49 startActivity(intent)50 }51}