Repo created
This commit is contained in:
parent
a629de6271
commit
3cef7c5092
2161 changed files with 246605 additions and 2 deletions
13
core/android/common/build.gradle.kts
Normal file
13
core/android/common/build.gradle.kts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.k9mail.core.android.common"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.core.common)
|
||||
testImplementation(projects.core.testing)
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package app.k9mail.core.android.common
|
||||
|
||||
import app.k9mail.core.android.common.contact.contactModule
|
||||
import app.k9mail.core.common.coreCommonModule
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
val coreCommonAndroidModule: Module = module {
|
||||
includes(coreCommonModule)
|
||||
|
||||
includes(contactModule)
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.net.Uri
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
|
||||
data class Contact(
|
||||
val id: Long,
|
||||
val name: String?,
|
||||
val emailAddress: EmailAddress,
|
||||
val uri: Uri,
|
||||
val photoUri: Uri?,
|
||||
)
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import app.k9mail.core.android.common.database.EmptyCursor
|
||||
import app.k9mail.core.android.common.database.getLongOrThrow
|
||||
import app.k9mail.core.android.common.database.getStringOrNull
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
|
||||
interface ContactDataSource {
|
||||
|
||||
fun getContactFor(emailAddress: EmailAddress): Contact?
|
||||
|
||||
fun hasContactFor(emailAddress: EmailAddress): Boolean
|
||||
}
|
||||
|
||||
internal class ContentResolverContactDataSource(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val contactPermissionResolver: ContactPermissionResolver,
|
||||
) : ContactDataSource {
|
||||
|
||||
override fun getContactFor(emailAddress: EmailAddress): Contact? {
|
||||
getCursorFor(emailAddress).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val contactId = cursor.getLongOrThrow(ContactsContract.CommonDataKinds.Email._ID)
|
||||
val lookupKey = cursor.getStringOrNull(ContactsContract.Contacts.LOOKUP_KEY)
|
||||
val uri = ContactsContract.Contacts.getLookupUri(contactId, lookupKey)
|
||||
|
||||
val name = cursor.getStringOrNull(ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME)
|
||||
|
||||
val photoUri = cursor.getStringOrNull(ContactsContract.CommonDataKinds.Photo.PHOTO_URI)
|
||||
?.let { photoUriString -> Uri.parse(photoUriString) }
|
||||
|
||||
return Contact(
|
||||
id = contactId,
|
||||
name = name,
|
||||
emailAddress = emailAddress,
|
||||
uri = uri,
|
||||
photoUri = photoUri,
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasContactFor(emailAddress: EmailAddress): Boolean {
|
||||
getCursorFor(emailAddress).use { cursor ->
|
||||
return cursor.count > 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCursorFor(emailAddress: EmailAddress): Cursor {
|
||||
return if (contactPermissionResolver.hasContactPermission()) {
|
||||
val uri = Uri.withAppendedPath(
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI,
|
||||
Uri.encode(emailAddress.address),
|
||||
)
|
||||
|
||||
contentResolver.query(
|
||||
uri,
|
||||
PROJECTION,
|
||||
null,
|
||||
null,
|
||||
SORT_ORDER,
|
||||
) ?: EmptyCursor()
|
||||
} else {
|
||||
EmptyCursor()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
private const val SORT_ORDER = ContactsContract.Contacts.DISPLAY_NAME +
|
||||
", " + ContactsContract.CommonDataKinds.Email._ID
|
||||
|
||||
private val PROJECTION = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Email._ID,
|
||||
ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Photo.PHOTO_URI,
|
||||
ContactsContract.Contacts.LOOKUP_KEY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.content.Context
|
||||
import app.k9mail.core.common.cache.Cache
|
||||
import app.k9mail.core.common.cache.ExpiringCache
|
||||
import app.k9mail.core.common.cache.SynchronizedCache
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
internal val contactModule = module {
|
||||
single<Cache<EmailAddress, Contact?>>(named(CACHE_NAME)) {
|
||||
SynchronizedCache(
|
||||
delegateCache = ExpiringCache(clock = get()),
|
||||
)
|
||||
}
|
||||
factory<ContactDataSource> {
|
||||
ContentResolverContactDataSource(
|
||||
contentResolver = get<Context>().contentResolver,
|
||||
contactPermissionResolver = get(),
|
||||
)
|
||||
}
|
||||
factory<ContactRepository> {
|
||||
CachingContactRepository(
|
||||
cache = get(named(CACHE_NAME)),
|
||||
dataSource = get(),
|
||||
)
|
||||
}
|
||||
factory<ContactPermissionResolver> {
|
||||
AndroidContactPermissionResolver(context = get())
|
||||
}
|
||||
}
|
||||
|
||||
internal const val CACHE_NAME = "ContactCache"
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.Manifest.permission.READ_CONTACTS
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
interface ContactPermissionResolver {
|
||||
fun hasContactPermission(): Boolean
|
||||
}
|
||||
|
||||
internal class AndroidContactPermissionResolver(private val context: Context) : ContactPermissionResolver {
|
||||
override fun hasContactPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, READ_CONTACTS) == PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import app.k9mail.core.common.cache.Cache
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
|
||||
interface ContactRepository {
|
||||
|
||||
fun getContactFor(emailAddress: EmailAddress): Contact?
|
||||
|
||||
fun hasContactFor(emailAddress: EmailAddress): Boolean
|
||||
|
||||
fun hasAnyContactFor(emailAddresses: List<EmailAddress>): Boolean
|
||||
}
|
||||
|
||||
interface CachingRepository {
|
||||
fun clearCache()
|
||||
}
|
||||
|
||||
internal class CachingContactRepository(
|
||||
private val cache: Cache<EmailAddress, Contact?>,
|
||||
private val dataSource: ContactDataSource,
|
||||
) : ContactRepository, CachingRepository {
|
||||
|
||||
override fun getContactFor(emailAddress: EmailAddress): Contact? {
|
||||
if (cache.hasKey(emailAddress)) {
|
||||
return cache[emailAddress]
|
||||
}
|
||||
|
||||
return dataSource.getContactFor(emailAddress).also {
|
||||
cache[emailAddress] = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasContactFor(emailAddress: EmailAddress): Boolean {
|
||||
if (cache.hasKey(emailAddress)) {
|
||||
return cache[emailAddress] != null
|
||||
}
|
||||
|
||||
return dataSource.hasContactFor(emailAddress)
|
||||
}
|
||||
|
||||
override fun hasAnyContactFor(emailAddresses: List<EmailAddress>): Boolean =
|
||||
emailAddresses.any { emailAddress -> hasContactFor(emailAddress) }
|
||||
|
||||
override fun clearCache() {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package app.k9mail.core.android.common.database
|
||||
|
||||
import android.database.Cursor
|
||||
|
||||
fun <T> Cursor.map(block: (Cursor) -> T): List<T> {
|
||||
return List(count) { index ->
|
||||
moveToPosition(index)
|
||||
block(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun Cursor.getStringOrNull(columnName: String): String? {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return if (isNull(columnIndex)) null else getString(columnIndex)
|
||||
}
|
||||
|
||||
fun Cursor.getIntOrNull(columnName: String): Int? {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return if (isNull(columnIndex)) null else getInt(columnIndex)
|
||||
}
|
||||
|
||||
fun Cursor.getLongOrNull(columnName: String): Long? {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return if (isNull(columnIndex)) null else getLong(columnIndex)
|
||||
}
|
||||
|
||||
fun Cursor.getStringOrThrow(columnName: String): String {
|
||||
return getStringOrNull(columnName) ?: error("Column $columnName must not be null")
|
||||
}
|
||||
|
||||
fun Cursor.getIntOrThrow(columnName: String): Int {
|
||||
return getIntOrNull(columnName) ?: error("Column $columnName must not be null")
|
||||
}
|
||||
|
||||
fun Cursor.getLongOrThrow(columnName: String): Long {
|
||||
return getLongOrNull(columnName) ?: error("Column $columnName must not be null")
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package app.k9mail.core.android.common.database
|
||||
|
||||
import android.database.AbstractCursor
|
||||
|
||||
/**
|
||||
* A dummy class that provides an empty cursor
|
||||
*/
|
||||
class EmptyCursor : AbstractCursor() {
|
||||
override fun getCount() = 0
|
||||
|
||||
override fun getColumnNames() = arrayOf<String>()
|
||||
|
||||
override fun getString(column: Int) = null
|
||||
|
||||
override fun getShort(column: Int): Short = 0
|
||||
|
||||
override fun getInt(column: Int) = 0
|
||||
|
||||
override fun getLong(column: Int): Long = 0
|
||||
|
||||
override fun getFloat(column: Int) = 0f
|
||||
|
||||
override fun getDouble(column: Int) = 0.0
|
||||
|
||||
override fun isNull(column: Int) = true
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package app.k9mail.core.android.common
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.koinApplication
|
||||
import org.koin.test.check.checkModules
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
internal class CoreCommonAndroidModuleKtTest {
|
||||
|
||||
@Test
|
||||
fun `should have a valid di module`() {
|
||||
koinApplication {
|
||||
modules(coreCommonAndroidModule)
|
||||
androidContext(RuntimeEnvironment.getApplication())
|
||||
checkModules()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.Manifest
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AndroidContactPermissionResolverTest {
|
||||
private val application = RuntimeEnvironment.getApplication()
|
||||
private val testSubject = AndroidContactPermissionResolver(context = application)
|
||||
|
||||
@Test
|
||||
fun `hasPermission() with contact permission`() {
|
||||
grantContactPermission()
|
||||
|
||||
val result = testSubject.hasContactPermission()
|
||||
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasPermission() without contact permission`() {
|
||||
denyContactPermission()
|
||||
|
||||
val result = testSubject.hasContactPermission()
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
private fun grantContactPermission() {
|
||||
Shadows.shadowOf(application).grantPermissions(Manifest.permission.READ_CONTACTS)
|
||||
}
|
||||
|
||||
private fun denyContactPermission() {
|
||||
Shadows.shadowOf(application).denyPermissions(Manifest.permission.READ_CONTACTS)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import app.k9mail.core.common.cache.InMemoryCache
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import kotlin.test.Test
|
||||
import org.junit.Before
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.doReturnConsecutively
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stub
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
internal class CachingContactRepositoryTest {
|
||||
|
||||
private val dataSource = mock<ContactDataSource>()
|
||||
private val cache = InMemoryCache<EmailAddress, Contact?>()
|
||||
|
||||
private val testSubject = CachingContactRepository(cache = cache, dataSource = dataSource)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactFor() returns null if no contact exists`() {
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactFor() returns contact if it exists`() {
|
||||
dataSource.stub { on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturn CONTACT }
|
||||
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isEqualTo(CONTACT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactFor() caches contact`() {
|
||||
dataSource.stub {
|
||||
on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturnConsecutively listOf(
|
||||
CONTACT,
|
||||
CONTACT.copy(id = 567L),
|
||||
)
|
||||
}
|
||||
|
||||
val result1 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
val result2 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result1).isEqualTo(result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactFor() caches null`() {
|
||||
dataSource.stub {
|
||||
on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturnConsecutively listOf(
|
||||
null,
|
||||
CONTACT,
|
||||
)
|
||||
}
|
||||
|
||||
val result1 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
val result2 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result1).isEqualTo(result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactFor() returns cached contact`() {
|
||||
cache[CONTACT_EMAIL_ADDRESS] = CONTACT
|
||||
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isEqualTo(CONTACT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactFor() returns false if no contact exists`() {
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactFor() returns false if cached contact is null`() {
|
||||
cache[CONTACT_EMAIL_ADDRESS] = null
|
||||
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactFor() returns true if contact exists`() {
|
||||
dataSource.stub { on { hasContactFor(CONTACT_EMAIL_ADDRESS) } doReturn true }
|
||||
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasAnyContactFor() returns false if no contact exists`() {
|
||||
val result = testSubject.hasAnyContactFor(listOf(CONTACT_EMAIL_ADDRESS))
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasAnyContactFor() returns false if list is empty`() {
|
||||
val result = testSubject.hasAnyContactFor(listOf())
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasAnyContactFor() returns true if contact exists`() {
|
||||
dataSource.stub { on { hasContactFor(CONTACT_EMAIL_ADDRESS) } doReturn true }
|
||||
|
||||
val result = testSubject.hasAnyContactFor(listOf(CONTACT_EMAIL_ADDRESS))
|
||||
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearCache() clears cache`() {
|
||||
cache[CONTACT_EMAIL_ADDRESS] = CONTACT
|
||||
|
||||
testSubject.clearCache()
|
||||
|
||||
assertThat(cache[CONTACT_EMAIL_ADDRESS]).isNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.net.Uri
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
|
||||
const val CONTACT_ID = 123L
|
||||
const val CONTACT_NAME = "user name"
|
||||
const val CONTACT_LOOKUP_KEY = "0r1-4F314D4F2F294F29"
|
||||
val CONTACT_EMAIL_ADDRESS = EmailAddress("user@example.com")
|
||||
val CONTACT_URI: Uri = Uri.parse("content://com.android.contacts/contacts/lookup/$CONTACT_LOOKUP_KEY/$CONTACT_ID")
|
||||
val CONTACT_PHOTO_URI: Uri = Uri.parse("content://com.android.contacts/display_photo/$CONTACT_ID")
|
||||
|
||||
val CONTACT = Contact(
|
||||
id = CONTACT_ID,
|
||||
name = CONTACT_NAME,
|
||||
emailAddress = CONTACT_EMAIL_ADDRESS,
|
||||
uri = CONTACT_URI,
|
||||
photoUri = CONTACT_PHOTO_URI,
|
||||
)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import app.k9mail.core.android.common.coreCommonAndroidModule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.koinApplication
|
||||
import org.koin.test.check.checkModules
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
internal class ContactKoinModuleKtTest {
|
||||
@Test
|
||||
fun `should have a valid di module`() {
|
||||
koinApplication {
|
||||
modules(coreCommonAndroidModule)
|
||||
|
||||
modules(contactModule)
|
||||
|
||||
androidContext(RuntimeEnvironment.getApplication())
|
||||
checkModules()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import kotlin.test.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.anyOrNull
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stub
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
internal class ContentResolverContactDataSourceTest {
|
||||
private val contactPermissionResolver = TestContactPermissionResolver(hasPermission = true)
|
||||
private val contentResolver = mock<ContentResolver>()
|
||||
|
||||
private val testSubject = ContentResolverContactDataSource(
|
||||
contentResolver = contentResolver,
|
||||
contactPermissionResolver = contactPermissionResolver,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `getContactForEmail() returns null if permission is not granted`() {
|
||||
contactPermissionResolver.hasContactPermission = false
|
||||
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactForEmail() returns null if no contact is found`() {
|
||||
setupContactProvider(setupEmptyContactCursor())
|
||||
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactForEmail() returns contact if a contact is found`() {
|
||||
setupContactProvider(setupContactCursor())
|
||||
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isEqualTo(CONTACT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactForEmail() returns false if permission is not granted`() {
|
||||
contactPermissionResolver.hasContactPermission = false
|
||||
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactForEmail() returns false if no contact is found`() {
|
||||
setupContactProvider(setupEmptyContactCursor())
|
||||
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactForEmail() returns true if a contact is found`() {
|
||||
setupContactProvider(setupContactCursor())
|
||||
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
private fun setupContactProvider(contactCursor: Cursor) {
|
||||
val emailUri = Uri.withAppendedPath(
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI,
|
||||
Uri.encode(CONTACT_EMAIL_ADDRESS.address),
|
||||
)
|
||||
|
||||
contentResolver.stub {
|
||||
on {
|
||||
query(eq(emailUri), eq(PROJECTION), anyOrNull(), anyOrNull(), eq(SORT_ORDER))
|
||||
} doReturn contactCursor
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupEmptyContactCursor(): Cursor {
|
||||
return MatrixCursor(PROJECTION)
|
||||
}
|
||||
|
||||
private fun setupContactCursor(): Cursor {
|
||||
return MatrixCursor(PROJECTION).apply {
|
||||
addRow(arrayOf(CONTACT_ID, CONTACT_NAME, CONTACT_PHOTO_URI, CONTACT_LOOKUP_KEY))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PROJECTION = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Email._ID,
|
||||
ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Photo.PHOTO_URI,
|
||||
ContactsContract.Contacts.LOOKUP_KEY,
|
||||
)
|
||||
|
||||
const val SORT_ORDER = ContactsContract.Contacts.DISPLAY_NAME +
|
||||
", " + ContactsContract.CommonDataKinds.Email._ID
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
class TestContactPermissionResolver(hasPermission: Boolean) : ContactPermissionResolver {
|
||||
var hasContactPermission = hasPermission
|
||||
|
||||
override fun hasContactPermission(): Boolean {
|
||||
return hasContactPermission
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package app.k9mail.core.android.common.database
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFailure
|
||||
import assertk.assertions.isNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
|
||||
data class CursorExtensionsAccessTestData<T : Any>(
|
||||
val name: String,
|
||||
val value: T,
|
||||
val access: (Cursor, String) -> T?,
|
||||
val throwingAccess: (Cursor, String) -> T,
|
||||
) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||
class CursorExtensionsKtAccessTest(data: CursorExtensionsAccessTestData<Any>) {
|
||||
|
||||
private val testValue = data.value
|
||||
private val testAction = data.access
|
||||
private val testThrowingAction = data.throwingAccess
|
||||
|
||||
@Test
|
||||
fun `testAction should return null if column is null`() {
|
||||
val cursor = MatrixCursor(arrayOf("column")).apply {
|
||||
addRow(arrayOf(null))
|
||||
}
|
||||
|
||||
val result = cursor.map { testAction(it, "column") }
|
||||
|
||||
assertThat(result[0]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testAction should return value if column is not null`() {
|
||||
val cursor = MatrixCursor(arrayOf("column")).apply {
|
||||
addRow(arrayOf(testValue))
|
||||
}
|
||||
|
||||
val result = cursor.map { testAction(it, "column") }
|
||||
|
||||
assertThat(result[0]).isEqualTo(testValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testThrowingAction should throw if column is null`() {
|
||||
val cursor = MatrixCursor(arrayOf("column")).apply {
|
||||
addRow(arrayOf(null))
|
||||
}
|
||||
|
||||
assertThat {
|
||||
cursor.map { testThrowingAction(it, "column") }
|
||||
}.isFailure().hasMessage("Column column must not be null")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testThrowingAction should return value if column is not null`() {
|
||||
val cursor = MatrixCursor(arrayOf("column")).apply {
|
||||
addRow(arrayOf(testValue))
|
||||
}
|
||||
|
||||
val result = cursor.map { testThrowingAction(it, "column") }
|
||||
|
||||
assertThat(result[0]).isEqualTo(testValue)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||
fun data(): Collection<CursorExtensionsAccessTestData<Any>> {
|
||||
return listOf(
|
||||
CursorExtensionsAccessTestData(
|
||||
name = "getString",
|
||||
value = "value",
|
||||
access = { cursor, column -> cursor.getStringOrNull(column) },
|
||||
throwingAccess = { cursor, column -> cursor.getStringOrThrow(column) },
|
||||
),
|
||||
CursorExtensionsAccessTestData(
|
||||
name = "getInt",
|
||||
value = Int.MAX_VALUE,
|
||||
access = { cursor, column -> cursor.getIntOrNull(column) },
|
||||
throwingAccess = { cursor, column -> cursor.getIntOrThrow(column) },
|
||||
),
|
||||
CursorExtensionsAccessTestData(
|
||||
name = "getLong",
|
||||
value = Long.MAX_VALUE,
|
||||
access = { cursor, column -> cursor.getLongOrNull(column) },
|
||||
throwingAccess = { cursor, column -> cursor.getLongOrThrow(column) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package app.k9mail.core.android.common.database
|
||||
|
||||
import android.database.MatrixCursor
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CursorExtensionsKtTest {
|
||||
|
||||
@Test
|
||||
fun `map should return an empty list if cursor is empty`() {
|
||||
val cursor = MatrixCursor(arrayOf("column"))
|
||||
|
||||
val result = cursor.map { it.getStringOrNull("column") }
|
||||
|
||||
assertThat(result).isEqualTo(emptyList<String>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `map should return a list of mapped values`() {
|
||||
val cursor = MatrixCursor(arrayOf("column")).apply {
|
||||
addRow(arrayOf("value1"))
|
||||
addRow(arrayOf("value2"))
|
||||
}
|
||||
|
||||
val result = cursor.map { it.getStringOrNull("column") }
|
||||
|
||||
assertThat(result).isEqualTo(listOf("value1", "value2"))
|
||||
}
|
||||
}
|
||||
9
core/common/build.gradle.kts
Normal file
9
core/common/build.gradle.kts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(projects.core.testing)
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package app.k9mail.core.common
|
||||
|
||||
import kotlinx.datetime.Clock
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
val coreCommonModule: Module = module {
|
||||
single<Clock> { Clock.System }
|
||||
}
|
||||
12
core/common/src/main/kotlin/app/k9mail/core/common/cache/Cache.kt
vendored
Normal file
12
core/common/src/main/kotlin/app/k9mail/core/common/cache/Cache.kt
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package app.k9mail.core.common.cache
|
||||
|
||||
interface Cache<KEY : Any, VALUE : Any?> {
|
||||
|
||||
operator fun get(key: KEY): VALUE?
|
||||
|
||||
operator fun set(key: KEY, value: VALUE)
|
||||
|
||||
fun hasKey(key: KEY): Boolean
|
||||
|
||||
fun clear()
|
||||
}
|
||||
46
core/common/src/main/kotlin/app/k9mail/core/common/cache/ExpiringCache.kt
vendored
Normal file
46
core/common/src/main/kotlin/app/k9mail/core/common/cache/ExpiringCache.kt
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package app.k9mail.core.common.cache
|
||||
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
class ExpiringCache<KEY : Any, VALUE : Any?>(
|
||||
private val clock: Clock,
|
||||
private val delegateCache: Cache<KEY, VALUE> = InMemoryCache(),
|
||||
private var lastClearTime: Instant = clock.now(),
|
||||
private val cacheTimeValidity: Long = CACHE_TIME_VALIDITY_IN_MILLIS,
|
||||
) : Cache<KEY, VALUE> {
|
||||
|
||||
override fun get(key: KEY): VALUE? {
|
||||
recycle()
|
||||
return delegateCache[key]
|
||||
}
|
||||
|
||||
override fun set(key: KEY, value: VALUE) {
|
||||
recycle()
|
||||
delegateCache[key] = value
|
||||
}
|
||||
|
||||
override fun hasKey(key: KEY): Boolean {
|
||||
recycle()
|
||||
return delegateCache.hasKey(key)
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
lastClearTime = clock.now()
|
||||
delegateCache.clear()
|
||||
}
|
||||
|
||||
private fun recycle() {
|
||||
if (isExpired()) {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isExpired(): Boolean {
|
||||
return (clock.now() - lastClearTime).inWholeMilliseconds >= cacheTimeValidity
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CACHE_TIME_VALIDITY_IN_MILLIS = 30_000L
|
||||
}
|
||||
}
|
||||
21
core/common/src/main/kotlin/app/k9mail/core/common/cache/InMemoryCache.kt
vendored
Normal file
21
core/common/src/main/kotlin/app/k9mail/core/common/cache/InMemoryCache.kt
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package app.k9mail.core.common.cache
|
||||
|
||||
class InMemoryCache<KEY : Any, VALUE : Any?>(
|
||||
private val cache: MutableMap<KEY, VALUE> = mutableMapOf(),
|
||||
) : Cache<KEY, VALUE> {
|
||||
override fun get(key: KEY): VALUE? {
|
||||
return cache[key]
|
||||
}
|
||||
|
||||
override fun set(key: KEY, value: VALUE) {
|
||||
cache[key] = value
|
||||
}
|
||||
|
||||
override fun hasKey(key: KEY): Boolean {
|
||||
return cache.containsKey(key)
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
30
core/common/src/main/kotlin/app/k9mail/core/common/cache/SynchronizedCache.kt
vendored
Normal file
30
core/common/src/main/kotlin/app/k9mail/core/common/cache/SynchronizedCache.kt
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package app.k9mail.core.common.cache
|
||||
|
||||
class SynchronizedCache<KEY : Any, VALUE : Any?>(
|
||||
private val delegateCache: Cache<KEY, VALUE>,
|
||||
) : Cache<KEY, VALUE> {
|
||||
|
||||
override fun get(key: KEY): VALUE? {
|
||||
synchronized(delegateCache) {
|
||||
return delegateCache[key]
|
||||
}
|
||||
}
|
||||
|
||||
override fun set(key: KEY, value: VALUE) {
|
||||
synchronized(delegateCache) {
|
||||
delegateCache[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasKey(key: KEY): Boolean {
|
||||
synchronized(delegateCache) {
|
||||
return delegateCache.hasKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
synchronized(delegateCache) {
|
||||
delegateCache.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
@JvmInline
|
||||
value class EmailAddress(val address: String) {
|
||||
init {
|
||||
require(address.isNotBlank()) { "Email address must not be blank" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package app.k9mail.core.common
|
||||
|
||||
import org.junit.Test
|
||||
import org.koin.dsl.koinApplication
|
||||
import org.koin.test.check.checkModules
|
||||
|
||||
internal class CoreCommonModuleKtTest {
|
||||
|
||||
@Test
|
||||
fun `should have a valid di module`() {
|
||||
koinApplication {
|
||||
modules(coreCommonModule)
|
||||
checkModules()
|
||||
}
|
||||
}
|
||||
}
|
||||
81
core/common/src/test/kotlin/app/k9mail/core/common/cache/CacheTest.kt
vendored
Normal file
81
core/common/src/test/kotlin/app/k9mail/core/common/cache/CacheTest.kt
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package app.k9mail.core.common.cache
|
||||
|
||||
import app.k9mail.core.testing.TestClock
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import kotlin.test.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
data class CacheTestData<KEY : Any, VALUE : Any?>(
|
||||
val name: String,
|
||||
val createCache: () -> Cache<KEY, VALUE>
|
||||
) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class CacheTest(data: CacheTestData<Any, Any?>) {
|
||||
|
||||
private val testSubject = data.createCache()
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{0}")
|
||||
fun data(): Collection<CacheTestData<Any, Any?>> {
|
||||
return listOf(
|
||||
CacheTestData("InMemoryCache") { InMemoryCache() },
|
||||
CacheTestData("ExpiringCache") { ExpiringCache(TestClock(), InMemoryCache()) },
|
||||
CacheTestData("SynchronizedCache") { SynchronizedCache(InMemoryCache()) }
|
||||
)
|
||||
}
|
||||
|
||||
const val KEY = "key"
|
||||
const val VALUE = "value"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get should return null with empty cache`() {
|
||||
assertThat(testSubject[KEY]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set should add entry with empty cache`() {
|
||||
testSubject[KEY] = VALUE
|
||||
|
||||
assertThat(testSubject[KEY]).isEqualTo(VALUE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set should overwrite entry when already present`() {
|
||||
testSubject[KEY] = VALUE
|
||||
|
||||
testSubject[KEY] = "$VALUE changed"
|
||||
|
||||
assertThat(testSubject[KEY]).isEqualTo("$VALUE changed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasKey should answer no with empty cache`() {
|
||||
assertThat(testSubject.hasKey(KEY)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasKey should answer yes when cache has entry`() {
|
||||
testSubject[KEY] = VALUE
|
||||
|
||||
assertThat(testSubject.hasKey(KEY)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear should empty cache`() {
|
||||
testSubject[KEY] = VALUE
|
||||
|
||||
testSubject.clear()
|
||||
|
||||
assertThat(testSubject[KEY]).isNull()
|
||||
}
|
||||
}
|
||||
68
core/common/src/test/kotlin/app/k9mail/core/common/cache/ExpiringCacheTest.kt
vendored
Normal file
68
core/common/src/test/kotlin/app/k9mail/core/common/cache/ExpiringCacheTest.kt
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package app.k9mail.core.common.cache
|
||||
|
||||
import app.k9mail.core.testing.TestClock
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import kotlin.test.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class ExpiringCacheTest {
|
||||
|
||||
private val clock = TestClock()
|
||||
|
||||
private val testSubject: Cache<String, String> = ExpiringCache(clock, InMemoryCache())
|
||||
|
||||
@Test
|
||||
fun `get should return null when entry present and cache expired`() {
|
||||
testSubject[KEY] = VALUE
|
||||
clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION)
|
||||
|
||||
val result = testSubject[KEY]
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set should clear cache and add new entry when cache expired`() {
|
||||
testSubject[KEY] = VALUE
|
||||
clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION)
|
||||
|
||||
testSubject[KEY + 1] = "$VALUE changed"
|
||||
|
||||
assertThat(testSubject[KEY]).isNull()
|
||||
assertThat(testSubject[KEY + 1]).isEqualTo("$VALUE changed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasKey should answer no when cache has entry and validity expired`() {
|
||||
testSubject[KEY] = VALUE
|
||||
clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION)
|
||||
|
||||
assertThat(testSubject.hasKey(KEY)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should keep cache when time progresses within expiration`() {
|
||||
testSubject[KEY] = VALUE
|
||||
clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION.minus(1L.milliseconds))
|
||||
|
||||
assertThat(testSubject[KEY]).isEqualTo(VALUE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should empty cache after time progresses to expiration`() {
|
||||
testSubject[KEY] = VALUE
|
||||
|
||||
clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION)
|
||||
|
||||
assertThat(testSubject[KEY]).isNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY = "key"
|
||||
const val VALUE = "value"
|
||||
val CACHE_TIME_VALIDITY_DURATION = 30_000L.milliseconds
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFails
|
||||
|
||||
internal class EmailAddressTest {
|
||||
|
||||
@Test
|
||||
fun `should reject blank email address`() {
|
||||
assertFails("Email address must not be blank") {
|
||||
EmailAddress("")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return email address`() {
|
||||
val emailAddress = EmailAddress(EMAIL_ADDRESS)
|
||||
|
||||
val address = emailAddress.address
|
||||
|
||||
assertThat(address).isEqualTo(EMAIL_ADDRESS)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val EMAIL_ADDRESS = "email@example.com"
|
||||
}
|
||||
}
|
||||
7
core/testing/build.gradle.kts
Normal file
7
core/testing/build.gradle.kts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.assertk)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package app.k9mail.core.testing
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
class TestClock(
|
||||
private var currentTime: Instant = Clock.System.now(),
|
||||
) : Clock {
|
||||
override fun now(): Instant = currentTime
|
||||
|
||||
fun changeTimeTo(time: Instant) {
|
||||
currentTime = time
|
||||
}
|
||||
|
||||
fun advanceTimeBy(duration: Duration) {
|
||||
currentTime += duration
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package assertk.assertions
|
||||
|
||||
import assertk.Assert
|
||||
import assertk.assertions.support.expected
|
||||
import assertk.assertions.support.show
|
||||
|
||||
fun <T> Assert<List<T>>.containsNoDuplicates() = given { actual ->
|
||||
val seen: MutableSet<T> = mutableSetOf()
|
||||
val duplicates = actual.filter { !seen.add(it) }
|
||||
if (duplicates.isNotEmpty()) {
|
||||
expected("to contain no duplicates but found: ${show(duplicates)}")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package app.k9mail.core.testing
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import kotlin.test.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
internal class TestClockTest {
|
||||
|
||||
@Test
|
||||
fun `should return the current time`() {
|
||||
val testClock = TestClock(Instant.DISTANT_PAST)
|
||||
|
||||
val currentTime = testClock.now()
|
||||
|
||||
assertThat(currentTime).isEqualTo(Instant.DISTANT_PAST)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return the changed time`() {
|
||||
val testClock = TestClock(Instant.DISTANT_PAST)
|
||||
testClock.changeTimeTo(Instant.DISTANT_FUTURE)
|
||||
|
||||
val currentTime = testClock.now()
|
||||
|
||||
assertThat(currentTime).isEqualTo(Instant.DISTANT_FUTURE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should advance time by duration`() {
|
||||
val testClock = TestClock(Instant.DISTANT_PAST)
|
||||
testClock.advanceTimeBy(1L.milliseconds)
|
||||
|
||||
val currentTime = testClock.now()
|
||||
|
||||
assertThat(currentTime).isEqualTo(Instant.DISTANT_PAST + 1L.milliseconds)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package assertk.assertions
|
||||
|
||||
import assertk.assertThat
|
||||
import kotlin.test.Test
|
||||
|
||||
class ListExtensionsKtTest {
|
||||
|
||||
@Test
|
||||
fun `containsNoDuplicates() should succeed with no duplicates`() {
|
||||
val list = listOf("a", "b", "c")
|
||||
|
||||
assertThat(list).containsNoDuplicates()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `containsNoDuplicates() should fail with duplicates`() {
|
||||
val list = listOf("a", "b", "c", "a", "a")
|
||||
|
||||
assertThat {
|
||||
assertThat(list).containsNoDuplicates()
|
||||
}.isFailure()
|
||||
.hasMessage(
|
||||
"""
|
||||
expected to contain no duplicates but found: <["a", "a"]>
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
3
core/ui/compose/common/README.md
Normal file
3
core/ui/compose/common/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## Core - UI - Compose - Common
|
||||
|
||||
This module contains common code for the compose UI.
|
||||
8
core/ui/compose/common/build.gradle.kts
Normal file
8
core/ui/compose/common/build.gradle.kts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.androidCompose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.k9mail.core.ui.compose.common"
|
||||
resourcePrefix = "core_ui_common_"
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package app.k9mail.core.ui.compose.common
|
||||
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
/**
|
||||
* A marker annotation for device previews.
|
||||
*
|
||||
* It's used to provide previews for a set of different devices and form factors.
|
||||
*/
|
||||
@Preview(name = "Phone", device = Devices.PHONE)
|
||||
@Preview(name = "Phone landscape", device = "spec:shape=Normal,width=891,height=411,unit=dp,dpi=420")
|
||||
@Preview(name = "Foldable", device = Devices.FOLDABLE)
|
||||
@Preview(name = "Tablet", device = Devices.TABLET)
|
||||
@Preview(name = "Desktop", device = Devices.DESKTOP)
|
||||
annotation class DevicePreviews
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package app.k9mail.core.ui.compose.common.window
|
||||
|
||||
/**
|
||||
* WindowSizeClass as defined by supporting different screen sizes.
|
||||
*
|
||||
* See: https://developer.android.com/guide/topics/large-screens/support-different-screen-sizes#window_size_classes
|
||||
*/
|
||||
enum class WindowSizeClass {
|
||||
Compact,
|
||||
Medium,
|
||||
Expanded,
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val COMPACT_MAX_WIDTH = 600
|
||||
const val COMPACT_MAX_HEIGHT = 480
|
||||
|
||||
const val MEDIUM_MAX_WIDTH = 840
|
||||
const val MEDIUM_MAX_HEIGHT = 900
|
||||
|
||||
fun fromWidth(width: Int): WindowSizeClass {
|
||||
return when {
|
||||
width < COMPACT_MAX_WIDTH -> Compact
|
||||
width < MEDIUM_MAX_WIDTH -> Medium
|
||||
else -> Expanded
|
||||
}
|
||||
}
|
||||
|
||||
fun fromHeight(height: Int): WindowSizeClass {
|
||||
return when {
|
||||
height < COMPACT_MAX_HEIGHT -> Compact
|
||||
height < MEDIUM_MAX_HEIGHT -> Medium
|
||||
else -> Expanded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package app.k9mail.core.ui.compose.common.window
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Returns the current window size info based on current Configuration.
|
||||
*/
|
||||
@Composable
|
||||
fun getWindowSizeInfo(): WindowSizeInfo {
|
||||
val configuration = LocalConfiguration.current
|
||||
|
||||
return WindowSizeInfo(
|
||||
screenWidthSizeClass = WindowSizeClass.fromWidth(configuration.screenWidthDp),
|
||||
screenHeightSizeClass = WindowSizeClass.fromHeight(configuration.screenHeightDp),
|
||||
screenWidth = configuration.screenWidthDp.dp,
|
||||
screenHeight = configuration.screenHeightDp.dp,
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class WindowSizeInfo(
|
||||
val screenWidthSizeClass: WindowSizeClass,
|
||||
val screenHeightSizeClass: WindowSizeClass,
|
||||
val screenWidth: Dp,
|
||||
val screenHeight: Dp,
|
||||
)
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package app.k9mail.core.ui.compose.common.window
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Test
|
||||
|
||||
class WindowSizeClassTest {
|
||||
|
||||
@Test
|
||||
fun `should return compact when width is less than 600`() {
|
||||
val width = 599
|
||||
|
||||
val windowSizeClass = WindowSizeClass.fromWidth(width)
|
||||
|
||||
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Compact)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return medium when width is 600`() {
|
||||
val width = 600
|
||||
|
||||
val windowSizeClass = WindowSizeClass.fromWidth(width)
|
||||
|
||||
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return medium when width is less than 840`() {
|
||||
val width = 839
|
||||
|
||||
val windowSizeClass = WindowSizeClass.fromWidth(width)
|
||||
|
||||
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return expanded when width is 840`() {
|
||||
val width = 840
|
||||
|
||||
val windowSizeClass = WindowSizeClass.fromWidth(width)
|
||||
|
||||
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Expanded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return compact when height is less than 480`() {
|
||||
val height = 479
|
||||
|
||||
val windowSizeClass = WindowSizeClass.fromHeight(height)
|
||||
|
||||
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Compact)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return medium when height is 480`() {
|
||||
val height = 480
|
||||
|
||||
val windowSizeClass = WindowSizeClass.fromHeight(height)
|
||||
|
||||
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return medium when height is less than 900`() {
|
||||
val height = 899
|
||||
|
||||
val windowSizeClass = WindowSizeClass.fromHeight(height)
|
||||
|
||||
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return expanded when height is 900`() {
|
||||
val height = 900
|
||||
|
||||
val windowSizeClass = WindowSizeClass.fromHeight(height)
|
||||
|
||||
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Expanded)
|
||||
}
|
||||
}
|
||||
38
core/ui/compose/designsystem/README.md
Normal file
38
core/ui/compose/designsystem/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
## Core - UI - Compose - Design system
|
||||
|
||||
Uses [`:core:ui:compose:theme`](../theme/README.md)
|
||||
|
||||
## Background
|
||||
|
||||
[Jetpack Compose](https://developer.android.com/jetpack/compose) is a declarative UI toolkit for Android that provides a modern and efficient way to build UIs for Android apps. In this context, design systems and atomic design can help designers and developers create more scalable, maintainable, and reusable UIs.
|
||||
|
||||
### Design system
|
||||
|
||||
A design system is a collection of guidelines, principles, and tools that help teams create consistent and cohesive visual designs and user experiences.
|
||||
It typically includes a set of reusable components, such as icons, typography, color palettes, and layouts, that can be combined and customized to create new designs.
|
||||
|
||||
The design system also provides documentation and resources for designers and developers to ensure that the designs are implemented consistently and efficiently across all platforms and devices.
|
||||
The goal of a design system is to streamline the design process, improve design quality, and maintain brand consistency.
|
||||
|
||||
An example is Google's [Material Design](https://m3.material.io/) that is used to develop cohesive apps.
|
||||
|
||||
### Atomic Design
|
||||
|
||||

|
||||
|
||||
Atomic design is a methodology for creating user interfaces (UI) in a design system by breaking them down into smaller, reusable components.
|
||||
These components are classified into five categories based on their level of abstraction: **atoms**, **molecules**, **organisms**, **templates**, and **pages**.
|
||||
|
||||
- **Atoms** are the smallest building blocks, such as buttons, labels, and input fields and could be combined to create more complex components.
|
||||
- **Molecules** are groups of atoms that work together, like search bars, forms or menus
|
||||
- **Organisms** are more complex components that combine molecules and atoms, such as headers or cards.
|
||||
- **Templates** are pages with placeholders for components
|
||||
- **Pages** are the final UI
|
||||
|
||||
By using atomic design, designers and developers can create more consistent and reusable UIs.
|
||||
This can save time and improve the overall quality, as well as facilitate collaboration between team members.
|
||||
|
||||
## Acknowledgement
|
||||
|
||||
- [Atomic Design Methodology | Atomic Design by Brad Frost](https://atomicdesign.bradfrost.com/chapter-2/)
|
||||
- [Atomic Design: Getting Started | Blog | We Are Mobile First](https://www.wearemobilefirst.com/blog/atomic-design)
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
16
core/ui/compose/designsystem/build.gradle.kts
Normal file
16
core/ui/compose/designsystem/build.gradle.kts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.androidCompose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.k9mail.core.ui.compose.designsystem"
|
||||
resourcePrefix = "designsystem_"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.core.ui.compose.theme)
|
||||
implementation(libs.androidx.compose.material)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
testImplementation(projects.core.ui.compose.testing)
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Surface as MaterialSurface
|
||||
|
||||
@Composable
|
||||
fun Background(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialSurface(
|
||||
modifier = modifier,
|
||||
content = content,
|
||||
color = MainTheme.colors.background,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun BackgroundPreview() {
|
||||
PreviewWithThemes {
|
||||
Background(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
content = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Checkbox as MaterialCheckbox
|
||||
|
||||
@Composable
|
||||
fun Checkbox(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
MaterialCheckbox(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun CheckboxPreview() {
|
||||
PreviewWithThemes {
|
||||
Checkbox(
|
||||
checked = true,
|
||||
onCheckedChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun CheckboxDisabledPreview() {
|
||||
PreviewWithThemes {
|
||||
Checkbox(
|
||||
checked = true,
|
||||
onCheckedChange = {},
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Surface as MaterialSurface
|
||||
|
||||
@Composable
|
||||
fun Surface(
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MainTheme.colors.surface,
|
||||
elevation: Dp = MainTheme.elevations.default,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialSurface(
|
||||
modifier = modifier,
|
||||
content = content,
|
||||
elevation = elevation,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun SurfacePreview() {
|
||||
PreviewWithThemes {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
content = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.button
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextButton
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Button as MaterialButton
|
||||
|
||||
@Composable
|
||||
fun Button(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
MaterialButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.buttonColors(),
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
TextButton(text = text)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun ButtonPreview() {
|
||||
PreviewWithThemes {
|
||||
Button(
|
||||
text = "Button",
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun ButtonDisabledPreview() {
|
||||
PreviewWithThemes {
|
||||
Button(
|
||||
text = "ButtonDisabled",
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.button
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextButton
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.OutlinedButton as MaterialOutlinedButton
|
||||
|
||||
@Composable
|
||||
fun ButtonOutlined(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
MaterialOutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.outlinedButtonColors(),
|
||||
border = BorderStroke(
|
||||
width = 1.dp,
|
||||
color = if (enabled) {
|
||||
MainTheme.colors.primary
|
||||
} else {
|
||||
MainTheme.colors.onSurface.copy(
|
||||
alpha = 0.12f,
|
||||
)
|
||||
},
|
||||
),
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
TextButton(text = text)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun ButtonOutlinedPreview() {
|
||||
PreviewWithThemes {
|
||||
ButtonOutlined(
|
||||
text = "ButtonOutlined",
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun ButtonOutlinedDisabledPreview() {
|
||||
PreviewWithThemes {
|
||||
ButtonOutlined(
|
||||
text = "ButtonOutlinedDisabled",
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.button
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextButton
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.TextButton as MaterialTextButton
|
||||
|
||||
@Composable
|
||||
fun ButtonText(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
MaterialTextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.textButtonColors(),
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
TextButton(text = text)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun ButtonTextPreview() {
|
||||
PreviewWithThemes {
|
||||
ButtonText(
|
||||
text = "ButtonText",
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun ButtonTextDisabledPreview() {
|
||||
PreviewWithThemes {
|
||||
ButtonText(
|
||||
text = "ButtonTextDisabled",
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextBody1(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text,
|
||||
style = MainTheme.typography.body1,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextBody1Preview() {
|
||||
PreviewWithThemes {
|
||||
TextBody1(text = "TextBody1")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextBody2(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text,
|
||||
style = MainTheme.typography.body2,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextBody2Preview() {
|
||||
PreviewWithThemes {
|
||||
TextBody2(text = "TextBody2")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextButton(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text.uppercase(),
|
||||
style = MainTheme.typography.button,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextButtonPreview() {
|
||||
PreviewWithThemes {
|
||||
TextButton(text = "TextButton")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextCaption(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text,
|
||||
style = MainTheme.typography.caption,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextCaptionPreview() {
|
||||
PreviewWithThemes {
|
||||
TextCaption(text = "TextCaption")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextHeadline1(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text,
|
||||
style = MainTheme.typography.h1,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextHeadline1Preview() {
|
||||
PreviewWithThemes {
|
||||
TextHeadline1(text = "TextHeadline1")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextHeadline2(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text,
|
||||
style = MainTheme.typography.h2,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextHeadline2Preview() {
|
||||
PreviewWithThemes {
|
||||
TextHeadline2(text = "TextHeadline2")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextHeadline3(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text,
|
||||
style = MainTheme.typography.h3,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextHeadline3Preview() {
|
||||
PreviewWithThemes {
|
||||
TextHeadline3(text = "TextHeadline3")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextHeadline4(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text,
|
||||
style = MainTheme.typography.h4,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextHeadline4Preview() {
|
||||
PreviewWithThemes {
|
||||
TextHeadline4(text = "TextHeadline4")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextHeadline5(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text,
|
||||
style = MainTheme.typography.h5,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextHeadline5Preview() {
|
||||
PreviewWithThemes {
|
||||
TextHeadline5(text = "TextHeadline5")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextHeadline6(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text,
|
||||
style = MainTheme.typography.h6,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextHeadline6Preview() {
|
||||
PreviewWithThemes {
|
||||
TextHeadline6(text = "TextHeadline6")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextOverline(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text.uppercase(),
|
||||
style = MainTheme.typography.overline,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextOverlinePreview() {
|
||||
PreviewWithThemes {
|
||||
TextOverline(text = "TextOverline")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextSubtitle1(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text,
|
||||
style = MainTheme.typography.subtitle1,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextSubtitle1Preview() {
|
||||
PreviewWithThemes {
|
||||
TextSubtitle1(text = "TextSubtitle1")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.Text as MaterialText
|
||||
|
||||
@Composable
|
||||
fun TextSubtitle2(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MaterialText(
|
||||
text = text,
|
||||
style = MainTheme.typography.subtitle2,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextSubtitle2Preview() {
|
||||
PreviewWithThemes {
|
||||
TextSubtitle2(text = "TextSubtitle2")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.textfield
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.designsystem.R
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.OutlinedTextField as MaterialOutlinedTextField
|
||||
|
||||
@Composable
|
||||
fun PasswordTextFieldOutlined(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
label: String? = null,
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
var passwordVisibilityState by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
MaterialOutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
label = selectLabel(label),
|
||||
trailingIcon = selectTrailingIcon(
|
||||
isEnabled = enabled,
|
||||
isPasswordVisible = passwordVisibilityState,
|
||||
onClick = { passwordVisibilityState = !passwordVisibilityState },
|
||||
),
|
||||
isError = isError,
|
||||
visualTransformation = selectVisualTransformation(
|
||||
isEnabled = enabled,
|
||||
isPasswordVisible = passwordVisibilityState,
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun selectLabel(label: String?): @Composable (() -> Unit)? {
|
||||
return if (label != null) {
|
||||
{
|
||||
Text(text = label)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectTrailingIcon(
|
||||
isEnabled: Boolean,
|
||||
isPasswordVisible: Boolean,
|
||||
onClick: () -> Unit,
|
||||
hasTrailingIcon: Boolean = true,
|
||||
): @Composable (() -> Unit)? {
|
||||
return if (hasTrailingIcon) {
|
||||
{
|
||||
val image = if (isShowPasswordAllowed(isEnabled, isPasswordVisible)) {
|
||||
Icons.Filled.Visibility
|
||||
} else {
|
||||
Icons.Filled.VisibilityOff
|
||||
}
|
||||
|
||||
val description = if (isShowPasswordAllowed(isEnabled, isPasswordVisible)) {
|
||||
stringResource(id = R.string.designsystem_atom_password_textfield_hide_password)
|
||||
} else {
|
||||
stringResource(id = R.string.designsystem_atom_password_textfield_show_password)
|
||||
}
|
||||
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(imageVector = image, contentDescription = description)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectVisualTransformation(
|
||||
isEnabled: Boolean,
|
||||
isPasswordVisible: Boolean,
|
||||
): VisualTransformation {
|
||||
return if (isShowPasswordAllowed(isEnabled, isPasswordVisible)) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isShowPasswordAllowed(isEnabled: Boolean, isPasswordVisible: Boolean) = isEnabled && isPasswordVisible
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun PasswordTextFieldOutlinedPreview() {
|
||||
PreviewWithThemes {
|
||||
PasswordTextFieldOutlined(
|
||||
value = "Input text",
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun PasswordTextFieldOutlinedWithLabelPreview() {
|
||||
PreviewWithThemes {
|
||||
PasswordTextFieldOutlined(
|
||||
value = "Input text",
|
||||
label = "Label",
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun PasswordTextFieldOutlinedDisabledPreview() {
|
||||
PreviewWithThemes {
|
||||
PasswordTextFieldOutlined(
|
||||
value = "Input text",
|
||||
onValueChange = {},
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun PasswordTextFieldOutlinedErrorPreview() {
|
||||
PreviewWithThemes {
|
||||
PasswordTextFieldOutlined(
|
||||
value = "Input text",
|
||||
onValueChange = {},
|
||||
isError = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.textfield
|
||||
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.theme.PreviewWithThemes
|
||||
import androidx.compose.material.OutlinedTextField as MaterialOutlinedTextField
|
||||
|
||||
@Composable
|
||||
fun TextFieldOutlined(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
label: String? = null,
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
MaterialOutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
label = selectLabel(label),
|
||||
isError = isError,
|
||||
)
|
||||
}
|
||||
|
||||
private fun selectLabel(label: String?): @Composable (() -> Unit)? {
|
||||
return if (label != null) {
|
||||
{
|
||||
Text(text = label)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextFieldOutlinedPreview() {
|
||||
PreviewWithThemes {
|
||||
TextFieldOutlined(
|
||||
value = "Input text",
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextFieldOutlinedWithLabelPreview() {
|
||||
PreviewWithThemes {
|
||||
TextFieldOutlined(
|
||||
value = "Input text",
|
||||
label = "Label",
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextFieldOutlinedDisabledPreview() {
|
||||
PreviewWithThemes {
|
||||
TextFieldOutlined(
|
||||
value = "Input text",
|
||||
onValueChange = {},
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
internal fun TextFieldOutlinedErrorPreview() {
|
||||
PreviewWithThemes {
|
||||
TextFieldOutlined(
|
||||
value = "Input text",
|
||||
onValueChange = {},
|
||||
isError = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.template
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.Surface
|
||||
import app.k9mail.core.ui.compose.theme.K9Theme
|
||||
|
||||
/**
|
||||
* The [LazyColumnWithFooter] composable creates a [LazyColumn] with a footer.
|
||||
*
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
* @param verticalArrangement The vertical arrangement of the children.
|
||||
* @param horizontalAlignment The horizontal alignment of the children.
|
||||
* @param footer The footer to be displayed at the bottom of the [LazyColumn].
|
||||
* @param content The content of the [LazyColumn].
|
||||
*/
|
||||
@Composable
|
||||
fun LazyColumnWithFooter(
|
||||
modifier: Modifier = Modifier,
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
footer: @Composable () -> Unit = {},
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
verticalArrangement = verticalArrangementWithFooter(verticalArrangement),
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
) {
|
||||
content()
|
||||
item { footer() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun verticalArrangementWithFooter(verticalArrangement: Arrangement.Vertical) = remember {
|
||||
object : Arrangement.Vertical {
|
||||
override fun Density.arrange(
|
||||
totalSize: Int,
|
||||
sizes: IntArray,
|
||||
outPositions: IntArray,
|
||||
) {
|
||||
val innerSizes = sizes.dropLast(1).toIntArray()
|
||||
val footerSize = sizes.last()
|
||||
val innerTotalSize = totalSize - footerSize
|
||||
|
||||
with(verticalArrangement) {
|
||||
arrange(
|
||||
totalSize = innerTotalSize,
|
||||
sizes = innerSizes,
|
||||
outPositions = outPositions,
|
||||
)
|
||||
}
|
||||
|
||||
outPositions[outPositions.lastIndex] = totalSize - footerSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
internal fun LazyColumnWithFooterPreview() {
|
||||
K9Theme {
|
||||
Surface {
|
||||
LazyColumnWithFooter(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically),
|
||||
footer = { Text(text = "Footer") },
|
||||
) {
|
||||
items(10) {
|
||||
Text(text = "Item $it")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.template
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.requiredHeight
|
||||
import androidx.compose.foundation.layout.requiredWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.k9mail.core.ui.compose.common.DevicePreviews
|
||||
import app.k9mail.core.ui.compose.common.window.WindowSizeClass
|
||||
import app.k9mail.core.ui.compose.common.window.getWindowSizeInfo
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.Surface
|
||||
import app.k9mail.core.ui.compose.theme.K9Theme
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
|
||||
/**
|
||||
* The [ResponsiveContent] composable automatically adapts its child content to different screen sizes and resolutions,
|
||||
* providing a responsive layout for a better user experience.
|
||||
*
|
||||
* It uses the [WindowSizeClass] (Compact, Medium, or Expanded) to make appropriate layout adjustments.
|
||||
*
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
* @param content The content to be displayed.
|
||||
*/
|
||||
@Composable
|
||||
fun ResponsiveContent(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val windowSizeClass = getWindowSizeInfo()
|
||||
|
||||
when (windowSizeClass.screenWidthSizeClass) {
|
||||
WindowSizeClass.Compact -> CompactContent(modifier = modifier, content = content)
|
||||
WindowSizeClass.Medium -> MediumContent(modifier = modifier, content = content)
|
||||
WindowSizeClass.Expanded -> ExpandedContent(modifier = modifier, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactContent(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(modifier),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediumContent(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(modifier),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.requiredWidth(WindowSizeClass.COMPACT_MAX_WIDTH.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandedContent(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
when (getWindowSizeInfo().screenHeightSizeClass) {
|
||||
WindowSizeClass.Compact -> MediumContent(modifier, content)
|
||||
WindowSizeClass.Medium -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(modifier),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.requiredWidth(WindowSizeClass.MEDIUM_MAX_WIDTH.dp),
|
||||
elevation = MainTheme.elevations.raised,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowSizeClass.Expanded -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(modifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.requiredWidth(WindowSizeClass.MEDIUM_MAX_WIDTH.dp)
|
||||
.requiredHeight(WindowSizeClass.MEDIUM_MAX_HEIGHT.dp),
|
||||
elevation = MainTheme.elevations.raised,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@DevicePreviews
|
||||
internal fun ResponsiveContentPreview() {
|
||||
K9Theme {
|
||||
Surface {
|
||||
ResponsiveContent {
|
||||
Surface(
|
||||
color = MainTheme.colors.info,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="designsystem_atom_password_textfield_hide_password">Hide password</string>
|
||||
<string name="designsystem_atom_password_textfield_show_password">Show password</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.textfield
|
||||
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import app.k9mail.core.ui.compose.designsystem.R
|
||||
import app.k9mail.core.ui.compose.testing.ComposeTest
|
||||
import org.junit.Test
|
||||
|
||||
private const val PASSWORD = "Password input"
|
||||
|
||||
class PasswordTextFieldOutlinedKtTest : ComposeTest() {
|
||||
|
||||
@Test
|
||||
fun `should not display password by default`() = runComposeTest {
|
||||
setContent {
|
||||
PasswordTextFieldOutlined(
|
||||
value = PASSWORD,
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
onNodeWithText(PASSWORD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should display password when show password is clicked`() = runComposeTest {
|
||||
setContent {
|
||||
PasswordTextFieldOutlined(
|
||||
value = PASSWORD,
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
onShowPasswordNode().performClick()
|
||||
|
||||
onNodeWithText(PASSWORD).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not display password when hide password is clicked`() = runComposeTest {
|
||||
setContent {
|
||||
PasswordTextFieldOutlined(
|
||||
value = PASSWORD,
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
onShowPasswordNode().performClick()
|
||||
|
||||
onHidePasswordNode().performClick()
|
||||
|
||||
onNodeWithText(PASSWORD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should display hide password icon when show password is clicked`() = runComposeTest {
|
||||
setContent {
|
||||
PasswordTextFieldOutlined(
|
||||
value = PASSWORD,
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
onShowPasswordNode().performClick()
|
||||
|
||||
onHidePasswordNode().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should display show password icon when hide password icon is clicked`() = runComposeTest {
|
||||
setContent {
|
||||
PasswordTextFieldOutlined(
|
||||
value = PASSWORD,
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
onShowPasswordNode().performClick()
|
||||
|
||||
onHidePasswordNode().performClick()
|
||||
|
||||
onShowPasswordNode().assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun SemanticsNodeInteractionsProvider.onShowPasswordNode(): SemanticsNodeInteraction {
|
||||
return onNodeWithContentDescription(
|
||||
getString(R.string.designsystem_atom_password_textfield_show_password),
|
||||
)
|
||||
}
|
||||
|
||||
private fun SemanticsNodeInteractionsProvider.onHidePasswordNode(): SemanticsNodeInteraction {
|
||||
return onNodeWithContentDescription(
|
||||
getString(R.string.designsystem_atom_password_textfield_hide_password),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package app.k9mail.core.ui.compose.designsystem.atom.textfield
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import app.k9mail.core.ui.compose.testing.ComposeTest
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
|
||||
private const val VALUE = "Input text"
|
||||
private const val LABEL = "Label"
|
||||
|
||||
data class TextFieldTestData(
|
||||
val name: String,
|
||||
val content: @Composable (
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier,
|
||||
enabled: Boolean?,
|
||||
label: String?,
|
||||
) -> Unit,
|
||||
)
|
||||
|
||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||
class TextFieldKtTest(
|
||||
data: TextFieldTestData,
|
||||
) : ComposeTest() {
|
||||
|
||||
private val testSubjectName = data.name
|
||||
private val testSubject = data.content
|
||||
|
||||
@Test
|
||||
fun `should call onValueChange when value changes`() = runComposeTest {
|
||||
var value = VALUE
|
||||
setContent {
|
||||
testSubject(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
modifier = Modifier.testTag(testSubjectName),
|
||||
enabled = null,
|
||||
label = null,
|
||||
)
|
||||
}
|
||||
|
||||
onNodeWithTag(testSubjectName).performClick()
|
||||
onNodeWithTag(testSubjectName).performTextInput(" + added text")
|
||||
|
||||
assertThat(value).isEqualTo("$VALUE + added text")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should be enabled by default`() = runComposeTest {
|
||||
setContent {
|
||||
testSubject(
|
||||
value = VALUE,
|
||||
onValueChange = {},
|
||||
modifier = Modifier.testTag(testSubjectName),
|
||||
enabled = null,
|
||||
label = null,
|
||||
)
|
||||
}
|
||||
|
||||
onNodeWithTag(testSubjectName).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should be disabled when enabled is false`() = runComposeTest {
|
||||
setContent {
|
||||
testSubject(
|
||||
value = VALUE,
|
||||
onValueChange = {},
|
||||
modifier = Modifier.testTag(testSubjectName),
|
||||
enabled = false,
|
||||
label = null,
|
||||
)
|
||||
}
|
||||
|
||||
onNodeWithTag(testSubjectName).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show label when label is not null`() = runComposeTest {
|
||||
setContent {
|
||||
testSubject(
|
||||
value = VALUE,
|
||||
onValueChange = {},
|
||||
modifier = Modifier.testTag(testSubjectName),
|
||||
enabled = null,
|
||||
label = LABEL,
|
||||
)
|
||||
}
|
||||
|
||||
onNodeWithText(LABEL).assertIsDisplayed()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||
fun data(): List<TextFieldTestData> = listOf(
|
||||
TextFieldTestData(
|
||||
name = "TextFieldOutlined",
|
||||
content = { value, onValueChange, modifier, enabled, label ->
|
||||
if (enabled != null) {
|
||||
TextFieldOutlined(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
label = label,
|
||||
)
|
||||
} else {
|
||||
TextFieldOutlined(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier,
|
||||
label = label,
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
TextFieldTestData(
|
||||
name = "PasswordTextFieldOutlined",
|
||||
content = { value, onValueChange, modifier, enabled, label ->
|
||||
if (enabled != null) {
|
||||
PasswordTextFieldOutlined(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
label = label,
|
||||
)
|
||||
} else {
|
||||
PasswordTextFieldOutlined(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier,
|
||||
label = label,
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
3
core/ui/compose/testing/README.md
Normal file
3
core/ui/compose/testing/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## Core - UI - Compose - Testing
|
||||
|
||||
Uses [`:core:ui:compose:theme`](../theme/README.md)
|
||||
14
core/ui/compose/testing/build.gradle.kts
Normal file
14
core/ui/compose/testing/build.gradle.kts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.androidCompose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.k9mail.core.ui.compose.testing"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.ui.compose.theme)
|
||||
implementation(libs.androidx.compose.material)
|
||||
|
||||
implementation(libs.bundles.shared.jvm.test.compose)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package app.k9mail.core.ui.compose.testing
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import org.junit.Rule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
open class ComposeTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
fun getString(@StringRes resourceId: Int): String = RuntimeEnvironment.getApplication().getString(resourceId)
|
||||
|
||||
fun runComposeTest(testContent: ComposeContentTestRule.() -> Unit): Unit = with(composeTestRule) {
|
||||
testContent()
|
||||
}
|
||||
}
|
||||
27
core/ui/compose/theme/README.md
Normal file
27
core/ui/compose/theme/README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
## Core - UI - Compose - Theme
|
||||
|
||||
This provides the `MainTheme` with dark/light variation, a wrapper for the Compose Material2 theme. It supports [CompositionLocal](https://developer.android.com/jetpack/compose/compositionlocal) changes to colors, typography, shapes and adds additionally elevations, sizes, spacings and images.
|
||||
|
||||
To change Material2 related properties use `MainTheme` instead of `MaterialTheme`:
|
||||
|
||||
- `MainTheme.colors`: Material2 colors
|
||||
- `MainTheme.typography`: Material 2 typography
|
||||
- `MainTheme.shapes`: Material2 shapes
|
||||
- `MainTheme.spacings`: Spacings (quarter, half, default, oneHalf, double, triple, quadruple) while default is 8 dp.
|
||||
- `MainTheme.sizes`: Sizes (smaller, small, medium, large, larger, huge, huger)
|
||||
- `MainTheme.elevations`: Elevation, e.g. card
|
||||
- `MainTheme.images`: Images used across the theme, e.g. logo
|
||||
|
||||
Included are two derived themes for K-9 and Thunderbird look: `K9Theme` and `ThunderbirdTheme`.
|
||||
|
||||
To render previews for both themes use `PreviewWithThemes`. This also includes a dark/light variation:
|
||||
|
||||
```
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun MyViewPreview() {
|
||||
PreviewWithThemes {
|
||||
MyView()
|
||||
}
|
||||
}
|
||||
```
|
||||
13
core/ui/compose/theme/build.gradle.kts
Normal file
13
core/ui/compose/theme/build.gradle.kts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.androidCompose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.k9mail.core.ui.compose.theme"
|
||||
resourcePrefix = "core_ui_theme_"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.core.ui.compose.common)
|
||||
implementation(libs.androidx.compose.material)
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package app.k9mail.core.ui.compose.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Immutable
|
||||
data class Elevations(
|
||||
val default: Dp = 0.dp,
|
||||
val raised: Dp = 2.dp,
|
||||
val card: Dp = 4.dp,
|
||||
)
|
||||
|
||||
internal val LocalElevations = staticCompositionLocalOf { Elevations() }
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package app.k9mail.core.ui.compose.theme
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
@Immutable
|
||||
data class Images(
|
||||
@DrawableRes val logo: Int,
|
||||
)
|
||||
|
||||
internal val LocalImages = staticCompositionLocalOf<Images> {
|
||||
error("No LocalImages defined")
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package app.k9mail.core.ui.compose.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import app.k9mail.core.ui.compose.theme.color.MaterialColor
|
||||
import app.k9mail.core.ui.compose.theme.color.darkColors
|
||||
import app.k9mail.core.ui.compose.theme.color.lightColors
|
||||
|
||||
private val k9LightColorPalette = lightColors(
|
||||
primary = MaterialColor.gray_800,
|
||||
primaryVariant = MaterialColor.gray_700,
|
||||
secondary = MaterialColor.pink_500,
|
||||
secondaryVariant = MaterialColor.pink_300,
|
||||
)
|
||||
|
||||
private val k9DarkColorPalette = darkColors(
|
||||
primary = MaterialColor.gray_100,
|
||||
primaryVariant = MaterialColor.gray_400,
|
||||
secondary = MaterialColor.pink_300,
|
||||
secondaryVariant = MaterialColor.pink_500,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun K9Theme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val images = Images(logo = R.drawable.core_ui_theme_k9_logo)
|
||||
|
||||
MainTheme(
|
||||
lightColorPalette = k9LightColorPalette,
|
||||
darkColorPalette = k9DarkColorPalette,
|
||||
lightImages = images,
|
||||
darkImages = images,
|
||||
darkTheme = darkTheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package app.k9mail.core.ui.compose.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Shapes
|
||||
import androidx.compose.material.Typography
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import app.k9mail.core.ui.compose.theme.color.Colors
|
||||
import app.k9mail.core.ui.compose.theme.color.LocalColors
|
||||
import app.k9mail.core.ui.compose.theme.color.toMaterialColors
|
||||
|
||||
@Composable
|
||||
fun MainTheme(
|
||||
lightColorPalette: Colors,
|
||||
darkColorPalette: Colors,
|
||||
lightImages: Images,
|
||||
darkImages: Images,
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = if (darkTheme) {
|
||||
darkColorPalette
|
||||
} else {
|
||||
lightColorPalette
|
||||
}
|
||||
val images = if (darkTheme) {
|
||||
darkImages
|
||||
} else {
|
||||
lightImages
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalColors provides colors,
|
||||
LocalElevations provides Elevations(),
|
||||
LocalImages provides images,
|
||||
LocalSizes provides Sizes(),
|
||||
LocalSpacings provides Spacings(),
|
||||
) {
|
||||
MaterialTheme(
|
||||
colors = colors.toMaterialColors(),
|
||||
typography = typography,
|
||||
shapes = shapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object MainTheme {
|
||||
val colors: Colors
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalColors.current
|
||||
|
||||
val typography: Typography
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = MaterialTheme.typography
|
||||
|
||||
val shapes: Shapes
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = MaterialTheme.shapes
|
||||
|
||||
val spacings: Spacings
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalSpacings.current
|
||||
|
||||
val sizes: Sizes
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalSizes.current
|
||||
|
||||
val elevations: Elevations
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalElevations.current
|
||||
|
||||
val images: Images
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalImages.current
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package app.k9mail.core.ui.compose.theme
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun PreviewWithThemes(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
PreviewHeader(themeName = "K9Theme")
|
||||
K9Theme {
|
||||
PreviewSurface(content = content)
|
||||
}
|
||||
K9Theme(darkTheme = true) {
|
||||
PreviewSurface(content = content)
|
||||
}
|
||||
|
||||
PreviewHeader(themeName = "ThunderbirdTheme")
|
||||
ThunderbirdTheme {
|
||||
PreviewSurface(content = content)
|
||||
}
|
||||
ThunderbirdTheme(darkTheme = true) {
|
||||
PreviewSurface(content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewHeader(
|
||||
themeName: String,
|
||||
) {
|
||||
Surface(
|
||||
color = Color.Cyan,
|
||||
) {
|
||||
Text(
|
||||
text = themeName,
|
||||
fontSize = 4.sp,
|
||||
modifier = Modifier.padding(
|
||||
start = MainTheme.spacings.half,
|
||||
end = MainTheme.spacings.half,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewSurface(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MainTheme.colors.background,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package app.k9mail.core.ui.compose.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val shapes = Shapes(
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(4.dp),
|
||||
large = RoundedCornerShape(0.dp),
|
||||
)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package app.k9mail.core.ui.compose.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Immutable
|
||||
data class Sizes(
|
||||
val smaller: Dp = 8.dp,
|
||||
val small: Dp = 16.dp,
|
||||
val medium: Dp = 32.dp,
|
||||
val large: Dp = 64.dp,
|
||||
val larger: Dp = 128.dp,
|
||||
val huge: Dp = 256.dp,
|
||||
val huger: Dp = 384.dp,
|
||||
)
|
||||
|
||||
internal val LocalSizes = staticCompositionLocalOf { Sizes() }
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package app.k9mail.core.ui.compose.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Immutable
|
||||
data class Spacings(
|
||||
val quarter: Dp = 2.dp,
|
||||
val half: Dp = 4.dp,
|
||||
val default: Dp = 8.dp,
|
||||
val oneHalf: Dp = 12.dp,
|
||||
val double: Dp = 16.dp,
|
||||
val triple: Dp = 24.dp,
|
||||
val quadruple: Dp = 32.dp,
|
||||
)
|
||||
|
||||
internal val LocalSpacings = staticCompositionLocalOf { Spacings() }
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package app.k9mail.core.ui.compose.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import app.k9mail.core.ui.compose.theme.color.MaterialColor
|
||||
import app.k9mail.core.ui.compose.theme.color.darkColors
|
||||
import app.k9mail.core.ui.compose.theme.color.lightColors
|
||||
|
||||
private val thunderbirdLightColorPalette = lightColors(
|
||||
primary = MaterialColor.blue_800,
|
||||
primaryVariant = MaterialColor.light_blue_700,
|
||||
secondary = MaterialColor.pink_500,
|
||||
secondaryVariant = MaterialColor.pink_300,
|
||||
)
|
||||
|
||||
private val thunderbirdDarkColorPalette = darkColors(
|
||||
primary = MaterialColor.blue_200,
|
||||
primaryVariant = MaterialColor.blue_400,
|
||||
secondary = MaterialColor.pink_300,
|
||||
secondaryVariant = MaterialColor.pink_500,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ThunderbirdTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val images = Images(logo = R.drawable.core_ui_theme_thunderbird_logo)
|
||||
|
||||
MainTheme(
|
||||
lightColorPalette = thunderbirdLightColorPalette,
|
||||
darkColorPalette = thunderbirdDarkColorPalette,
|
||||
lightImages = images,
|
||||
darkImages = images,
|
||||
darkTheme = darkTheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package app.k9mail.core.ui.compose.theme
|
||||
|
||||
import androidx.compose.material.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
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
val typography = typographyFromDefaults(
|
||||
h1 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 96.sp,
|
||||
letterSpacing = (-1.5).sp,
|
||||
),
|
||||
h2 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 60.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
),
|
||||
h3 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 48.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
h4 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 34.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
),
|
||||
h5 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
h6 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 20.sp,
|
||||
letterSpacing = 0.15.sp,
|
||||
),
|
||||
subtitle1 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
letterSpacing = 0.15.sp,
|
||||
),
|
||||
subtitle2 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
body1 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
body2 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
),
|
||||
button = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
letterSpacing = 1.25.sp,
|
||||
),
|
||||
caption = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 0.4.sp,
|
||||
),
|
||||
overline = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 1.5.sp,
|
||||
),
|
||||
)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun typographyFromDefaults(
|
||||
h1: TextStyle,
|
||||
h2: TextStyle,
|
||||
h3: TextStyle,
|
||||
h4: TextStyle,
|
||||
h5: TextStyle,
|
||||
h6: TextStyle,
|
||||
subtitle1: TextStyle,
|
||||
subtitle2: TextStyle,
|
||||
body1: TextStyle,
|
||||
body2: TextStyle,
|
||||
button: TextStyle,
|
||||
caption: TextStyle,
|
||||
overline: TextStyle,
|
||||
): Typography {
|
||||
val defaults = Typography()
|
||||
return Typography(
|
||||
h1 = defaults.h1.merge(h1),
|
||||
h2 = defaults.h2.merge(h2),
|
||||
h3 = defaults.h3.merge(h3),
|
||||
h4 = defaults.h4.merge(h4),
|
||||
h5 = defaults.h5.merge(h5),
|
||||
h6 = defaults.h6.merge(h6),
|
||||
subtitle1 = defaults.subtitle1.merge(subtitle1),
|
||||
subtitle2 = defaults.subtitle2.merge(subtitle2),
|
||||
body1 = defaults.body1.merge(body1),
|
||||
body2 = defaults.body2.merge(body2),
|
||||
button = defaults.button.merge(button),
|
||||
caption = defaults.caption.merge(caption),
|
||||
overline = defaults.overline.merge(overline),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package app.k9mail.core.ui.compose.theme.color
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.material.Colors as MaterialColors
|
||||
|
||||
@Immutable
|
||||
data class Colors(
|
||||
val primary: Color,
|
||||
val primaryVariant: Color,
|
||||
val secondary: Color,
|
||||
val secondaryVariant: Color,
|
||||
val background: Color,
|
||||
val surface: Color,
|
||||
val success: Color,
|
||||
val error: Color,
|
||||
val warning: Color,
|
||||
val info: Color,
|
||||
val onPrimary: Color,
|
||||
val onSecondary: Color,
|
||||
val onBackground: Color,
|
||||
val onSurface: Color,
|
||||
val onMessage: Color,
|
||||
val isLight: Boolean,
|
||||
)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal fun lightColors(
|
||||
primary: Color = MaterialColor.deep_purple_600,
|
||||
primaryVariant: Color = MaterialColor.deep_purple_900,
|
||||
secondary: Color = MaterialColor.cyan_600,
|
||||
secondaryVariant: Color = MaterialColor.cyan_800,
|
||||
background: Color = Color.White,
|
||||
surface: Color = Color.White,
|
||||
success: Color = MaterialColor.green_600,
|
||||
error: Color = MaterialColor.red_600,
|
||||
warning: Color = MaterialColor.orange_600,
|
||||
info: Color = MaterialColor.yellow_600,
|
||||
onPrimary: Color = Color.White,
|
||||
onSecondary: Color = Color.Black,
|
||||
onBackground: Color = Color.Black,
|
||||
onSurface: Color = Color.Black,
|
||||
onMessage: Color = Color.White,
|
||||
) = Colors(
|
||||
primary = primary,
|
||||
primaryVariant = primaryVariant,
|
||||
secondary = secondary,
|
||||
secondaryVariant = secondaryVariant,
|
||||
background = background,
|
||||
surface = surface,
|
||||
success = success,
|
||||
error = error,
|
||||
warning = warning,
|
||||
info = info,
|
||||
onPrimary = onPrimary,
|
||||
onSecondary = onSecondary,
|
||||
onBackground = onBackground,
|
||||
onSurface = onSurface,
|
||||
onMessage = onMessage,
|
||||
isLight = true,
|
||||
)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal fun darkColors(
|
||||
primary: Color = MaterialColor.deep_purple_200,
|
||||
primaryVariant: Color = MaterialColor.deep_purple_50,
|
||||
secondary: Color = MaterialColor.cyan_300,
|
||||
secondaryVariant: Color = secondary,
|
||||
background: Color = MaterialColor.gray_950,
|
||||
surface: Color = MaterialColor.gray_950,
|
||||
success: Color = MaterialColor.green_300,
|
||||
error: Color = MaterialColor.red_300,
|
||||
warning: Color = MaterialColor.orange_300,
|
||||
info: Color = MaterialColor.yellow_300,
|
||||
onPrimary: Color = Color.Black,
|
||||
onSecondary: Color = Color.Black,
|
||||
onBackground: Color = Color.White,
|
||||
onSurface: Color = Color.White,
|
||||
onMessage: Color = Color.Black,
|
||||
) = Colors(
|
||||
primary = primary,
|
||||
primaryVariant = primaryVariant,
|
||||
secondary = secondary,
|
||||
secondaryVariant = secondaryVariant,
|
||||
background = background,
|
||||
surface = surface,
|
||||
success = success,
|
||||
error = error,
|
||||
warning = warning,
|
||||
info = info,
|
||||
onPrimary = onPrimary,
|
||||
onSecondary = onSecondary,
|
||||
onBackground = onBackground,
|
||||
onSurface = onSurface,
|
||||
onMessage = onMessage,
|
||||
isLight = false,
|
||||
)
|
||||
|
||||
internal fun Colors.toMaterialColors(): MaterialColors {
|
||||
return MaterialColors(
|
||||
primary = primary,
|
||||
primaryVariant = primaryVariant,
|
||||
secondary = secondary,
|
||||
secondaryVariant = secondaryVariant,
|
||||
background = background,
|
||||
surface = surface,
|
||||
error = error,
|
||||
onPrimary = onPrimary,
|
||||
onSecondary = onSecondary,
|
||||
onBackground = onBackground,
|
||||
onSurface = onSurface,
|
||||
onError = onMessage,
|
||||
isLight = isLight,
|
||||
)
|
||||
}
|
||||
|
||||
internal val LocalColors = staticCompositionLocalOf { lightColors() }
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
@file:Suppress("unused")
|
||||
|
||||
package app.k9mail.core.ui.compose.theme.color
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
internal object MaterialColor {
|
||||
|
||||
val red_50 = Color(color = 0xFFFFEBEE)
|
||||
val red_100 = Color(color = 0xFFFFCDD2)
|
||||
val red_200 = Color(color = 0xFFEF9A9A)
|
||||
val red_300 = Color(color = 0xFFE57373)
|
||||
val red_400 = Color(color = 0xFFEF5350)
|
||||
val red_500 = Color(color = 0xFFF44336)
|
||||
val red_600 = Color(color = 0xFFE53935)
|
||||
val red_700 = Color(color = 0xFFD32F2F)
|
||||
val red_800 = Color(color = 0xFFC62828)
|
||||
val red_900 = Color(color = 0xFFB71C1C)
|
||||
|
||||
val deep_purple_50 = Color(color = 0xFFEDE7F6)
|
||||
val deep_purple_100 = Color(color = 0xFFD1C4E9)
|
||||
val deep_purple_200 = Color(color = 0xFFB39DDB)
|
||||
val deep_purple_300 = Color(color = 0xFF9575CD)
|
||||
val deep_purple_400 = Color(color = 0xFF7E57C2)
|
||||
val deep_purple_500 = Color(color = 0xFF673AB7)
|
||||
val deep_purple_600 = Color(color = 0xFF5E35B1)
|
||||
val deep_purple_700 = Color(color = 0xFF512DA8)
|
||||
val deep_purple_800 = Color(color = 0xFF4527A0)
|
||||
val deep_purple_900 = Color(color = 0xFF311B92)
|
||||
|
||||
val light_blue_50 = Color(color = 0xFFE1F5FE)
|
||||
val light_blue_100 = Color(color = 0xFFB3E5FC)
|
||||
val light_blue_200 = Color(color = 0xFF81D4FA)
|
||||
val light_blue_300 = Color(color = 0xFF4FC3F7)
|
||||
val light_blue_400 = Color(color = 0xFF29B6F6)
|
||||
val light_blue_500 = Color(color = 0xFF03A9F4)
|
||||
val light_blue_600 = Color(color = 0xFF039BE5)
|
||||
val light_blue_700 = Color(color = 0xFF0288D1)
|
||||
val light_blue_800 = Color(color = 0xFF0277BD)
|
||||
val light_blue_900 = Color(color = 0xFF01579B)
|
||||
|
||||
val green_50 = Color(color = 0xFFE8F5E9)
|
||||
val green_100 = Color(color = 0xFFC8E6C9)
|
||||
val green_200 = Color(color = 0xFFA5D6A7)
|
||||
val green_300 = Color(color = 0xFF81C784)
|
||||
val green_400 = Color(color = 0xFF66BB6A)
|
||||
val green_500 = Color(color = 0xFF4CAF50)
|
||||
val green_600 = Color(color = 0xFF43A047)
|
||||
val green_700 = Color(color = 0xFF388E3C)
|
||||
val green_800 = Color(color = 0xFF2E7D32)
|
||||
val green_900 = Color(color = 0xFF1B5E20)
|
||||
|
||||
val yellow_50 = Color(color = 0xFFFFFDE7)
|
||||
val yellow_100 = Color(color = 0xFFFFF9C4)
|
||||
val yellow_200 = Color(color = 0xFFFFF59D)
|
||||
val yellow_300 = Color(color = 0xFFFFF176)
|
||||
val yellow_400 = Color(color = 0xFFFFEE58)
|
||||
val yellow_500 = Color(color = 0xFFFFEB3B)
|
||||
val yellow_600 = Color(color = 0xFFFDD835)
|
||||
val yellow_700 = Color(color = 0xFFFBC02D)
|
||||
val yellow_800 = Color(color = 0xFFF9A825)
|
||||
val yellow_900 = Color(color = 0xFFF57F17)
|
||||
|
||||
val deep_orange_50 = Color(color = 0xFFFBE9E7)
|
||||
val deep_orange_100 = Color(color = 0xFFFFCCBC)
|
||||
val deep_orange_200 = Color(color = 0xFFFFAB91)
|
||||
val deep_orange_300 = Color(color = 0xFFFF8A65)
|
||||
val deep_orange_400 = Color(color = 0xFFFF7043)
|
||||
val deep_orange_500 = Color(color = 0xFFFF5722)
|
||||
val deep_orange_600 = Color(color = 0xFFF4511E)
|
||||
val deep_orange_700 = Color(color = 0xFFE64A19)
|
||||
val deep_orange_800 = Color(color = 0xFFD84315)
|
||||
val deep_orange_900 = Color(color = 0xFFBF360C)
|
||||
|
||||
val blue_gray_50 = Color(color = 0xFFECEFF1)
|
||||
val blue_gray_100 = Color(color = 0xFFCFD8DC)
|
||||
val blue_gray_200 = Color(color = 0xFFB0BEC5)
|
||||
val blue_gray_300 = Color(color = 0xFF90A4AE)
|
||||
val blue_gray_400 = Color(color = 0xFF78909C)
|
||||
val blue_gray_500 = Color(color = 0xFF607D8B)
|
||||
val blue_gray_600 = Color(color = 0xFF546E7A)
|
||||
val blue_gray_700 = Color(color = 0xFF455A64)
|
||||
val blue_gray_800 = Color(color = 0xFF37474F)
|
||||
val blue_gray_900 = Color(color = 0xFF263238)
|
||||
|
||||
val pink_50 = Color(color = 0xFFFCE4EC)
|
||||
val pink_100 = Color(color = 0xFFF8BBD0)
|
||||
val pink_200 = Color(color = 0xFFF48FB1)
|
||||
val pink_300 = Color(color = 0xFFF06292)
|
||||
val pink_400 = Color(color = 0xFFEC407A)
|
||||
val pink_500 = Color(color = 0xFFE91E63)
|
||||
val pink_600 = Color(color = 0xFFD81B60)
|
||||
val pink_700 = Color(color = 0xFFC2185B)
|
||||
val pink_800 = Color(color = 0xFFAD1457)
|
||||
val pink_900 = Color(color = 0xFF880E4F)
|
||||
|
||||
val indigo_50 = Color(color = 0xFFE8EAF6)
|
||||
val indigo_100 = Color(color = 0xFFC5CAE9)
|
||||
val indigo_200 = Color(color = 0xFF9FA8DA)
|
||||
val indigo_300 = Color(color = 0xFF7986CB)
|
||||
val indigo_400 = Color(color = 0xFF5C6BC0)
|
||||
val indigo_500 = Color(color = 0xFF3F51B5)
|
||||
val indigo_600 = Color(color = 0xFF3949AB)
|
||||
val indigo_700 = Color(color = 0xFF303F9F)
|
||||
val indigo_800 = Color(color = 0xFF283593)
|
||||
val indigo_900 = Color(color = 0xFF1A237E)
|
||||
|
||||
val cyan_50 = Color(color = 0xFFE0F7FA)
|
||||
val cyan_100 = Color(color = 0xFFB2EBF2)
|
||||
val cyan_200 = Color(color = 0xFF80DEEA)
|
||||
val cyan_300 = Color(color = 0xFF4DD0E1)
|
||||
val cyan_400 = Color(color = 0xFF26C6DA)
|
||||
val cyan_500 = Color(color = 0xFF00BCD4)
|
||||
val cyan_600 = Color(color = 0xFF00ACC1)
|
||||
val cyan_700 = Color(color = 0xFF0097A7)
|
||||
val cyan_800 = Color(color = 0xFF00838F)
|
||||
val cyan_900 = Color(color = 0xFF006064)
|
||||
|
||||
val light_green_50 = Color(color = 0xFFF1F8E9)
|
||||
val light_green_100 = Color(color = 0xFFDCEDC8)
|
||||
val light_green_200 = Color(color = 0xFFC5E1A5)
|
||||
val light_green_300 = Color(color = 0xFFAED581)
|
||||
val light_green_400 = Color(color = 0xFF9CCC65)
|
||||
val light_green_500 = Color(color = 0xFF8BC34A)
|
||||
val light_green_600 = Color(color = 0xFF7CB342)
|
||||
val light_green_700 = Color(color = 0xFF689F38)
|
||||
val light_green_800 = Color(color = 0xFF558B2F)
|
||||
val light_green_900 = Color(color = 0xFF33691E)
|
||||
|
||||
val amber_50 = Color(color = 0xFFFFF8E1)
|
||||
val amber_100 = Color(color = 0xFFFFECB3)
|
||||
val amber_200 = Color(color = 0xFFFFE082)
|
||||
val amber_300 = Color(color = 0xFFFFD54F)
|
||||
val amber_400 = Color(color = 0xFFFFCA28)
|
||||
val amber_500 = Color(color = 0xFFFFC107)
|
||||
val amber_600 = Color(color = 0xFFFFB300)
|
||||
val amber_700 = Color(color = 0xFFFFA000)
|
||||
val amber_800 = Color(color = 0xFFFF8F00)
|
||||
val amber_900 = Color(color = 0xFFFF6F00)
|
||||
|
||||
val brown_50 = Color(color = 0xFFEFEBE9)
|
||||
val brown_100 = Color(color = 0xFFD7CCC8)
|
||||
val brown_200 = Color(color = 0xFFBCAAA4)
|
||||
val brown_300 = Color(color = 0xFFA1887F)
|
||||
val brown_400 = Color(color = 0xFF8D6E63)
|
||||
val brown_500 = Color(color = 0xFF795548)
|
||||
val brown_600 = Color(color = 0xFF6D4C41)
|
||||
val brown_700 = Color(color = 0xFF5D4037)
|
||||
val brown_800 = Color(color = 0xFF4E342E)
|
||||
val brown_900 = Color(color = 0xFF3E2723)
|
||||
|
||||
val purple_50 = Color(color = 0xFFF3E5F5)
|
||||
val purple_100 = Color(color = 0xFFE1BEE7)
|
||||
val purple_200 = Color(color = 0xFFCE93D8)
|
||||
val purple_300 = Color(color = 0xFFBA68C8)
|
||||
val purple_400 = Color(color = 0xFFAB47BC)
|
||||
val purple_500 = Color(color = 0xFF9C27B0)
|
||||
val purple_600 = Color(color = 0xFF8E24AA)
|
||||
val purple_700 = Color(color = 0xFF7B1FA2)
|
||||
val purple_800 = Color(color = 0xFF6A1B9A)
|
||||
val purple_900 = Color(color = 0xFF4A148C)
|
||||
|
||||
val blue_50 = Color(color = 0xFFE3F2FD)
|
||||
val blue_100 = Color(color = 0xFFBBDEFB)
|
||||
val blue_200 = Color(color = 0xFF90CAF9)
|
||||
val blue_300 = Color(color = 0xFF64B5F6)
|
||||
val blue_400 = Color(color = 0xFF42A5F5)
|
||||
val blue_500 = Color(color = 0xFF2196F3)
|
||||
val blue_600 = Color(color = 0xFF1E88E5)
|
||||
val blue_700 = Color(color = 0xFF1976D2)
|
||||
val blue_800 = Color(color = 0xFF1565C0)
|
||||
val blue_900 = Color(color = 0xFF0D47A1)
|
||||
|
||||
val teal_50 = Color(color = 0xFFE0F2F1)
|
||||
val teal_100 = Color(color = 0xFFB2DFDB)
|
||||
val teal_200 = Color(color = 0xFF80CBC4)
|
||||
val teal_300 = Color(color = 0xFF4DB6AC)
|
||||
val teal_400 = Color(color = 0xFF26A69A)
|
||||
val teal_500 = Color(color = 0xFF009688)
|
||||
val teal_600 = Color(color = 0xFF00897B)
|
||||
val teal_700 = Color(color = 0xFF00796B)
|
||||
val teal_800 = Color(color = 0xFF00695C)
|
||||
val teal_900 = Color(color = 0xFF004D40)
|
||||
|
||||
val lime_50 = Color(color = 0xFFF9FBE7)
|
||||
val lime_100 = Color(color = 0xFFF0F4C3)
|
||||
val lime_200 = Color(color = 0xFFE6EE9C)
|
||||
val lime_300 = Color(color = 0xFFDCE775)
|
||||
val lime_400 = Color(color = 0xFFD4E157)
|
||||
val lime_500 = Color(color = 0xFFCDDC39)
|
||||
val lime_600 = Color(color = 0xFFC0CA33)
|
||||
val lime_700 = Color(color = 0xFFAFB42B)
|
||||
val lime_800 = Color(color = 0xFF9E9D24)
|
||||
val lime_900 = Color(color = 0xFF827717)
|
||||
|
||||
val orange_50 = Color(color = 0xFFFFF3E0)
|
||||
val orange_100 = Color(color = 0xFFFFE0B2)
|
||||
val orange_200 = Color(color = 0xFFFFCC80)
|
||||
val orange_300 = Color(color = 0xFFFFB74D)
|
||||
val orange_400 = Color(color = 0xFFFFA726)
|
||||
val orange_500 = Color(color = 0xFFFF9800)
|
||||
val orange_600 = Color(color = 0xFFFB8C00)
|
||||
val orange_700 = Color(color = 0xFFF57C00)
|
||||
val orange_800 = Color(color = 0xFFEF6C00)
|
||||
val orange_900 = Color(color = 0xFFE65100)
|
||||
|
||||
val gray_50 = Color(color = 0xFFFAFAFA)
|
||||
val gray_100 = Color(color = 0xFFF5F5F5)
|
||||
val gray_200 = Color(color = 0xFFEEEEEE)
|
||||
val gray_300 = Color(color = 0xFFE0E0E0)
|
||||
val gray_400 = Color(color = 0xFFBDBDBD)
|
||||
val gray_500 = Color(color = 0xFF9E9E9E)
|
||||
val gray_600 = Color(color = 0xFF757575)
|
||||
val gray_700 = Color(color = 0xFF616161)
|
||||
val gray_800 = Color(color = 0xFF424242)
|
||||
val gray_900 = Color(color = 0xFF212121)
|
||||
val gray_950 = Color(color = 0xFF121212)
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="192"
|
||||
android:viewportHeight="192">
|
||||
<group
|
||||
android:scaleX="0.52411765"
|
||||
android:scaleY="0.52411765"
|
||||
android:translateX="45.684708"
|
||||
android:translateY="44.75294">
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M50,12C46.68,12 44,14.68 44,18V26C44,29.32 46.68,32 50,32H64V48H72V32H74C77.32,32 80,29.32 80,26V18C80,14.68 77.32,12 74,12H50ZM118,12C114.68,12 112,14.68 112,18V26C112,29.32 114.68,32 118,32H120V48H128V32H142C145.32,32 148,29.32 148,26V18C148,14.68 145.32,12 142,12H118ZM32,120V132L57.61,170C59.68,173.59 63.54,176 68,176H124C128.46,176 132.32,173.59 134.39,170H134.4L160,132V120H32Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M50,8C46.68,8 44,10.68 44,14V22C44,25.32 46.68,28 50,28H64V44H72V28H74C77.32,28 80,25.32 80,22V14C80,10.68 77.32,8 74,8H50ZM118,8C114.68,8 112,10.68 112,14V22C112,25.32 114.68,28 118,28H120V44H128V28H142C145.32,28 148,25.32 148,22V14C148,10.68 145.32,8 142,8H118ZM32,116V128L57.61,166C59.68,169.59 63.54,172 68,172H124C128.46,172 132.32,169.59 134.39,166H134.4L160,128V116H32Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M24,116L32,128L57.61,166C59.68,169.59 63.54,172 68,172H124C128.46,172 132.32,169.59 134.39,166H134.4L160,128L168,116H24Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillColor="#607D8B"
|
||||
android:pathData="M32,116V128L57.61,166C59.68,169.59 63.54,172 68,172H124C128.46,172 132.32,169.59 134.39,166H134.4L160.01,128V116H32Z" />
|
||||
<path
|
||||
android:fillColor="#263238"
|
||||
android:pathData="M72,16H64V44H72V16Z" />
|
||||
<path
|
||||
android:fillColor="#263238"
|
||||
android:pathData="M128,16H120V44H128V16Z" />
|
||||
<path
|
||||
android:fillColor="#4D6570"
|
||||
android:pathData="M32,127V128L57.61,166C59.68,169.59 63.54,172 68,172H124C128.46,172 132.32,169.59 134.39,166H134.4L160,128V127L134.4,165H134.39C132.32,168.59 128.46,171 124,171H68C63.54,171 59.68,168.59 57.61,165L32,127Z" />
|
||||
<path
|
||||
android:fillColor="#607D8B"
|
||||
android:pathData="M80,22V14C80,10.69 77.31,8 74,8L50,8C46.69,8 44,10.69 44,14V22C44,25.31 46.69,28 50,28L74,28C77.31,28 80,25.31 80,22Z" />
|
||||
<path
|
||||
android:fillColor="#607D8B"
|
||||
android:pathData="M148,22V14C148,10.69 145.31,8 142,8L118,8C114.69,8 112,10.69 112,14V22C112,25.31 114.69,28 118,28L142,28C145.31,28 148,25.31 148,22Z" />
|
||||
<path
|
||||
android:fillColor="#4D6570"
|
||||
android:pathData="M44,21V22C44,25.32 46.68,28 50,28H74C77.32,28 80,25.32 80,22V21C80,24.32 77.32,27 74,27H50C46.68,27 44,24.32 44,21Z" />
|
||||
<path
|
||||
android:fillColor="#4D6570"
|
||||
android:pathData="M112,21V22C112,25.32 114.68,28 118,28H142C145.32,28 148,25.32 148,22V21C148,24.32 145.32,27 142,27H118C114.68,27 112,24.32 112,21Z" />
|
||||
<path
|
||||
android:fillColor="#8097A2"
|
||||
android:pathData="M50,8C46.68,8 44,10.68 44,14V15C44,11.68 46.68,9 50,9H74C77.32,9 80,11.68 80,15V14C80,10.68 77.32,8 74,8H50Z" />
|
||||
<path
|
||||
android:fillColor="#8097A2"
|
||||
android:pathData="M118,8C114.68,8 112,10.68 112,14V15C112,11.68 114.68,9 118,9H142C145.32,9 148,11.68 148,15V14C148,10.68 145.32,8 142,8H118Z" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#37abc8"
|
||||
android:pathData="M171.99,120V52C171.99,45.37 166.62,40 159.99,40L31.99,40C25.37,40 19.99,45.37 19.99,52V120C19.99,126.62 25.37,132 31.99,132H159.99C166.62,132 171.99,126.62 171.99,120Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#5fbcd3"
|
||||
android:pathData="M171.99,116V48C171.99,41.37 166.62,36 159.99,36L31.99,36C25.37,36 19.99,41.37 19.99,48V116C19.99,122.62 25.37,128 31.99,128L159.99,128C166.62,128 171.99,122.62 171.99,116Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillColor="#FF2C55"
|
||||
android:pathData="M172,116V48C172,41.37 166.63,36 160,36L32,36C25.37,36 20,41.37 20,48V116C20,122.63 25.37,128 32,128H160C166.63,128 172,122.63 172,116Z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M36,52L96,84L156,52"
|
||||
android:strokeWidth="6"
|
||||
android:strokeColor="#FBE9E7"
|
||||
android:strokeLineCap="round" />
|
||||
<path
|
||||
android:fillColor="#FF2C55"
|
||||
android:pathData="M32,36C25.35,36 20,41.35 20,48V49C20,42.35 25.35,37 32,37H160C166.65,37 172,42.35 172,49V48C172,41.35 166.65,36 160,36H32Z" />
|
||||
<path
|
||||
android:fillColor="#C2185B"
|
||||
android:pathData="M20,115V116C20,122.65 25.35,128 32,128H160C166.65,128 172,122.65 172,116V115C172,121.65 166.65,127 160,127H32C25.35,127 20,121.65 20,115Z" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M90,156C86.68,156 84,158.68 84,162V174C84,174.27 84.03,174.54 84.06,174.8C84.06,174.8 84.06,174.81 84.06,174.81C84.02,175.2 84,175.6 84,176C84,179.18 85.26,182.23 87.51,184.48C89.77,186.73 92.82,188 96,188C99.18,188 102.24,186.73 104.49,184.48C106.74,182.23 108,179.18 108,176C108,175.61 107.97,175.23 107.93,174.85C107.97,174.57 108,174.29 108,174V162C108,158.67 105.33,156 102,156L90,156Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M90,152C86.68,152 84,154.68 84,158V170C84,170.27 84.03,170.54 84.06,170.8C84.06,170.8 84.06,170.81 84.06,170.81C84.02,171.2 84,171.6 84,172C84,175.18 85.26,178.23 87.51,180.48C89.77,182.73 92.82,184 96,184C99.18,184 102.24,182.73 104.49,180.48C106.74,178.23 108,175.18 108,172C108,171.61 107.97,171.23 107.93,170.85C107.97,170.57 108,170.29 108,170V158C108,154.67 105.33,152 102,152L90,152Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillColor="#263238"
|
||||
android:pathData="M108,170V158C108,154.69 105.31,152 102,152H90C86.69,152 84,154.69 84,158V170C84,173.31 86.69,176 90,176H102C105.31,176 108,173.31 108,170Z" />
|
||||
<path
|
||||
android:fillColor="#263238"
|
||||
android:pathData="M96,184C102.63,184 108,178.63 108,172C108,165.37 102.63,160 96,160C89.37,160 84,165.37 84,172C84,178.63 89.37,184 96,184Z" />
|
||||
<path
|
||||
android:fillColor="#37474F"
|
||||
android:pathData="M90,152C86.68,152 84,154.68 84,158V159C84,155.68 86.68,153 90,153H102C105.32,153 108,155.68 108,159V158C108,154.68 105.32,152 102,152H90Z" />
|
||||
<path
|
||||
android:fillColor="#1A252A"
|
||||
android:pathData="M84.02,171.43C84.01,171.62 84,171.81 84,172C84,175.18 85.26,178.24 87.51,180.49C89.77,182.74 92.82,184 96,184C99.18,184 102.24,182.74 104.49,180.49C106.74,178.24 108,175.18 108,172C108,171.86 107.99,171.73 107.98,171.59C107.83,174.67 106.5,177.57 104.27,179.69C102.04,181.81 99.08,183 96,183C92.89,183 89.91,181.79 87.68,179.63C85.44,177.47 84.13,174.53 84.02,171.43Z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="192"
|
||||
android:viewportHeight="192">
|
||||
<group
|
||||
android:scaleX="0.52411765"
|
||||
android:scaleY="0.52411765"
|
||||
android:translateX="45.684708"
|
||||
android:translateY="44.75294">
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M50,12C46.68,12 44,14.68 44,18V26C44,29.32 46.68,32 50,32H64V48H72V32H74C77.32,32 80,29.32 80,26V18C80,14.68 77.32,12 74,12H50ZM118,12C114.68,12 112,14.68 112,18V26C112,29.32 114.68,32 118,32H120V48H128V32H142C145.32,32 148,29.32 148,26V18C148,14.68 145.32,12 142,12H118ZM32,120V132L57.61,170C59.68,173.59 63.54,176 68,176H124C128.46,176 132.32,173.59 134.39,170H134.4L160,132V120H32Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M50,8C46.68,8 44,10.68 44,14V22C44,25.32 46.68,28 50,28H64V44H72V28H74C77.32,28 80,25.32 80,22V14C80,10.68 77.32,8 74,8H50ZM118,8C114.68,8 112,10.68 112,14V22C112,25.32 114.68,28 118,28H120V44H128V28H142C145.32,28 148,25.32 148,22V14C148,10.68 145.32,8 142,8H118ZM32,116V128L57.61,166C59.68,169.59 63.54,172 68,172H124C128.46,172 132.32,169.59 134.39,166H134.4L160,128V116H32Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M24,116L32,128L57.61,166C59.68,169.59 63.54,172 68,172H124C128.46,172 132.32,169.59 134.39,166H134.4L160,128L168,116H24Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillColor="#607D8B"
|
||||
android:pathData="M32,116V128L57.61,166C59.68,169.59 63.54,172 68,172H124C128.46,172 132.32,169.59 134.39,166H134.4L160.01,128V116H32Z" />
|
||||
<path
|
||||
android:fillColor="#263238"
|
||||
android:pathData="M72,16H64V44H72V16Z" />
|
||||
<path
|
||||
android:fillColor="#263238"
|
||||
android:pathData="M128,16H120V44H128V16Z" />
|
||||
<path
|
||||
android:fillColor="#4D6570"
|
||||
android:pathData="M32,127V128L57.61,166C59.68,169.59 63.54,172 68,172H124C128.46,172 132.32,169.59 134.39,166H134.4L160,128V127L134.4,165H134.39C132.32,168.59 128.46,171 124,171H68C63.54,171 59.68,168.59 57.61,165L32,127Z" />
|
||||
<path
|
||||
android:fillColor="#607D8B"
|
||||
android:pathData="M80,22V14C80,10.69 77.31,8 74,8L50,8C46.69,8 44,10.69 44,14V22C44,25.31 46.69,28 50,28L74,28C77.31,28 80,25.31 80,22Z" />
|
||||
<path
|
||||
android:fillColor="#607D8B"
|
||||
android:pathData="M148,22V14C148,10.69 145.31,8 142,8L118,8C114.69,8 112,10.69 112,14V22C112,25.31 114.69,28 118,28L142,28C145.31,28 148,25.31 148,22Z" />
|
||||
<path
|
||||
android:fillColor="#4D6570"
|
||||
android:pathData="M44,21V22C44,25.32 46.68,28 50,28H74C77.32,28 80,25.32 80,22V21C80,24.32 77.32,27 74,27H50C46.68,27 44,24.32 44,21Z" />
|
||||
<path
|
||||
android:fillColor="#4D6570"
|
||||
android:pathData="M112,21V22C112,25.32 114.68,28 118,28H142C145.32,28 148,25.32 148,22V21C148,24.32 145.32,27 142,27H118C114.68,27 112,24.32 112,21Z" />
|
||||
<path
|
||||
android:fillColor="#8097A2"
|
||||
android:pathData="M50,8C46.68,8 44,10.68 44,14V15C44,11.68 46.68,9 50,9H74C77.32,9 80,11.68 80,15V14C80,10.68 77.32,8 74,8H50Z" />
|
||||
<path
|
||||
android:fillColor="#8097A2"
|
||||
android:pathData="M118,8C114.68,8 112,10.68 112,14V15C112,11.68 114.68,9 118,9H142C145.32,9 148,11.68 148,15V14C148,10.68 145.32,8 142,8H118Z" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#37abc8"
|
||||
android:pathData="M171.99,120V52C171.99,45.37 166.62,40 159.99,40L31.99,40C25.37,40 19.99,45.37 19.99,52V120C19.99,126.62 25.37,132 31.99,132H159.99C166.62,132 171.99,126.62 171.99,120Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#5fbcd3"
|
||||
android:pathData="M171.99,116V48C171.99,41.37 166.62,36 159.99,36L31.99,36C25.37,36 19.99,41.37 19.99,48V116C19.99,122.62 25.37,128 31.99,128L159.99,128C166.62,128 171.99,122.62 171.99,116Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillColor="#1E88E5"
|
||||
android:pathData="M172,116V48C172,41.37 166.63,36 160,36L32,36C25.37,36 20,41.37 20,48V116C20,122.63 25.37,128 32,128H160C166.63,128 172,122.63 172,116Z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M36,52L96,84L156,52"
|
||||
android:strokeWidth="6"
|
||||
android:strokeColor="#FBE9E7"
|
||||
android:strokeLineCap="round" />
|
||||
<path
|
||||
android:fillColor="#1E88E5"
|
||||
android:pathData="M32,36C25.35,36 20,41.35 20,48V49C20,42.35 25.35,37 32,37H160C166.65,37 172,42.35 172,49V48C172,41.35 166.65,36 160,36H32Z" />
|
||||
<path
|
||||
android:fillColor="#00796B"
|
||||
android:pathData="M20,115V116C20,122.65 25.35,128 32,128H160C166.65,128 172,122.65 172,116V115C172,121.65 166.65,127 160,127H32C25.35,127 20,121.65 20,115Z" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M90,156C86.68,156 84,158.68 84,162V174C84,174.27 84.03,174.54 84.06,174.8C84.06,174.8 84.06,174.81 84.06,174.81C84.02,175.2 84,175.6 84,176C84,179.18 85.26,182.23 87.51,184.48C89.77,186.73 92.82,188 96,188C99.18,188 102.24,186.73 104.49,184.48C106.74,182.23 108,179.18 108,176C108,175.61 107.97,175.23 107.93,174.85C107.97,174.57 108,174.29 108,174V162C108,158.67 105.33,156 102,156L90,156Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillAlpha="0.2"
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M90,152C86.68,152 84,154.68 84,158V170C84,170.27 84.03,170.54 84.06,170.8C84.06,170.8 84.06,170.81 84.06,170.81C84.02,171.2 84,171.6 84,172C84,175.18 85.26,178.23 87.51,180.48C89.77,182.73 92.82,184 96,184C99.18,184 102.24,182.73 104.49,180.48C106.74,178.23 108,175.18 108,172C108,171.61 107.97,171.23 107.93,170.85C107.97,170.57 108,170.29 108,170V158C108,154.67 105.33,152 102,152L90,152Z"
|
||||
android:strokeAlpha="0.2" />
|
||||
<path
|
||||
android:fillColor="#263238"
|
||||
android:pathData="M108,170V158C108,154.69 105.31,152 102,152H90C86.69,152 84,154.69 84,158V170C84,173.31 86.69,176 90,176H102C105.31,176 108,173.31 108,170Z" />
|
||||
<path
|
||||
android:fillColor="#263238"
|
||||
android:pathData="M96,184C102.63,184 108,178.63 108,172C108,165.37 102.63,160 96,160C89.37,160 84,165.37 84,172C84,178.63 89.37,184 96,184Z" />
|
||||
<path
|
||||
android:fillColor="#37474F"
|
||||
android:pathData="M90,152C86.68,152 84,154.68 84,158V159C84,155.68 86.68,153 90,153H102C105.32,153 108,155.68 108,159V158C108,154.68 105.32,152 102,152H90Z" />
|
||||
<path
|
||||
android:fillColor="#1A252A"
|
||||
android:pathData="M84.02,171.43C84.01,171.62 84,171.81 84,172C84,175.18 85.26,178.24 87.51,180.49C89.77,182.74 92.82,184 96,184C99.18,184 102.24,182.74 104.49,180.49C106.74,178.24 108,175.18 108,172C108,171.86 107.99,171.73 107.98,171.59C107.83,174.67 106.5,177.57 104.27,179.69C102.04,181.81 99.08,183 96,183C92.89,183 89.91,181.79 87.68,179.63C85.44,177.47 84.13,174.53 84.02,171.43Z" />
|
||||
</group>
|
||||
</vector>
|
||||
Loading…
Add table
Add a link
Reference in a new issue