commit 0ef8a1e2551c7b7a4bed733135946f15784ec8f1 Author: Amk Date: Tue Feb 24 13:56:54 2026 +0000 Initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3c19c17 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = space +indent_size = 4 + +[*.{kt,kts}] +indent_size = 4 + +[*.{xml,json,yaml,yml,toml}] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[*.bat] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9f95d99 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef14ec5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,126 @@ +# ---> Kotlin +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +!gradle/wrapper/gradle-wrapper.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +# Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects +.kotlin/ +# ---> Swift +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +*.iml +.kotlin +.gradle +**/build/ +xcuserdata +!src/**/build/ +local.properties +.idea/* +!.idea/codeStyles/ +.DS_Store +captures +.externalNativeBuild +.cxx +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings + +# Database files +*.sqlite +*.sqlite-wal +*.sqlite-shm +*.db +*.db-wal +*.db-shm + +keystore.properties +release-keystore.jks +release-keystore.jks.base64 +/composeApp/src/androidMain/res/xml/locales_config.xml + diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..96bbd61 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cd5617 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Contacts Assessment App + +A simplified Kotlin Multiplatform (KMP) contacts management app designed for technical interviews. +This app mirrors GART architecture patterns and contains intentional bugs for candidates to identify +and fix. + +## Project Overview + +This is an Android-focused KMP app that displays contacts and groups with the following features: + +- Bottom navigation with Groups and Contacts tabs +- Status badges for contacts (Active, Pending, Inactive) +- Nostr-style npub identifiers for contacts +- Avatar images loaded from network +- Room database with reactive Flow-based queries +- Clean Architecture with domain, data, and presentation layers +- ViewObjects (VOs) separating domain from presentation +- Dependency injection with Koin + +## Getting Started + +### Prerequisites + +- Android Studio Ladybug (2024.2.1) or newer +- Android SDK 26+ +- JDK 17 is **auto-provisioned** by the Gradle toolchain — no manual install needed + +### Build & Run + +```bash +# Build debug APK +./gradlew assembleDebug + +# On Windows, use: +gradlew.bat assembleDebug + +# Install on connected device +adb install -r composeApp/build/outputs/apk/debug/composeApp-debug.apk + +# Or run directly from Android Studio +``` + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `Unsupported class file major version` | The toolchain should handle this automatically. If not, ensure JDK 17+ is installed. | +| Out of memory during build | Increase heap in `gradle.properties`: `org.gradle.jvmargs=-Xmx3072M` | +| `SDK location not found` | Create a `local.properties` file with `sdk.dir=/path/to/your/Android/Sdk` | +| Gradle sync fails in Android Studio | File -> Invalidate Caches -> Restart, then re-sync. | + +## Architecture + +``` +composeApp/src/ +├── androidMain/kotlin/io/assessment/contacts/ +│ ├── ContactsApplication.kt +│ ├── MainActivity.kt +│ └── di/DatabaseModule.kt # Android-specific Room setup +└── commonMain/kotlin/io/assessment/contacts/ + ├── App.kt + ├── MainViewModel.kt + ├── core/ + │ ├── data/ + │ │ ├── database/ # Room entities, DAOs, DatabaseSeeder + │ │ ├── datasource/ # Data sources + │ │ └── repository/ # Repository implementations + │ ├── domain/ + │ │ ├── contact/ # Contact, ContactStatus, ContactError + │ │ ├── group/ # Group, GroupError + │ │ ├── result/ # Result type utilities + │ │ └── usecase/ # Use case interfaces + │ └── presentation/ # Shared presentation utilities + ├── contacts/ + │ ├── data/ # ContactMapper + │ ├── domain/ # ObserveContactsUseCase + │ ├── di/ # ContactsModule + │ └── presentation/ # ViewModel, Screen, State, ContactVO + ├── groups/ + │ ├── data/ # GroupMapper + │ ├── domain/ # ObserveGroupsUseCase + │ ├── di/ # GroupsModule + │ └── presentation/ # ViewModel, Screen, State, GroupVO + ├── designsystem/ # Theme, Colors, Typography + ├── navigation/ # NavigationRoot, BottomNavDestItem + └── di/ # App-level DI modules +``` diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8f973f0 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.kotlinSerialization) apply false +} diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts new file mode 100644 index 0000000..ee97208 --- /dev/null +++ b/composeApp/build.gradle.kts @@ -0,0 +1,105 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.ksp) + alias(libs.plugins.room) +} + +kotlin { + jvmToolchain(17) + + androidTarget() + + sourceSets { + androidMain.dependencies { + implementation(libs.compose.ui.tooling.preview) + implementation(libs.androidx.activity.compose) + implementation(libs.koin.android) + implementation(libs.ktor.client.okhttp) + } + + commonMain.dependencies { + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation(compose.components.resources) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + + api(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.navigation.compose) + + implementation(libs.room.runtime) + implementation(libs.sqlite.bundled) + + implementation(libs.kotlinx.coroutines.core) + + implementation(libs.coil.compose) + implementation(libs.coil.network.ktor3) + } + + commonTest.dependencies { + implementation(libs.kotlin.test) + } + } +} + +android { + namespace = "io.assessment.contacts" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + buildFeatures { + buildConfig = true + } + + lint { + abortOnError = false + } + + defaultConfig { + applicationId = "io.assessment.contacts" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0.0" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + buildTypes { + getByName("debug") { + isDebuggable = true + } + getByName("release") { + isMinifyEnabled = false + } + } + +} + +dependencies { + add("kspAndroid", libs.room.compiler) + debugImplementation(libs.compose.ui.tooling) +} + +room { + schemaDirectory("$projectDir/schemas") +} + +tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } +} diff --git a/composeApp/schemas/io.assessment.contacts.core.data.database.ContactsDatabase/1.json b/composeApp/schemas/io.assessment.contacts.core.data.database.ContactsDatabase/1.json new file mode 100644 index 0000000..64bffaf --- /dev/null +++ b/composeApp/schemas/io.assessment.contacts.core.data.database.ContactsDatabase/1.json @@ -0,0 +1,89 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "c812bdba76dc08f1c83d24558a84ae29", + "entities": [ + { + "tableName": "contacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `npub` TEXT NOT NULL, `avatarUrl` TEXT, `status` TEXT NOT NULL, `groupId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "npub", + "columnName": "npub", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `memberCount` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberCount", + "columnName": "memberCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c812bdba76dc08f1c83d24558a84ae29')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..212f678 --- /dev/null +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/kotlin/io/assessment/contacts/ContactsApplication.kt b/composeApp/src/androidMain/kotlin/io/assessment/contacts/ContactsApplication.kt new file mode 100644 index 0000000..1aa9212 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/assessment/contacts/ContactsApplication.kt @@ -0,0 +1,34 @@ +package io.assessment.contacts + +import android.app.Application +import io.assessment.contacts.core.data.database.DatabaseSeeder +import io.assessment.contacts.di.databaseModule +import io.assessment.contacts.di.sharedAppModule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin + +class ContactsApplication : Application() { + + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val databaseSeeder: DatabaseSeeder by inject() + + override fun onCreate() { + super.onCreate() + + startKoin { + androidContext(this@ContactsApplication) + androidLogger() + modules(databaseModule, sharedAppModule) + } + + applicationScope.launch { + databaseSeeder.seedIfEmpty() + } + } +} diff --git a/composeApp/src/androidMain/kotlin/io/assessment/contacts/MainActivity.kt b/composeApp/src/androidMain/kotlin/io/assessment/contacts/MainActivity.kt new file mode 100644 index 0000000..de5e7a7 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/assessment/contacts/MainActivity.kt @@ -0,0 +1,18 @@ +package io.assessment.contacts + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + App() + } + } +} diff --git a/composeApp/src/androidMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsScreenPreview.kt b/composeApp/src/androidMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsScreenPreview.kt new file mode 100644 index 0000000..bd20f1e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsScreenPreview.kt @@ -0,0 +1,149 @@ +package io.assessment.contacts.contacts.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import io.assessment.contacts.core.domain.contact.ContactStatus +import io.assessment.contacts.designsystem.ContactsTheme + +private val sampleContacts = listOf( + ContactVO( + id = "1", + name = "Alice Johnson", + npub = "npub1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0", + avatarUrl = null, + status = ContactStatus.ACTIVE, + ), + ContactVO( + id = "2", + name = "Bob Smith", + npub = "npub1z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4j3i2h", + avatarUrl = null, + status = ContactStatus.PENDING, + ), + ContactVO( + id = "3", + name = "Charlie Brown", + npub = "npub1m2n3o4p5q6r7s8t9u0v1w2x3y4z5a6b7c8d9e", + avatarUrl = null, + status = ContactStatus.INACTIVE, + ), +) + +@Preview +@Composable +private fun ContactsScreenContentPreview() { + ContactsTheme { + ContactsScreenContent( + state = ContactsState( + contacts = sampleContacts, + isLoading = false, + ), + ) + } +} + +@Preview +@Composable +private fun ContactsScreenLoadingPreview() { + ContactsTheme { + ContactsScreenContent( + state = ContactsState(isLoading = true), + ) + } +} + +@Preview +@Composable +private fun ContactsScreenEmptyPreview() { + ContactsTheme { + ContactsScreenContent( + state = ContactsState( + contacts = emptyList(), + isLoading = false, + ), + ) + } +} + +@Preview +@Composable +private fun ContactListItemActivePreview() { + ContactsTheme { + ContactListItem( + contact = ContactVO( + id = "1", + name = "Alice Johnson", + npub = "npub1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0", + avatarUrl = null, + status = ContactStatus.ACTIVE, + ), + ) + } +} + +@Preview +@Composable +private fun ContactListItemPendingPreview() { + ContactsTheme { + ContactListItem( + contact = ContactVO( + id = "2", + name = "Bob Smith", + npub = "npub1z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4j3i2h", + avatarUrl = null, + status = ContactStatus.PENDING, + ), + ) + } +} + +@Preview +@Composable +private fun ContactListItemInactivePreview() { + ContactsTheme { + ContactListItem( + contact = ContactVO( + id = "3", + name = "Charlie Brown", + npub = "npub1m2n3o4p5q6r7s8t9u0v1w2x3y4z5a6b7c8d9e", + avatarUrl = null, + status = ContactStatus.INACTIVE, + ), + ) + } +} + +@Preview +@Composable +private fun ContactAvatarWithoutImagePreview() { + ContactsTheme { + ContactAvatar( + avatarUrl = null, + name = "Alice Johnson", + ) + } +} + +@Preview +@Composable +private fun StatusBadgeActivePreview() { + ContactsTheme { + StatusBadge(status = ContactStatus.ACTIVE) + } +} + +@Preview +@Composable +private fun StatusBadgePendingPreview() { + ContactsTheme { + StatusBadge(status = ContactStatus.PENDING) + } +} + +@Preview +@Composable +private fun StatusBadgeInactivePreview() { + ContactsTheme { + StatusBadge(status = ContactStatus.INACTIVE) + } +} diff --git a/composeApp/src/androidMain/kotlin/io/assessment/contacts/core/data/database/DatabaseFactory.android.kt b/composeApp/src/androidMain/kotlin/io/assessment/contacts/core/data/database/DatabaseFactory.android.kt new file mode 100644 index 0000000..793eed1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/assessment/contacts/core/data/database/DatabaseFactory.android.kt @@ -0,0 +1,19 @@ +package io.assessment.contacts.core.data.database + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase + +class DatabaseFactory( + private val context: Context, +) { + fun create(): RoomDatabase.Builder { + val appContext = context.applicationContext + val dbFile = appContext.getDatabasePath("contacts.db") + return Room.databaseBuilder( + context = appContext, + klass = ContactsDatabase::class.java, + name = dbFile.absolutePath, + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/io/assessment/contacts/di/DatabaseModule.kt b/composeApp/src/androidMain/kotlin/io/assessment/contacts/di/DatabaseModule.kt new file mode 100644 index 0000000..d6ad7e5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/assessment/contacts/di/DatabaseModule.kt @@ -0,0 +1,18 @@ +package io.assessment.contacts.di + +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import io.assessment.contacts.core.data.database.ContactsDatabase +import io.assessment.contacts.core.data.database.DatabaseFactory +import kotlinx.coroutines.Dispatchers +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val databaseModule = module { + single { + DatabaseFactory(androidContext()) + .create() + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + } +} diff --git a/composeApp/src/androidMain/kotlin/io/assessment/contacts/groups/presentation/GroupsScreenPreview.kt b/composeApp/src/androidMain/kotlin/io/assessment/contacts/groups/presentation/GroupsScreenPreview.kt new file mode 100644 index 0000000..fa1776f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/assessment/contacts/groups/presentation/GroupsScreenPreview.kt @@ -0,0 +1,73 @@ +package io.assessment.contacts.groups.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import io.assessment.contacts.designsystem.ContactsTheme + +private val sampleGroups = listOf( + GroupVO( + id = "1", + name = "Engineering Team", + memberCount = 12, + ), + GroupVO( + id = "2", + name = "Design Team", + memberCount = 5, + ), + GroupVO( + id = "3", + name = "Marketing", + memberCount = 8, + ), +) + +@Preview +@Composable +private fun GroupsScreenContentPreview() { + ContactsTheme { + GroupsScreenContent( + state = GroupsState( + groups = sampleGroups, + isLoading = false, + ), + ) + } +} + +@Preview +@Composable +private fun GroupsScreenLoadingPreview() { + ContactsTheme { + GroupsScreenContent( + state = GroupsState(isLoading = true), + ) + } +} + +@Preview +@Composable +private fun GroupsScreenEmptyPreview() { + ContactsTheme { + GroupsScreenContent( + state = GroupsState( + groups = emptyList(), + isLoading = false, + ), + ) + } +} + +@Preview +@Composable +private fun GroupListItemPreview() { + ContactsTheme { + GroupListItem( + group = GroupVO( + id = "1", + name = "Engineering Team", + memberCount = 12, + ), + ) + } +} diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..0834f20 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..3d1a04f Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..7b5013b Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..df591b7 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..3507345 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..ce36934 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..7c4a06d Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..465a18e Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..884efbe Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..0e70633 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..4809588 --- /dev/null +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Contacts Assessment + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000..9f64935 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,21 @@ + + + Contacts Assessment + + + Groups + Contacts + + + Contacts + Avatar of %1$s + + + Groups + %1$d members + + + Active + Pending + Inactive + diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/App.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/App.kt new file mode 100644 index 0000000..f6f027d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/App.kt @@ -0,0 +1,12 @@ +package io.assessment.contacts + +import androidx.compose.runtime.Composable +import io.assessment.contacts.designsystem.ContactsTheme +import io.assessment.contacts.navigation.NavigationRoot + +@Composable +fun App() { + ContactsTheme { + NavigationRoot() + } +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/MainViewModel.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/MainViewModel.kt new file mode 100644 index 0000000..ddee944 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/MainViewModel.kt @@ -0,0 +1,5 @@ +package io.assessment.contacts + +import androidx.lifecycle.ViewModel + +class MainViewModel : ViewModel() diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/data/ContactMapper.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/data/ContactMapper.kt new file mode 100644 index 0000000..d6fe2d5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/data/ContactMapper.kt @@ -0,0 +1,27 @@ +package io.assessment.contacts.contacts.data + +import io.assessment.contacts.core.data.database.ContactEntity +import io.assessment.contacts.core.domain.contact.Contact +import io.assessment.contacts.core.domain.contact.ContactStatus + +fun ContactEntity.toDomain(): Contact { + return Contact( + id = id, + name = name, + npub = npub, + avatarUrl = avatarUrl, + status = ContactStatus.INACTIVE, + groupId = groupId, + ) +} + +fun Contact.toEntity(): ContactEntity { + return ContactEntity( + id = id, + name = name, + npub = npub, + avatarUrl = avatarUrl, + status = status, + groupId = groupId, + ) +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/data/ObserveContactsUseCaseImpl.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/data/ObserveContactsUseCaseImpl.kt new file mode 100644 index 0000000..672de6b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/data/ObserveContactsUseCaseImpl.kt @@ -0,0 +1,17 @@ +package io.assessment.contacts.contacts.data + +import io.assessment.contacts.contacts.domain.ObserveContactsUseCase +import io.assessment.contacts.core.domain.contact.Contact +import io.assessment.contacts.core.domain.contact.ContactError +import io.assessment.contacts.core.domain.contact.ContactRepository +import io.assessment.contacts.core.domain.result.Result +import kotlinx.coroutines.flow.Flow + +class ObserveContactsUseCaseImpl( + private val contactRepository: ContactRepository, +) : ObserveContactsUseCase { + + override fun invoke(): Flow, ContactError>> { + return contactRepository.observeContacts() + } +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/di/ContactsModule.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/di/ContactsModule.kt new file mode 100644 index 0000000..c0b3514 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/di/ContactsModule.kt @@ -0,0 +1,14 @@ +package io.assessment.contacts.contacts.di + +import io.assessment.contacts.contacts.domain.ObserveContactsUseCase +import io.assessment.contacts.contacts.data.ObserveContactsUseCaseImpl +import io.assessment.contacts.contacts.presentation.ContactsViewModel +import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.bind +import org.koin.dsl.module + +val contactsModule = module { + factoryOf(::ObserveContactsUseCaseImpl) bind ObserveContactsUseCase::class + viewModelOf(::ContactsViewModel) +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/domain/ObserveContactsUseCase.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/domain/ObserveContactsUseCase.kt new file mode 100644 index 0000000..59b7b40 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/domain/ObserveContactsUseCase.kt @@ -0,0 +1,7 @@ +package io.assessment.contacts.contacts.domain + +import io.assessment.contacts.core.domain.contact.Contact +import io.assessment.contacts.core.domain.contact.ContactError +import io.assessment.contacts.core.domain.usecase.NoParamsFlowUseCase + +interface ObserveContactsUseCase : NoParamsFlowUseCase, ContactError> diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactVO.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactVO.kt new file mode 100644 index 0000000..3b536ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactVO.kt @@ -0,0 +1,22 @@ +package io.assessment.contacts.contacts.presentation + +import io.assessment.contacts.core.domain.contact.Contact +import io.assessment.contacts.core.domain.contact.ContactStatus + +data class ContactVO( + val id: String, + val name: String, + val npub: String, + val avatarUrl: String?, + val status: ContactStatus, +) + +fun Contact.toVO(): ContactVO { + return ContactVO( + id = id, + name = name, + npub = npub, + avatarUrl = avatarUrl, + status = status, + ) +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsScreen.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsScreen.kt new file mode 100644 index 0000000..dac7a83 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsScreen.kt @@ -0,0 +1,200 @@ +package io.assessment.contacts.contacts.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import contactsassessment.composeapp.generated.resources.Res +import contactsassessment.composeapp.generated.resources.contacts_avatar_description +import contactsassessment.composeapp.generated.resources.contacts_title +import contactsassessment.composeapp.generated.resources.status_active +import contactsassessment.composeapp.generated.resources.status_inactive +import contactsassessment.composeapp.generated.resources.status_pending +import io.assessment.contacts.core.domain.contact.ContactStatus +import io.assessment.contacts.designsystem.extendedColors +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactsScreen( + viewModel: ContactsViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + ContactsScreenContent(state = state) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ContactsScreenContent( + state: ContactsState, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.contacts_title)) }, + ) + }, + ) { paddingValues -> + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = state.contacts, + key = { it.id }, + ) { contact -> + ContactListItem(contact = contact) + } + } + } + } +} + +@Composable +internal fun ContactListItem( + contact: ContactVO, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ContactAvatar( + avatarUrl = contact.avatarUrl, + name = contact.name, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = contact.name, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = contact.npub, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + StatusBadge(status = contact.status) + } + } +} + +@Composable +internal fun ContactAvatar( + avatarUrl: String?, + name: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + if (avatarUrl != null) { + AsyncImage( + model = avatarUrl, + contentDescription = stringResource(Res.string.contacts_avatar_description, name), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } +} + +@Composable +internal fun StatusBadge( + status: ContactStatus, + modifier: Modifier = Modifier, +) { + val (backgroundColor, text) = when (status) { + ContactStatus.ACTIVE -> extendedColors.statusActive to stringResource(Res.string.status_active) + ContactStatus.PENDING -> extendedColors.statusPending to stringResource(Res.string.status_pending) + ContactStatus.INACTIVE -> extendedColors.statusInactive to stringResource(Res.string.status_inactive) + } + + Box( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(backgroundColor.copy(alpha = 0.2f)) + .padding(horizontal = 12.dp, vertical = 4.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = backgroundColor, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsState.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsState.kt new file mode 100644 index 0000000..483044d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsState.kt @@ -0,0 +1,6 @@ +package io.assessment.contacts.contacts.presentation + +data class ContactsState( + val contacts: List = emptyList(), + val isLoading: Boolean = true, +) diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsViewModel.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsViewModel.kt new file mode 100644 index 0000000..1873728 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/ContactsViewModel.kt @@ -0,0 +1,26 @@ +package io.assessment.contacts.contacts.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.assessment.contacts.contacts.domain.ObserveContactsUseCase +import io.assessment.contacts.core.domain.result.filterSuccess +import io.assessment.contacts.core.presentation.stateInWhileSubscribed +import kotlinx.coroutines.flow.map + +class ContactsViewModel( + observeContactsUseCase: ObserveContactsUseCase, +) : ViewModel() { + + val state = observeContactsUseCase() + .filterSuccess() + .map { contacts -> + ContactsState( + contacts = contacts.take(1).map { it.toVO() }, + isLoading = false, + ) + } + .stateInWhileSubscribed( + scope = viewModelScope, + initialValue = ContactsState(), + ) +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/navigation/ContactsNavDestinations.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/navigation/ContactsNavDestinations.kt new file mode 100644 index 0000000..3fe8fdb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/contacts/presentation/navigation/ContactsNavDestinations.kt @@ -0,0 +1,9 @@ +package io.assessment.contacts.contacts.presentation.navigation + +import kotlinx.serialization.Serializable + +@Serializable +object ContactsNavDestinations { + @Serializable + object ContactsList +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/ContactDao.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/ContactDao.kt new file mode 100644 index 0000000..b80c6e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/ContactDao.kt @@ -0,0 +1,39 @@ +package io.assessment.contacts.core.data.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.assessment.contacts.core.domain.contact.ContactStatus +import kotlinx.coroutines.flow.Flow + +@Dao +interface ContactDao { + + @Query("SELECT * FROM contacts ORDER BY name ASC") + fun observeAll(): Flow> + + @Query("SELECT * FROM contacts WHERE name LIKE '%' || :query || '%' ORDER BY name ASC") + fun observeByNameQuery(query: String): Flow> + + @Query("SELECT * FROM contacts WHERE status = :status ORDER BY name ASC") + fun observeByStatus(status: ContactStatus): Flow> + + @Query("SELECT * FROM contacts WHERE groupId = :groupId ORDER BY name ASC") + fun observeByGroupId(groupId: String): Flow> + + @Query("SELECT * FROM contacts WHERE id = :id") + suspend fun getById(id: String): ContactEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(contacts: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(contact: ContactEntity) + + @Query("DELETE FROM contacts") + suspend fun deleteAll() + + @Query("DELETE FROM contacts WHERE id = :id") + suspend fun deleteById(id: String) +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/ContactEntity.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/ContactEntity.kt new file mode 100644 index 0000000..03de494 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/ContactEntity.kt @@ -0,0 +1,16 @@ +package io.assessment.contacts.core.data.database + +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.assessment.contacts.core.domain.contact.ContactStatus + +@Entity(tableName = "contacts") +data class ContactEntity( + @PrimaryKey + val id: String, + val name: String, + val npub: String, + val avatarUrl: String?, + val status: ContactStatus, + val groupId: String?, +) diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/ContactsDatabase.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/ContactsDatabase.kt new file mode 100644 index 0000000..5ffdba5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/ContactsDatabase.kt @@ -0,0 +1,17 @@ +package io.assessment.contacts.core.data.database + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [ + ContactEntity::class, + GroupEntity::class, + ], + version = 1, + exportSchema = true, +) +abstract class ContactsDatabase : RoomDatabase() { + abstract val contactDao: ContactDao + abstract val groupDao: GroupDao +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/DatabaseSeeder.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/DatabaseSeeder.kt new file mode 100644 index 0000000..000c669 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/DatabaseSeeder.kt @@ -0,0 +1,108 @@ +package io.assessment.contacts.core.data.database + +import io.assessment.contacts.core.domain.contact.ContactStatus + +class DatabaseSeeder( + private val contactDao: ContactDao, + private val groupDao: GroupDao, +) { + suspend fun seedIfEmpty() { + if (contactDao.getById("c1") == null) { + seedContacts() + } + if (groupDao.getById("g1") == null) { + seedGroups() + } + } + + private suspend fun seedContacts() { + val contacts = listOf( + ContactEntity( + id = "c1", + name = "Alice Johnson", + npub = "npub1alice...7x8q", + avatarUrl = "https://i.pravatar.cc/150?u=alice", + status = ContactStatus.ACTIVE, + groupId = "g1", + ), + ContactEntity( + id = "c2", + name = "Bob Smith", + npub = "npub1bob...3k9m", + avatarUrl = "https://i.pravatar.cc/150?u=bob", + status = ContactStatus.PENDING, + groupId = "g1", + ), + ContactEntity( + id = "c3", + name = "Charlie Brown", + npub = "npub1charlie...5n2p", + avatarUrl = "https://i.pravatar.cc/150?u=charlie", + status = ContactStatus.ACTIVE, + groupId = "g1", + ), + ContactEntity( + id = "c4", + name = "Diana Ross", + npub = "npub1diana...8w4r", + avatarUrl = "https://i.pravatar.cc/150?u=diana", + status = ContactStatus.INACTIVE, + groupId = "g2", + ), + ContactEntity( + id = "c5", + name = "Edward Norton", + npub = "npub1edward...2j6t", + avatarUrl = "https://i.pravatar.cc/150?u=edward", + status = ContactStatus.PENDING, + groupId = "g2", + ), + ContactEntity( + id = "c6", + name = "Fiona Apple", + npub = "npub1fiona...9v3s", + avatarUrl = "https://i.pravatar.cc/150?u=fiona", + status = ContactStatus.ACTIVE, + groupId = "g3", + ), + ContactEntity( + id = "c7", + name = "George Clooney", + npub = "npub1george...4h8y", + avatarUrl = "https://i.pravatar.cc/150?u=george", + status = ContactStatus.ACTIVE, + groupId = null, + ), + ContactEntity( + id = "c8", + name = "Helen Mirren", + npub = "npub1helen...6m1z", + avatarUrl = "https://i.pravatar.cc/150?u=helen", + status = ContactStatus.PENDING, + groupId = null, + ), + ) + contactDao.insertAll(contacts) + } + + private suspend fun seedGroups() { + val groups = listOf( + GroupEntity( + id = "g1", + name = "Alice's group", + memberCount = 3, + ), + GroupEntity( + id = "g2", + name = "Close friends", + memberCount = 2, + ), + GroupEntity( + id = "g3", + name = "Colleagues", + memberCount = 1, + ), + ) + groupDao.insertAll(groups) + } +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/GroupDao.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/GroupDao.kt new file mode 100644 index 0000000..85f3817 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/GroupDao.kt @@ -0,0 +1,26 @@ +package io.assessment.contacts.core.data.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface GroupDao { + + @Query("SELECT * FROM groups ORDER BY name ASC") + fun observeAll(): Flow> + + @Query("SELECT * FROM groups WHERE id = :id") + suspend fun getById(id: String): GroupEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(groups: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(group: GroupEntity) + + @Query("DELETE FROM groups") + suspend fun deleteAll() +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/GroupEntity.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/GroupEntity.kt new file mode 100644 index 0000000..fceaf42 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/database/GroupEntity.kt @@ -0,0 +1,12 @@ +package io.assessment.contacts.core.data.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "groups") +data class GroupEntity( + @PrimaryKey + val id: String, + val name: String, + val memberCount: Int, +) diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/datasource/ContactDataSource.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/datasource/ContactDataSource.kt new file mode 100644 index 0000000..bc53809 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/datasource/ContactDataSource.kt @@ -0,0 +1,23 @@ +package io.assessment.contacts.core.data.datasource + +import io.assessment.contacts.core.data.database.ContactDao +import io.assessment.contacts.core.data.database.ContactEntity +import kotlinx.coroutines.flow.Flow + +interface ContactDataSource { + fun observeContacts(): Flow> + fun observeContactsByName(query: String): Flow> +} + +class RoomContactDataSource( + private val contactDao: ContactDao, +) : ContactDataSource { + + override fun observeContacts(): Flow> { + return contactDao.observeAll() + } + + override fun observeContactsByName(query: String): Flow> { + return contactDao.observeByNameQuery(query) + } +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/datasource/GroupDataSource.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/datasource/GroupDataSource.kt new file mode 100644 index 0000000..f788800 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/datasource/GroupDataSource.kt @@ -0,0 +1,18 @@ +package io.assessment.contacts.core.data.datasource + +import io.assessment.contacts.core.data.database.GroupDao +import io.assessment.contacts.core.data.database.GroupEntity +import kotlinx.coroutines.flow.Flow + +interface GroupDataSource { + fun observeGroups(): Flow> +} + +class RoomGroupDataSource( + private val groupDao: GroupDao, +) : GroupDataSource { + + override fun observeGroups(): Flow> { + return groupDao.observeAll() + } +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/repository/ContactRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/repository/ContactRepositoryImpl.kt new file mode 100644 index 0000000..5acb35e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/repository/ContactRepositoryImpl.kt @@ -0,0 +1,29 @@ +package io.assessment.contacts.core.data.repository + +import io.assessment.contacts.contacts.data.toDomain +import io.assessment.contacts.core.data.datasource.ContactDataSource +import io.assessment.contacts.core.domain.contact.Contact +import io.assessment.contacts.core.domain.contact.ContactError +import io.assessment.contacts.core.domain.contact.ContactRepository +import io.assessment.contacts.core.domain.result.Result +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class ContactRepositoryImpl( + private val contactDataSource: ContactDataSource, +) : ContactRepository { + + override fun observeContacts(): Flow, ContactError>> { + return contactDataSource.observeContacts() + .map { entities -> + Result.Success(entities.map { it.toDomain() }) + } + } + + override fun observeContactsByName(query: String): Flow, ContactError>> { + return contactDataSource.observeContactsByName(query) + .map { entities -> + Result.Success(entities.map { it.toDomain() }) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/repository/GroupRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/repository/GroupRepositoryImpl.kt new file mode 100644 index 0000000..2dd2bde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/data/repository/GroupRepositoryImpl.kt @@ -0,0 +1,22 @@ +package io.assessment.contacts.core.data.repository + +import io.assessment.contacts.core.data.datasource.GroupDataSource +import io.assessment.contacts.core.domain.group.Group +import io.assessment.contacts.core.domain.group.GroupError +import io.assessment.contacts.core.domain.group.GroupRepository +import io.assessment.contacts.core.domain.result.Result +import io.assessment.contacts.groups.data.toDomain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GroupRepositoryImpl( + private val groupDataSource: GroupDataSource, +) : GroupRepository { + + override fun observeGroups(): Flow, GroupError>> { + return groupDataSource.observeGroups() + .map { entities -> + Result.Success(entities.map { it.toDomain() }) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/Contact.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/Contact.kt new file mode 100644 index 0000000..175da2c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/Contact.kt @@ -0,0 +1,10 @@ +package io.assessment.contacts.core.domain.contact + +data class Contact( + val id: String, + val name: String, + val npub: String, + val avatarUrl: String?, + val status: ContactStatus, + val groupId: String?, +) diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/ContactError.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/ContactError.kt new file mode 100644 index 0000000..9ddbe8d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/ContactError.kt @@ -0,0 +1,9 @@ +package io.assessment.contacts.core.domain.contact + +import io.assessment.contacts.core.domain.result.Error + +sealed interface ContactError : Error { + data object NotFound : ContactError + data object NetworkError : ContactError + data class Unknown(val message: String) : ContactError +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/ContactRepository.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/ContactRepository.kt new file mode 100644 index 0000000..069e6d3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/ContactRepository.kt @@ -0,0 +1,9 @@ +package io.assessment.contacts.core.domain.contact + +import io.assessment.contacts.core.domain.result.Result +import kotlinx.coroutines.flow.Flow + +interface ContactRepository { + fun observeContacts(): Flow, ContactError>> + fun observeContactsByName(query: String): Flow, ContactError>> +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/ContactStatus.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/ContactStatus.kt new file mode 100644 index 0000000..6adba68 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/contact/ContactStatus.kt @@ -0,0 +1,7 @@ +package io.assessment.contacts.core.domain.contact + +enum class ContactStatus { + ACTIVE, + PENDING, + INACTIVE, +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/group/Group.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/group/Group.kt new file mode 100644 index 0000000..84965d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/group/Group.kt @@ -0,0 +1,7 @@ +package io.assessment.contacts.core.domain.group + +data class Group( + val id: String, + val name: String, + val memberCount: Int, +) diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/group/GroupError.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/group/GroupError.kt new file mode 100644 index 0000000..9ad4fb5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/group/GroupError.kt @@ -0,0 +1,9 @@ +package io.assessment.contacts.core.domain.group + +import io.assessment.contacts.core.domain.result.Error + +sealed interface GroupError : Error { + data object NotFound : GroupError + data object NetworkError : GroupError + data class Unknown(val message: String) : GroupError +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/group/GroupRepository.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/group/GroupRepository.kt new file mode 100644 index 0000000..fec81e3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/group/GroupRepository.kt @@ -0,0 +1,8 @@ +package io.assessment.contacts.core.domain.group + +import io.assessment.contacts.core.domain.result.Result +import kotlinx.coroutines.flow.Flow + +interface GroupRepository { + fun observeGroups(): Flow, GroupError>> +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/result/Error.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/result/Error.kt new file mode 100644 index 0000000..9e1f3f2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/result/Error.kt @@ -0,0 +1,3 @@ +package io.assessment.contacts.core.domain.result + +interface Error diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/result/FlowResultExtensions.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/result/FlowResultExtensions.kt new file mode 100644 index 0000000..0e6a16a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/result/FlowResultExtensions.kt @@ -0,0 +1,60 @@ +package io.assessment.contacts.core.domain.result + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transform + +fun Flow>.mapSuccess(transform: suspend (T) -> R): Flow> = + transform { result -> + when (result) { + is Result.Success -> emit(Result.Success(transform(result.data))) + is Result.Error -> emit(Result.Error(result.error)) + } + } + +fun Flow>.mapError(transform: (E1) -> E2): Flow> = + map { result -> result.mapError(transform) } + +fun Flow>.flatMapSuccess(transform: suspend (T) -> Result): Flow> = + transform { result -> + when (result) { + is Result.Success -> emit(transform(result.data)) + is Result.Error -> emit(Result.Error(result.error)) + } + } + +fun Flow>.filterSuccess(): Flow = + transform { result -> + if (result is Result.Success) { + emit(result.data) + } + } + +fun Flow>.filterError(): Flow = + transform { result -> + if (result is Result.Error) { + emit(result.error) + } + } + +fun Flow>.biMap( + onSuccess: suspend (T) -> Result, + onError: (E1) -> E2, +): Flow> = + transform { result -> + when (result) { + is Result.Success -> emit(onSuccess(result.data)) + is Result.Error -> emit(Result.Error(onError(result.error))) + } + } + +fun Flow>.transformResult( + onSuccess: suspend (T) -> R, + onError: (E1) -> E2, +): Flow> = + transform { result -> + when (result) { + is Result.Success -> emit(Result.Success(onSuccess(result.data))) + is Result.Error -> emit(Result.Error(onError(result.error))) + } + } diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/result/Result.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/result/Result.kt new file mode 100644 index 0000000..80aee09 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/result/Result.kt @@ -0,0 +1,77 @@ +package io.assessment.contacts.core.domain.result + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +sealed interface Result { + data class Success( + val data: D, + ) : Result + + data class Error( + val error: E, + ) : Result +} + +inline fun Result.map(map: (T) -> R): Result = + when (this) { + is Result.Error -> Result.Error(error) + is Result.Success -> Result.Success(map(data)) + } + +fun Result.asEmptyDataResult(): EmptyResult = map { } + +inline fun Result.onSuccess(action: (T) -> Unit): Result = + when (this) { + is Result.Error -> this + is Result.Success -> { + action(data) + this + } + } + +inline fun Result.onError(action: (E) -> Unit): Result = + when (this) { + is Result.Error -> { + action(error) + this + } + is Result.Success -> this + } + +fun Result.successValue(): T? = + when (this) { + is Result.Success -> data + is Result.Error -> null + } + +inline fun Result.mapSuccess(transform: (T) -> R): Result = + when (this) { + is Result.Success -> Result.Success(transform(data)) + is Result.Error -> Result.Error(error) + } + +inline fun Result.mapError(transform: (E1) -> E2): Result = + when (this) { + is Result.Success -> Result.Success(data) + is Result.Error -> Result.Error(transform(error)) + } + +inline fun Result.flatMapSuccess(transform: (T) -> Result): Result = + when (this) { + is Result.Success -> transform(data) + is Result.Error -> Result.Error(error) + } + +inline fun Result.fold( + onSuccess: (T) -> R, + onError: (E) -> R, +): R = + when (this) { + is Result.Success -> onSuccess(data) + is Result.Error -> onError(error) + } + +typealias EmptyResult = Result + +fun Result.asFlow(): Flow> = flowOf(this) diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/usecase/UseCase.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/usecase/UseCase.kt new file mode 100644 index 0000000..7f1e665 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/domain/usecase/UseCase.kt @@ -0,0 +1,24 @@ +package io.assessment.contacts.core.domain.usecase + +import io.assessment.contacts.core.domain.result.Error +import io.assessment.contacts.core.domain.result.Result +import kotlinx.coroutines.flow.Flow + +interface UseCase { + suspend operator fun invoke(params: P): Result +} + +interface NoParamsUseCase { + suspend operator fun invoke(): Result +} + +interface FlowUseCase { + operator fun invoke(params: P): Flow> +} + +interface NoParamsFlowUseCase { + operator fun invoke(): Flow> +} + +typealias ActionUseCase = UseCase +typealias NoParamsActionUseCase = NoParamsUseCase diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/presentation/ObserveAsEvents.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/presentation/ObserveAsEvents.kt new file mode 100644 index 0000000..9ad950f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/presentation/ObserveAsEvents.kt @@ -0,0 +1,27 @@ +package io.assessment.contacts.core.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +@Composable +fun ObserveAsEvents( + flow: Flow, + key1: Any? = null, + key2: Any? = null, + onEvent: (T) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(flow, lifecycleOwner.lifecycle, key1, key2) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + flow.collect(onEvent) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/presentation/StateFlowExt.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/presentation/StateFlowExt.kt new file mode 100644 index 0000000..8f4b9d9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/core/presentation/StateFlowExt.kt @@ -0,0 +1,12 @@ +package io.assessment.contacts.core.presentation + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +fun Flow.stateInWhileSubscribed( + scope: CoroutineScope, + initialValue: T, +): StateFlow = this.stateIn(scope, SharingStarted.WhileSubscribed(5000), initialValue) diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/Color.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/Color.kt new file mode 100644 index 0000000..25f64e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/Color.kt @@ -0,0 +1,63 @@ +package io.assessment.contacts.designsystem + +import androidx.compose.ui.graphics.Color + +// Common Red Palette Colors +val RedDarkest = Color(0xFF410001) +val RedSecondaryDarkest = Color(0xFF410004) +val RedVeryDark = Color(0xFF690003) +val RedDark = Color(0xFF8B0006) +val RedMediumDark = Color(0xFF930007) +val RedMedium = Color(0xFFB50E11) +val RedBright = Color(0xFFBA1A1A) +val RedSecondaryDark = Color(0xFF68000C) +val RedSecondaryMedium = Color(0xFFB51824) +val RedSecondaryBright = Color(0xFFD93539) +val RedTertiaryDark = Color(0xFF5A0009) +val RedTertiaryMedium = Color(0xFF930015) +val RedAccent = Color(0xFFBC1515) +val RedLight = Color(0xFFFF5353) +val RedVeryLight = Color(0xFFFFB3AE) +val RedPastel = Color(0xFFFFB4AA) +val RedSoft = Color(0xFFFFB4AB) +val RedPale = Color(0xFFFFC3BC) +val RedMuted = Color(0xFFE5BDB8) +val RedVeryPale = Color(0xFFFFDAD5) +val RedSecondaryPale = Color(0xFFFFDAD7) +val RedAlmostWhite = Color(0xFFFFDAD6) +val RedUltraPale = Color(0xFFFFEDEB) + +// Surface Colors +val SurfaceDarkest = Color(0xFF0F0E0E) +val SurfaceVeryDark = Color(0xFF141313) +val SurfaceDark = Color(0xFF1D1B1B) +val SurfaceMediumDark = Color(0xFF211F1F) +val SurfaceMedium = Color(0xFF2B2A29) +val SurfaceGray = Color(0xFF323030) +val SurfaceLightGray = Color(0xFF363434) +val SurfaceBright = Color(0xFF3B3938) +val SurfaceOutline = Color(0xFF444748) +val SurfaceVariant = Color(0xFF747878) +val SurfaceOutlineLight = Color(0xFF8E9192) +val SurfaceLight = Color(0xFFC4C7C7) +val SurfacePale = Color(0xFFDED9D8) +val SurfaceVeryPale = Color(0xFFE6E1E1) +val SurfaceLightest = Color(0xFFECE7E6) +val SurfaceNearWhite = Color(0xFFF2EDEC) +val SurfaceAlmostWhite = Color(0xFFF5F0EF) +val SurfaceOffWhite = Color(0xFFF8F2F2) +val SurfaceWhiteTinted = Color(0xFFFDF8F7) +val SurfaceAlmostPure = Color(0xFFFFFBFF) + +// Pure Colors +val PureBlack = Color(0xFF000000) +val PureWhite = Color(0xFFFFFFFF) + +// Accent Colors +val AccentGreen = Color(0xFF34C759) +val AccentOrange = Color(0xFFFF9500) + +// Status Colors +val StatusGreen = Color(0xFF4CAF50) +val StatusOrange = Color(0xFFFFA726) +val StatusGray = Color(0xFF9E9E9E) diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/ExtendedColors.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/ExtendedColors.kt new file mode 100644 index 0000000..9ad6711 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/ExtendedColors.kt @@ -0,0 +1,137 @@ +package io.assessment.contacts.designsystem + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +val LocalExtendedColors = staticCompositionLocalOf { extendedColors } + +val ColorScheme.extended: ExtendedColors + @ReadOnlyComposable + @Composable + get() = LocalExtendedColors.current + +@Immutable +data class ExtendedColors( + val primaryFixed: Color, + val primaryFixedDim: Color, + val onPrimaryFixed: Color, + val onPrimaryFixedVariant: Color, + val secondaryFixed: Color, + val secondaryFixedDim: Color, + val onSecondaryFixed: Color, + val onSecondaryFixedVariant: Color, + val tertiaryFixed: Color, + val tertiaryFixedDim: Color, + val onTertiaryFixed: Color, + val onTertiaryFixedVariant: Color, + val shadow: Color, + val accentGreen: Color, + val accentOrange: Color, + val disabledContainer: Color, + val statusActive: Color, + val statusPending: Color, + val statusInactive: Color, +) + +val LightColorScheme = + lightColorScheme( + primary = RedDark, + onPrimary = PureWhite, + primaryContainer = RedMedium, + onPrimaryContainer = RedPale, + secondary = RedSecondaryMedium, + onSecondary = PureWhite, + secondaryContainer = RedSecondaryBright, + onSecondaryContainer = SurfaceAlmostPure, + tertiary = RedDark, + onTertiary = PureWhite, + tertiaryContainer = RedMedium, + onTertiaryContainer = RedPale, + error = RedBright, + onError = PureWhite, + errorContainer = RedAlmostWhite, + onErrorContainer = RedMediumDark, + surfaceDim = SurfacePale, + surface = SurfaceWhiteTinted, + surfaceBright = SurfaceDark, + surfaceTint = RedUltraPale, + surfaceContainerLowest = PureWhite, + surfaceContainerLow = SurfaceOffWhite, + surfaceContainer = SurfaceNearWhite, + surfaceContainerHigh = SurfaceLightest, + surfaceContainerHighest = SurfaceVeryPale, + onSurface = SurfaceDark, + onSurfaceVariant = SurfaceOutline, + outline = SurfaceVariant, + outlineVariant = RedMuted, + inverseSurface = SurfaceGray, + inverseOnSurface = SurfaceAlmostWhite, + inversePrimary = RedPastel, + scrim = PureBlack, + ) + +val DarkColorScheme = + darkColorScheme( + primary = RedPastel, + onPrimary = RedVeryDark, + primaryContainer = RedMedium, + onPrimaryContainer = RedPale, + secondary = RedVeryLight, + onSecondary = RedSecondaryDark, + secondaryContainer = RedLight, + onSecondaryContainer = RedTertiaryDark, + tertiary = RedPastel, + onTertiary = RedVeryDark, + tertiaryContainer = RedMedium, + onTertiaryContainer = RedPale, + error = RedSoft, + onError = RedVeryDark, + errorContainer = RedMediumDark, + onErrorContainer = RedAlmostWhite, + surfaceDim = SurfaceVeryDark, + surface = SurfaceVeryDark, + surfaceBright = SurfaceBright, + surfaceTint = RedMediumDark, + surfaceContainerLowest = SurfaceDarkest, + surfaceContainerLow = SurfaceDark, + surfaceContainer = SurfaceMediumDark, + surfaceContainerHigh = SurfaceMedium, + surfaceContainerHighest = RedMediumDark, + onSurface = SurfaceVeryPale, + onSurfaceVariant = SurfaceLight, + outline = SurfaceOutlineLight, + outlineVariant = SurfaceOutline, + inverseSurface = SurfaceVeryPale, + inverseOnSurface = SurfaceGray, + inversePrimary = RedAccent, + scrim = PureBlack, + ) + +val extendedColors = + ExtendedColors( + primaryFixed = RedVeryPale, + primaryFixedDim = RedPastel, + onPrimaryFixed = RedDarkest, + onPrimaryFixedVariant = RedMediumDark, + secondaryFixed = RedSecondaryPale, + secondaryFixedDim = RedVeryLight, + onSecondaryFixed = RedSecondaryDarkest, + onSecondaryFixedVariant = RedTertiaryMedium, + tertiaryFixed = RedVeryPale, + tertiaryFixedDim = RedPastel, + onTertiaryFixed = RedDarkest, + onTertiaryFixedVariant = RedMediumDark, + shadow = PureBlack, + accentGreen = AccentGreen, + accentOrange = AccentOrange, + disabledContainer = SurfaceVeryPale, + statusActive = StatusGreen, + statusPending = StatusOrange, + statusInactive = StatusGray, + ) diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/Theme.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/Theme.kt new file mode 100644 index 0000000..6effa28 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/Theme.kt @@ -0,0 +1,20 @@ +package io.assessment.contacts.designsystem + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalInspectionMode + +@Composable +fun ContactsTheme( + darkTheme: Boolean = if (LocalInspectionMode.current) isSystemInDarkTheme() else false, + content: @Composable () -> Unit, +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + MaterialTheme( + colorScheme = colorScheme, + typography = ContactsTypography, + content = content, + ) +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/Type.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/Type.kt new file mode 100644 index 0000000..9573bad --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/designsystem/Type.kt @@ -0,0 +1,115 @@ +package io.assessment.contacts.designsystem + +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 + +val ContactsTypography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/di/CoreDataModule.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/di/CoreDataModule.kt new file mode 100644 index 0000000..bffaa00 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/di/CoreDataModule.kt @@ -0,0 +1,33 @@ +package io.assessment.contacts.di + +import io.assessment.contacts.core.data.database.ContactsDatabase +import io.assessment.contacts.core.data.database.DatabaseSeeder +import io.assessment.contacts.core.data.datasource.ContactDataSource +import io.assessment.contacts.core.data.datasource.GroupDataSource +import io.assessment.contacts.core.data.datasource.RoomContactDataSource +import io.assessment.contacts.core.data.datasource.RoomGroupDataSource +import io.assessment.contacts.core.data.repository.ContactRepositoryImpl +import io.assessment.contacts.core.data.repository.GroupRepositoryImpl +import io.assessment.contacts.core.domain.contact.ContactRepository +import io.assessment.contacts.core.domain.group.GroupRepository +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module + +val coreDataModule = module { + single { + get().contactDao + } + + single { + get().groupDao + } + + singleOf(::DatabaseSeeder) + + singleOf(::RoomContactDataSource) bind ContactDataSource::class + singleOf(::RoomGroupDataSource) bind GroupDataSource::class + + singleOf(::ContactRepositoryImpl) bind ContactRepository::class + singleOf(::GroupRepositoryImpl) bind GroupRepository::class +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/di/SharedAppModule.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/di/SharedAppModule.kt new file mode 100644 index 0000000..35ae6c6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/di/SharedAppModule.kt @@ -0,0 +1,13 @@ +package io.assessment.contacts.di + +import io.assessment.contacts.contacts.di.contactsModule +import io.assessment.contacts.groups.di.groupsModule +import org.koin.dsl.module + +val sharedAppModule = module { + includes( + coreDataModule, + contactsModule, + groupsModule, + ) +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/data/GroupMapper.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/data/GroupMapper.kt new file mode 100644 index 0000000..df1dd39 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/data/GroupMapper.kt @@ -0,0 +1,20 @@ +package io.assessment.contacts.groups.data + +import io.assessment.contacts.core.data.database.GroupEntity +import io.assessment.contacts.core.domain.group.Group + +fun GroupEntity.toDomain(): Group { + return Group( + id = id, + name = name, + memberCount = memberCount, + ) +} + +fun Group.toEntity(): GroupEntity { + return GroupEntity( + id = id, + name = name, + memberCount = memberCount, + ) +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/data/ObserveGroupsUseCaseImpl.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/data/ObserveGroupsUseCaseImpl.kt new file mode 100644 index 0000000..59ea92c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/data/ObserveGroupsUseCaseImpl.kt @@ -0,0 +1,17 @@ +package io.assessment.contacts.groups.data + +import io.assessment.contacts.core.domain.group.Group +import io.assessment.contacts.core.domain.group.GroupError +import io.assessment.contacts.core.domain.group.GroupRepository +import io.assessment.contacts.core.domain.result.Result +import io.assessment.contacts.groups.domain.ObserveGroupsUseCase +import kotlinx.coroutines.flow.Flow + +class ObserveGroupsUseCaseImpl( + private val groupRepository: GroupRepository, +) : ObserveGroupsUseCase { + + override fun invoke(): Flow, GroupError>> { + return groupRepository.observeGroups() + } +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/di/GroupsModule.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/di/GroupsModule.kt new file mode 100644 index 0000000..4fb07c3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/di/GroupsModule.kt @@ -0,0 +1,14 @@ +package io.assessment.contacts.groups.di + +import io.assessment.contacts.groups.domain.ObserveGroupsUseCase +import io.assessment.contacts.groups.data.ObserveGroupsUseCaseImpl +import io.assessment.contacts.groups.presentation.GroupsViewModel +import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.bind +import org.koin.dsl.module + +val groupsModule = module { + factoryOf(::ObserveGroupsUseCaseImpl) bind ObserveGroupsUseCase::class + viewModelOf(::GroupsViewModel) +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/domain/ObserveGroupsUseCase.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/domain/ObserveGroupsUseCase.kt new file mode 100644 index 0000000..16979c3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/domain/ObserveGroupsUseCase.kt @@ -0,0 +1,7 @@ +package io.assessment.contacts.groups.domain + +import io.assessment.contacts.core.domain.group.Group +import io.assessment.contacts.core.domain.group.GroupError +import io.assessment.contacts.core.domain.usecase.NoParamsFlowUseCase + +interface ObserveGroupsUseCase : NoParamsFlowUseCase, GroupError> diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupVO.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupVO.kt new file mode 100644 index 0000000..b80bbdf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupVO.kt @@ -0,0 +1,17 @@ +package io.assessment.contacts.groups.presentation + +import io.assessment.contacts.core.domain.group.Group + +data class GroupVO( + val id: String, + val name: String, + val memberCount: Int, +) + +fun Group.toVO(): GroupVO { + return GroupVO( + id = id, + name = name, + memberCount = memberCount, + ) +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupsScreen.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupsScreen.kt new file mode 100644 index 0000000..6a87e74 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupsScreen.kt @@ -0,0 +1,140 @@ +package io.assessment.contacts.groups.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Group +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import contactsassessment.composeapp.generated.resources.Res +import contactsassessment.composeapp.generated.resources.groups_member_count +import contactsassessment.composeapp.generated.resources.groups_title +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupsScreen( + viewModel: GroupsViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + GroupsScreenContent(state = state) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun GroupsScreenContent( + state: GroupsState, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.groups_title)) }, + ) + }, + ) { paddingValues -> + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = state.groups, + key = { it.id }, + ) { group -> + GroupListItem(group = group) + } + } + } + } +} + +@Composable +internal fun GroupListItem( + group: GroupVO, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Group, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = group.name, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(Res.string.groups_member_count, group.memberCount), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupsState.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupsState.kt new file mode 100644 index 0000000..916e084 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupsState.kt @@ -0,0 +1,6 @@ +package io.assessment.contacts.groups.presentation + +data class GroupsState( + val groups: List = emptyList(), + val isLoading: Boolean = true, +) diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupsViewModel.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupsViewModel.kt new file mode 100644 index 0000000..3387b61 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/GroupsViewModel.kt @@ -0,0 +1,26 @@ +package io.assessment.contacts.groups.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.assessment.contacts.core.domain.result.filterSuccess +import io.assessment.contacts.core.presentation.stateInWhileSubscribed +import io.assessment.contacts.groups.domain.ObserveGroupsUseCase +import kotlinx.coroutines.flow.map + +class GroupsViewModel( + observeGroupsUseCase: ObserveGroupsUseCase, +) : ViewModel() { + + val state = observeGroupsUseCase() + .filterSuccess() + .map { groups -> + GroupsState( + groups = groups.map { it.toVO() }, + isLoading = false, + ) + } + .stateInWhileSubscribed( + scope = viewModelScope, + initialValue = GroupsState(), + ) +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/navigation/GroupsNavDestinations.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/navigation/GroupsNavDestinations.kt new file mode 100644 index 0000000..9c8d0d0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/groups/presentation/navigation/GroupsNavDestinations.kt @@ -0,0 +1,9 @@ +package io.assessment.contacts.groups.presentation.navigation + +import kotlinx.serialization.Serializable + +@Serializable +object GroupsNavDestinations { + @Serializable + object GroupsList +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/navigation/BottomNavDestItem.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/navigation/BottomNavDestItem.kt new file mode 100644 index 0000000..bd68385 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/navigation/BottomNavDestItem.kt @@ -0,0 +1,41 @@ +package io.assessment.contacts.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.Group +import androidx.compose.material.icons.outlined.Person +import androidx.compose.ui.graphics.vector.ImageVector +import contactsassessment.composeapp.generated.resources.Res +import contactsassessment.composeapp.generated.resources.nav_contacts +import contactsassessment.composeapp.generated.resources.nav_groups +import kotlinx.serialization.Serializable +import org.jetbrains.compose.resources.StringResource + +enum class BottomNavDestItem( + val route: Any, + val labelRes: StringResource, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, +) { + Groups( + route = BottomNavRoute.Groups, + labelRes = Res.string.nav_groups, + selectedIcon = Icons.Filled.Group, + unselectedIcon = Icons.Outlined.Group, + ), + Contacts( + route = BottomNavRoute.Contacts, + labelRes = Res.string.nav_contacts, + selectedIcon = Icons.Filled.Person, + unselectedIcon = Icons.Outlined.Person, + ), +} + +@Serializable +sealed interface BottomNavRoute { + @Serializable + data object Groups : BottomNavRoute + @Serializable + data object Contacts : BottomNavRoute +} diff --git a/composeApp/src/commonMain/kotlin/io/assessment/contacts/navigation/NavigationRoot.kt b/composeApp/src/commonMain/kotlin/io/assessment/contacts/navigation/NavigationRoot.kt new file mode 100644 index 0000000..b466dde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/assessment/contacts/navigation/NavigationRoot.kt @@ -0,0 +1,73 @@ +package io.assessment.contacts.navigation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +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.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import io.assessment.contacts.contacts.presentation.ContactsScreen +import io.assessment.contacts.groups.presentation.GroupsScreen +import org.jetbrains.compose.resources.stringResource + +@Composable +fun NavigationRoot() { + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + Scaffold( + bottomBar = { + NavigationBar { + BottomNavDestItem.entries.forEach { item -> + val selected = currentDestination?.hasRoute(item.route::class) == true + + NavigationBarItem( + selected = selected, + onClick = { + navController.navigate(item.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + imageVector = if (selected) item.selectedIcon else item.unselectedIcon, + contentDescription = stringResource(item.labelRes), + ) + }, + label = { Text(stringResource(item.labelRes)) }, + ) + } + } + }, + ) { paddingValues -> + NavHost( + navController = navController, + startDestination = BottomNavRoute.Groups, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + composable { + GroupsScreen() + } + composable { + ContactsScreen() + } + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..ca79e37 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,12 @@ +#Kotlin +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx2048M + +#Gradle +org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +org.gradle.caching=true + +#Android +android.nonTransitiveRClass=true +android.useAndroidX=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..289f1b7 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,77 @@ +[versions] +# Core +agp = "8.7.3" +kotlin = "2.2.0" +ksp = "2.2.0-2.0.2" + +# Android +android-compileSdk = "35" +android-minSdk = "26" +android-targetSdk = "35" + +# Compose & UI +compose-multiplatform = "1.10.0" + +# AndroidX - Core +androidx-activity = "1.10.1" +androidx-lifecycle = "2.9.1" +androidx-navigation = "2.9.0-beta03" + +# Database +room = "2.7.2" +sqlite-bundled = "2.5.2" + +# DI +koin = "4.1.0" + +# Coroutines +kotlinx-coroutines = "1.10.2" + +# Image Loading +coil = "3.3.0" +ktor = "3.1.3" + +[libraries] +# Kotlin +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } + +# Coroutines +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } + +# Compose +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } +compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } +compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" } +compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" } +compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" } + +# Database +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite-bundled" } + +# DI +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } +koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } + +# AndroidX +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } + +# Image Loading +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +room = { id = "androidx.room", version.ref = "room" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c35211 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..064bc88 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,35 @@ +rootProject.name = "ContactsAssessment" +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" +} + +include(":composeApp")