用 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 数据库 - 一个用于存储用户数据的 Postgres 数据库,并且有 行级安全,所以数据是受保护的,用户只能访问自己的信息。
- Supabase Auth - 用户通过发送到他们邮箱的魔法链接登录(无需设置密码)。
- Supabase Storage - 用户可以上传个人头像。

如果你在使用本指南的过程中遇到困难,可以参考 GitHub 上的完整示例。
🌐 If you get stuck while working through this guide, refer to the full example on GitHub.
项目设置 #
🌐 Project setup
在构建之前,你必须在 Supabase 中为一个新项目设置好数据库和 API,然后在数据库里创建一个“schema”。
🌐 Before building, you must set up your Database and API with a new Project in Supabase and then create a "schema" inside the database.
创建一个项目 #
🌐 Create a project
- 在 Supabase 仪表板中创建一个新项目。
- 输入你的项目详情。
- 等新数据库上线。
设置数据库模式 #
🌐 Set up the database schema
现在我们要设置数据库模式了。你可以从下面复制/粘贴 SQL 并自己运行。
🌐 Now we are going to set up the database schema. You can copy/paste the SQL from below and run it yourself.
-- Create a table for public profilescreate table public.products ( id uuid not null default gen_random_uuid (), name text not null, price real not null, image text null, constraint products_pkey primary key (id) ) tablespace pg_default;-- Set up Storage!insert into storage.buckets (id, name) values ('Product Image', 'Product Image');-- Set up access controls for storage.-- See https://supabase.nodejs.cn/docs/guides/storage/security/access-control#policy-examples for more details.CREATE POLICY "Enable read access for all users" ON "storage"."objects"AS PERMISSIVE FOR SELECTTO publicUSING (true)CREATE POLICY "Enable insert for all users" ON "storage"."objects"AS PERMISSIVE FOR INSERTTO authenticated, anonWITH CHECK (true)CREATE POLICY "Enable update for all users" ON "storage"."objects"AS PERMISSIVE FOR UPDATETO publicUSING (true)WITH CHECK (true)获取 API 详情 #
🌐 Get API details
要与数据库表中的数据进行交互,你可以使用封装了自动生成的数据 API 端点的客户端库,并使用来自项目 Connect 对话框的项目 URL 和密钥进行认证。
🌐 To interact with data in database tables, you use the client libraries that wrap the auto-generated Data API endpoints, authenticating using the Project URL and key from the project Connect dialog.
设置谷歌认证 #
🌐 Set up Google authentication
从 Google 控制台 创建一个新项目并添加 OAuth2 凭证。
🌐 From the Google Console, create a new project and add OAuth2 credentials.

在你的 Supabase 认证设置 中启用 Google 作为提供商,并按照 认证文档 中的说明设置所需的凭据。
🌐 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
打开 Android Studio > 新建项目 > 基础活动(Jetpack Compose)。
🌐 Open Android Studio > New Project > Base Activity (Jetpack Compose).

安全地设置 API 密钥和密钥密码 #
🌐 Set up API key and secret securely
创建本地环境密钥 #
🌐 Create local environment secret
在你项目的根目录(与 build.gradle 同级)创建或编辑 local.properties 文件。
🌐 Create or edit the local.properties file at the root (same level as build.gradle) of your project.
注意:不要将此文件提交到你的源代码管理,例如,不要将其添加到你的
.gitignore文件中!
SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEYSUPABASE_URL=YOUR_SUPABASE_URL读取并设置 BuildConfig#
🌐 Read and set value to BuildConfig
在你的 build.gradle(应用)文件中,创建一个 Properties 对象,并通过调用 buildConfigField 方法从你的 local.properties 文件中读取值:
🌐 In your build.gradle (app) file, create a Properties object and read the values from your local.properties file by calling the buildConfigField method:
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")}\"")}使用 BuildConfig#
🌐 Use value from BuildConfig
从 BuildConfig 读取值:
🌐 Read the value from BuildConfig:
val url = BuildConfig.SUPABASE_URLval apiKey = BuildConfig.SUPABASE_PUBLISHABLE_KEY设置 Supabase 依赖 #
🌐 Set up Supabase dependencies

