From b178faced21c66003bf4351d31d12cdaeaf2eb6c Mon Sep 17 00:00:00 2001 From: phamhieu Date: Fri, 19 May 2023 08:19:58 +0700 Subject: [PATCH 001/282] feat: initial kotlin starter guides --- apps/docs/components/Frameworks.tsx | 8 ++ apps/docs/components/HomePageCover.tsx | 10 ++ .../NavigationMenu.constants.ts | 6 +- apps/docs/pages/guides/getting-started.mdx | 14 ++ .../getting-started/quickstarts/kotlin.mdx | 131 ++++++++++++++++++ .../getting-started/tutorials/with-kotlin.mdx | 37 +++++ apps/docs/public/sitemap.xml | 12 ++ apps/www/components/Hero/HeroFrameworks.tsx | 6 + apps/www/lib/redirects.js | 5 + .../Command/utils/shared-nav-items.json | 4 + 10 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx create mode 100644 apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx diff --git a/apps/docs/components/Frameworks.tsx b/apps/docs/components/Frameworks.tsx index a1b6d0da29b..42e4a40c0b0 100644 --- a/apps/docs/components/Frameworks.tsx +++ b/apps/docs/components/Frameworks.tsx @@ -37,6 +37,14 @@ const Frameworks = () => { }, href: '/reference/javascript/installing#javascript', }, + { + name: 'Kotlin', + logo: { + light: '/docs/img/icons/flutter-icon.svg', + dark: '/docs/img/icons/flutter-icon.svg', + }, + href: '/guides/with-kotlin', + }, { name: 'Next.js', logo: { diff --git a/apps/docs/components/HomePageCover.tsx b/apps/docs/components/HomePageCover.tsx index edf3252ca64..06c1273b391 100644 --- a/apps/docs/components/HomePageCover.tsx +++ b/apps/docs/components/HomePageCover.tsx @@ -66,6 +66,16 @@ const HomePageCover = (props) => { iconSize={iconSize} hideArrow tooltip="Flutter" + icon="/docs/img/icons/kotlin-icon" + /> + + + + + diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 912faa31c0c..c53cb597263 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -208,6 +208,7 @@ export const gettingstarted = { { name: 'NextJS', url: '/guides/getting-started/quickstarts/nextjs' }, { name: 'RedwoodJS', url: '/guides/getting-started/quickstarts/redwoodjs' }, { name: 'Flutter', url: '/guides/getting-started/quickstarts/flutter' }, + { name: 'Kotlin', url: '/guides/getting-started/quickstarts/kotlin' }, { name: 'SvelteKit', url: '/guides/getting-started/quickstarts/sveltekit' }, { name: 'SolidJS', url: '/guides/getting-started/quickstarts/solidjs' }, { name: 'Vue', url: '/guides/getting-started/quickstarts/vue' }, @@ -265,7 +266,10 @@ export const gettingstarted = { name: 'Expo', url: '/guides/getting-started/tutorials/with-expo', }, - + { + name: 'Kotlin', + url: '/guides/getting-started/tutorials/with-kotlin', + }, { name: 'Ionic React', url: '/guides/getting-started/tutorials/with-ionic-react', diff --git a/apps/docs/pages/guides/getting-started.mdx b/apps/docs/pages/guides/getting-started.mdx index f2e4d4532c5..65d0e2af1f0 100644 --- a/apps/docs/pages/guides/getting-started.mdx +++ b/apps/docs/pages/guides/getting-started.mdx @@ -150,6 +150,13 @@ export const quickstarts = [ 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Flutter app.', icon: '/docs/img/icons/flutter-icon', }, + { + title: 'Kotlin', + href: '/guides/getting-started/quickstarts/kotlin', + description: + 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Kotlin app.', + icon: '/docs/img/icons/kotlin-icon', + }, { title: 'SvelteKit', href: '/guides/getting-started/quickstarts/sveltekit', @@ -247,6 +254,13 @@ export const mobile = [ 'Learn how to build a user management app with Expo and Supabase Database, Auth, and Storage functionality.', icon: '/docs/img/icons/expo-icon', }, + { + title: 'Kotlin', + href: '/guides/getting-started/tutorials/with-kotlin', + description: + 'Learn how to build a groceries store app with Kotlin and Supabase Database, Auth, and Storage functionality.', + icon: '/docs/img/icons/kotlin-icon', + }, { title: 'Ionic React', href: '/guides/getting-started/tutorials/with-ionic-react', diff --git a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx new file mode 100644 index 00000000000..e2dfa410a8e --- /dev/null +++ b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx @@ -0,0 +1,131 @@ +import Layout from '~/layouts/DefaultGuideLayout' +import StepHikeCompact from '~/components/StepHikeCompact' + +export const meta = { + title: 'Use Supabase with Kotlin', + subtitle: + 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Kotlin app.', + breadcrumb: 'Framework Quickstarts', +} + + + + + + + [Create a new project](https://app.supabase.com) in the Supabase Dashboard. + + After your project is ready, create a table in your Supabase database using the [SQL Editor](https://app.supabase.com/project/_/sql) in the Dashboard. Use the following SQL statement to create a `countries` table with some sample data. + + + + + + ```sql SQL_EDITOR + -- Create the table + CREATE TABLE countries ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL + ); + -- Insert some sample data into the table + INSERT INTO countries (name) VALUES ('United States'); + INSERT INTO countries (name) VALUES ('Canada'); + INSERT INTO countries (name) VALUES ('Mexico'); + ```` + + + + + + + + + + ... + + + + + + ```bash Terminal + ... + ``` + + + + + + + + + + ... + + + + + + ```kotlin + ... + ``` + + + + + + + + + + ... + + + + + + ```kotlin MainActivity.kt + ... + ``` + + + + + + + + + + ... + + + + + + ```kotlin MainActivity.kt + ... + ``` + + + + + + + + + + + + + ```bash Terminal + ... + ``` + + + + + + + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx new file mode 100644 index 00000000000..4f1e711c513 --- /dev/null +++ b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx @@ -0,0 +1,37 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + title: 'Build a Groceries Store App with Kotlin', + description: 'Learn how to use Supabase in your Kotlin App.', +} + + + +![Supabase User Management example](/docs/img/supabase-flutter-demo.png) + + + If you get stuck while working through this guide, refer to the [full example on + GitHub](https://github.com/supabase/supabase/tree/master/examples). + + + + +## Building the App + +Let's start building the Kotlin app from scratch. + +### Initialize a Kotlin app + +... + +```bash +... +``` + +### Setup deep links + +... + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/public/sitemap.xml b/apps/docs/public/sitemap.xml index 78ce4d811b9..2632034b190 100644 --- a/apps/docs/public/sitemap.xml +++ b/apps/docs/public/sitemap.xml @@ -1122,6 +1122,12 @@ 0.5 + + https://supabase.com/docs/guides/getting-started/quickstarts/kotlin + weekly + 0.5 + + https://supabase.com/docs/guides/getting-started/quickstarts/nextjs weekly @@ -1290,6 +1296,12 @@ 0.5 + + https://supabase.com/docs/guides/getting-started/tutorials/with-kotlin + weekly + 0.5 + + https://supabase.com/docs/guides/getting-started/tutorials/with-ionic-angular weekly diff --git a/apps/www/components/Hero/HeroFrameworks.tsx b/apps/www/components/Hero/HeroFrameworks.tsx index 3d59f73161b..109163e96f5 100644 --- a/apps/www/components/Hero/HeroFrameworks.tsx +++ b/apps/www/components/Hero/HeroFrameworks.tsx @@ -31,6 +31,12 @@ const frameworks = [ docs: '/docs/guides/getting-started/quickstarts/flutter', gaEvent: 'www_hp_hero_frameworks_flutter', }, + { + name: 'Kotlin', + icon: '/images/logos/frameworks/kotlin.svg', + docs: '/docs/guides/getting-started/quickstarts/kotlin', + gaEvent: 'www_hp_hero_frameworks_kotlin', + }, { name: 'Svelte', icon: '/images/logos/frameworks/svelte.svg', diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 7331a19da0b..a8448eceaa2 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -1707,6 +1707,11 @@ module.exports = [ source: '/docs/guides/with-expo', destination: '/docs/guides/getting-started/tutorials/with-expo', }, + { + permanent: true, + source: '/docs/guides/with-kotlin', + destination: '/docs/guides/getting-started/tutorials/with-kotlin', + }, { permanent: true, source: '/docs/guides/with-ionic-react', diff --git a/packages/ui/src/components/Command/utils/shared-nav-items.json b/packages/ui/src/components/Command/utils/shared-nav-items.json index c3c515cc9d4..8c313ed43d2 100644 --- a/packages/ui/src/components/Command/utils/shared-nav-items.json +++ b/packages/ui/src/components/Command/utils/shared-nav-items.json @@ -208,6 +208,10 @@ "label": "Flutter", "url": "https://supabase.com/docs/guides/getting-started/quickstarts/flutter" }, + { + "label": "Kotlin", + "url": "https://supabase.com/docs/guides/getting-started/quickstarts/kotlin" + }, { "label": "SvelteKit", "url": "https://supabase.com/docs/guides/getting-started/quickstarts/sveltekit" From 4603f90653e333bdf18049a1a605433f0dcfad6b Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Mon, 22 May 2023 22:35:26 +0700 Subject: [PATCH 002/282] docs: update steps to setup --- .../getting-started/quickstarts/kotlin.mdx | 129 +++++++++++------- 1 file changed, 80 insertions(+), 49 deletions(-) diff --git a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx index e2dfa410a8e..1d43b086fde 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx @@ -2,35 +2,40 @@ import Layout from '~/layouts/DefaultGuideLayout' import StepHikeCompact from '~/components/StepHikeCompact' export const meta = { - title: 'Use Supabase with Kotlin', + title: 'Use Supabase with Android Kotlin', subtitle: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Kotlin app.', + 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Android Kotlin app.', breadcrumb: 'Framework Quickstarts', } - + [Create a new project](https://app.supabase.com) in the Supabase Dashboard. - After your project is ready, create a table in your Supabase database using the [SQL Editor](https://app.supabase.com/project/_/sql) in the Dashboard. Use the following SQL statement to create a `countries` table with some sample data. + After your project is ready, create a table in your Supabase database using the [SQL Editor](https://app.supabase.com/project/_/sql) in the Dashboard. Use the following SQL statement to create all tables. ```sql SQL_EDITOR - -- Create the table - CREATE TABLE countries ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL - ); - -- Insert some sample data into the table - INSERT INTO countries (name) VALUES ('United States'); - INSERT INTO countries (name) VALUES ('Canada'); - INSERT INTO countries (name) VALUES ('Mexico'); + -- Create products table + create table + public.products ( + _id bigint generated by default as identity not null, + productid text not null, + name text null, + description text null, + price real null, + image text null, + category text null, + nutrition text null, + constraint products_pkey primary key (productid), + ) tablespace pg_default; + ```` @@ -39,34 +44,30 @@ export const meta = { - - - ... + + Setup environment for Android Development following official document. + Open Android Studio > New > New Android Project - - - ```bash Terminal - ... - ``` - - - - - ... - + Setup Supabase libraries requires some others libraries from ktor, so make sure all dependencies are added + ```kotlin - ... + implementation("io.ktor:ktor-client-cio:1.6.0") + implementation("io.github.jan-tennert.supabase:postgrest-kt-android:0.9.0-alpha-4") + implementation "io.ktor:ktor-client-android:2.2.1" + implementation "io.ktor:ktor-utils-jvm:1.6.0" + implementation "io.ktor:ktor-client-logging-jvm:1.6.0" + implementation("io.ktor:ktor-client-core:2.2.4") ``` @@ -76,15 +77,53 @@ export const meta = { - - ... - + You can create a whenever you need to perform some API call. + However, it is recommended that we should provide this kind of instance by using dependency injection library like Hilt. So see the code below to provide them properly. This is better for testability and maintainability - ```kotlin MainActivity.kt - ... + ```kotlin + // Handy create instance when need + val client = createSupabaseClient( + supabaseUrl = "https://xyzcompany.supabase.co", + supabaseKey = "public-anon-key") { + install(GoTrue) + install(Postgrest) + //install other modules + } + ``` + + + + + + + + + + + + ```kotlin + @Serializable + data class ProductDto( + @SerialName("productid") + val productId: String, + @SerialName("name") + val name: String, + @SerialName("description") + val description: String, + @SerialName("price") + val price: Double, + @SerialName("image") + val image: String, + @SerialName("category") + val category: String, + @SerialName("nutrition") + val nutrition: String, + @SerialName("_id") + val _id: Int, + ) ``` @@ -94,34 +133,26 @@ export const meta = { - - ... - + - - ```kotlin MainActivity.kt - ... + ```kotlin + override suspend fun getProducts() { + val result = supabasePostgrest["products"] + .select().decodeList() + // Handle result data for next step + } ``` - - + - - - ```bash Terminal - ... - ``` - - - From 6435c6f46b3669b1d3fef6c25560f29254ae1b9c Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Mon, 5 Jun 2023 23:07:59 +0700 Subject: [PATCH 003/282] docs: add sample --- .../getting-started/tutorials/with-kotlin.mdx | 770 +++++++++++++++++- 1 file changed, 759 insertions(+), 11 deletions(-) diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx index 4f1e711c513..653d9472fe7 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx @@ -1,36 +1,784 @@ import Layout from '~/layouts/DefaultGuideLayout' export const meta = { - title: 'Build a Groceries Store App with Kotlin', - description: 'Learn how to use Supabase in your Kotlin App.', + title: 'Build a Product Management App with Jetpack Compose in Android', + description: 'Learn how to use Supabase in your Android Kotlin App.', } -![Supabase User Management example](/docs/img/supabase-flutter-demo.png) +![manage-product-cover](https://github.com/hieuwu/product-sample-supabase-kt/assets/43868345/1bed0c84-208a-4266-a2ec-2bac50ddf80c) If you get stuck while working through this guide, refer to the [full example on - GitHub](https://github.com/supabase/supabase/tree/master/examples). + GitHub](https://github.com/hieuwu/product-sample-supabase-kt). -## Building the App -Let's start building the Kotlin app from scratch. +### 1. Create new Android with Jetpack Compose in Android Studio +Open Android Studio > New Project > Base Activity (Jetpack Compose) -### Initialize a Kotlin app +Screen Shot 2023-06-02 at 21 33 16 -... +### 2. Set up API key and secret securely +API key and secret from any services are credential information, we should not want to expose these to others because some strangers will use it for bad purpose + +#### 2.1. Create local environment secret +Create or edit `local.properties` file at top level (same level at build.gradle of the project). + +> **Note**: This file should not be pushed directly to the repository, meaning that you should include this in `.gitignore` file + +```kotlin +API_KEY=YOUR_SUPABASE_API_KEY +SECRET=YOUR_SUPABASE_SECRET +SUPABASE_URL=YOUR_SUPABASE_URL -```bash -... ``` -### Setup deep links +#### 2.2. Read and set value to BuildConfig +In `build.gradle (app)`, create `Properties` object and read value from `local.properties` set value for `BuildConfig` by calling `buildConfigField` method +```kotlin +defaultConfig { + applicationId "com.hieuwu.groceriesstore" + 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", "API_KEY", "\"${properties.getProperty("API_KEY")}\"") + buildConfigField("String", "SECRET", "\"${properties.getProperty("SECRET")}\"") + buildConfigField("String", "SUPABASE_URL", "\"${properties.getProperty("SUPABASE_URL")}\"") +} +``` + +#### 2.3. Use value from `BuildConfig` +Read value from `BuildConfig` (Remember to import the right class, it is one from your project). For example: +```kotlin +val url = BuildConfig.SUPABASE_URL +val apiKey = BuildConfig.API_KEY +``` + +### 3. Set up Supabase dependencies including Postgrest, GoTrue, Storage +Screen Shot 2023-06-05 at 21 39 24 + +In build.gradle (app) file, add these dependencies then press Sync now +```kotlin +// Supabase setup +implementation("io.ktor:ktor-client-cio:1.6.0") +implementation("io.github.jan-tennert.supabase:postgrest-kt-android:1.0.0") +implementation("io.github.jan-tennert.supabase:storage-kt:1.0.0") +implementation "io.ktor:ktor-client-android:2.2.1" +implementation "io.ktor:ktor-utils-jvm:1.6.0" +implementation "io.ktor:ktor-client-logging-jvm:1.6.0" +implementation("io.ktor:ktor-client-core:2.2.4") +``` + +### 4. Set up Hilt for dependency injection +In build.gradle (app) file, add these +```kotlin +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") +``` +Create new `ManageProductApplication.kt` class extending Application with `@HiltAndroidApp` annotation +```kotlin +// ManageProductApplication.kt +@HiltAndroidApp +class ManageProductApplication: Application() +``` +Open AndroidManifest.xml file, update name property of Application tag +```xml + + +``` +Add annotation for MainActivity +```kotlin +@AndroidEntryPoint +class MainActivity : ComponentActivity() { +} +``` +### 5. Provide Supabase instances with Hilt +You can create it by just calling the constructor. However to make the app more testable, these should be provided +Create SupabaseMdule.kt +```kotlin +@InstallIn(SingletonComponent::class) +@Module +object SupabaseModule { + + @Provides + @Singleton + fun provideSupabaseClient(): SupabaseClient { + return createSupabaseClient( + supabaseUrl = BuildConfig.SUPABASE_URL, + supabaseKey = BuildConfig.API_KEY + ) { + install(Postgrest) + install(GoTrue) + install(Storage) + } + } + + @Provides + @Singleton + fun provideSupabaseDatabase(client: SupabaseClient): Postgrest { + return client.postgrest + } + + @Provides + @Singleton + fun provideSupabaseGoTrue(client: SupabaseClient): GoTrue { + return client.gotrue + } + + + @Provides + @Singleton + fun provideSupabaseStorage(client: SupabaseClient): Storage { + return client.storage + } + +} +``` + +### 6. Create data transfer object (DTO) to get data +Create `ProductDto.kt` class and use annotations to parse data from Supabase +```kotlin +@Serializable +data class ProductDto( + + @SerialName("name") + val name: String, + + @SerialName("price") + val price: Double, + + @SerialName("image") + val image: String? = "", + + @SerialName("id") + val id: String? = "", +) +``` + +### 7. Implement repository to interact with Supabase services +Create ProductRepository interface and its implementation named ProductRepositoryImpl. +This kind of class will hold every logic to interact with data source from Supabase. + +```kotlin +interface ProductRepository { + suspend fun createProduct(product: Product): Boolean + suspend fun getProducts(): List? + suspend fun getProduct(id: String): ProductDto + suspend fun deleteProduct(id: String) + suspend fun updateProduct( + id: String, name: String, price: Double, imageName: String, imageFile: ByteArray + ) +} +``` + +```kotlin +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["products"].insert(productDto) + true + } + true + } catch (e: java.lang.Exception) { + throw e + } + } + + override suspend fun getProducts(): List? { + return withContext(Dispatchers.IO) { + val result = postgrest["products"] + .select().decodeList() + result + } + } + + + override suspend fun getProduct(id: String): ProductDto { + return withContext(Dispatchers.IO) { + postgrest["products"].select { + eq("id", id) + }.decodeSingle() + } + } + + override suspend fun deleteProduct(id: String) { + return withContext(Dispatchers.IO) { + postgrest["products"].delete { + 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["Product%20Image"].upload( + path = "$imageName.png", + data = imageFile, + upsert = true + ) + postgrest["products"].update({ + set("name", name) + set("price", price) + set("image", buildImageUrl(imageFileName = imageUrl)) + }) { + eq("id", id) + } + } else { + postgrest["products"].update({ + set("name", name) + set("price", price) + }) { + 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") +} +``` + +### 8. Implement screens + + By the time this page is written, there might be some changes in the code to make it more simple. If you face any problem when implement, feel free to refer the example repository above + +#### 8.1 Product list + +Create ProductListViewModel +```kotlin +@HiltViewModel +class ProductListViewModel @Inject constructor( + private val productRepository: ProductRepository, +) : ViewModel() { + + private val _productList = MutableStateFlow?>(listOf()) + override val productList: Flow?> = _productList + + + private val _isLoading = MutableStateFlow(false) + val isLoading: Flow = _isLoading + + init { + getProducts() + } + + override fun getProducts() { + viewModelScope.launch { + val products = productRepository.getProducts() + _productList.emit(products?.map { it -> it.asDomainModel() }) + } + } + + override fun removeItem(product: Product) { + viewModelScope.launch { + val newList = mutableListOf().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 ?: "" + ) + } +} +``` + +Create ProductListScreen.kt +```kotlin +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) +@Composable +fun 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!") + } + + } + } +} + +@Composable +private 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, + ) + } +} +``` + + +Product details +Create ProductDetailsViewModel.kt +```kotlin + +@HiltViewModel +class ProductDetailsViewModel @Inject constructor( + private val productRepository: ProductRepository, + savedStateHandle: SavedStateHandle, + + ) : ViewModel() { + private val _product = MutableStateFlow(null) + override val product: Flow = _product + + private val _name = MutableStateFlow("") + override val name: Flow = _name + + private val _price = MutableStateFlow(0.0) + override val price: Flow = _price + + private val _imageUrl = MutableStateFlow("") + override val imageUrl: Flow = _imageUrl + + init { + val productId = savedStateHandle.get(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 + } + + override 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}", + ) + } + } + + override 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 ?: "" + ) + } +} +``` + +Create ProductDetailsScreen.kt + +```kotlin +@OptIn(ExperimentalCoilApi::class) +@SuppressLint("UnusedMaterialScaffoldPaddingParameter") +@Composable +fun 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 = "").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() +} +``` + +Add product +Create AddProductScreen +```kotlin +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun 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() + }) + } + + } +} +``` + +Create AddProductViewModel.kt +```kotlin +@HiltViewModel +class AddProductViewModel @Inject constructor( + private val productRepository: ProductRepository, +) : ViewModel() { + + private val _isLoading = MutableStateFlow(false) + override val isLoading: Flow = _isLoading + + private val _showSuccessMessage = MutableStateFlow(false) + override val showSuccessMessage: Flow = _showSuccessMessage + override 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) + + } + } +} +``` + export const Page = ({ children }) => From 16557f831bb5e4b64f7fe27119660f03f659a264 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Tue, 6 Jun 2023 23:32:24 +0700 Subject: [PATCH 004/282] docs: Update icon --- apps/docs/components/Frameworks.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/components/Frameworks.tsx b/apps/docs/components/Frameworks.tsx index 42e4a40c0b0..12e38b7910d 100644 --- a/apps/docs/components/Frameworks.tsx +++ b/apps/docs/components/Frameworks.tsx @@ -40,8 +40,8 @@ const Frameworks = () => { { name: 'Kotlin', logo: { - light: '/docs/img/icons/flutter-icon.svg', - dark: '/docs/img/icons/flutter-icon.svg', + light: '/docs/img/icons/kotlin-icon.svg', + dark: '/docs/img/icons/kotlin-icon.svg', }, href: '/guides/with-kotlin', }, From 743f067580549adb49746daf5e33407eb3aa21f9 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Tue, 6 Jun 2023 23:32:48 +0700 Subject: [PATCH 005/282] docs: replace icon --- apps/docs/components/HomePageCover.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/components/HomePageCover.tsx b/apps/docs/components/HomePageCover.tsx index 06c1273b391..d3f6a47d4cb 100644 --- a/apps/docs/components/HomePageCover.tsx +++ b/apps/docs/components/HomePageCover.tsx @@ -66,7 +66,7 @@ const HomePageCover = (props) => { iconSize={iconSize} hideArrow tooltip="Flutter" - icon="/docs/img/icons/kotlin-icon" + icon="/docs/img/icons/flutter-icon" /> @@ -76,7 +76,7 @@ const HomePageCover = (props) => { iconSize={iconSize} hideArrow tooltip="Kotlin" - icon="/docs/img/icons/flutter-icon" + icon="/docs/img/icons/kotlin-icon" /> From 5120c9e2328d33691df2b16a4418fa32c85cfe97 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Tue, 6 Jun 2023 23:33:11 +0700 Subject: [PATCH 006/282] docs: update version display and additional step --- .../getting-started/quickstarts/kotlin.mdx | 68 +++++++++++++------ .../getting-started/tutorials/with-kotlin.mdx | 60 ++++++++++------ 2 files changed, 87 insertions(+), 41 deletions(-) diff --git a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx index 1d43b086fde..ba4c38b92d1 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx @@ -55,19 +55,27 @@ export const meta = { - Setup Supabase libraries requires some others libraries from ktor, so make sure all dependencies are added - + Setup Supabase libraries requires some others libraries from ktor, so make sure all dependencies are added. + Replace place holder of the dependency versions `$supabase_version` and `$ktor_version` with latest versions as below + + - ```kotlin - implementation("io.ktor:ktor-client-cio:1.6.0") - implementation("io.github.jan-tennert.supabase:postgrest-kt-android:0.9.0-alpha-4") - implementation "io.ktor:ktor-client-android:2.2.1" - implementation "io.ktor:ktor-utils-jvm:1.6.0" - implementation "io.ktor:ktor-client-logging-jvm:1.6.0" - implementation("io.ktor:ktor-client-core:2.2.4") + implementation "io.github.jan-tennert.supabase:postgrest-kt-android:$supabase_version" + implementation "io.ktor:ktor-client-android:$ktor_version" + implementation "io.ktor:ktor-client-logging-jvm:$ktor_version" + implementation "io.ktor:ktor-client-core:$ktor_version" + implementation "io.ktor:ktor-client-cio:$ktor_version" + implementation "io.ktor:ktor-utils-jvm:$ktor_version" ``` @@ -76,6 +84,26 @@ export const meta = { + + Open `build.gradle` (app), add serialization plugin to use annotation for data parsing + + + + + ```kotlin + plugins { + ... + id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.0' + ... + } + ``` + + + + + + + You can create a whenever you need to perform some API call. However, it is recommended that we should provide this kind of instance by using dependency injection library like Hilt. So see the code below to provide them properly. This is better for testability and maintainability @@ -86,19 +114,19 @@ export const meta = { ```kotlin // Handy create instance when need val client = createSupabaseClient( - supabaseUrl = "https://xyzcompany.supabase.co", - supabaseKey = "public-anon-key") { - install(GoTrue) - install(Postgrest) - //install other modules - } + supabaseUrl = "https://xyzcompany.supabase.co", + supabaseKey = "public-anon-key") { + install(GoTrue) + install(Postgrest) + install(Storage) + } ``` - + @@ -130,7 +158,7 @@ export const meta = { - + @@ -138,8 +166,8 @@ export const meta = { ```kotlin - override suspend fun getProducts() { - val result = supabasePostgrest["products"] + suspend fun getProducts() { + val result = client.postgrest["products"] .select().decodeList() // Handle result data for next step } @@ -148,7 +176,7 @@ export const meta = { - + diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx index 653d9472fe7..7c6a575f5b4 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx @@ -65,19 +65,36 @@ val url = BuildConfig.SUPABASE_URL val apiKey = BuildConfig.API_KEY ``` -### 3. Set up Supabase dependencies including Postgrest, GoTrue, Storage +### 3. Set up Supabase dependencies including Postgrest, GoTrue, Storage and serialization plugin Screen Shot 2023-06-05 at 21 39 24 -In build.gradle (app) file, add these dependencies then press Sync now +In build.gradle (app) file, add these dependencies then press Sync now. Replace place holder of the dependency versions `$supabase_version` and `$ktor_version` with latest versions as below + ```kotlin // Supabase setup -implementation("io.ktor:ktor-client-cio:1.6.0") -implementation("io.github.jan-tennert.supabase:postgrest-kt-android:1.0.0") -implementation("io.github.jan-tennert.supabase:storage-kt:1.0.0") -implementation "io.ktor:ktor-client-android:2.2.1" -implementation "io.ktor:ktor-utils-jvm:1.6.0" -implementation "io.ktor:ktor-client-logging-jvm:1.6.0" -implementation("io.ktor:ktor-client-core:2.2.4") +implementation "io.github.jan-tennert.supabase:postgrest-kt-android:$supabase_version" +implementation "io.github.jan-tennert.supabase:storage-kt:$supabase_version" +implementation "io.ktor:ktor-client-android:$ktor_version" +implementation "io.ktor:ktor-client-logging-jvm:$ktor_version" +implementation "io.ktor:ktor-client-core:$ktor_version" +implementation "io.ktor:ktor-client-cio:$ktor_version" +implementation "io.ktor:ktor-utils-jvm:$ktor_version" +``` + +Also in build.gradle (app) file, add plugin for serialization +```kotlin +plugins { + ... + id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.0' + ... +} ``` ### 4. Set up Hilt for dependency injection @@ -287,7 +304,7 @@ class ProductListViewModel @Inject constructor( ) : ViewModel() { private val _productList = MutableStateFlow?>(listOf()) - override val productList: Flow?> = _productList + val productList: Flow?> = _productList private val _isLoading = MutableStateFlow(false) @@ -297,14 +314,14 @@ class ProductListViewModel @Inject constructor( getProducts() } - override fun getProducts() { + fun getProducts() { viewModelScope.launch { val products = productRepository.getProducts() _productList.emit(products?.map { it -> it.asDomainModel() }) } } - override fun removeItem(product: Product) { + fun removeItem(product: Product) { viewModelScope.launch { val newList = mutableListOf().apply { _productList.value?.let { addAll(it) } } newList.remove(product) @@ -456,16 +473,16 @@ class ProductDetailsViewModel @Inject constructor( ) : ViewModel() { private val _product = MutableStateFlow(null) - override val product: Flow = _product + val product: Flow = _product private val _name = MutableStateFlow("") - override val name: Flow = _name + val name: Flow = _name private val _price = MutableStateFlow(0.0) - override val price: Flow = _price + val price: Flow = _price private val _imageUrl = MutableStateFlow("") - override val imageUrl: Flow = _imageUrl + val imageUrl: Flow = _imageUrl init { val productId = savedStateHandle.get(ProductDetailsDestination.productId) @@ -491,7 +508,7 @@ class ProductDetailsViewModel @Inject constructor( _price.value = price } - override fun onSaveProduct(image: ByteArray) { + fun onSaveProduct(image: ByteArray) { viewModelScope.launch { productRepository.updateProduct( id = _product.value?.id ?: "", @@ -503,7 +520,7 @@ class ProductDetailsViewModel @Inject constructor( } } - override fun onImageChange(url: String) { + fun onImageChange(url: String) { _imageUrl.value = url } @@ -757,11 +774,12 @@ class AddProductViewModel @Inject constructor( ) : ViewModel() { private val _isLoading = MutableStateFlow(false) - override val isLoading: Flow = _isLoading + val isLoading: Flow = _isLoading private val _showSuccessMessage = MutableStateFlow(false) - override val showSuccessMessage: Flow = _showSuccessMessage - override fun onCreateProduct(name: String, price: Double) { + val showSuccessMessage: Flow = _showSuccessMessage + + fun onCreateProduct(name: String, price: Double) { if (name.isEmpty() || price <= 0) return viewModelScope.launch { _isLoading.value = true From 2e035241a8f4093bb966a112ec922b7eacf146a9 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Wed, 7 Jun 2023 22:04:49 +0700 Subject: [PATCH 007/282] docs: update sql template --- .../components/MDX/kotlin_project_setup.mdx | 45 +++++++++++++++++++ .../MDX/product_management_sql_template.mdx | 41 +++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 apps/docs/components/MDX/kotlin_project_setup.mdx create mode 100644 apps/docs/components/MDX/product_management_sql_template.mdx diff --git a/apps/docs/components/MDX/kotlin_project_setup.mdx b/apps/docs/components/MDX/kotlin_project_setup.mdx new file mode 100644 index 00000000000..11bfe2f9805 --- /dev/null +++ b/apps/docs/components/MDX/kotlin_project_setup.mdx @@ -0,0 +1,45 @@ +import ProductManagementSQLTemplate from './product_management_sql_template.mdx' +import { Tabs } from 'ui' +export const TabPanel = Tabs.Panel + +## Project setup + +Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase and then creating a "schema" inside the database. + +### Create a project + +1. [Create a new project](https://app.supabase.com) in the Supabase Dashboard. +1. Enter your project details. +1. Wait for the new database to launch. + +### Set up the database schema + +Now we are going to set up the database schema. You can just copy/paste the SQL from below and run it yourself. + + + + +1. Go to the [SQL Editor](https://app.supabase.com/project/_/sql) page in the Dashboard. +2. Click **Product Management**. +3. Click **Run**. + + + + + + + + + +### Get the API Keys + +Now that you've created some database tables, you are ready to insert data using the auto-generated API. +We just need to get the Project URL and `anon` key from the API settings. + +1. Go to the [API Settings](https://app.supabase.com/project/_/settings/api) page in the Dashboard. +1. Find your Project `URL`, `anon`, and `service_role` keys on this page. diff --git a/apps/docs/components/MDX/product_management_sql_template.mdx b/apps/docs/components/MDX/product_management_sql_template.mdx new file mode 100644 index 00000000000..e728bc67b91 --- /dev/null +++ b/apps/docs/components/MDX/product_management_sql_template.mdx @@ -0,0 +1,41 @@ +```sql +-- Create a table for public profiles +create table products ( + id uuid references auth.users not null primary key, + updated_at timestamp with time zone, + username text unique, + full_name text, + avatar_url text, + website text, + constraint username_length check (char_length(username) >= 3) +); + +-- Set up Row Level Security (RLS) +-- See https://supabase.com/docs/guides/auth/row-level-security for more details. +alter table products + enable row level security; + +create policy "Public profiles are viewable by everyone." on profiles + for select using (true); + +create policy "Users can insert their own profile." on profiles + for insert with check (auth.uid() = id); + +create policy "Users can update own profile." on profiles + for update using (auth.uid() = id); + +-- Set up Storage! +insert into storage.buckets (id, name) + values ('products', 'products'); + +-- Set up access controls for storage. +-- See https://supabase.com/docs/guides/storage#policy-examples for more details. +create policy "Avatar images are publicly accessible." on storage.objects + for select using (bucket_id = 'products'); + +create policy "Anyone can upload an avatar." on storage.objects + for insert with check (bucket_id = 'products'); + +create policy "Anyone can update their own avatar." on storage.objects + for update using (auth.uid() = owner) with check (bucket_id = 'products'); +``` From 47ed8ef1bfef26cb7f83b703fd0bf8cc86b02101 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Wed, 7 Jun 2023 22:05:04 +0700 Subject: [PATCH 008/282] docs: provide kotlin project setup component --- apps/docs/components/index.tsx | 2 ++ .../docs/pages/guides/getting-started/tutorials/with-kotlin.mdx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/docs/components/index.tsx b/apps/docs/components/index.tsx index 831b1fa4027..73588c60cf7 100644 --- a/apps/docs/components/index.tsx +++ b/apps/docs/components/index.tsx @@ -21,6 +21,7 @@ import QuickstartIntro from './MDX/quickstart_intro.mdx' import SocialProviderSettingsSupabase from './MDX/social_provider_settings_supabase.mdx' import SocialProviderSetup from './MDX/social_provider_setup.mdx' import StorageManagement from './MDX/storage_management.mdx' +import KotlinProjectSetup from './MDX/kotlin_project_setup.mdx' // import { CH } from '@code-hike/mdx/components' import RefHeaderSection from './reference/RefHeaderSection' @@ -67,6 +68,7 @@ const components = { JwtGenerator, QuickstartIntro, ProjectSetup, + KotlinProjectSetup, SocialProviderSetup, SocialProviderSettingsSupabase, StepHikeCompact, diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx index 7c6a575f5b4..d21c23c7353 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx @@ -14,7 +14,7 @@ export const meta = { GitHub](https://github.com/hieuwu/product-sample-supabase-kt). - + ### 1. Create new Android with Jetpack Compose in Android Studio From 808256abc7567348862dc79deaa1935b7aa62c53 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Thu, 8 Jun 2023 21:54:07 +0700 Subject: [PATCH 009/282] docs: update sql query --- .../MDX/product_management_sql_template.mdx | 54 +++++++++---------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/apps/docs/components/MDX/product_management_sql_template.mdx b/apps/docs/components/MDX/product_management_sql_template.mdx index e728bc67b91..ed2a6d087bc 100644 --- a/apps/docs/components/MDX/product_management_sql_template.mdx +++ b/apps/docs/components/MDX/product_management_sql_template.mdx @@ -1,41 +1,35 @@ ```sql -- Create a table for public profiles -create table products ( - id uuid references auth.users not null primary key, - updated_at timestamp with time zone, - username text unique, - full_name text, - avatar_url text, - website text, - constraint username_length check (char_length(username) >= 3) -); --- Set up Row Level Security (RLS) --- See https://supabase.com/docs/guides/auth/row-level-security for more details. -alter table products - enable row level security; - -create policy "Public profiles are viewable by everyone." on profiles - for select using (true); - -create policy "Users can insert their own profile." on profiles - for insert with check (auth.uid() = id); - -create policy "Users can update own profile." on profiles - for update using (auth.uid() = id); +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; -- Set up Storage! insert into storage.buckets (id, name) - values ('products', 'products'); + values ('Product Image', 'Product Image'); -- Set up access controls for storage. -- See https://supabase.com/docs/guides/storage#policy-examples for more details. -create policy "Avatar images are publicly accessible." on storage.objects - for select using (bucket_id = 'products'); +CREATE POLICY "Enable read access for all users" ON "storage"."objects" +AS PERMISSIVE FOR SELECT +TO public +USING (true) -create policy "Anyone can upload an avatar." on storage.objects - for insert with check (bucket_id = 'products'); +CREATE POLICY "Enable insert for all users" ON "storage"."objects" +AS PERMISSIVE FOR INSERT +TO authenticated, anon +WITH CHECK (true) -create policy "Anyone can update their own avatar." on storage.objects - for update using (auth.uid() = owner) with check (bucket_id = 'products'); -``` +CREATE POLICY "Enable update for all users" ON "storage"."objects" +AS PERMISSIVE FOR UPDATE +TO public +USING (true) +WITH CHECK (true) + +``` \ No newline at end of file From 5706b363659fe46a081f0816635ca56db329f416 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Thu, 8 Jun 2023 22:20:39 +0700 Subject: [PATCH 010/282] docs: update wrong class attribute --- .../getting-started/tutorials/with-kotlin.mdx | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx index d21c23c7353..08df410a6c3 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx @@ -181,10 +181,20 @@ data class ProductDto( val price: Double, @SerialName("image") - val image: String? = "", + val image: String?, @SerialName("id") - val id: String? = "", + val id: String, +) +``` + +Domain object `Product.kt`to provide to view +```kotlin +data class Product( + val id: String, + val name: String, + val price: Double, + val image: String? ) ``` @@ -294,8 +304,8 @@ class ProductRepositoryImpl @Inject constructor( By the time this page is written, there might be some changes in the code to make it more simple. If you face any problem when implement, feel free to refer the example repository above -#### 8.1 Product list +#### 8.1 Product list Create ProductListViewModel ```kotlin @HiltViewModel @@ -335,10 +345,10 @@ class ProductListViewModel @Inject constructor( private fun ProductDto.asDomainModel(): Product { return Product( - id = this.id ?: "", + id = this.id, name = this.name, price = this.price, - image = this.image ?: "" + image = this.image ) } } @@ -511,11 +521,11 @@ class ProductDetailsViewModel @Inject constructor( fun onSaveProduct(image: ByteArray) { viewModelScope.launch { productRepository.updateProduct( - id = _product.value?.id ?: "", + id = _product.value?.id, price = _price.value, name = _name.value, imageFile = image, - imageName = "image_${_product.value?.id}", + imageName = "image_${_product.value.id}", ) } } @@ -526,10 +536,10 @@ class ProductDetailsViewModel @Inject constructor( private fun ProductDto.asDomainModel(): Product { return Product( - id = this.id ?: "", + id = this.id, name = this.name, price = this.price, - image = this.image ?: "" + image = this.image ) } } @@ -577,7 +587,7 @@ fun ProductDetailsScreen( ) { val name = viewModel.name.collectAsState(initial = "") val price = viewModel.price.collectAsState(initial = 0.0) - var imageUrl = Uri.parse(viewModel.imageUrl.collectAsState(initial = "").value) + var imageUrl = Uri.parse(viewModel.imageUrl.collectAsState(initial = null).value) val contentResolver = LocalContext.current.contentResolver Column( From e839bd08cd7589d6fc9e27bad6127686c9ceea34 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Thu, 8 Jun 2023 22:42:40 +0700 Subject: [PATCH 011/282] docs: update version and reformat code --- apps/docs/components/index.tsx | 2 -- .../guides/getting-started/quickstarts/kotlin.mdx | 13 ++++++++----- .../getting-started/tutorials/with-kotlin.mdx | 7 +++++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/docs/components/index.tsx b/apps/docs/components/index.tsx index f255dcd1455..e47b7800c63 100644 --- a/apps/docs/components/index.tsx +++ b/apps/docs/components/index.tsx @@ -23,7 +23,6 @@ import SocialProviderSettingsSupabase from './MDX/social_provider_settings_supab import SocialProviderSetup from './MDX/social_provider_setup.mdx' import StorageManagement from './MDX/storage_management.mdx' import KotlinProjectSetup from './MDX/kotlin_project_setup.mdx' -import { CH } from '@code-hike/mdx/components' import RefHeaderSection from './reference/RefHeaderSection' // Ref version specific @@ -60,7 +59,6 @@ const components = { Admonition, Button, ButtonCard, - CH, CodeBlock, GlassPanel, Link, diff --git a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx index ba4c38b92d1..38642fa6321 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx @@ -61,7 +61,7 @@ export const meta = { latest stable supabase-kt version - + latest ktor version @@ -85,15 +85,17 @@ export const meta = { - Open `build.gradle` (app), add serialization plugin to use annotation for data parsing - + Open `build.gradle` (app), add serialization plugin to use annotation for data parsing. Please note that the plugin version should be the same with Kotlin version in your app. + + latest stable supabase-kt version + ```kotlin plugins { ... - id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.0' + id 'org.jetbrains.kotlin.plugin.serialization' version '$kotlin_version' ... } ``` @@ -115,7 +117,8 @@ export const meta = { // Handy create instance when need val client = createSupabaseClient( supabaseUrl = "https://xyzcompany.supabase.co", - supabaseKey = "public-anon-key") { + supabaseKey = "public-anon-key" + ) { install(GoTrue) install(Postgrest) install(Storage) diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx index 08df410a6c3..d523f227ee7 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx @@ -88,11 +88,14 @@ implementation "io.ktor:ktor-client-cio:$ktor_version" implementation "io.ktor:ktor-utils-jvm:$ktor_version" ``` -Also in build.gradle (app) file, add plugin for serialization +Also in build.gradle (app) file, add plugin for serialization. The version of this plugin should be the same with Kotlin version + + latest stable supabase-kt version + ```kotlin plugins { ... - id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.0' + id 'org.jetbrains.kotlin.plugin.serialization' version '$kotlin_version' ... } ``` From f6d435a3d8ac30f2c0b902009377c4cedd184968 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Thu, 8 Jun 2023 22:45:09 +0700 Subject: [PATCH 012/282] docs: revert unrelated change --- apps/docs/components/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/docs/components/index.tsx b/apps/docs/components/index.tsx index e47b7800c63..f255dcd1455 100644 --- a/apps/docs/components/index.tsx +++ b/apps/docs/components/index.tsx @@ -23,6 +23,7 @@ import SocialProviderSettingsSupabase from './MDX/social_provider_settings_supab import SocialProviderSetup from './MDX/social_provider_setup.mdx' import StorageManagement from './MDX/storage_management.mdx' import KotlinProjectSetup from './MDX/kotlin_project_setup.mdx' +import { CH } from '@code-hike/mdx/components' import RefHeaderSection from './reference/RefHeaderSection' // Ref version specific @@ -59,6 +60,7 @@ const components = { Admonition, Button, ButtonCard, + CH, CodeBlock, GlassPanel, Link, From c28b52fddac205fd8a35ac9278c77caa419f863e Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Fri, 9 Jun 2023 23:22:57 +0700 Subject: [PATCH 013/282] docs: update more details of how display data --- .../getting-started/quickstarts/kotlin.mdx | 107 +++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx index 38642fa6321..df0815e0ede 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx @@ -161,6 +161,30 @@ export const meta = { + + + + This kind of object will be consumed by the view + + + + ```kotlin + data class Product( + val productId: String, + val name: String, + val description: String, + val price: Double, + val image: String, + val category: String, + val nutrition: String, + val _id: Int, + ) + ``` + + + + + @@ -169,10 +193,11 @@ export const meta = { ```kotlin - suspend fun getProducts() { + suspend fun getProducts(): ProductDto { val result = client.postgrest["products"] .select().decodeList() // Handle result data for next step + return result } ``` @@ -180,6 +205,86 @@ export const meta = { + + + + + + ```kotlin + class ProductListViewModel @Inject constructor( + private val productRepository: ProductRepository + ) : ViewModel() { + + private val _productList = MutableStateFlow?>(listOf()) + val productList: Flow?> = _productList + + init { + getProducts() + } + + fun getProducts() { + viewModelScope.launch { + val products = productRepository.getProducts() + _productList.emit(products?.map { it -> it.asDomainModel() }) + } + } + + private fun ProductDto.asDomainModel(): Product { + return Product( + productId = this.productId, + name = this.name, + price = this.price, + image = this.image, + description = this.description, + category = this.category, + nutrition = this.nutrition, + _id = this._id + ) + } + ``` + + + + + + + + + ```kotlin + @Composable + fun ProductListScreen( + modifier: Modifier = Modifier, + navController: NavController, + viewModel: ProductListViewModel = hiltViewModel(), + ) { + val productList = viewModel.productList.collectAsState(initial = listOf()).value + if (!productList.isNullOrEmpty()) { + LazyColumn( + modifier = modifier.padding(24.dp), + contentPadding = PaddingValues(5.dp) + ) { + items(productList) { item -> + ProductListItem( + product = item, + modifier = modifier, + onClick = { + navController.navigate( + ProductDetailsDestination.createRouteWithParam( + item.id + ) + ) + }, + ) + } + } + } + } + ``` + + + + + From 524f8314c24976036fa89967ac4e313f7e81131b Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Sat, 10 Jun 2023 11:45:51 +0700 Subject: [PATCH 014/282] docs: update wording, reformat code --- .../getting-started/quickstarts/kotlin.mdx | 46 +++++++++---------- .../getting-started/tutorials/with-kotlin.mdx | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx index df0815e0ede..b13cb1cb6b2 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx @@ -56,7 +56,7 @@ export const meta = { Setup Supabase libraries requires some others libraries from ktor, so make sure all dependencies are added. - Replace place holder of the dependency versions `$supabase_version` and `$ktor_version` with latest versions as below + Replace the dependency version placeholders `$supabase_version` and `$ktor_version` with the latest versions as shown below.
latest stable supabase-kt version @@ -215,31 +215,31 @@ export const meta = { private val productRepository: ProductRepository ) : ViewModel() { - private val _productList = MutableStateFlow?>(listOf()) - val productList: Flow?> = _productList + private val _productList = MutableStateFlow?>(listOf()) + val productList: Flow?> = _productList - init { - getProducts() - } + init { + getProducts() + } - fun getProducts() { - viewModelScope.launch { - val products = productRepository.getProducts() - _productList.emit(products?.map { it -> it.asDomainModel() }) - } - } + fun getProducts() { + viewModelScope.launch { + val products = productRepository.getProducts() + _productList.emit(products?.map { it -> it.asDomainModel() }) + } + } - private fun ProductDto.asDomainModel(): Product { - return Product( - productId = this.productId, - name = this.name, - price = this.price, - image = this.image, - description = this.description, - category = this.category, - nutrition = this.nutrition, - _id = this._id - ) + private fun ProductDto.asDomainModel(): Product { + return Product( + productId = this.productId, + name = this.name, + price = this.price, + image = this.image, + description = this.description, + category = this.category, + nutrition = this.nutrition, + _id = this._id + ) } ``` diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx index d523f227ee7..2a8c97b5aa4 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx @@ -68,7 +68,7 @@ val apiKey = BuildConfig.API_KEY ### 3. Set up Supabase dependencies including Postgrest, GoTrue, Storage and serialization plugin Screen Shot 2023-06-05 at 21 39 24 -In build.gradle (app) file, add these dependencies then press Sync now. Replace place holder of the dependency versions `$supabase_version` and `$ktor_version` with latest versions as below +In build.gradle (app) file, add these dependencies then press Sync now. Replace the dependency version placeholders `$supabase_version` and `$ktor_version` with the latest versions as shown below.
latest stable supabase-kt version From 5fd2a0e5e4d0940966471b91d5e98d8482ac5e51 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Sat, 10 Jun 2023 12:01:27 +0700 Subject: [PATCH 015/282] docs: update steps --- .../guides/getting-started/quickstarts/kotlin.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx index b13cb1cb6b2..06f57c43084 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx @@ -161,7 +161,7 @@ export const meta = { - + This kind of object will be consumed by the view @@ -185,7 +185,7 @@ export const meta = { - + @@ -204,7 +204,7 @@ export const meta = { - + @@ -246,7 +246,7 @@ export const meta = { - + @@ -284,7 +284,7 @@ export const meta = { - + From bdb1a4943380bfce9078cabe62913e5a1402862a Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Sat, 10 Jun 2023 18:17:52 +0700 Subject: [PATCH 016/282] docs: add steps to provide dependency with Hilt --- .../getting-started/quickstarts/kotlin.mdx | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx index 06f57c43084..d0d34d7ce91 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx @@ -188,26 +188,50 @@ export const meta = { - + Usually, we will put the code of interacting with data source in repository. Create a repository: ```kotlin - suspend fun getProducts(): ProductDto { - val result = client.postgrest["products"] - .select().decodeList() - // Handle result data for next step - return result + interface ProductRepository { + fun getProducts(): List + } + + class ProductRepositoryImpl @Inject constructor( + private val postgrest: Postgrest, + ) : ProductRepository { + override suspend fun getProducts(): List { + val result = client.postgrest["products"] + .select().decodeList() + // Handle result data for next step + return result + } } ``` - - - + + + For dependency injection, we will use Hilt. Learn more here [Dependency injection with Hilt](https://developer.android.com/training/dependency-injection/hilt-android). + + + ```kotlin + InstallIn(SingletonComponent::class) + @Module + abstract class RepositoryModule { + @Binds + abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository + } + ``` + + + + + + To use repository in ViewModel, use `@Inject` annotation. ```kotlin @@ -246,7 +270,7 @@ export const meta = { - + @@ -284,7 +308,7 @@ export const meta = { - + From 446f1fe8e570f6a290b4e40c74579aaee55aaa62 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Sat, 10 Jun 2023 18:19:38 +0700 Subject: [PATCH 017/282] docs: update wording --- apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx index d0d34d7ce91..cf0dd2cc994 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx @@ -214,7 +214,7 @@ export const meta = { - For dependency injection, we will use Hilt. Learn more here [Dependency injection with Hilt](https://developer.android.com/training/dependency-injection/hilt-android). + For dependency injection, we will use Hilt. Learn more about [Dependency injection with Hilt](https://developer.android.com/training/dependency-injection/hilt-android). ```kotlin From 5341854a15a988a371166de608f28b10af58b293 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Sat, 10 Jun 2023 18:22:08 +0700 Subject: [PATCH 018/282] docs: update alt text --- .../docs/pages/guides/getting-started/tutorials/with-kotlin.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx index 2a8c97b5aa4..62582c69f82 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-kotlin.mdx @@ -90,7 +90,7 @@ implementation "io.ktor:ktor-utils-jvm:$ktor_version" Also in build.gradle (app) file, add plugin for serialization. The version of this plugin should be the same with Kotlin version - latest stable supabase-kt version + latest kotlin version ```kotlin plugins { From c49b52b0f3332cb5c4b1c5be4f4e59095de18f0a Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Sat, 10 Jun 2023 18:26:10 +0700 Subject: [PATCH 019/282] docs: update wording --- apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx index cf0dd2cc994..527545b6ff0 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/kotlin.mdx @@ -231,7 +231,7 @@ export const meta = { - To use repository in ViewModel, use `@Inject` annotation. + To use the repository in a ViewModel, use the `@Inject` annotation. ```kotlin From 89efd01aa8a73b63f875f863be6b07e9572e0f4a Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Mon, 12 Jun 2023 20:49:39 +0700 Subject: [PATCH 020/282] docs: update product sample --- examples/product-sample-supabase-kt/LICENSE | 21 ++ examples/product-sample-supabase-kt/README.md | 20 ++ .../product-sample-supabase-kt/app/.gitignore | 1 + .../app/build.gradle | 98 +++++++++ .../app/proguard-rules.pro | 21 ++ .../manageproducts/ExampleInstrumentedTest.kt | 24 ++ .../app/src/main/AndroidManifest.xml | 28 +++ .../example/manageproducts/MainActivity.kt | 58 +++++ .../ManageProductApplication.kt | 7 + .../data/network/dto/ProductDto.kt | 20 ++ .../data/repository/ImageRepository.kt | 6 + .../data/repository/ProductRepository.kt | 14 ++ .../repository/impl/ImageRepositoryImpl.kt | 24 ++ .../repository/impl/ProductRepositoryImpl.kt | 94 ++++++++ .../manageproducts/di/RepositoryModule.kt | 22 ++ .../manageproducts/di/SupabaseModule.kt | 55 +++++ .../manageproducts/di/UseCaseModule.kt | 31 +++ .../manageproducts/domain/model/Product.kt | 8 + .../domain/usecase/CreateProductUseCase.kt | 16 ++ .../domain/usecase/DeleteProductUseCase.kt | 9 + .../usecase/GetProductDetailsUseCase.kt | 12 + .../domain/usecase/GetProductsUseCase.kt | 11 + .../domain/usecase/UpdateProductUseCase.kt | 16 ++ .../domain/usecase/UploadImageUseCase.kt | 8 + .../manageproducts/domain/usecase/UseCase.kt | 5 + .../usecase/impl/CreateProductUseCaseImpl.kt | 28 +++ .../usecase/impl/DeleteProductUseCaseImpl.kt | 14 ++ .../impl/GetProductDetailsUseCaseImpl.kt | 22 ++ .../usecase/impl/GetProductsUseCaseImpl.kt | 29 +++ .../usecase/impl/UpdateProductUseCaseImpl.kt | 17 ++ .../usecase/impl/UploadImageUseCaseImpl.kt | 20 ++ .../feature/addproduct/AddProductContract.kt | 14 ++ .../feature/addproduct/AddProductScreen.kt | 129 +++++++++++ .../feature/addproduct/AddProductViewModel.kt | 36 +++ .../addproduct/composables/FailScreen.kt | 51 +++++ .../addproduct/composables/LoadingScreen.kt | 36 +++ .../addproduct/composables/SuccessScreen.kt | 53 +++++ .../productdetails/ProductDetailsContract.kt | 14 ++ .../productdetails/ProductDetailsScreen.kt | 205 ++++++++++++++++++ .../productdetails/ProductDetailsViewModel.kt | 85 ++++++++ .../productlist/ProductListContract.kt | 10 + .../feature/productlist/ProductListItem.kt | 56 +++++ .../feature/productlist/ProductListScreen.kt | 152 +++++++++++++ .../productlist/ProductListViewModel.kt | 57 +++++ .../presentation/navigation/Destination.kt | 31 +++ .../presentation/navigation/NavRegister.kt | 32 +++ .../example/manageproducts/ui/theme/Color.kt | 11 + .../example/manageproducts/ui/theme/Theme.kt | 68 ++++++ .../example/manageproducts/ui/theme/Type.kt | 34 +++ .../drawable-v24/ic_launcher_foreground.xml | 30 +++ .../res/drawable/ic_launcher_background.xml | 170 +++++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../res/mipmap-anydpi-v33/ic_launcher.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../app/src/main/res/values/colors.xml | 10 + .../app/src/main/res/values/strings.xml | 6 + .../app/src/main/res/values/themes.xml | 5 + .../app/src/main/res/xml/backup_rules.xml | 13 ++ .../main/res/xml/data_extraction_rules.xml | 19 ++ .../example/manageproducts/ExampleUnitTest.kt | 17 ++ .../product-sample-supabase-kt/build.gradle | 13 ++ .../gradle.properties | 23 ++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + examples/product-sample-supabase-kt/gradlew | 185 ++++++++++++++++ .../product-sample-supabase-kt/gradlew.bat | 89 ++++++++ .../local.properties | 13 ++ .../settings.gradle | 16 ++ 78 files changed, 2464 insertions(+) create mode 100644 examples/product-sample-supabase-kt/LICENSE create mode 100644 examples/product-sample-supabase-kt/README.md create mode 100644 examples/product-sample-supabase-kt/app/.gitignore create mode 100644 examples/product-sample-supabase-kt/app/build.gradle create mode 100644 examples/product-sample-supabase-kt/app/proguard-rules.pro create mode 100644 examples/product-sample-supabase-kt/app/src/androidTest/java/com/example/manageproducts/ExampleInstrumentedTest.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/AndroidManifest.xml create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/MainActivity.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ManageProductApplication.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/network/dto/ProductDto.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/ImageRepository.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/ProductRepository.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/impl/ImageRepositoryImpl.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/impl/ProductRepositoryImpl.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/RepositoryModule.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/SupabaseModule.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/UseCaseModule.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/model/Product.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/CreateProductUseCase.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/DeleteProductUseCase.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/GetProductDetailsUseCase.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/GetProductsUseCase.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UpdateProductUseCase.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UploadImageUseCase.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UseCase.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/CreateProductUseCaseImpl.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/DeleteProductUseCaseImpl.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/GetProductDetailsUseCaseImpl.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/GetProductsUseCaseImpl.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/UpdateProductUseCaseImpl.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/UploadImageUseCaseImpl.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductContract.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductScreen.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductViewModel.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/FailScreen.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/LoadingScreen.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/SuccessScreen.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsContract.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsScreen.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsViewModel.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListContract.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListItem.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListScreen.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListViewModel.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/navigation/Destination.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/navigation/NavRegister.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Color.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Theme.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Type.kt create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/values/colors.xml create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/values/strings.xml create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/values/themes.xml create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/xml/backup_rules.xml create mode 100644 examples/product-sample-supabase-kt/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 examples/product-sample-supabase-kt/app/src/test/java/com/example/manageproducts/ExampleUnitTest.kt create mode 100644 examples/product-sample-supabase-kt/build.gradle create mode 100644 examples/product-sample-supabase-kt/gradle.properties create mode 100644 examples/product-sample-supabase-kt/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/product-sample-supabase-kt/gradle/wrapper/gradle-wrapper.properties create mode 100755 examples/product-sample-supabase-kt/gradlew create mode 100644 examples/product-sample-supabase-kt/gradlew.bat create mode 100644 examples/product-sample-supabase-kt/local.properties create mode 100644 examples/product-sample-supabase-kt/settings.gradle diff --git a/examples/product-sample-supabase-kt/LICENSE b/examples/product-sample-supabase-kt/LICENSE new file mode 100644 index 00000000000..4651f913ec3 --- /dev/null +++ b/examples/product-sample-supabase-kt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Hieu Vu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/product-sample-supabase-kt/README.md b/examples/product-sample-supabase-kt/README.md new file mode 100644 index 00000000000..5e3d0c88cc5 --- /dev/null +++ b/examples/product-sample-supabase-kt/README.md @@ -0,0 +1,20 @@ +# product-sample-supabase-kt +Demonstration and best practices of how to use Supabase database for CRUD operation + +[![Android CI](https://github.com/hieuwu/product-sample-supabase-kt/actions/workflows/app-build.yml/badge.svg)](https://github.com/hieuwu/product-sample-supabase-kt/actions/workflows/app-build.yml) + + + +![manage-product-cover](https://github.com/hieuwu/product-sample-supabase-kt/assets/43868345/1bed0c84-208a-4266-a2ec-2bac50ddf80c) + +### Setup +Android Studio with SDK 30 or above +Open `local.properties` file, add these +```kotlin +API_KEY=YOUR_SUPABASE_API_KEY +SECRET=YOUR_SUPABASE_SECRET +SUPABASE_URL=YOUR_SUPABASE_URL + +``` diff --git a/examples/product-sample-supabase-kt/app/.gitignore b/examples/product-sample-supabase-kt/app/.gitignore new file mode 100644 index 00000000000..42afabfd2ab --- /dev/null +++ b/examples/product-sample-supabase-kt/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/build.gradle b/examples/product-sample-supabase-kt/app/build.gradle new file mode 100644 index 00000000000..1cdd75de001 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/build.gradle @@ -0,0 +1,98 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'com.google.dagger.hilt.android' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' + id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.21' +} + +android { + namespace 'com.example.manageproducts' + compileSdk 33 + + defaultConfig { + applicationId "com.example.manageproducts" + minSdk 28 + targetSdk 33 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary true + } + Properties properties = new Properties() + properties.load(project.rootProject.file("local.properties").newDataInputStream()) + buildConfigField("String", "API_KEY", "\"${properties.getProperty("API_KEY")}\"") + buildConfigField("String", "SECRET", "\"${properties.getProperty("SECRET")}\"") + buildConfigField("String", "SUPABASE_URL", "\"${properties.getProperty("SUPABASE_URL")}\"") + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion '1.2.0' + } + packagingOptions { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } +} + +dependencies { + + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' + implementation 'androidx.activity:activity-compose:1.3.1' + implementation "androidx.compose.ui:ui:$compose_version" + implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" + implementation 'androidx.compose.material3:material3:1.0.0-alpha11' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" + debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" + debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + + implementation 'androidx.compose.material3:material3:1.0.0-alpha02' + implementation "androidx.compose.material:material:1.1.1" + + // Supabase setup + implementation("io.ktor:ktor-client-cio:1.6.0") + implementation("io.github.jan-tennert.supabase:postgrest-kt-android:1.0.0") + implementation "io.ktor:ktor-client-android:2.2.1" + implementation "io.ktor:ktor-utils-jvm:1.6.0" + implementation "io.ktor:ktor-client-logging-jvm:1.6.0" + implementation("io.ktor:ktor-client-core:2.2.4") + implementation("io.github.jan-tennert.supabase:storage-kt:1.0.0") + implementation "io.coil-kt:coil-compose:1.3.2" + + + 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") + + // For instrumentation tests + androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" + androidTestAnnotationProcessor "com.google.dagger:hilt-compiler:$hilt_version" + kapt "com.google.dagger:hilt-android-compiler:$hilt_version" + + implementation 'androidx.navigation:navigation-compose:2.5.3' + implementation 'com.google.accompanist:accompanist-swiperefresh:0.24.13-rc' +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/proguard-rules.pro b/examples/product-sample-supabase-kt/app/proguard-rules.pro new file mode 100644 index 00000000000..481bb434814 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/androidTest/java/com/example/manageproducts/ExampleInstrumentedTest.kt b/examples/product-sample-supabase-kt/app/src/androidTest/java/com/example/manageproducts/ExampleInstrumentedTest.kt new file mode 100644 index 00000000000..8bafdae8a69 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/androidTest/java/com/example/manageproducts/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.manageproducts + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.manageproducts", appContext.packageName) + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/AndroidManifest.xml b/examples/product-sample-supabase-kt/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..3ce2a8a88c6 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/MainActivity.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/MainActivity.kt new file mode 100644 index 00000000000..da28bf02ef9 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/MainActivity.kt @@ -0,0 +1,58 @@ +package com.example.manageproducts + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.example.manageproducts.presentation.navigation.ProductListDestination +import com.example.manageproducts.presentation.navigation.navRegistration +import com.example.manageproducts.ui.theme.ManageProductsTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + @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) + ) { + navRegistration(navController) + } + } + } + } + } +} + +@Composable +fun Greeting(name: String) { + Text(text = "Hello $name!") +} + +@Preview(showBackground = true) +@Composable +fun DefaultPreview() { + ManageProductsTheme { + Greeting("Android") + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ManageProductApplication.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ManageProductApplication.kt new file mode 100644 index 00000000000..664636731eb --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ManageProductApplication.kt @@ -0,0 +1,7 @@ +package com.example.manageproducts + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class ManageProductApplication: Application() \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/network/dto/ProductDto.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/network/dto/ProductDto.kt new file mode 100644 index 00000000000..3fcf772d9b7 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/network/dto/ProductDto.kt @@ -0,0 +1,20 @@ +package com.example.manageproducts.data.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ProductDto( + + @SerialName("name") + val name: String, + + @SerialName("price") + val price: Double, + + @SerialName("image") + val image: String? = "", + + @SerialName("id") + val id: String? = "", +) diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/ImageRepository.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/ImageRepository.kt new file mode 100644 index 00000000000..4a84ebce96e --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/ImageRepository.kt @@ -0,0 +1,6 @@ +package com.example.manageproducts.data.repository + +interface ImageRepository { + suspend fun uploadImage(fileName: String, image: ByteArray): String + suspend fun getImage() +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/ProductRepository.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/ProductRepository.kt new file mode 100644 index 00000000000..9a431cbfcfd --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/ProductRepository.kt @@ -0,0 +1,14 @@ +package com.example.manageproducts.data.repository + +import com.example.manageproducts.data.network.dto.ProductDto +import com.example.manageproducts.domain.model.Product + +interface ProductRepository { + suspend fun createProduct(product: Product): Boolean + suspend fun getProducts(): List? + suspend fun getProduct(id: String): ProductDto + suspend fun deleteProduct(id: String) + suspend fun updateProduct( + id: String, name: String, price: Double, imageName: String, imageFile: ByteArray + ) +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/impl/ImageRepositoryImpl.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/impl/ImageRepositoryImpl.kt new file mode 100644 index 00000000000..348c40ec667 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/impl/ImageRepositoryImpl.kt @@ -0,0 +1,24 @@ +package com.example.manageproducts.data.repository.impl + +import com.example.manageproducts.BuildConfig +import com.example.manageproducts.data.repository.ImageRepository +import io.github.jan.supabase.storage.Storage +import javax.inject.Inject + +class ImageRepositoryImpl @Inject constructor( + private val storage: Storage +) : ImageRepository { + override suspend fun uploadImage(fileName: String, image: ByteArray): String { + val result = + storage["Product%20Image"].upload(path = "$fileName.png", data = image, upsert = false) + return result + } + + override suspend fun getImage() { + TODO("Not yet implemented") + } + + private fun buildImageUrl(imageFileName: String) = + "${BuildConfig.SUPABASE_URL}/storage/v1/object/public/" + +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/impl/ProductRepositoryImpl.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/impl/ProductRepositoryImpl.kt new file mode 100644 index 00000000000..058ea96d636 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/data/repository/impl/ProductRepositoryImpl.kt @@ -0,0 +1,94 @@ +package com.example.manageproducts.data.repository.impl + +import com.example.manageproducts.BuildConfig +import com.example.manageproducts.data.network.dto.ProductDto +import com.example.manageproducts.data.repository.ProductRepository +import com.example.manageproducts.domain.model.Product +import io.github.jan.supabase.postgrest.Postgrest +import io.github.jan.supabase.storage.Storage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +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["products"].insert(productDto) + true + } + true + } catch (e: java.lang.Exception) { + throw e + } + } + + override suspend fun getProducts(): List? { + return withContext(Dispatchers.IO) { + val result = postgrest["products"] + .select().decodeList() + result + } + } + + + override suspend fun getProduct(id: String): ProductDto { + return withContext(Dispatchers.IO) { + postgrest["products"].select { + eq("id", id) + }.decodeSingle() + } + } + + override suspend fun deleteProduct(id: String) { + return withContext(Dispatchers.IO) { + postgrest["products"].delete { + 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["Product%20Image"].upload( + path = "$imageName.png", + data = imageFile, + upsert = true + ) + postgrest["products"].update({ + set("name", name) + set("price", price) + set("image", buildImageUrl(imageFileName = imageUrl)) + }) { + eq("id", id) + } + } else { + postgrest["products"].update({ + set("name", name) + set("price", price) + }) { + eq("id", id) + } + } + } + } + + private fun buildImageUrl(imageFileName: String) = + "${BuildConfig.SUPABASE_URL}/storage/v1/object/public/${imageFileName}".replace(" ", "%20") +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/RepositoryModule.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/RepositoryModule.kt new file mode 100644 index 00000000000..25f416f383f --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/RepositoryModule.kt @@ -0,0 +1,22 @@ +package com.example.manageproducts.di + +import com.example.manageproducts.data.repository.ImageRepository +import com.example.manageproducts.data.repository.ProductRepository +import com.example.manageproducts.data.repository.impl.ImageRepositoryImpl +import com.example.manageproducts.data.repository.impl.ProductRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@InstallIn(SingletonComponent::class) +@Module +abstract class RepositoryModule { + + @Binds + abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository + + @Binds + abstract fun bindImageRepository(impl: ImageRepositoryImpl): ImageRepository + +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/SupabaseModule.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/SupabaseModule.kt new file mode 100644 index 00000000000..a9a55683d05 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/SupabaseModule.kt @@ -0,0 +1,55 @@ +package com.example.manageproducts.di + +import com.example.manageproducts.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.createSupabaseClient +import io.github.jan.supabase.gotrue.GoTrue +import io.github.jan.supabase.gotrue.gotrue +import io.github.jan.supabase.postgrest.Postgrest +import io.github.jan.supabase.postgrest.postgrest +import io.github.jan.supabase.storage.Storage +import io.github.jan.supabase.storage.storage +import javax.inject.Singleton + + +@InstallIn(SingletonComponent::class) +@Module +object SupabaseModule { + + @Provides + @Singleton + fun provideSupabaseClient(): SupabaseClient { + return createSupabaseClient( + supabaseUrl = BuildConfig.SUPABASE_URL, + supabaseKey = BuildConfig.API_KEY + ) { + install(Postgrest) + install(GoTrue) + install(Storage) + } + } + + @Provides + @Singleton + fun provideSupabaseDatabase(client: SupabaseClient): Postgrest { + return client.postgrest + } + + @Provides + @Singleton + fun provideSupabaseGoTrue(client: SupabaseClient): GoTrue { + return client.gotrue + } + + + @Provides + @Singleton + fun provideSupabaseStorage(client: SupabaseClient): Storage { + return client.storage + } + +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/UseCaseModule.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/UseCaseModule.kt new file mode 100644 index 00000000000..fdc1af1de4d --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/di/UseCaseModule.kt @@ -0,0 +1,31 @@ +package com.example.manageproducts.di + +import com.example.manageproducts.domain.usecase.* +import com.example.manageproducts.domain.usecase.impl.* +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@InstallIn(SingletonComponent::class) +@Module +abstract class UseCaseModule { + + @Binds + abstract fun bindGetProductsUseCase(impl: GetProductsUseCaseImpl): GetProductsUseCase + + @Binds + abstract fun bindCreateProductUseCase(impl: CreateProductUseCaseImpl): CreateProductUseCase + + @Binds + abstract fun bindGetProductDetailsUseCase(impl: GetProductDetailsUseCaseImpl): GetProductDetailsUseCase + + @Binds + abstract fun bindDeleteProductUseCase(impl: DeleteProductUseCaseImpl): DeleteProductUseCase + + @Binds + abstract fun bindUpdateProductUseCase(impl: UpdateProductUseCaseImpl): UpdateProductUseCase + + @Binds + abstract fun bindUploadImageUseCaseImpl(impl: UploadImageUseCaseImpl): UploadImageUseCase +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/model/Product.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/model/Product.kt new file mode 100644 index 00000000000..2072b55b9a6 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/model/Product.kt @@ -0,0 +1,8 @@ +package com.example.manageproducts.domain.model + +data class Product( + val id: String, + val name: String, + val price: Double, + val image: String = "" +) diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/CreateProductUseCase.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/CreateProductUseCase.kt new file mode 100644 index 00000000000..768ed01e195 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/CreateProductUseCase.kt @@ -0,0 +1,16 @@ +package com.example.manageproducts.domain.usecase + +import com.example.manageproducts.domain.model.Product + +interface CreateProductUseCase : UseCase { + class Input(val product: Product) + sealed class Output { + class Success(val result: Boolean) : Output() + open class Failure : Output() { + object Conflict : Failure() + object Unauthorized : Failure() + object BadRequest : Failure() + object InternalError : Failure() + } + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/DeleteProductUseCase.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/DeleteProductUseCase.kt new file mode 100644 index 00000000000..b9fe9bf9535 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/DeleteProductUseCase.kt @@ -0,0 +1,9 @@ +package com.example.manageproducts.domain.usecase + +interface DeleteProductUseCase: UseCase { + class Input(val productId: String) + + sealed class Output { + object Success: Output() + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/GetProductDetailsUseCase.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/GetProductDetailsUseCase.kt new file mode 100644 index 00000000000..f012e15e5f8 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/GetProductDetailsUseCase.kt @@ -0,0 +1,12 @@ +package com.example.manageproducts.domain.usecase + +import com.example.manageproducts.domain.model.Product + +interface GetProductDetailsUseCase : + UseCase { + class Input(val id: String) + sealed class Output { + class Success(val data: Product): Output() + object Failure : Output() + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/GetProductsUseCase.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/GetProductsUseCase.kt new file mode 100644 index 00000000000..36fc89e1ed3 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/GetProductsUseCase.kt @@ -0,0 +1,11 @@ +package com.example.manageproducts.domain.usecase + +import com.example.manageproducts.domain.model.Product + +interface GetProductsUseCase : UseCase { + sealed class Output { + class Success(val data: List): Output() + object Failure : Output() + } + +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UpdateProductUseCase.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UpdateProductUseCase.kt new file mode 100644 index 00000000000..e62781ac451 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UpdateProductUseCase.kt @@ -0,0 +1,16 @@ +package com.example.manageproducts.domain.usecase + +interface UpdateProductUseCase : UseCase { + class Input( + val id: String, + val name: String, + val price: Double, + val imageName: String, + val imageFile: ByteArray, + ) + + sealed class Output() { + object Success : Output() + object Failure : Output() + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UploadImageUseCase.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UploadImageUseCase.kt new file mode 100644 index 00000000000..83a7eb11e01 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UploadImageUseCase.kt @@ -0,0 +1,8 @@ +package com.example.manageproducts.domain.usecase + +interface UploadImageUseCase : UseCase { + class Input(val fileName: String, val imageByteArray: ByteArray) + sealed class Output { + class Success(val image: String? = null) : Output() + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UseCase.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UseCase.kt new file mode 100644 index 00000000000..90c37002815 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/UseCase.kt @@ -0,0 +1,5 @@ +package com.example.manageproducts.domain.usecase + +interface UseCase { + suspend fun execute(input: InputT): OutputT +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/CreateProductUseCaseImpl.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/CreateProductUseCaseImpl.kt new file mode 100644 index 00000000000..806a386b1db --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/CreateProductUseCaseImpl.kt @@ -0,0 +1,28 @@ +package com.example.manageproducts.domain.usecase.impl + +import com.example.manageproducts.data.repository.ProductRepository +import com.example.manageproducts.domain.usecase.CreateProductUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import javax.inject.Inject + +class CreateProductUseCaseImpl @Inject constructor( + private val productRepository: ProductRepository, +) : CreateProductUseCase { + override suspend fun execute(input: CreateProductUseCase.Input): CreateProductUseCase.Output { + return try { + withContext(Dispatchers.IO) { + val result = productRepository.createProduct(product = input.product) + if (result) { + CreateProductUseCase.Output.Success(result = result) + } else { + CreateProductUseCase.Output.Failure() + } + } + } catch (e: Exception) { + return CreateProductUseCase.Output.Failure.Conflict + + } + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/DeleteProductUseCaseImpl.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/DeleteProductUseCaseImpl.kt new file mode 100644 index 00000000000..ce454c3d73a --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/DeleteProductUseCaseImpl.kt @@ -0,0 +1,14 @@ +package com.example.manageproducts.domain.usecase.impl + +import com.example.manageproducts.data.repository.ProductRepository +import com.example.manageproducts.domain.usecase.DeleteProductUseCase +import javax.inject.Inject + +class DeleteProductUseCaseImpl @Inject constructor( + private val productRepository: ProductRepository +) : DeleteProductUseCase { + override suspend fun execute(input: DeleteProductUseCase.Input): DeleteProductUseCase.Output { + productRepository.deleteProduct(input.productId) + return DeleteProductUseCase.Output.Success + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/GetProductDetailsUseCaseImpl.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/GetProductDetailsUseCaseImpl.kt new file mode 100644 index 00000000000..2fa94333d9b --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/GetProductDetailsUseCaseImpl.kt @@ -0,0 +1,22 @@ +package com.example.manageproducts.domain.usecase.impl + +import com.example.manageproducts.data.repository.ProductRepository +import com.example.manageproducts.domain.model.Product +import com.example.manageproducts.domain.usecase.GetProductDetailsUseCase +import javax.inject.Inject + +class GetProductDetailsUseCaseImpl @Inject constructor( + private val productRepository: ProductRepository, +) : GetProductDetailsUseCase { + override suspend fun execute(input: GetProductDetailsUseCase.Input): GetProductDetailsUseCase.Output { + val result = productRepository.getProduct(input.id) + return GetProductDetailsUseCase.Output.Success( + data = Product( + id = result.id ?: "", + name = result.name, + price = result.price, + image = result.image ?: "", + ) + ) + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/GetProductsUseCaseImpl.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/GetProductsUseCaseImpl.kt new file mode 100644 index 00000000000..bf36d7bbb07 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/GetProductsUseCaseImpl.kt @@ -0,0 +1,29 @@ +package com.example.manageproducts.domain.usecase.impl + +import com.example.manageproducts.data.network.dto.ProductDto +import com.example.manageproducts.data.repository.ProductRepository +import com.example.manageproducts.domain.model.Product +import com.example.manageproducts.domain.usecase.GetProductsUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class GetProductsUseCaseImpl @Inject constructor( + private val productRepository: ProductRepository, +) : GetProductsUseCase { + override suspend fun execute(input: Unit): GetProductsUseCase.Output = + withContext(Dispatchers.IO) { + val result = productRepository.getProducts() + return@withContext result?.let { it -> + GetProductsUseCase.Output.Success(data = it.map { + Product( + id = it.id ?: "", + name = it.name, + price = it.price, + image = it.image ?: "" + ) + }) + } ?: GetProductsUseCase.Output.Failure + } +} + diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/UpdateProductUseCaseImpl.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/UpdateProductUseCaseImpl.kt new file mode 100644 index 00000000000..60fac7586f3 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/UpdateProductUseCaseImpl.kt @@ -0,0 +1,17 @@ +package com.example.manageproducts.domain.usecase.impl + +import com.example.manageproducts.data.repository.ProductRepository +import com.example.manageproducts.domain.usecase.UpdateProductUseCase +import javax.inject.Inject + +class UpdateProductUseCaseImpl @Inject constructor( + private val productRepository: ProductRepository +) : UpdateProductUseCase { + override suspend fun execute(input: UpdateProductUseCase.Input): UpdateProductUseCase.Output { + productRepository.updateProduct( + id = input.id, name = input.name, price = input.price, + imageName = input.imageName, imageFile = input.imageFile + ) + return UpdateProductUseCase.Output.Success + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/UploadImageUseCaseImpl.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/UploadImageUseCaseImpl.kt new file mode 100644 index 00000000000..b12eb7af3c4 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/domain/usecase/impl/UploadImageUseCaseImpl.kt @@ -0,0 +1,20 @@ +package com.example.manageproducts.domain.usecase.impl + +import android.util.Log +import com.example.manageproducts.data.repository.ImageRepository +import com.example.manageproducts.domain.usecase.UploadImageUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class UploadImageUseCaseImpl @Inject constructor( + private val imageRepository: ImageRepository +) : UploadImageUseCase { + override suspend fun execute(input: UploadImageUseCase.Input): UploadImageUseCase.Output { + return withContext(Dispatchers.IO) { + val url = imageRepository.uploadImage(input.fileName, input.imageByteArray) + Log.d("UploadImageUseCase", url) + UploadImageUseCase.Output.Success() + } + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductContract.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductContract.kt new file mode 100644 index 00000000000..d34d0d0acfc --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductContract.kt @@ -0,0 +1,14 @@ +package com.example.manageproducts.presentation.feature.addproduct + +import com.example.manageproducts.domain.usecase.CreateProductUseCase +import kotlinx.coroutines.flow.Flow + +interface AddProductContract { + + val navigateAddProductSuccess: Flow + val isLoading: Flow + val showSuccessMessage: Flow + fun onCreateProduct(name: String, price: Double) + fun onAddMoreProductSelected() + fun onRetrySelected() +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductScreen.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductScreen.kt new file mode 100644 index 00000000000..7e4584956fa --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductScreen.kt @@ -0,0 +1,129 @@ +package com.example.manageproducts.presentation.feature.addproduct + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.example.manageproducts.R +import com.example.manageproducts.domain.usecase.CreateProductUseCase +import com.example.manageproducts.presentation.feature.addproduct.composables.FailScreen +import com.example.manageproducts.presentation.feature.addproduct.composables.LoadingScreen +import com.example.manageproducts.presentation.feature.addproduct.composables.SuccessScreen + +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun 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 -> + Column( + modifier = modifier + .padding(padding) + .padding(16.dp) + .fillMaxSize() + ) { + val name = rememberSaveable { mutableStateOf("") } + val price = rememberSaveable { mutableStateOf("") } + 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 = { + name.value = 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, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + onValueChange = { + price.value = it + }, + ) + Spacer(modifier = modifier.height(12.dp)) + Spacer(modifier = modifier.weight(1f)) + OutlinedButton( + modifier = modifier + .fillMaxWidth(), + onClick = { + navController.navigateUp() + }) { + Text(text = "Cancel") + } + Spacer(modifier = modifier.height(12.dp)) + Button( + modifier = modifier.fillMaxWidth(), + onClick = { + viewModel.onCreateProduct( + name = name.value, + price = if (price.value.isEmpty()) 0.0 else price.value.trim() + .toDouble(), + ) + }) { + Text(text = "Add Product") + } + } + } +} diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductViewModel.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductViewModel.kt new file mode 100644 index 00000000000..aa0ff925d8b --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/AddProductViewModel.kt @@ -0,0 +1,36 @@ +package com.example.manageproducts.presentation.feature.addproduct + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.manageproducts.data.repository.ProductRepository +import com.example.manageproducts.domain.model.Product +import com.example.manageproducts.domain.usecase.CreateProductUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class AddProductViewModel @Inject constructor( + private val productRepository: ProductRepository, +) : ViewModel() { + + private val _isLoading = MutableStateFlow(false) + val isLoading: Flow = _isLoading + + 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 + } + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/FailScreen.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/FailScreen.kt new file mode 100644 index 00000000000..a5e40791880 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/FailScreen.kt @@ -0,0 +1,51 @@ +package com.example.manageproducts.presentation.feature.addproduct.composables + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun FailScreen( + modifier: Modifier = Modifier, + message: String, + reason: String, + onRetrySelected: () -> Unit, + onNavigateBack: () -> Unit, +) { + + Column( + modifier = modifier + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + Icon(imageVector = Icons.Filled.Close, contentDescription = null, + modifier = modifier.size(128.dp), + tint = Color.Red) + Text(text = message, + style = MaterialTheme.typography.titleLarge) + Spacer(modifier = modifier.height(24.dp)) + Text(text = reason) + Spacer(modifier = modifier.height(24.dp)) + OutlinedButton( + modifier = modifier + .fillMaxWidth(), + onClick = onRetrySelected) { + Text(text = "Retry") + } + Spacer(modifier = modifier.height(12.dp)) + OutlinedButton( + modifier = modifier + .fillMaxWidth(), + onClick = onNavigateBack) { + Text(text = "Cancel") + } + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/LoadingScreen.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/LoadingScreen.kt new file mode 100644 index 00000000000..7a861d55494 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/LoadingScreen.kt @@ -0,0 +1,36 @@ +package com.example.manageproducts.presentation.feature.addproduct.composables + +import androidx.compose.foundation.layout.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun LoadingScreen( + modifier: Modifier = Modifier, + message: String, + onCancelSelected: () -> Unit, +) { + Column( + modifier = modifier + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(modifier = modifier.size(64.dp)) + Text(text = message, + style = MaterialTheme.typography.titleLarge) + Spacer(modifier = modifier.weight(1f)) + OutlinedButton( + modifier = modifier + .fillMaxWidth(), + onClick = onCancelSelected + ) { + Text(text = "Cancel") + } + } + +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/SuccessScreen.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/SuccessScreen.kt new file mode 100644 index 00000000000..780ac68a50c --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/addproduct/composables/SuccessScreen.kt @@ -0,0 +1,53 @@ +package com.example.manageproducts.presentation.feature.addproduct.composables + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun SuccessScreen( + modifier: Modifier = Modifier, + message: String, + onMoreAction: () -> Unit, + onNavigateBack: () -> Unit, +) { + Column( + modifier = modifier + .padding(24.dp) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, contentDescription = null, + modifier = modifier.size(128.dp), + tint = Color.Green + ) + Text( + text = message, + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = modifier.weight(1.0f)) + OutlinedButton( + modifier = modifier + .fillMaxWidth(), + onClick = onMoreAction + ) { + Text(text = "Add More Product") + } + Spacer(modifier = modifier.height(12.dp)) + Spacer(modifier = modifier.height(12.dp)) + Button( + modifier = modifier + .fillMaxWidth(), + onClick = onNavigateBack + ) { + Text(text = "Done") + } + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsContract.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsContract.kt new file mode 100644 index 00000000000..94c66684571 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsContract.kt @@ -0,0 +1,14 @@ +package com.example.manageproducts.presentation.feature.productdetails + +import com.example.manageproducts.domain.model.Product +import kotlinx.coroutines.flow.Flow + +interface ProductDetailsContract { + val product: Flow + val name: Flow + val price: Flow + val imageUrl: Flow + + fun onSaveProduct(image: ByteArray) + fun onImageChange(url: String) +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsScreen.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsScreen.kt new file mode 100644 index 00000000000..578d93865ed --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsScreen.kt @@ -0,0 +1,205 @@ +package com.example.manageproducts.presentation.feature.productdetails + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.annotation.ExperimentalCoilApi +import coil.compose.rememberImagePainter +import com.example.manageproducts.R +import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream +import java.io.InputStream + +@OptIn(ExperimentalCoilApi::class) +@SuppressLint("UnusedMaterialScaffoldPaddingParameter") +@Composable +fun 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 = "").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() +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsViewModel.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsViewModel.kt new file mode 100644 index 00000000000..bafa82a7cb8 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productdetails/ProductDetailsViewModel.kt @@ -0,0 +1,85 @@ +package com.example.manageproducts.presentation.feature.productdetails + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.manageproducts.data.network.dto.ProductDto +import com.example.manageproducts.data.repository.ProductRepository +import com.example.manageproducts.domain.model.Product +import com.example.manageproducts.domain.usecase.GetProductDetailsUseCase +import com.example.manageproducts.domain.usecase.UpdateProductUseCase +import com.example.manageproducts.domain.usecase.UploadImageUseCase +import com.example.manageproducts.presentation.navigation.ProductDetailsDestination +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ProductDetailsViewModel @Inject constructor( + private val productRepository: ProductRepository, + savedStateHandle: SavedStateHandle, + + ) : ViewModel(), ProductDetailsContract { + private val _product = MutableStateFlow(null) + override val product: Flow = _product + + private val _name = MutableStateFlow("") + override val name: Flow = _name + + private val _price = MutableStateFlow(0.0) + override val price: Flow = _price + + private val _imageUrl = MutableStateFlow("") + override val imageUrl: Flow = _imageUrl + + init { + val productId = savedStateHandle.get(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 + } + + override 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}", + ) + } + } + + override 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 ?: "" + ) + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListContract.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListContract.kt new file mode 100644 index 00000000000..881c345babf --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListContract.kt @@ -0,0 +1,10 @@ +package com.example.manageproducts.presentation.feature.productlist + +import com.example.manageproducts.domain.model.Product +import kotlinx.coroutines.flow.Flow + +interface ProductListContract { + val productList: Flow?> + fun removeItem(product: Product) + fun getProducts() +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListItem.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListItem.kt new file mode 100644 index 00000000000..39e9c9632d5 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListItem.kt @@ -0,0 +1,56 @@ +package com.example.manageproducts.presentation.feature.productlist + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.annotation.ExperimentalCoilApi +import coil.compose.rememberImagePainter +import com.example.manageproducts.domain.model.Product + +@OptIn(ExperimentalMaterialApi::class, ExperimentalCoilApi::class) +@Composable +fun ProductListItem( + modifier: Modifier = Modifier, + product: Product, + onClick: () -> Unit +) { + Card( + modifier = modifier + .padding(4.dp) + .fillMaxWidth(), + elevation = 8.dp, + shape = RoundedCornerShape(12.dp), + onClick = onClick + ) { + Row( + modifier = modifier.padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Image( + contentDescription = null, + painter = rememberImagePainter(product.image), + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(16.dp, 8.dp) + .size(64.dp) + ) + Text( + text = product.name, + modifier = modifier.weight(1.0f) + ) + Text( + text = "$${product.price}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListScreen.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListScreen.kt new file mode 100644 index 00000000000..c09813df30d --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListScreen.kt @@ -0,0 +1,152 @@ +package com.example.manageproducts.presentation.feature.productlist + +import android.annotation.SuppressLint +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import com.example.manageproducts.R +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.navigation.NavController +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import com.example.manageproducts.presentation.navigation.AddProductDestination +import com.example.manageproducts.presentation.navigation.ProductDetailsDestination + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) +@Composable +fun 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!") + } + + } + } +} + +@Composable +private 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, + ) + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListViewModel.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListViewModel.kt new file mode 100644 index 00000000000..bcb9d4f1b4c --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/feature/productlist/ProductListViewModel.kt @@ -0,0 +1,57 @@ +package com.example.manageproducts.presentation.feature.productlist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.manageproducts.data.network.dto.ProductDto +import com.example.manageproducts.data.repository.ProductRepository +import com.example.manageproducts.domain.model.Product +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ProductListViewModel @Inject constructor( + private val productRepository: ProductRepository, +) : ViewModel(), ProductListContract { + + private val _productList = MutableStateFlow?>(listOf()) + override val productList: Flow?> = _productList + + + private val _isLoading = MutableStateFlow(false) + val isLoading: Flow = _isLoading + + init { + getProducts() + } + + override fun getProducts() { + viewModelScope.launch { + val products = productRepository.getProducts() + _productList.emit(products?.map { it -> it.asDomainModel() }) + } + } + + override fun removeItem(product: Product) { + viewModelScope.launch { + val newList = mutableListOf().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 ?: "" + ) + } +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/navigation/Destination.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/navigation/Destination.kt new file mode 100644 index 00000000000..e65c32e75d4 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/navigation/Destination.kt @@ -0,0 +1,31 @@ +package com.example.manageproducts.presentation.navigation + +import androidx.navigation.NavType +import androidx.navigation.navArgument + + +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" +} diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/navigation/NavRegister.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/navigation/NavRegister.kt new file mode 100644 index 00000000000..a4518dc8df8 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/presentation/navigation/NavRegister.kt @@ -0,0 +1,32 @@ +package com.example.manageproducts.presentation.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.example.manageproducts.presentation.feature.addproduct.AddProductScreen +import com.example.manageproducts.presentation.feature.productdetails.ProductDetailsScreen +import com.example.manageproducts.presentation.feature.productlist.ProductListScreen + +fun NavGraphBuilder.navRegistration(navController: NavController) { + composable(ProductListDestination.route) { + ProductListScreen( + navController = navController + ) + } + + composable(AddProductDestination.route) { + AddProductScreen( + navController = navController + ) + } + composable(route = "${ProductDetailsDestination.route}/{${ProductDetailsDestination.productId}}", + arguments = ProductDetailsDestination.arguments) { navBackStackEntry -> + val productName = + navBackStackEntry.arguments?.getString(ProductDetailsDestination.productId) + ProductDetailsScreen( + productId = productName, + navController = navController, + ) + } + +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Color.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Color.kt new file mode 100644 index 00000000000..ee8610408d3 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.manageproducts.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Theme.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Theme.kt new file mode 100644 index 00000000000..7f6befdc86e --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Theme.kt @@ -0,0 +1,68 @@ +package com.example.manageproducts.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.ViewCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun ManageProductsTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + (view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb() + ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Type.kt b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Type.kt new file mode 100644 index 00000000000..a121b1a25e1 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/java/com/example/manageproducts/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.manageproducts.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/examples/product-sample-supabase-kt/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000000..2b068d11462 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/res/drawable/ic_launcher_background.xml b/examples/product-sample-supabase-kt/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000000..07d5da9cbf1 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000000..eca70cfe52e --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000000..eca70cfe52e --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml b/examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml new file mode 100644 index 00000000000..6f3b755bf50 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/examples/product-sample-supabase-kt/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/examples/product-sample-supabase-kt/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/examples/product-sample-supabase-kt/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/examples/product-sample-supabase-kt/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/examples/product-sample-supabase-kt/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/examples/product-sample-supabase-kt/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/examples/product-sample-supabase-kt/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/examples/product-sample-supabase-kt/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/examples/product-sample-supabase-kt/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} zs4%E?srLW`CJh0GCIK@hTkrW7A15Iu%N&?Q^$0+!{Tv&|t^Y@u%!L zglTg&?Q5q#ijZ;&HBQ?FNPp;k3J5!&{^+SGq?AX~SiOM9jJMRpyP?RCr@z38AQyy&WRMaC;n4una$~nJKSp?q|s8F00c9?Q! zY_ovvjTFm+DeQM^LXJ#v0}6HRt3R1%5PT*}W!k8BEM;Jrj8dIceFo2fhzTqaB3KKk zGlCLI)gU25(#u6ch6GeB1k@eHq7l{EHXv0n6xE#ws#ri}08kkCf8hUt{|Ejb`2YW* zvg}0nSSX1m=76s?sZhRY$K=3dpJ+y*eDULGnL2}4>4nvW^7_<~wIM_5fjvwt4h1|g z)g0Z6ZFq9j<~9~b8((~TN{Z?ZQfw|is&Xp~AC61sj;xItKyCHdI|tCMC_LbXF>~vR z=w6V3^H=W4CbAgR4#xw}ETTwu2guW~=Crl@SMXv85jQ=%y!s^?m4PI0My7MWICO;- z175jm%&PcPWh8QdOU(#8bp4!N7ET-+)N}N2zk2)8ch|4Q&lPFNQgT-thu053`r*h3 z_8dI@G;`zn;lH$zX3RzIk`E8~`J=BBdR}qD%n@vVG1834)!pS1Y?zVkJGtsa(sB~y zNfMYKsOJb%5J(0ivK8d+l2D2y&5X!cg3BG!AJ}910|_${nF}sC1QF^nLIhzXk-Y#x z0)&1iK!O;Og0Ky!;`b~v%b$`S4E&fB)1NB4v@8wr( z&+NX4e^&o)ecb=)dd~C!{(1e6t?&9j{l8%U*k4)?`(L3;Qjw z#w7FS+U(94MaJKS!J9O8^$)36_J8;thW#2$y9i{bB{?M{QS_inZIJ!jwqAbfXYVd$ zQ5fC$6Nc9hFi8m^;oI-%C#BS|c8vy+@{jx6hFcf^_;2VRgkoN(0h!_VSGmgNPRsxI z8$rTo0LaYq-H5i&gtj81=&xU?H-Y2==G@uQV7E`@+2E9XQW@{&j`?EOktk|Ho{HU>ZqDzvgjwBmdex z&uZNd2C1h{{}2k6Ys9$*nFP3;K%u!MhW`uZy7Sn`1M1zs@Es&;z*Z>Gsh@-3Fe6pE zQD2@cqF((NrRevgvLsvM_8;;iNyJ5nyPyy?e!kvKjGj`6diRFBEe49Oa7wwkJFV7Z z$YT&DWloYu-H?3<0BKn9L&JYDT-SK~*6c5pi18P26$JESKRYj{T7Zk6KiRJcbvOO*{P56Q6s8msbeI3>|j>K9}Q9UBeq*inXKemCm`-<5|-$ZyN4u$(3 z&HcvqehFD%5Yrmykg-^d`=BSa8(i=>ZoC77^mWY{evp(km@aHqhUECBz76YiR+VYK zY_avFC~V3$=`6C4JhfHAQ@DZtUOwH`L;oYX6zK0-uI^?hS$ALfq}A7evR;ohJHij} zHSZdW?EKv9U1s4oD*<(0oQ*;MaQ6@cvGL zuHCPgm_NhVsgp^sfr*ia^Db}swo1?O(_Q2)y+S$CBm+g=9wCOUPbz(x)_GbaKa@A7 zuI&!ynLiZRT#V%_y_-D`0Z5lT*auoe{(U5NylTzFSJW()W-#F6*&A`LNO1bV#Y;QJ zSbLBnp|B^dtK|KIWC|No>JjWBWE@n7O)x{&^E(WMeMvp57#qA8m* zeTow*U@_86B#Fm*rxyYu5PRWaWHx8y> z*qmHEp(AMDl0v)ij(AY8fnH=~ZwwjVAbu*m5;xPfidh@ov6d8g zfJsi&!QyK53Es%sC39ts;54V68koALD4b|%tNHW0bIkZAJKa=W&FomJSEDT>W1xIX z1x%Z>AvNIsSPLcn3RTcHXb@KB?cuM)=x6fcIx>&(GxqZ8w3p#jJ(GVgc*`c0HG}dv zIop&Qim!K1NFwic%07KcjWgHBPUkq7f~lj;TPqVGTiT#cUeim>;nY`>h@a*S{qQex zQ`z62WK|Mj)Y{tfF{;T4P;c8$Q|KU?Joh zIkA^z%X7z|r>4aTh@|StTi!-r1D!g=zb#3d#{{&K3CqE$Iz-UH<%37c zRfkO`&uM%#AD3PHv`g5t0e^O%nVL0d{Xlx^EjEC3#skF@`zl-7PF^0oxW)1!C!JxR zWvuAHH?)61FKA1QeT*_sY7;_Id#!GmV4n`MO{~sv}VLSK` zXRw=Y=Clz*00B(5y^K;gCZMAzjT5+c3IC=)l(9VIDdatpxj3y89WwI|bH&$!ZEvp` zPR!T@#!(|KfI-w?!&+7$N3F6>tD{YO4Qg$d_`nNEdfVCha9vaPn0jI0`)`@*72hq! zpU5ND^P*RoEkbD5o#az(-g=Y)L>HH>Oc%}$ zT3Rs_ih0;4+Lv4Y;@Iv(;fUbQ=i-G(#>vghec~*j(I#r|5mqFiJBpzi&hzEcD{u$< zRsm0BVYn=pT;0>R(itW|*D&;O%bOc7et9ACaH#J>z3A1A~6fdP>pmbM%xzm4>|;c_?B+%sl;Qs2{t!60$^u zH1t@9^6>;?!FuusnISi$f5CL&;z?EqJN$FBuWDA#D5`cy_UvCFIVvf{c?4N0teh;d zET$7aVbj08KTQS!x?Nd1Is8q8qFzs}a=!@nJ;7FSfCY^T@D-gpw`w<6e#X3+;O}1h z$%I!M)0bg|EKUA04Qjn@+x{Rj8vt6Wn!R|3A92z}^$KfF5(#CWr4y#~re1CN4i4w0 z#GsypBR{xA3Er7sgAi(|}1-W?s~n$7?K|9WL8kpVfw-;#b9 z+mn;=ep!162U5R>_t}fOt~tE?s#m( zO-S$7>Ay6*hHdZ)7_oU915WYYCIX;hFI-U2EWYX!pllONr@Q--2o~`!isi6vTPLJ4@(|o=%NHYjo0_S&q*UQIROw@*N-By@PaQ&;YxFZ0aR zX&}LeOEz);#m~Hwm^VAY8DK}b$F4bo{jMN?d!lxKPhNklzr^Cd`0f4oJr^z=I|l`* zm8AHm*fPV`0=lF3Pnnp}&J0N1X@}-D94YvmUabFrLGSnTz7Mu^21F#O5tN#CuY9Vh zUZBH=ez%h*wkf0hBtXJh1SN3d+IF{gzT7lp)j}n?03lt;XSQRAh7qd&v;RwTYDuQ# zbI2*r<>?x-G0@hM{;%{VBD7nLKt~D`T~-HAt5;h%i0_=Ifs=yHma5dhJ+QMG?Ux(a z|E?1CMy1!~oA`FP!k~iG=t&5#>bVdz=peT8HMB6Y)#7PpETtNryT^+Rv3vpJaF^zP z{H}0-LyV9Fu21ID%wO9f1IKlFr1p4c{o-?03vyB-tr5duk^&L$;m_|f$vs`^Sl{j2 z95}oY{LlY+=ZS%J+tZoXCd0*sSU7w^gjovXn+g7uyra5{cU49@yHf#Z^Jl-$9cIfo z+AJuxH$VLb=#+uBbVmUjnx zxb1pZ@-O9=AIk4@S)m6fJ2?{HrNYwwnL3a45muuNjr;6$O`bGEM0T4A2_S$t=86*- zcO+0mywg*j#A4mU}enR_!cGmIYQ;qwfchWtFEXL)AK%*;=j znYne+hS4EMy3S)C*mZ1KI>!+)0V@9!N6H$Y}~MJ{rYuf zz^KljIWvFi-?#?V@LPR&c6Nn{!=XM z>}-h$S76;$H{E{Y%@^zlmOl^efBwa%UU+jJD9UVukQ3ti_kH-?H*RC0?M1W%FCvMB zM_+v6fk$6X2sx)-p~B3&Kl{nscK}pNLM*qjtpaf9>AU{-iPKQZR8yCg!TY}Qg*(;) z)gdvCcB%kppZc$VdvsK@)3l1{&DG!d_6OHOS`y=ITLEVu`unSKA2E%JD*DVX{LJ}K z9l>hMRDqxQh0lnpGHpVYneX}eA3Pt|2v%=q;rt)``R|#bDyB)OXY&vI_@|*}h}G?^ z@aZ4_!7cQPX`!fW_?{oT1NTwHs#l5L-0`E|y@48<3Q^HFf8=Idi zpJYD%1MkII!~|7I^WGo)IF=?{>ACnjJ_WUi39C}!Q{QnheVJqeKKqq5^o5CBde(g9 zvw$X6^jz_^E2$wSw4!q5*RG(C2_^XO$HBn_55vbl44OnTTRwRaePP0vo{K)U1#99& z<>rq7V&V(<&@I%MFoN5zrY}sz=(*-L&}1QQ*a%`u25h{cFj===17eB_uGuzG&byQ< zrm8BJZl4r_E$3k|Wo6FW0-6M7>qac5uFQsQcmkLWGfeH74S3Z_rJ!jgN++!@i=HW8 zkyjI(oPH-+-N#Qc^-mpNO`bc6r=2-<%&Wy5K1vfFJB(L_IkpS6fY^NmuL8qsgj>MD zn~BHH9WM~32_3vd=W&B)k7F9q%stJx+b_L_X-4zr^LVUMCmyCTA3sWtkvsmME?Xiy z?xOSfB=_$oY06~J-HcCq&)qcW{j;uP;?Dm}=hkq?zh&n!;m((-G-u_t|6x399Q;>A zgNpxoJNj{u|MFDH7Rhq@FCAl0dE|ddnl!oh9{Lq?@JDoR6L;C941IK`ISfdE$4S zE0AUQ8+2|Ncl_q5QkSp#AODp~(^mfP&%Au@@|TBQwoP`UU+V{6u8|)6ZA{~uKmQ*M zmrMTDU8S~8Eqi{^v0Ug&5Upcm#y7Z1(RbgZAG8jB$eRwCspQ)>5;U)oGZ&E5aeR*K z8Yt`Y0$G))Yd(Y3KH}tA4`-_QmNke5hU_|nq=xtyjwW(_o?itz>B>WM&^63bNdQ)k@-IgDHW*RW$Xo9#RzrTrCn7L2H{9Amq|qNg@#eZY=|P zCoI?2s+L)zsM%WX(NbVEY^`C>lFjIBYmJ6@DKJ0ZT4&F&WHW!dwa%QzOG!?jY_2(S zDcEzZbz*2Q!43|z))9yOP9X1Xt%DXzwY(3tl-TR=Qb_MbZYRrooh;dYYmS!U_as1(=YVB?Q_A|tNu5Ut&_q3jbfDM zoFxT^uEuH`nX3*sB%K?GuHUkweYReBwnHqh3P)~`+s3+Tj!rDA1e)8vuBv5J*IsxC zkd^~b(aGzArj08{>cnzOuy04C+C`}gb|Yz-1avxeWzev3NzcHbz_&4W@QCr$z3~w=8Ua- z`;vfG1~BP8CyLb=F7t1am~ph_#|O%$khSJ9%Vtcn)YmpgQxF?xM^_Vb+5fnpB^W0I`f%X8gb9#X{Q-yJG0{Z56aWeI&zPxnf5pdJA38bM`cYnS#x)% z`n1tFf$i)W-hGm(f9mde^=X@NcV_lFb=P`4&CI&H=IArijGwdCk&X@uQ$5xmj!~^? z#$ROCI)V-~t%L%GS#wo@U27ddR`4`3)WoB{R-4snfNrfee|kI8^bu#yDgYqOwas9# zmcb`3!kRJ`Cr=_tq)8aMt{aGtUZsqwVlj6DgCGre>AEt&x8H_in!x@uwgExIh|-mA zjdaC(29~CTVSaaF7HPbql&*9Uo8P@f)>LqCXclr}peS7_1BQ28u9PO8Eq1@`l3q9o zkfKCaO2?T?ZyA6loW<#9_c^O=m<&h}CA!ineAD@=(gbq`vyT|tiJ6#^B1$P;;qax` z55k&Q?wEh#87niLo*+n4L@65J(Nz~=Ya%7^(miLb(E>A3B@|Jjl;FU&D>o|9#7PJH z?|ago!o;WC^h=|T7PVBg(DAB}72cyUS zb(f>Bwbr!F1eTCO5fpj<{PqhY5>143p?~5ZA5H40);=@M#MYvrB6gqHbU_!GSY??i z%s=>-ciA4*zOOZHds0a(kWewZ4h(k8h(ua7HX)Au&mY~H8KY6(_cb$_&fA@QjIW-*heP3%$d!m5^AdnT}`12qA^c@!g3DOwZ5WwE2?)-yU z!)Vx#Mtxt?FzFTwK!77sy7)sMzUd->w4^bxtpM2j!b1pjgyk zGKwWGeb4)^zjy{9Es&PU1}gwg?|J#L$KJB7ett9@4M%-nGtIQr0>Fl@8-yh`-+1ed zS6r}(MeSvgSoFmH*_WPu@i?}!AB~2?;i&IxrkNg~cQ9Som98tcq)k^|eeER|Zl77t za-TVUc;DNvzVXJ%w52+#weN?+;i#{f#!Oc&z?81*N>^e~ltRS%ZI@lR{rs()HmqG! zx*}ZrI-EZ}ckJMiy>A^oofwDfC~IH)z8{VHKGT@#E5I(Ll&+MnMCl>~AV7+>Gi%mF zkU1QlKASdR0B80!YhP<$Ywi0?W2Ux45oPfxv9QolWzJPD^weBfvo4SONxP35106sAmh(e+vAs0GboFD@PvNs)jNPvarhW}0YliZEg{Gazv z+JDIpoojRVPr<*C|BTq<`6ga{5q^8^!|0cxe=rZ!zxH3%f5ZO0cQ*Z<^$Yt2{|Ek0 zyT|*F+CO@K;(owBKtGg!S^xj-Z~rga2m6nxKl9J=fBSuNKW_dLKWhJKeg^-Xe`^1? z`TyJj)8E!#>_3Y?uKrwqq3LJ#SGU>AzUO|6`nR^u&3FNN_jGOc zw)Nw`wr3yIKhgcee6IaN=ws>M{6677%)hPwx&HzC(f&u~&)6@b2kNRzBDQAP0*H73 zq%McOmRk{B3i47qRe=DA*$&odrbEJZ*pV9XXa&p@wlW~@Yfs>V{yiTtplMhgM*-Bz zsSnlq&pG;z0OUN%$~$3=g1UF+G*>+17eRbBf3=y79J}KR8owon@$1Z7MIrvvWWH)34nK2SD)GsrJ{l z1Cl#oVo3A8qY3e=aF)qzms~FG#2$LzT=gs&aVMOj>(%{y<&O0cG!nCiESl~x=^dF{ zKvj8F1K8Ng171wwM5Fh4KoQw`_c6#y$(5cAm7e}~nJ#A*fx+c9;y#&W!#VukR)ugk zKp3=+;Ut+IYn%m+r4d*<`L2h%aDnX5}^!5R|H;(34AoVWjRx(msBZvk;rCI*|~ zdOijqI@9Z{Vu!~jvHW{lBa$rnl4+!s_5sfK3bCGk-B%iDe&@-}+%fOKU|(9?V1 zHE8&@4z)Kx!RAvAs z!Wic9=o#(bg?kc-G68-m(jZ`^=XGUXb)}t(%&~sjFnV^sEX%hSy6UKC4iOhgV=BHV z2w`4g7Y=s#Vu2B_?#VQ|hP39@eArgfX>-0S+dd&^mx0*wp}>)x;c4RUgxz%;oNe?& z-7-lJ@Y^2^C;=qJsxx5|xF)*pTGhch2B&kxtn;f!7=gznk}I3}Dh}(CoMXgA5-p&kS202!l?!fT3t|HG*rIP~mS* z$Wjo}jq3}z$Qq!9yrtd3fM0N629ZM?LU$nv@Tv9b7I;D|;0H2dsA~g7Z7zp1| zB)XmrkMgF6OQr|R)HHD^TE{Y#j!~SR?b`Xt3Qs`B+x<hxexYeAjMUWdZ-*n9%(1)Wb(n2U<><7&9dwGJmrob)4%H? zlQ%z+L-^$dFhhH|@u$%97Qz?*Ynh2VG@q|?8vY&L74&fs&_b&3$x&Oyjl~LQDRRap zJU4U*R+(2Dd!G+lh8!V{pT_UJn+^1Qg6$` zqkNm(a#hWyc6SP+p5=C4HL8-m`pO`5o~`-LI?_h5CsH?F_%?nDodmz&pWR20WTpJE z?N|wSzLjMUK8E)a2tI}Lf;+;*M|h3Y(U#>)g1>zk9|Hd}oZAa2 zLYBWBoSW!Ts!RwXr^8h+U*@{9{zqS^iH)Op<;r`Uw~nc}<^$V~_i%$GFjaG?X1@E|M`h)nekvFKt`Dh-f>@|0-`Xoq)o` zx;JmzDfOV9qCx|EVpogEe0LK~tGS?5$$L_i6P$P6wIsCQaP_;d{{N=iV@+8LI}o#( zvo*Ejy=IIn{rdIQh1&q-{EuohpVOjJ^Q3lD*YTp37$^RRgn8ihpdu5{Ct%5-KO!VL zcNB6dUajXI9jkm-P|i3~GB-A(X`P1Oqqb$tcku)UJw0w3GeUijb__#QT4j%64z%EeB7S?jlWwx_7&+EEvB|6N=kV}DwnyAlX=?j`) zmU#!$*^@NIu#n_d7;WoJV@*Fbv9|yJO4;n|BNF2xy(54RyB>t~8lUOUW$&2%Nwi1y zx6JxW88>U2$#qhl^6KUbtmg9}D0o5vYDT7kWJthLGkpGnN4T>{St^_EU>4;DmLF9o zr|LqsA8_MoNLQ=}w?8u!ziSZ@PC#Y<#9uJFo-ozVo6D;<8j^1$c|qAE3ZTE5i~zmE z$BU5lw6l=EWsg^y^;8>r9qH{xfL|~PZYK#md$zZ0?o11gV<*WSW~cgy2GYGQir%wf zt4iW8D+;s*;RGrmd(-T<@2&j(Cb9xhV*l-x`TpK`xq|7p?5R%5*s!69?2c!cC*VY* z2DE^9pvOPLU!1e}wA8S8opcTJ3`NB>hY=JQnL~QFXR4K8A$BqJnoEB$wn-%u@E6Mh zCfMF4kusv3N!(aHC}4)Xs^xoOwXd%e^6pi5|DZo=Q25j+6HlJ^7FodH6y1bMROR^q zGu6)fopS`h%Sw<;ZH%TEPf+#81-#_v+@8nlR0jLcIDKQtLleOC)6yLZgC!D9X3GgS zohwU{v$jl=quD#Go^hB{`@Qw*a%`(^jyT~=q^bWgGzRj;|12J55HWdCWV}EB|K=%N z3Nq-qxJJ`>^|1MNN+q}zTB&ooE3j==AgK@^UW<^oSbeALa2peF)Th6{@sj0KyMNHZ zksk1+MXN2tv+22A%cQOGpS9)77(uP9mh+!5T5ERLvF@b}$+WvXM45Z?-kCa)fb~f1 znVbTD$Gx-0Zxc`0D@YgHakge6SL0H`-vN_x?AP0>iGH0_EE&=v83hMJgaKAI0jJXm zVxVz;X<$v6WW7}fxROO7vr#YLP;;lij5VrX{;>7kK6TtOH&6|Ar^xo>00%+u$C4@# z>!jOt6*3><171+WxoZnKDTzJtDRw+T030;yI}~uV@9fCnei^I*j>Bp&mzP2d=FPb_ zCM*l_+$LDR3B*a!A$g#>xsrZvw0lckxmMg>0aQd7tPyN=t{dgXb;Ie+T8{fZH=gdu zM7Rg9c(kg(Jg0?ARRRl=AONFKrvFj)lTY$KfT%6^6s`mk*ABGhsce*LsoD>K{z_M2 ziPpnu+lw22PfF!CoId^6n*G4H(Ix+#+N{C(da7t1BYMGEaE#PdpOLxsVD5riQXHp@OX;`S`8VnpM~)I920w~<3|mo0 zf8~Az`*?2?H&gZ&*K&bRkV@qzvMlRHXys8*Ze2+1c?5o!^+$&MHxB@4Ee5cke52R! zmn7AZtY6ST%ixgU5)%$%QcwHj7Es-Qu^kLAPwy%7pGBw_4Q9#da^W2$}axNHr03)_nw z5?yuNmXrI5HgS46)c5&}B)Tts49oU92>3xBLLy}FMUW=84DQbVq^;7_e7|(Sdz|&J z73N+M`rc2rt*oSWu#7S{*s~nH6HRHJS1SmzeXk|;CA)FI4bat3<%}nkB%;;?=F>B7ms9QSxv#@+69;@>QaR?REYX4&)=itG>rM{<{A79Rmk)`5ON#GL`*KX%}Ihk3w(RtM-WLt z?f&FLF}4N^yE!(pZ&Yj&Bc`~K0@4_}*0Om?wN|}4WJ>WL;G^H2*QpgEkGA~OET-Km zkwz|5{6dnz1U<2Pe9DNL>3g5FEIvp1jzP&2K#z~j%g6!7B;^zF+o95?fV{3mnB8*RMhCDNp>Am-3e@jNfMj?jHV$MWjk!DDKP zkAz$Y?Sr)!GUOX}qTQ5aMh|wq1uq}~joWyKl=b_LboM#wi{CMuz5x6BKlA-qy++cM01D3b7`uD z#l6M4pI;JCypO8JZ6?U&wNxR!{4oB_ zlV!x9+-&Qy6{%MQ{~yoZGkKiTSC`YS_j22~G;xUV855g2&C(zm^V!(wpcm@zn{%!g z4}JGo(sGZ1O~to-}le

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/examples/product-sample-supabase-kt/app/src/main/res/values/colors.xml b/examples/product-sample-supabase-kt/app/src/main/res/values/colors.xml new file mode 100644 index 00000000000..f8c6127d327 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/res/values/strings.xml b/examples/product-sample-supabase-kt/app/src/main/res/values/strings.xml new file mode 100644 index 00000000000..bf4e9fe7d7b --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + ManageProducts + Product List + Product Details + Add Product + \ No newline at end of file diff --git a/examples/product-sample-supabase-kt/app/src/main/res/values/themes.xml b/examples/product-sample-supabase-kt/app/src/main/res/values/themes.xml new file mode 100644 index 00000000000..2c9a78d5a47 --- /dev/null +++ b/examples/product-sample-supabase-kt/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +