使用 Jetpack Compose 构建产品管理 Android 应用
本教程演示如何构建一个基础的产品管理应用。该应用展示了以下功能的管理操作、照片上传、账户创建和认证:
- Supabase 数据库 - 用于存储用户数据的 Postgres 数据库,通过行级安全保护数据,确保用户只能访问自己的信息。
- Supabase 认证 - 用户通过发送到邮箱的魔法链接登录(无需设置密码)。
- Supabase 存储 - 用户可以上传个人资料照片。
如果在学习本指南时遇到困难,请参考GitHub上的完整示例。
项目设置
在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。
创建项目
- 在 Supabase 仪表板中创建一个新项目。
- 输入项目详细信息。
- 等待新数据库启动。
设置数据库模式
现在我们要设置数据库模式。你可以直接复制/粘贴下面的 SQL 并自行运行。
1234567891011121314151617181920212223242526272829303132-- 创建一个用于公共配置文件的表CREATE 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;-- 配置存储!INSERT INTO storage.buckets (id, name) VALUES ('Product Image', 'Product Image');-- 为存储设置访问控制。-- 更多详细信息,请参阅 https://supabase.com/docs/guides/storage/security/access-control#policy-examples。CREATE POLICY "允许所有用户读取访问权限" ON "storage"."objects"AS PERMISSIVE FOR SELECTTO publicUSING (true)CREATE POLICY "允许所有用户插入权限" ON "storage"."objects"AS PERMISSIVE FOR INSERTTO authenticated, anonWITH CHECK (true)CREATE POLICY "允许所有用户更新权限" ON "storage"."objects"AS PERMISSIVE FOR UPDATETO publicUSING (true)WITH CHECK (true)
获取 API 密钥
既然已经创建了一些数据库表,就可以使用自动生成的 API 插入数据了。
我们只需要从 API 设置中获取项目 URL 和 anon
密钥。
- 转到仪表板中的API 设置页面。
- 在该页面上找到项目的
URL
、anon
和service_role
密钥。
设置 Google 身份验证
从Google 控制台创建一个新项目并添加 OAuth2 凭据。
在Supabase 身份验证设置中,启用 Google 作为身份验证提供商,并按照身份验证文档中的说明设置所需的凭据。
构建应用
创建新的 Android 项目
打开 Android Studio > 新建项目 > 基础 Activity (Jetpack Compose)。
安全设置 API 密钥和密钥
创建本地环境密钥
在项目根目录(与 build.gradle
同级)创建或编辑 local.properties
文件。
注意:不要将此文件提交到源代码管理,例如可以将其添加到
.gitignore
文件!
12SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEYSUPABASE_URL=YOUR_SUPABASE_URL
读取并设置 BuildConfig
的值
在您的 build.gradle
(app) 文件中,创建一个 Properties
对象,并通过调用 buildConfigField
方法从 local.properties
文件中读取值:
123456789101112131415defaultConfig { applicationId "com.example.manageproducts" minSdkVersion 22 targetSdkVersion 33 versionCode 5 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // 设置值部分 Properties properties = new Properties() properties.load(project.rootProject.file("local.properties").newDataInputStream()) buildConfigField("String", "SUPABASE_ANON_KEY", "\"${properties.getProperty("SUPABASE_ANON_KEY")}\"") buildConfigField("String", "SECRET", "\"${properties.getProperty("SECRET")}\"") buildConfigField("String", "SUPABASE_URL", "\"${properties.getProperty("SUPABASE_URL")}\"")}
使用 BuildConfig
中的值
从 BuildConfig
读取值:
12val url = BuildConfig.SUPABASE_URLval apiKey = BuildConfig.SUPABASE_ANON_KEY
配置 Supabase 依赖项
在 build.gradle
(app) 文件中添加以下依赖项,然后点击"立即同步"。将占位符版本号 $supabase_version
和 $ktor_version
替换为各自的最新版本。
123456implementation "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 版本一致。
12345plugins { ... id 'org.jetbrains.kotlin.plugin.serialization' version '$kotlin_version' ...}
配置 Hilt 依赖注入
在 build.gradle
(app) 文件中添加以下内容:
123implementation "com.google.dagger:hilt-android:$hilt_version"annotationProcessor "com.google.dagger:hilt-compiler:$hilt_version"implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
创建一个继承 Application 的新类 ManageProductApplication.kt
,并添加 @HiltAndroidApp
注解:
123// ManageProductApplication.kt@HiltAndroidAppclass ManageProductApplication: Application()
打开 AndroidManifest.xml
文件,更新 Application 标签的 name 属性:
12345<application... android:name=".ManageProductApplication"...</application>
创建 MainActivity
:
1234@AndroidEntryPointclass MainActivity : ComponentActivity() { //这部分内容稍后补充}
使用 Hilt 提供 Supabase 实例
为了使应用更易于测试,创建如下 SupabaseModule.kt
文件:
1234567891011121314151617181920212223242526272829303132333435363738394041@InstallIn(SingletonComponent::class)@Moduleobject SupabaseModule { @Provides @Singleton fun provideSupabaseClient(): SupabaseClient { return createSupabaseClient( supabaseUrl = BuildConfig.SUPABASE_URL, supabaseKey = BuildConfig.SUPABASE_ANON_KEY ) { install(Postgrest) install(Auth) { flowType = FlowType.PKCE scheme = "app" host = "supabase.com" } install(Storage) } } @Provides @Singleton fun provideSupabaseDatabase(client: SupabaseClient): Postgrest { return client.postgrest } @Provides @Singleton fun provideSupabaseAuth(client: SupabaseClient): Auth { return client.auth } @Provides @Singleton fun provideSupabaseStorage(client: SupabaseClient): Storage { return client.storage }}
创建数据传输对象
创建 ProductDto.kt
类并使用注解解析来自 Supabase 的数据:
123456789101112131415@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
中创建领域对象,用于在视图中展示数据:
123456data class Product( val id: String, val name: String, val price: Double, val image: String?)
实现仓库层
创建 ProductRepository
接口及其实现类 ProductRepositoryImpl
,用于封装与 Supabase 数据源的交互逻辑。同样方式创建 AuthenticationRepository
。
创建商品仓库:
123456789interface 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 )}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091class 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) } } } } } // 由于存储桶命名为"Product Image",在URL中会转换为"%20" // 更佳实践是创建存储桶时避免使用空格 private fun buildImageUrl(imageFileName: String) = "${BuildConfig.SUPABASE_URL}/storage/v1/object/public/${imageFileName}".replace(" ", "%20")}
创建认证仓库:
12345interface AuthenticationRepository { suspend fun signIn(email: String, password: String): Boolean suspend fun signUp(email: String, password: String): Boolean suspend fun signInWithGoogle(): Boolean}
123456789101112131415161718192021222324252627282930313233343536class 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 } }}
实现屏幕界面
使用 AndroidX 导航库来实现屏幕间导航。为实现路由,创建一个 Destination
接口:
12345678910111213141516171819202122232425262728293031323334interface Destination { val route: String val title: String}object ProductListDestination : Destination { override val route = "product_list" override val title = "产品列表"}object ProductDetailsDestination : Destination { override val route = "product_details" override val title = "产品详情" 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 = "添加产品"}object AuthenticationDestination: Destination { override val route = "authentication" override val title = "身份验证"}object SignUpDestination: Destination { override val route = "signup" override val title = "注册"}
这将有助于后续的屏幕间导航。
创建 ProductListViewModel
:
12345678910111213141516171819202122232425262728293031323334353637383940414243@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()) // 调用API删除 productRepository.deleteProduct(id = product.id) // 然后重新获取 getProducts() } } private fun ProductDto.asDomainModel(): Product { return Product( id = this.id, name = this.name, price = this.price, image = this.image ) }}
创建 ProductListScreen.kt
:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112@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) { // 处理删除项 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("产品列表为空!") } } }}@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
:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667@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
:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163@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 = "产品名称", 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 = "产品价格", 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 = "产品更新成功!", duration = SnackbarDuration.Short ) } }) { Text(text = "保存更改") } Spacer(modifier = modifier.height(12.dp)) OutlinedButton( modifier = modifier .fillMaxWidth(), onClick = { navController.navigateUp() }) { Text(text = "取消") } } }}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
:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253@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 = "正在添加产品", onCancelSelected = { navController.navigateUp() }) } else { SuccessScreen( message = "产品添加成功", onMoreAction = { viewModel.onAddMoreProductSelected() }, onNavigateBack = { navController.navigateUp() }) } }}
创建 AddProductViewModel.kt
:
1234567891011121314151617181920212223242526@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
:
12345678910111213141516171819202122232425262728@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
:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293@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 = "注册", 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 = "邮箱", 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 = "密码", 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 = "账号创建成功。立即登录!", duration = SnackbarDuration.Long ) } }) { Text("注册") } } }}
创建 SignInViewModel
:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869@HiltViewModelclass### 实现 `MainActivity`在之前创建的 `MainActivity` 中,展示您新建的屏幕:```kotlin@AndroidEntryPointclass MainActivity : ComponentActivity() { @Inject lateinit var supabaseClient: SupabaseClient @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { ManageProductsTheme { // 使用主题中的'background'颜色的表面容器 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, ) } } } } } }}
创建成功界面
为了处理OAuth和OTP登录,需要创建一个新的Activity来处理在AndroidManifest.xml
中设置的深度链接:
12345678910111213141516171819202122232425262728293031323334353637383940<?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
:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051@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", "登录成功,用户信息: ${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) }}