在 build.gradle(应用)文件中,添加这些依赖,然后点击“立即同步”。将依赖版本占位符 $supabase_version 和 $ktor_version 替换为各自的最新版本。
🌐 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.
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"同样在 build.gradle(app)文件中,添加用于序列化的插件。这个插件的版本应该和你的 Kotlin 版本一样。
🌐 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.
plugins { ... id 'org.jetbrains.kotlin.plugin.serialization' version '$kotlin_version' ...}为依赖注入设置 Hilt #
🌐 Set up Hilt for dependency injection
在 build.gradle(应用)文件中,添加以下内容:
🌐 In the build.gradle (app) file, add the following:
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")创建一个带有 @HiltAndroidApp 注解的、继承自 Application 的新 ManageProductApplication.kt 类:
🌐 Create a new ManageProductApplication.kt class extending Application with @HiltAndroidApp annotation:
// ManageProductApplication.kt@HiltAndroidAppclass ManageProductApplication: Application()打开 AndroidManifest.xml 文件,更新 Application 标签的 name 属性:
🌐 Open the AndroidManifest.xml file, update name property of Application tag:
<application... android:name=".ManageProductApplication"...</application>创建 MainActivity:
🌐 Create the MainActivity:
@AndroidEntryPointclass MainActivity : ComponentActivity() { //This will come later}用 Hilt 提供 Supabase 实例 #
🌐 Provide Supabase instances with Hilt
为了让这个应用更容易测试,创建一个 SupabaseModule.kt 文件,如下所示:
🌐 To make the app easier to test, create a SupabaseModule.kt file as follows:
@InstallIn(SingletonComponent::class)@Moduleobject 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
创建一个 ProductDto.kt 类,并使用注解从 Supabase 解析数据:
🌐 Create a ProductDto.kt class and use annotations to parse data from Supabase:
@Serializabledata class ProductDto( @SerialName("name") val name: String, @SerialName("price") val price: Double, @SerialName("image") val image: String?, @SerialName("id") val id: String,)在 Product.kt 中创建一个域对象,在你的视图中展示数据:
🌐 Create a Domain object in Product.kt expose the data in your view:
data class Product( val id: String, val name: String, val price: Double, val image: String?)实现仓库 #
🌐 Implement repositories
创建一个 ProductRepository 接口及其实现,命名为 ProductRepositoryImpl。它包含与 Supabase 数据源交互的逻辑。对 AuthenticationRepository 也做同样的处理。
🌐 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:
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 )}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:
interface AuthenticationRepository { suspend fun signIn(email: String, password: String): Boolean suspend fun signUp(email: String, password: String): Boolean suspend fun signInWithGoogle(): Boolean}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
要导航屏幕,请使用 AndroidX 导航库。对于路由,请实现 Destination 接口:
🌐 To navigate screens, use the AndroidX navigation library. For routes, implement a Destination interface:
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.
创建一个 ProductListViewModel:
🌐 Create a ProductListViewModel:
@HiltViewModelclass 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 ) }}创建 ProductListScreen.kt:
🌐 Create the ProductListScreen.kt:
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)@Composablefun 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!") } } }}@Composableprivate 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, ) }}创建 ProductDetailsViewModel.kt:
🌐 Create the ProductDetailsViewModel.kt:
@HiltViewModelclass 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 ) }}创建 ProductDetailsScreen.kt:
🌐 Create the ProductDetailsScreen.kt:
@OptIn(ExperimentalCoilApi::class)@SuppressLint("UnusedMaterialScaffoldPaddingParameter")@Composablefun 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()}创建一个 AddProductScreen:
🌐 Create a AddProductScreen:
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")@OptIn(ExperimentalMaterial3Api::class)@Composablefun 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() }) } }}创建 AddProductViewModel.kt:
🌐 Create the AddProductViewModel.kt:
@HiltViewModelclass 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) } }}创建一个 SignUpViewModel:
🌐 Create a SignUpViewModel:
@HiltViewModelclass 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 ) } }}创建 SignUpScreen.kt:
🌐 Create the SignUpScreen.kt:
@Composablefun 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") } } }}创建一个 SignInViewModel:
🌐 Create a SignInViewModel:
@HiltViewModelclass 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() } }}创建 SignInScreen.kt:
🌐 Create the SignInScreen.kt:
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)@Composablefun 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") } } }}实现 MainActivity#
🌐 Implement the MainActivity
在你之前创建的 MainActivity 中,显示你新建的屏幕:
🌐 In the MainActivity you created earlier, show your newly created screens:
@AndroidEntryPointclass 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
要处理 OAuth 和 OTP 登录,创建一个新活动来处理你在 AndroidManifest.xml 中设置的深度链接:
🌐 To handle OAuth and OTP signins, create a new activity to handle the deep link you set in AndroidManifest.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>然后创建 DeepLinkHandlerActivity:
🌐 Then create the DeepLinkHandlerActivity:
@AndroidEntryPointclass 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) }}