快速开始

使用 Jetpack Compose 构建产品管理 Android 应用


本教程演示如何构建一个基础的产品管理应用。该应用展示了以下功能的管理操作、照片上传、账户创建和认证:

manage-product-cover

项目设置

在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。

创建项目

  1. 在 Supabase 仪表板中创建一个新项目
  2. 输入项目详细信息。
  3. 等待新数据库启动。

设置数据库模式

现在我们要设置数据库模式。你可以直接复制/粘贴下面的 SQL 并自行运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
-- 创建一个用于公共配置文件的表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 密钥。

  1. 转到仪表板中的API 设置页面。
  2. 在该页面上找到项目的 URLanonservice_role 密钥。

设置 Google 身份验证

Google 控制台创建一个新项目并添加 OAuth2 凭据。

创建 Google OAuth 凭据

Supabase 身份验证设置中,启用 Google 作为身份验证提供商,并按照身份验证文档中的说明设置所需的凭据。

构建应用

创建新的 Android 项目

打开 Android Studio > 新建项目 > 基础 Activity (Jetpack Compose)。

Android Studio 新建项目

安全设置 API 密钥和密钥

创建本地环境密钥

在项目根目录(与 build.gradle 同级)创建或编辑 local.properties 文件。

注意:不要将此文件提交到源代码管理,例如可以将其添加到 .gitignore 文件!

1
2
SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEYSUPABASE_URL=YOUR_SUPABASE_URL

读取并设置 BuildConfig 的值

在您的 build.gradle (app) 文件中,创建一个 Properties 对象,并通过调用 buildConfigField 方法从 local.properties 文件中读取值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
defaultConfig { 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 读取值:

1
2
val url = BuildConfig.SUPABASE_URLval apiKey = BuildConfig.SUPABASE_ANON_KEY

配置 Supabase 依赖项

Gradle 依赖项

build.gradle (app) 文件中添加以下依赖项,然后点击"立即同步"。将占位符版本号 $supabase_version$ktor_version 替换为各自的最新版本。

1
2
3
4
5
6
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 版本一致。

1
2
3
4
5
plugins { ... id 'org.jetbrains.kotlin.plugin.serialization' version '$kotlin_version' ...}

配置 Hilt 依赖注入

build.gradle (app) 文件中添加以下内容:

1
2
3
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")

创建一个继承 Application 的新类 ManageProductApplication.kt,并添加 @HiltAndroidApp 注解:

1
2
3
// ManageProductApplication.kt@HiltAndroidAppclass ManageProductApplication: Application()

打开 AndroidManifest.xml 文件,更新 Application 标签的 name 属性:

1
2
3
4
5
<application... android:name=".ManageProductApplication"...</application>

创建 MainActivity

1
2
3
4
@AndroidEntryPointclass MainActivity : ComponentActivity() { //这部分内容稍后补充}

使用 Hilt 提供 Supabase 实例

为了使应用更易于测试,创建如下 SupabaseModule.kt 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@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 的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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 中创建领域对象,用于在视图中展示数据:

1
2
3
4
5
6
data class Product( val id: String, val name: String, val price: Double, val image: String?)

实现仓库层

创建 ProductRepository 接口及其实现类 ProductRepositoryImpl,用于封装与 Supabase 数据源的交互逻辑。同样方式创建 AuthenticationRepository

创建商品仓库:

1
2
3
4
5
6
7
8
9
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 )}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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) } } } } } // 由于存储桶命名为"Product Image",在URL中会转换为"%20" // 更佳实践是创建存储桶时避免使用空格 private fun buildImageUrl(imageFileName: String) = "${BuildConfig.SUPABASE_URL}/storage/v1/object/public/${imageFileName}".replace(" ", "%20")}

创建认证仓库:

1
2
3
4
5
interface AuthenticationRepository { suspend fun signIn(email: String, password: String): Boolean suspend fun signUp(email: String, password: String): Boolean suspend fun signInWithGoogle(): Boolean}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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 } }}

实现屏幕界面

使用 AndroidX 导航库来实现屏幕间导航。为实现路由,创建一个 Destination 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
interface 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@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中设置的深度链接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@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) }}