...
 
Commits (13)
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="EntryPointsManager">
<list size="1">
<item index="0" class="java.lang.String" itemvalue="dagger.Binds" />
</list>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="false" project-jdk-name="1.8" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="false" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
......
...@@ -14,8 +14,7 @@ android { ...@@ -14,8 +14,7 @@ android {
targetSdkVersion 30 targetSdkVersion 30
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
testInstrumentationRunner "com.ignacio.composesample1.CustomTestRunner"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
...@@ -37,7 +36,24 @@ android { ...@@ -37,7 +36,24 @@ android {
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion compose_version kotlinCompilerExtensionVersion compose_version
kotlinCompilerVersion '1.4.20' kotlinCompilerVersion '1.4.21'
}
packagingOptions {
exclude "/META-INF/AL2.0"
exclude "/META-INF/LGPL2.1"
exclude "win32-x86/attach_hotspot_windows.dll"
exclude 'win32-x86-64/attach_hotspot_windows.dll'
exclude 'META-INF/licenses/ASM'
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/license.txt'
exclude 'META-INF/NOTICE'
exclude 'META-INF/NOTICE.txt'
exclude 'META-INF/notice.txt'
exclude 'META-INF/ASL2.0'
exclude("META-INF/*.kotlin_module")
} }
} }
...@@ -46,18 +62,22 @@ dependencies { ...@@ -46,18 +62,22 @@ dependencies {
implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.2.1' implementation 'com.google.android.material:material:1.2.1'
implementation "androidx.compose.runtime:runtime:$compose_version"
implementation "androidx.compose.ui:ui:$compose_version" implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version" implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version" implementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha06' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-rc01'
implementation "androidx.navigation:navigation-compose:1.0.0-alpha03" implementation "androidx.navigation:navigation-compose:1.0.0-alpha04"
implementation "androidx.compose.material:material-icons-extended:$compose_version" implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation 'com.jakewharton.timber:timber:4.7.1' implementation 'com.jakewharton.timber:timber:4.7.1'
implementation "com.google.dagger:hilt-android:$hilt_version" implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version" kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
implementation "androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02" implementation "androidx.hilt:hilt-lifecycle-viewmodel:$hilt_androidx_version"
kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha02' kapt "androidx.hilt:hilt-compiler:$hilt_androidx_version"
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
androidTestImplementation "androidx.hilt:hilt-lifecycle-viewmodel:$hilt_androidx_version"
// database: Room // database: Room
implementation "androidx.room:room-runtime:2.2.6" implementation "androidx.room:room-runtime:2.2.6"
...@@ -69,6 +89,10 @@ dependencies { ...@@ -69,6 +89,10 @@ dependencies {
testImplementation 'junit:junit:4.13.1' testImplementation 'junit:junit:4.13.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
androidTestImplementation("androidx.ui:ui-test:$compose_version") androidTestImplementation "androidx.compose.ui:ui-test:$compose_version"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
androidTestImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
androidTestImplementation "org.mockito:mockito-android:3.5.0"
} }
\ No newline at end of file
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.ignacio.composesample1
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
package com.ignacio.composesample1.ui
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.ignacio.composesample1.MainActivity
import com.ignacio.composesample1.R
import com.ignacio.composesample1.data.FakeMailDataSource
import com.ignacio.composesample1.data.MailDataSource
import com.ignacio.composesample1.data.MailRepository
import com.ignacio.composesample1.data.MailRepositoryImpl
import com.ignacio.composesample1.di.DataModule
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import dagger.hilt.components.SingletonComponent
import org.junit.Rule
import org.junit.Test
import javax.inject.Singleton
@UninstallModules(DataModule::class)
@HiltAndroidTest
class MainScreenTest {
@JvmField
@Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@JvmField
@Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
private val helper = UITestHelper(composeTestRule)
@Module
@InstallIn(SingletonComponent::class)
abstract class TestDataModule {
@Binds
@Singleton
abstract fun bindMailDataSource(
fakeMailDataSource: FakeMailDataSource
): MailDataSource
@Binds
abstract fun bindMailRepository(
mailRepositoryImpl: MailRepositoryImpl
): MailRepository
}
@ExperimentalAnimationApi
@Test
fun navigationTest() {
assertWeAreInMessageListScreen()
openMessage(0)
assertWeAreInMessageViewScreen()
closeMessage()
assertWeAreInMessageListScreen()
openMessage(1)
assertWeAreInMessageViewScreen()
}
@Test
fun flagMessageTest() {
assertWeAreInMessageListScreen()
openMessage(0)
assertMessageFlaggedInMessageView(false).performClick()
assertMessageFlaggedInMessageView(true)
closeMessage()
assertMessageFlaggedInMessageList(0, true).performClick()
openMessage(0)
assertMessageFlaggedInMessageView(false)
}
@Test
fun toggleMessageUnreadTest() {
assertWeAreInMessageListScreen()
assertMessageUnreadInMessageList(0, true)
openMessage(0)
openOptionsMenu()
helper.assertNodeWithText(R.string.mark_as_unread_action)
closeMessage()
assertMessageUnreadInMessageList(0, false)
openMessage(0)
openOptionsMenu()
helper.assertNodeWithText(R.string.mark_as_unread_action).performClick()
openOptionsMenu()
helper.assertNodeWithText(R.string.mark_as_read_action)
closeMessage()
assertMessageUnreadInMessageList(0, true)
}
@Test
fun deleteMessageTest() {
assertWeAreInMessageListScreen()
openMessage(1)
deleteMessage()
assertWeAreInMessageListScreen()
composeTestRule
.onNodeWithTag("messageViewHolderTitleAndAbstract1")
.assertDoesNotExist()
}
private fun deleteMessage() {
helper.assertNodeWithTag("messageViewDeleteMenuItem").performClick()
}
private fun closeMessage() {
helper.assertNodeWithTag("messageViewNavigationIcon").performClick()
assertWeAreInMessageListScreen()
}
private fun assertMessageFlaggedInMessageView(flagged: Boolean): SemanticsNodeInteraction {
return helper.assertInternalNodeWithTag("messageViewFlagMenuItem").assert(
hasContentDescription(
helper.context.getString(
if (flagged) R.string.message_remove_star_action
else R.string.message_add_star_action
)
)
)
}
@Suppress("SameParameterValue")
private fun assertMessageFlaggedInMessageList(
id: Int,
flagged: Boolean
): SemanticsNodeInteraction {
return helper.assertInternalNodeWithTag("messageViewHolderFlag$id").assert(
hasContentDescription(
helper.context.getString(
if (flagged) R.string.message_remove_star_action
else R.string.message_add_star_action
)
)
)
}
private fun assertWeAreInMessageListScreen() {
helper.assertNodeWithText(R.string.app_name)
helper.assertNodeWithText(R.string.subtitle_dummy)
}
private fun assertWeAreInMessageViewScreen() {
helper.assertNodeWithTag("messageViewTopBarBadge")
helper.assertNodeWithText(R.string.secure_and_trusted)
}
private fun openMessage(id: Int) {
helper.assertInternalNodeWithTag("messageViewHolderTitleAndAbstract$id")
.performClick()
assertWeAreInMessageViewScreen()
}
private fun openOptionsMenu() {
helper.assertNodeWithTag("overflowMenu").performClick()
}
@Suppress("SameParameterValue")
private fun assertMessageUnreadInMessageList(
id: Int,
unread: Boolean
): SemanticsNodeInteraction {
return helper.assertNodeWithTag("messageViewHolderTitleAndAbstract$id")
.assert(
hasContentDescription(
helper.context.getString(
if (unread) R.string.unread_message_conent_description
else R.string.read_message_conent_description
)
)
)
}
}
\ No newline at end of file
package com.ignacio.composesample1.ui
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.unit.Duration
import androidx.lifecycle.SavedStateHandle
import androidx.test.core.app.ApplicationProvider
import com.ignacio.composesample1.data.FakeMailDataSource
import com.ignacio.composesample1.data.MailRepositoryImpl
import com.ignacio.composesample1.domain.MyMessage
import com.ignacio.composesample1.ui.home.HomeViewModel
import com.ignacio.composesample1.ui.home.HomeViewModelImpl
import com.ignacio.composesample1.ui.messagelist.MessageViewHolderActionListener
import com.ignacio.composesample1.ui.messageview.MessageViewMenuActionListener
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import timber.log.Timber
class UITestHelper(private val composeTestRule: ComposeTestRule) {
val context = ApplicationProvider.getApplicationContext<Context>()
private var result = "nothing yet"
val fakeMailDataSource = FakeMailDataSource()
val account = fakeMailDataSource.accounts.first()
val username = account.username
val email = account.email
val allAccounts = fakeMailDataSource.accounts
val specialFolders = fakeMailDataSource.sfolders
val folders = fakeMailDataSource.folders
val allMessages = fakeMailDataSource.messages
val drawerActionClickListener: DrawerActionClickListener = mock()
val optionsMenuItemClickListener: OptionsMenuItemClickListener = mock()
val messageViewHolderActionListener: MessageViewHolderActionListener = mock()
val messageViewMenuActionListener: MessageViewMenuActionListener = mock()
val viewModel: HomeViewModel = mock()
fun stubViewModel(
currentMessage: MyMessage?,
messageVisible: Boolean
) {
doReturn(mutableStateOf(specialFolders)).`when`(viewModel).specialFolders
doReturn(mutableStateOf(folders)).`when`(viewModel).allFolders
doReturn(mutableStateOf(allMessages)).`when`(viewModel).allMessages
doReturn(mutableStateOf(allAccounts)).`when`(viewModel).installedAccounts
doReturn(currentMessage).`when`(viewModel).currentMessage
doReturn(messageVisible).`when`(viewModel).messageVisible
}
//HomeViewModelImpl(MailRepositoryImpl(FakeMailDataSource()), SavedStateHandle())
/**
* Perform [SemanticsNodeInteraction], assertions, etc given by the lambda [interactionOnEach]
* on each item ("viewholder") of the scrollable list widget with tag [listTag].
* On [AssertionError], it performs the lambda [onAssertionFailed]. This is by default
* swipping up the list so that unseen items of the list appear.
* There is a limit for consecutive failures, [maxFailures]. If we reach this limit the first
* exception that caused the succession of failures will be thrown.
*/
fun <T> assertScrollableList(
listTag: String,
itemsList: List<T>,
useUnmergedTree: Boolean = false,
maxFailures: Int = 3,
onAssertionFailed: (Int) -> Unit = {
swipeListUpOneStep(listTag, useUnmergedTree)
},
interactionOnEach: (Int) -> Unit
) {
composeTestRule.onNodeWithTag(listTag, useUnmergedTree)
.assertIsDisplayed()
var i = 0
var failures = 0
var firstError: AssertionError? = null
while (i < itemsList.size) {
try {
interactionOnEach(i)
i++
firstError = null
failures = 0
} catch (e: AssertionError) {
firstError ?: let { firstError = e }
Timber.w(e, "Assertion failed in list: ")
failures++
if (failures == maxFailures) {
Timber.e(
"==================================\n" +
"assertScrollableList failed"
)
throw firstError!!
}
onAssertionFailed(i)
}
}
}
private fun swipeListUpOneStep(
listTag: String,
useUnmergedTree: Boolean = false,
startPositionX: Float = 200f,
endPostionX: Float = 200f,
startPositionY: Float = 300f,
endPositionY: Float = 100f
) {
composeTestRule.onNodeWithTag(listTag, useUnmergedTree)
.assertIsDisplayed()
.performGesture {
swipe(
start = Offset(startPositionX, startPositionY),
end = Offset(endPostionX, endPositionY),
duration = Duration(milliseconds = 500)
)
}
}
fun findInternalNodeWithText(text: String) =
composeTestRule.onNodeWithText(text, useUnmergedTree = true)
fun assertInternalNodeWithText(text: String) =
findInternalNodeWithText(text).assertIsDisplayed()
fun findInternalNodeWithText(@StringRes text: Int) =
composeTestRule.onNodeWithText(
context.getString(text),
useUnmergedTree = true
)
fun assertInternalNodeWithText(@StringRes text: Int) =
findInternalNodeWithText(text).assertIsDisplayed()
fun findInternalNodeWithTag(tag: String) =
composeTestRule.onNodeWithTag(tag, useUnmergedTree = true)
fun assertInternalNodeWithTag(tag: String) =
findInternalNodeWithTag(tag).assertIsDisplayed()
fun findNodeWithText(text: String) =
composeTestRule.onNodeWithText(text)
fun assertNodeWithText(text: String) =
composeTestRule.onNodeWithText(text).assertIsDisplayed()
fun findNodeWithText(@StringRes text: Int) =
composeTestRule.onNodeWithText(context.getString(text))
fun assertNodeWithText(@StringRes text: Int) =
findNodeWithText(text).assertIsDisplayed()
fun findNodeWithTag(tag: String) =
composeTestRule.onNodeWithTag(tag)
fun assertNodeWithTag(tag: String) =
findNodeWithTag(tag).assertIsDisplayed()
fun launchSreen(screen: @Composable () -> Unit) {
composeTestRule.setContent {
ComposeSample1Theme {
screen()
}
}
}
}
\ No newline at end of file
package com.ignacio.composesample1.ui.messagelist
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.performClick
import com.ignacio.composesample1.R
import com.ignacio.composesample1.ui.DrawerUser
import com.ignacio.composesample1.ui.UITestHelper
import com.nhaarman.mockitokotlin2.verify
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class DrawerTest {
@get:Rule
val composeTestRule = createComposeRule()
private val helper = UITestHelper(composeTestRule)
private val drawerUser = DrawerUser()
@Before
fun setUp() {
helper.stubViewModel(null, false)
helper.launchSreen {
MessageListScreen(
drawerUser = drawerUser,
drawerActionClickListener = helper.drawerActionClickListener,
optionsMenuItemClickListener = helper.optionsMenuItemClickListener,
homeViewModel = helper.viewModel
)
}
}
@Test
fun app_launches() {
helper.assertNodeWithText(R.string.app_name)
}
@Test
fun drawer_test() {
openDrawer()
helper.assertInternalNodeWithTag("currentAccountAvatar")
helper.assertInternalNodeWithText(helper.username)
helper.assertInternalNodeWithText(helper.email)
helper.assertInternalNodeWithTag("otherAccountClicker1").performClick()
verify(helper.drawerActionClickListener).onDrawerAccountCircleClicked(helper.allAccounts[1])
helper.assertInternalNodeWithTag("otherAccountClicker2").performClick()
verify(helper.drawerActionClickListener).onDrawerAccountCircleClicked(helper.allAccounts[2])
assertSpecialFolderList()
helper.assertInternalNodeWithText(R.string.folders_title)
assertNormalFolderList()
helper.assertInternalNodeWithText(helper.username).performClick()
assertAccountList()
assertAccountModeActions()
drawerUser.closeDrawer()
assertDrawerClosed()
}
private fun assertDrawerClosed() {
helper.findInternalNodeWithTag("currentAccountAvatar").assertIsNotDisplayed()
helper.findInternalNodeWithText(helper.username).assertIsNotDisplayed()
helper.findInternalNodeWithText(helper.email).assertIsNotDisplayed()
}
private fun assertAccountModeActions() {
helper.assertInternalNodeWithText(R.string.add_account_action).performClick()
verify(helper.drawerActionClickListener).onDrawerActionItemClicked(R.string.add_account_action)
helper.assertInternalNodeWithText(R.string.settings_action).performClick()
verify(helper.drawerActionClickListener).onDrawerActionItemClicked(R.string.settings_action)
}
private fun openDrawer() {
helper.assertNodeWithTag("drawerIcon").performClick()
}
private fun assertSpecialFolderList() {
helper.assertScrollableList(
"drawerSpecialFolderList",
helper.specialFolders,
true
) { index ->
val folder = helper.specialFolders[index]
helper.assertInternalNodeWithText(folder.name).performClick()
verify(helper.drawerActionClickListener).onDrawerFolderItemClicked(folder)
}
}
private fun assertNormalFolderList() {
helper.assertScrollableList(
"drawerFolderList",
helper.folders,
true
) { index ->
val folder = helper.folders[index]
helper.assertInternalNodeWithText(folder.name).performClick()
verify(helper.drawerActionClickListener).onDrawerFolderItemClicked(folder)
}
}
private fun assertAccountList() {
val otherAccounts = helper.allAccounts
.takeLast(helper.allAccounts.size - 1)
helper.assertScrollableList(
"drawerAccountList",
otherAccounts,
true
) { index ->
val account = otherAccounts[index]
helper.assertInternalNodeWithText(account.email).performClick()
verify(helper.drawerActionClickListener).onDrawerAccountItemClicked(account)
}
}
}
\ No newline at end of file
package com.ignacio.composesample1.ui.messagelist
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithSubstring
import androidx.compose.ui.test.performClick
import com.ignacio.composesample1.R
import com.ignacio.composesample1.ui.UITestHelper
import com.nhaarman.mockitokotlin2.reset
import com.nhaarman.mockitokotlin2.verify
import org.junit.Rule
import org.junit.Test
import java.text.SimpleDateFormat
import java.util.*
class MessageListTest {
@get:Rule
val composeTestRule = createComposeRule()
private val helper = UITestHelper(composeTestRule)
@Test
fun messageListTopBarTest() {
helper.launchSreen {
MessageListMainLayout(
optionsMenuItemClickListener = helper.optionsMenuItemClickListener
)
}
helper.assertNodeWithText(R.string.app_name)
helper.assertNodeWithText(R.string.subtitle_dummy)
helper.assertNodeWithTag("searchAction").performClick()
verify(helper.optionsMenuItemClickListener).onOptionsMenuItemClicked(R.string.search_action)
helper.assertNodeWithTag("overflowMenu").performClick()
helper.assertNodeWithText(R.string.option_1_action).performClick()
verify(helper.optionsMenuItemClickListener).onOptionsMenuItemClicked(R.string.option_1_action)
helper.findNodeWithText(R.string.option_1_action).assertDoesNotExist()
helper.assertNodeWithTag("overflowMenu").performClick()
helper.assertNodeWithText(R.string.option_2_action).performClick()
verify(helper.optionsMenuItemClickListener).onOptionsMenuItemClicked(R.string.option_2_action)
helper.findNodeWithText(R.string.option_2_action).assertDoesNotExist()
}
@Test
fun MessageListTest() {
helper.launchSreen {
MessageList(
messages = helper.allMessages,
messageViewHolderActionListener = helper.messageViewHolderActionListener
)
}
helper.assertScrollableList(
"messageList",
helper.allMessages
) { position ->
val message = helper.allMessages[position]
if (position == 0) {
composeTestRule
.onNodeWithSubstring("Subject${message.id}", useUnmergedTree = true)
.assertIsDisplayed()
composeTestRule
.onNodeWithSubstring("Body${message.id}", useUnmergedTree = true)
.assertIsDisplayed()
} else {
helper.assertInternalNodeWithText("Subject${message.id}")
helper.assertInternalNodeWithText("Body${message.id}")
}
helper.assertNodeWithTag("messageViewHolderContactBadge${message.id}").performClick()
verify(helper.messageViewHolderActionListener).onContactClicked(message.from.first())
helper.assertNodeWithTag("messageViewHolderTitleAndAbstract${message.id}")
.performClick()
verify(helper.messageViewHolderActionListener).onMessageClicked(message)
val sdf = SimpleDateFormat("MMM d", Locale.getDefault())
helper.assertNodeWithTag("messageViewHolderDate${message.id}")
.assertTextEquals(sdf.format(Date()))
helper.assertNodeWithTag("messageViewHolderFlag${message.id}").performClick()
verify(helper.messageViewHolderActionListener).onFlagClicked(message)
reset(helper.messageViewHolderActionListener)
}
}
}
\ No newline at end of file
package com.ignacio.composesample1.ui.messageview
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.performClick
import com.ignacio.composesample1.R
import com.ignacio.composesample1.ui.UITestHelper
import com.nhaarman.mockitokotlin2.verify
import org.junit.Rule
import org.junit.Test
class MessageViewTest {
@get:Rule
val composeTestRule = createComposeRule()
private val helper = UITestHelper(composeTestRule)
@Test
fun messageViewTopBarTest() {
helper.launchSreen {
MessageViewMainLayout(
optionsMenuItemClickListener = helper.optionsMenuItemClickListener,
messageViewMenuActionListener = helper.messageViewMenuActionListener,
messageInfo = MessageViewMenuMessageInfo(helper.allMessages.first())
)
}
helper.assertNodeWithTag("messageViewNavigationIcon").performClick()
verify(helper.messageViewMenuActionListener).onNavigationClicked()
helper.assertNodeWithTag("messageViewTopBarBadge")
helper.assertNodeWithText(R.string.secure_and_trusted)
helper.assertNodeWithTag("messageViewFlagMenuItem").performClick()
verify(helper.messageViewMenuActionListener).onFlagIconToggled()
helper.assertNodeWithTag("messageViewDeleteMenuItem").performClick()
verify(helper.messageViewMenuActionListener).onDeleteIconClicked()
helper.assertNodeWithTag("overflowMenu").performClick()
helper.assertNodeWithText(R.string.mark_as_read_action).performClick()
helper.findNodeWithText(R.string.mark_as_read_action).assertDoesNotExist()
verify(helper.messageViewMenuActionListener).onUnreadToggleClicked()
helper.assertNodeWithTag("overflowMenu").performClick()
helper.assertNodeWithText(R.string.option_3_action).performClick()
helper.findNodeWithText(R.string.option_3_action).assertDoesNotExist()
verify(helper.optionsMenuItemClickListener).onOptionsMenuItemClicked(R.string.option_3_action)
}
@Test
fun messageViewScreenTest() {
val message = helper.allMessages.first()
helper.stubViewModel(message, true)
helper.launchSreen {
MessageViewScreen(
optionsMenuItemClickListener = helper.optionsMenuItemClickListener,
homeviewModel = helper.viewModel
)
}
helper.assertNodeWithTag("messageViewNavigationIcon").performClick()
verify(helper.viewModel).closeMessage()
helper.assertNodeWithTag("messageViewTopBarBadge")
helper.assertNodeWithText(R.string.secure_and_trusted)
helper.assertNodeWithTag("messageViewFlagMenuItem").performClick()
verify(helper.viewModel).toggleMessageFlag(message)
helper.assertNodeWithTag("messageViewDeleteMenuItem").performClick()
verify(helper.viewModel).deleteMessage(message)
helper.assertNodeWithTag("overflowMenu").performClick()
helper.assertNodeWithText(R.string.mark_as_read_action).performClick()
helper.findNodeWithText(R.string.mark_as_read_action).assertDoesNotExist()
verify(helper.viewModel).toggleMessageUnread(message)
helper.assertNodeWithTag("messageViewAvatar")
helper.assertNodeWithText(message.subject)
helper.assertNodeWithText(message.body)
helper.assertNodeWithText(
helper.context.getString(
R.string.message_from_field,
message.from.first().name
)
)
helper.assertNodeWithText(
helper.context.getString(R.string.message_to_field,
message.recipients.joinToString(", ") { it.name })
)
helper.assertNodeWithTag("messageViewDate")
}
}
\ No newline at end of file
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<application tools:ignore="AllowBackup">
<activity android:name="androidx.activity.ComponentActivity" />
</application>
</manifest>
\ No newline at end of file
...@@ -5,21 +5,20 @@ import android.widget.Toast ...@@ -5,21 +5,20 @@ import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.material.DrawerState
import androidx.compose.ui.platform.setContent import androidx.compose.ui.platform.setContent
import com.ignacio.composesample1.domain.MyAccount import com.ignacio.composesample1.domain.MyAccount
import com.ignacio.composesample1.domain.MyFolder import com.ignacio.composesample1.domain.MyFolder
import com.ignacio.composesample1.ui.* import com.ignacio.composesample1.ui.*
import com.ignacio.composesample1.ui.home.HomeViewModel import com.ignacio.composesample1.ui.home.HomeViewModelImpl
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity(), class MainActivity : AppCompatActivity(),
OptionsMenuItemClickListener, OptionsMenuItemClickListener,
DrawerActionClickListener, DrawerActionClickListener {
DrawerUser { private val viewModel: HomeViewModelImpl by viewModels()
private val viewModel: HomeViewModel by viewModels()
override lateinit var drawerState: DrawerState private val drawerUser = DrawerUser()
@ExperimentalAnimationApi @ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
...@@ -27,19 +26,18 @@ class MainActivity : AppCompatActivity(), ...@@ -27,19 +26,18 @@ class MainActivity : AppCompatActivity(),
setContent { setContent {
ComposeSample1Theme { ComposeSample1Theme {
MainScreen( MainScreen(
drawerUser = this, drawerUser = drawerUser,
drawerActionClickListener = this, drawerActionClickListener = this,
optionsMenuItemClickListener = this, optionsMenuItemClickListener = this
viewModel = viewModel
) )
} }
} }
} }
override fun onBackPressed() { override fun onBackPressed() {
if (::drawerState.isInitialized && drawerState.isOpen) { if (viewModel.messageVisible) {
drawerState.close() viewModel.closeMessage()
} else { } else if (!drawerUser.closeDrawer()) {
super.onBackPressed() super.onBackPressed()
} }
} }
......
...@@ -5,7 +5,11 @@ import com.ignacio.composesample1.domain.MyAccount ...@@ -5,7 +5,11 @@ import com.ignacio.composesample1.domain.MyAccount
import com.ignacio.composesample1.domain.MyContact import com.ignacio.composesample1.domain.MyContact
import com.ignacio.composesample1.domain.MyFolder import com.ignacio.composesample1.domain.MyFolder
import com.ignacio.composesample1.domain.MyMessage import com.ignacio.composesample1.domain.MyMessage
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
...@@ -50,7 +54,7 @@ class MailDataSourceImpl @Inject constructor( ...@@ -50,7 +54,7 @@ class MailDataSourceImpl @Inject constructor(
/** /**
* To be used for compose previews and tests. * To be used for compose previews and tests.
*/ */
class FakeMailDataSource : MailDataSource { class FakeMailDataSource @Inject constructor() : MailDataSource {
override val myContacts = listOf<MyContact>( override val myContacts = listOf<MyContact>(
MyContact("Juan", "juan@juan.es"), MyContact("Juan", "juan@juan.es"),
MyContact("Ramon", "ramon@ramon.es"), MyContact("Ramon", "ramon@ramon.es"),
...@@ -83,36 +87,54 @@ class FakeMailDataSource : MailDataSource { ...@@ -83,36 +87,54 @@ class FakeMailDataSource : MailDataSource {
Date().time, Date().time,
2 2
) )
) ) + List<MyMessage>(10) {
override var allMessages = flowOf(messages) MyMessage(
listOf(myContacts[3]),
override val specialFolders = flowOf( myContacts.subList(0, 3),
listOf<MyFolder>( "Subject${it + 3}",
MyFolder("Unified Inbox", 0, 5), "Body${it + 3}",
MyFolder("All Messages", 1, 8) Date().time,
it + 3
) )
}
val accounts = listOf<MyAccount>(
MyAccount(0, "paco", "paco@paco.com"),
MyAccount(1, "pepe", "pepe@pepe.com"),
MyAccount(2, "ramontxu", "ramontxu@ramontxu.com")
) )
override val allFolders = flowOf(List<MyFolder>(20) { val folders = List<MyFolder>(10) {
MyFolder("Folder $it", it + 2, Random().nextInt(20)) MyFolder("Folder $it", it + 2, it)
}) }
override val installedAccounts = flowOf( val sfolders = listOf<MyFolder>(
listOf<MyAccount>( MyFolder("Unified Inbox", 0, 5),
MyAccount(0, "paco", "paco@paco.com"), MyFolder("All Messages", 1, 8)
MyAccount(1, "pepe", "pepe@pepe.com"),
MyAccount(2, "ramontxu", "ramontxu@ramontxu.com")
)
) )
@ExperimentalCoroutinesApi
private val messagesChannel = ConflatedBroadcastChannel<List<MyMessage>>(messages)
@FlowPreview
@ExperimentalCoroutinesApi
override var allMessages: Flow<List<MyMessage>> = messagesChannel.asFlow()
override val specialFolders = flowOf(sfolders)
override val allFolders = flowOf(folders)
override val installedAccounts = flowOf(accounts)
@ExperimentalCoroutinesApi
override suspend fun updateMessage(message: MyMessage) { override suspend fun updateMessage(message: MyMessage) {
val index = messages.indexOfFirst { it.id == message.id } val index = messages.indexOfFirst { it.id == message.id }
messages = messages.toMutableList().also { messages = messages.toMutableList().also {
it[index] = message it[index] = message
} }
messagesChannel.offer(messages)
} }
@ExperimentalCoroutinesApi
override suspend fun deleteMessage(message: MyMessage) { override suspend fun deleteMessage(message: MyMessage) {
messages = messages.toMutableList().also { messages = messages.toMutableList().also {
it.remove(message) it.remove(message)
} }
messagesChannel.offer(messages)
} }
} }
\ No newline at end of file
...@@ -18,6 +18,7 @@ import javax.inject.Singleton ...@@ -18,6 +18,7 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class DataModule { abstract class DataModule {
@Binds @Binds
@Singleton
abstract fun bindMailDataSource( abstract fun bindMailDataSource(
mailDataSourceImpl: MailDataSourceImpl mailDataSourceImpl: MailDataSourceImpl
): MailDataSource ): MailDataSource
......
package com.ignacio.composesample1.ui
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.onActive
import androidx.compose.runtime.onCommit
import androidx.compose.ui.viewinterop.viewModel
import com.ignacio.composesample1.ui.home.HomeViewModel
import com.ignacio.composesample1.ui.home.HomeViewModelImpl
@SuppressLint("LogNotTimber")
@Composable
fun LogCallbacksWithText(text: String) {
onActive {
Log.d("ComposeCallbacks", "onActive $text")
}
onCommit {
Log.d("ComposeCallbacks", "onCommit $text")
}
androidx.compose.runtime.onDispose {
Log.d("ComposeCallbacks", "onDispose $text")
}
}
@Composable
fun getViewModel(): HomeViewModel = viewModel<HomeViewModelImpl>()
\ No newline at end of file
...@@ -12,7 +12,9 @@ import androidx.compose.runtime.Composable ...@@ -12,7 +12,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.AmbientConfiguration import androidx.compose.ui.platform.AmbientConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.Position import androidx.compose.ui.unit.Position
...@@ -29,8 +31,9 @@ fun MainScreen( ...@@ -29,8 +31,9 @@ fun MainScreen(
drawerUser: DrawerUser, drawerUser: DrawerUser,
drawerActionClickListener: DrawerActionClickListener, drawerActionClickListener: DrawerActionClickListener,
optionsMenuItemClickListener: OptionsMenuItemClickListener, optionsMenuItemClickListener: OptionsMenuItemClickListener,
viewModel: HomeViewModel homeviewModel: HomeViewModel? = null
) { ) {
val viewModel = homeviewModel ?: getViewModel()
val messageVisible = viewModel.messageVisible val messageVisible = viewModel.messageVisible
AnimatedVisibility( AnimatedVisibility(
visible = !messageVisible, visible = !messageVisible,
...@@ -99,7 +102,8 @@ fun MyDropDownMenu( ...@@ -99,7 +102,8 @@ fun MyDropDownMenu(
}, },
expanded = showMenu.value, expanded = showMenu.value,
onDismissRequest = { showMenu.value = false }, onDismissRequest = { showMenu.value = false },
dropdownOffset = Position((screenWidth).dp, 0.dp) dropdownOffset = Position((screenWidth).dp, 0.dp),
toggleModifier = Modifier.testTag("overflowMenu")
) { ) {
myMenuScope.content() myMenuScope.content()
} }
...@@ -116,6 +120,16 @@ interface DrawerActionClickListener { ...@@ -116,6 +120,16 @@ interface DrawerActionClickListener {
fun onDrawerAccountCircleClicked(account: MyAccount) fun onDrawerAccountCircleClicked(account: MyAccount)
} }
interface DrawerUser { class DrawerUser {
var drawerState: DrawerState var drawerState: DrawerState? = null
fun closeDrawer(): Boolean {
return drawerState?.let { state ->
if (state.isOpen) {
state.close()
true
} else {
false
}
} ?: false
}
} }
\ No newline at end of file
...@@ -2,9 +2,7 @@ package com.ignacio.composesample1.ui.home ...@@ -2,9 +2,7 @@ package com.ignacio.composesample1.ui.home
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.hilt.Assisted
import androidx.hilt.lifecycle.ViewModelInject import androidx.hilt.lifecycle.ViewModelInject
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.ignacio.composesample1.data.MailRepository import com.ignacio.composesample1.data.MailRepository
...@@ -18,28 +16,39 @@ import kotlinx.coroutines.flow.collectLatest ...@@ -18,28 +16,39 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class HomeViewModel @ViewModelInject constructor( interface HomeViewModel {
private val repository: MailRepository, val currentMessage: MyMessage?
@Assisted private val savedStateHandle: SavedStateHandle val messageVisible: Boolean
val specialFolders: MutableState<List<MyFolder>>
val allFolders: MutableState<List<MyFolder>>
val installedAccounts: MutableState<List<MyAccount>>
val allMessages: MutableState<List<MyMessage>>
fun deleteMessage(message: MyMessage)
fun openMessage(messageId: Int)
fun closeMessage()
fun toggleMessageFlag(message: MyMessage)
fun markMessageAsRead(message: MyMessage)
fun toggleMessageUnread(message: MyMessage)
}
) : ViewModel() { class HomeViewModelImpl @ViewModelInject constructor(
val specialFolders = mutableStateOf(listOf<MyFolder>()) private val repository: MailRepository
val allFolders = mutableStateOf(listOf<MyFolder>()) ) : ViewModel(), HomeViewModel {
val installedAccounts = mutableStateOf(listOf<MyAccount>()) override val specialFolders = mutableStateOf(listOf<MyFolder>())
override val allFolders = mutableStateOf(listOf<MyFolder>())
override val installedAccounts = mutableStateOf(listOf<MyAccount>())
override val allMessages = mutableStateOf(listOf<MyMessage>())
// private state // private state
private var currentMessageId = mutableStateOf(-1) private var currentMessageId = mutableStateOf(-1)
val messageVisible: Boolean override val messageVisible: Boolean
get() = currentMessageId.value != -1 get() = currentMessageId.value != -1
// state override val currentMessage: MyMessage?
val allMessages = mutableStateOf(listOf<MyMessage>())
val currentMessage: MyMessage?
get() = allMessages.value.find { it.id == currentMessageId.value } get() = allMessages.value.find { it.id == currentMessageId.value }
// event: delete message // event: delete message
fun deleteMessage(message: MyMessage) { override fun deleteMessage(message: MyMessage) {
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
repository.deleteMessage(message) repository.deleteMessage(message)
...@@ -48,28 +57,28 @@ class HomeViewModel @ViewModelInject constructor( ...@@ -48,28 +57,28 @@ class HomeViewModel @ViewModelInject constructor(
} }
} }
fun openMessage(messageId: Int) { override fun openMessage(messageId: Int) {
currentMessageId.value = messageId currentMessageId.value = messageId
} }
fun closeMessage() { override fun closeMessage() {
currentMessageId.value = -1 currentMessageId.value = -1
} }
fun toggleMessageFlag(message: MyMessage) { override fun toggleMessageFlag(message: MyMessage) {
viewModelScope.launch { viewModelScope.launch {
val newMessage = message.copy(flagged = !message.flagged) val newMessage = message.copy(flagged = !message.flagged)
updateMessage(newMessage) updateMessage(newMessage)
} }
} }
fun markMessageAsRead(message: MyMessage) { override fun markMessageAsRead(message: MyMessage) {
if (!message.read) { if (!message.read) {
toggleMessageUnread(message) toggleMessageUnread(message)
} }
} }
fun toggleMessageUnread(message: MyMessage) { override fun toggleMessageUnread(message: MyMessage) {
viewModelScope.launch { viewModelScope.launch {
val newMessage = message.copy(read = !message.read) val newMessage = message.copy(read = !message.read)
updateMessage(newMessage) updateMessage(newMessage)
......
...@@ -7,7 +7,7 @@ import androidx.compose.foundation.Image ...@@ -7,7 +7,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumnFor import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
...@@ -20,9 +20,12 @@ import androidx.compose.ui.draw.clip ...@@ -20,9 +20,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.AmbientConfiguration import androidx.compose.ui.platform.AmbientConfiguration
import androidx.compose.ui.platform.AmbientContext import androidx.compose.ui.platform.AmbientContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
...@@ -44,8 +47,9 @@ fun MessageListScreen( ...@@ -44,8 +47,9 @@ fun MessageListScreen(
drawerUser: DrawerUser, drawerUser: DrawerUser,
drawerActionClickListener: DrawerActionClickListener, drawerActionClickListener: DrawerActionClickListener,
optionsMenuItemClickListener: OptionsMenuItemClickListener, optionsMenuItemClickListener: OptionsMenuItemClickListener,
viewModel: HomeViewModel homeViewModel: HomeViewModel? = null
) { ) {
val viewModel = homeViewModel ?: getViewModel()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
drawerUser.drawerState = drawerState drawerUser.drawerState = drawerState
val context = AmbientContext.current val context = AmbientContext.current
...@@ -76,7 +80,7 @@ fun MessageListScreen( ...@@ -76,7 +80,7 @@ fun MessageListScreen(
) )
}, },
bodyContent = { bodyContent = {
MyMainLayout( MessageListMainLayout(
optionsMenuItemClickListener = optionsMenuItemClickListener, optionsMenuItemClickListener = optionsMenuItemClickListener,
openDrawer = { drawerState.open() }, openDrawer = { drawerState.open() },
) { modifier -> ) { modifier ->
...@@ -91,11 +95,11 @@ fun MessageListScreen( ...@@ -91,11 +95,11 @@ fun MessageListScreen(
} }
@Composable @Composable
fun MyMainLayout( fun MessageListMainLayout(
optionsMenuItemClickListener: OptionsMenuItemClickListener, optionsMenuItemClickListener: OptionsMenuItemClickListener,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
openDrawer: () -> Unit = {}, openDrawer: () -> Unit = {},
content: @Composable (Modifier) -> Unit, content: @Composable (Modifier) -> Unit = {}
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
...@@ -109,7 +113,8 @@ fun MyMainLayout( ...@@ -109,7 +113,8 @@ fun MyMainLayout(
content( content(
Modifier Modifier
.padding(innerPadding) .padding(innerPadding)
.padding(start = 8.dp, end = 8.dp)) .padding(start = 8.dp, end = 8.dp)
)
} }
} }
...@@ -135,7 +140,10 @@ fun MyTopBar( ...@@ -135,7 +140,10 @@ fun MyTopBar(
}, },
backgroundColor = colorResource(id = R.color.pep_green), backgroundColor = colorResource(id = R.color.pep_green),
navigationIcon = { navigationIcon = {
IconButton(onClick = { onNavigationClicked() }) { IconButton(
onClick = { onNavigationClicked() },
modifier = Modifier.testTag("drawerIcon")
) {
Image(vectorResource(id = R.drawable.ic_baseline_menu_24)) Image(vectorResource(id = R.drawable.ic_baseline_menu_24))
} }
}, },
...@@ -153,9 +161,12 @@ fun MyTopBar( ...@@ -153,9 +161,12 @@ fun MyTopBar(
fun MessageListOptionsMenu( fun MessageListOptionsMenu(
optionsMenuItemClickListener: OptionsMenuItemClickListener optionsMenuItemClickListener: OptionsMenuItemClickListener
) { ) {
IconButton(onClick = { IconButton(
optionsMenuItemClickListener.onOptionsMenuItemClicked(R.string.search_action) onClick = {
}) { optionsMenuItemClickListener.onOptionsMenuItemClicked(R.string.search_action)
},
modifier = Modifier.testTag("searchAction")
) {
Image(vectorResource(id = R.drawable.ic_baseline_search_24)) Image(vectorResource(id = R.drawable.ic_baseline_search_24))
} }
MyDropDownMenu( MyDropDownMenu(
...@@ -176,6 +187,8 @@ fun MyDrawer( ...@@ -176,6 +187,8 @@ fun MyDrawer(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column(modifier = modifier.clickable(onClick = { /*TODO*/ })) { Column(modifier = modifier.clickable(onClick = { /*TODO*/ })) {
LogCallbacksWithText(text = "MyDrawer.Column")
val accountsOrFoldersMode = remember { mutableStateOf(DrawerMode.FOLDERS) } val accountsOrFoldersMode = remember { mutableStateOf(DrawerMode.FOLDERS) }
NavigationHeader(accounts, drawerActionClickListener) { NavigationHeader(accounts, drawerActionClickListener) {
accountsOrFoldersMode.value = accountsOrFoldersMode.value =
...@@ -190,7 +203,9 @@ fun MyDrawer( ...@@ -190,7 +203,9 @@ fun MyDrawer(
) )
} else { } else {
DrawerBodyAccountMode( DrawerBodyAccountMode(
accounts = accounts, accounts =
if (accounts.isNotEmpty()) accounts.takeLast(accounts.size - 1)
else emptyList(),
drawerActionClickListener = drawerActionClickListener drawerActionClickListener = drawerActionClickListener
) )
} }
...@@ -210,12 +225,17 @@ fun DrawerBodyFolderMode( ...@@ -210,12 +225,17 @@ fun DrawerBodyFolderMode(
Column { Column {
DrawerFolderList( DrawerFolderList(
folders = specialFolders, folders = specialFolders,
drawerActionClickListener = drawerActionClickListener drawerActionClickListener = drawerActionClickListener,
modifier = Modifier.testTag("drawerSpecialFolderList")
)
Text(
stringResource(id = R.string.folders_title),
modifier = Modifier.padding(start = 32.dp)
) )
Text("Folders", modifier = Modifier.padding(start = 32.dp))
DrawerFolderList( DrawerFolderList(
folders = folders, folders = folders,
drawerActionClickListener = drawerActionClickListener drawerActionClickListener = drawerActionClickListener,
modifier = Modifier.testTag("drawerFolderList")
) )
} }
} }
...@@ -239,7 +259,8 @@ private fun NavigationHeader( ...@@ -239,7 +259,8 @@ private fun NavigationHeader(
modifier = Modifier modifier = Modifier
.preferredSize(50.dp) .preferredSize(50.dp)
.clip(shape = CircleShape) .clip(shape = CircleShape)
.background(colorResource(id = R.color.pep_green)), .background(colorResource(id = R.color.pep_green))
.testTag("currentAccountAvatar"),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
...@@ -247,7 +268,8 @@ private fun NavigationHeader( ...@@ -247,7 +268,8 @@ private fun NavigationHeader(
OtherAccountClicker( OtherAccountClicker(
image = R.drawable.ic_launcher_foreground, image = R.drawable.ic_launcher_foreground,
account = accounts[1], account = accounts[1],
drawerActionClickListener = drawerActionClickListener drawerActionClickListener = drawerActionClickListener,
modifier = Modifier.testTag("otherAccountClicker1")
) )
} }
if (accounts.size > 2) { if (accounts.size > 2) {
...@@ -255,14 +277,17 @@ private fun NavigationHeader( ...@@ -255,14 +277,17 @@ private fun NavigationHeader(
OtherAccountClicker( OtherAccountClicker(
image = R.drawable.ic_launcher_foreground, image = R.drawable.ic_launcher_foreground,
account = accounts[2], account = accounts[2],
drawerActionClickListener = drawerActionClickListener drawerActionClickListener = drawerActionClickListener,
modifier = Modifier.testTag("otherAccountClicker2")
) )
} }
} }
Spacer(modifier = Modifier Spacer(
.fillMaxWidth() modifier = Modifier
.preferredHeight(16.dp)) .fillMaxWidth()
.preferredHeight(16.dp)
)
if (accounts.isNotEmpty()) { if (accounts.isNotEmpty()) {
Text(accounts[0].username, color = colorResource(id = R.color.white)) Text(accounts[0].username, color = colorResource(id = R.color.white))
Text(accounts[0].email, color = colorResource(id = R.color.white)) Text(accounts[0].email, color = colorResource(id = R.color.white))
...@@ -274,11 +299,12 @@ private fun NavigationHeader( ...@@ -274,11 +299,12 @@ private fun NavigationHeader(
fun OtherAccountClicker( fun OtherAccountClicker(
@DrawableRes image: Int, @DrawableRes image: Int,
account: MyAccount, account: MyAccount,
drawerActionClickListener: DrawerActionClickListener drawerActionClickListener: DrawerActionClickListener,
modifier: Modifier = Modifier
) { ) {
Image( Image(
vectorResource(id = image), vectorResource(id = image),
modifier = Modifier modifier = modifier
.preferredSize(25.dp) .preferredSize(25.dp)
.clip(shape = CircleShape) .clip(shape = CircleShape)
.background(colorResource(id = R.color.pep_green)) .background(colorResource(id = R.color.pep_green))
...@@ -293,7 +319,7 @@ fun OtherAccountClicker( ...@@ -293,7 +319,7 @@ fun OtherAccountClicker(
@Composable @Composable
fun MessageListPreview() { fun MessageListPreview() {
ComposeSample1Theme { ComposeSample1Theme {
MyMainLayout( MessageListMainLayout(
object : OptionsMenuItemClickListener { object : OptionsMenuItemClickListener {
override fun onOptionsMenuItemClicked(menuItem: Int) { override fun onOptionsMenuItemClicked(menuItem: Int) {
// NOP // NOP
...@@ -311,20 +337,20 @@ fun MessageListPreview() { ...@@ -311,20 +337,20 @@ fun MessageListPreview() {
@Composable @Composable
fun MessageList( fun MessageList(
modifier: Modifier = Modifier,
messages: List<MyMessage>, messages: List<MyMessage>,
messageViewHolderActionListener: MessageViewHolderActionListener messageViewHolderActionListener: MessageViewHolderActionListener,
modifier: Modifier = Modifier
) { ) {
LazyColumnFor( LazyColumn(modifier = modifier.testTag("messageList")) {
items = messages, items(items = messages,
modifier = modifier, itemContent = { message ->
itemContent = { message -> MessageViewHolder(
MessageViewHolder( message,
message, messageViewHolderActionListener = messageViewHolderActionListener
messageViewHolderActionListener = messageViewHolderActionListener )
) }
} )
) }
} }
@Composable @Composable
...@@ -333,26 +359,36 @@ fun MessageViewHolder( ...@@ -333,26 +359,36 @@ fun MessageViewHolder(
messageViewHolderActionListener: MessageViewHolderActionListener, messageViewHolderActionListener: MessageViewHolderActionListener,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Spacer(modifier = Modifier LogCallbacksWithText(text = "MessageViewHolder with message id: ${message.id}")
.fillMaxWidth() Spacer(
.preferredHeight(8.dp)) modifier = Modifier
.fillMaxWidth()
.preferredHeight(8.dp)
)
Row( Row(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.testTag("messageViewHolder${message.id}")
) { ) {
MessageViewHolderContactBadge(message, messageViewHolderActionListener) MessageViewHolderContactBadge(message, messageViewHolderActionListener)
Spacer(modifier = Modifier Spacer(
.fillMaxHeight() modifier = Modifier
.preferredWidth(8.dp)) .fillMaxHeight()
.preferredWidth(8.dp)
)
MessageViewHolderTitleAndAbstract(message, messageViewHolderActionListener) MessageViewHolderTitleAndAbstract(message, messageViewHolderActionListener)
Spacer(modifier = Modifier Spacer(
.fillMaxHeight() modifier = Modifier
.preferredWidth(8.dp)) .fillMaxHeight()
.preferredWidth(8.dp)
)
MessageViewHolderDateAndFlag(message, messageViewHolderActionListener) MessageViewHolderDateAndFlag(message, messageViewHolderActionListener)
} }
Spacer(modifier = Modifier Spacer(
.fillMaxWidth() modifier = Modifier
.preferredHeight(8.dp)) .fillMaxWidth()
.preferredHeight(8.dp)
)
} }
@Composable @Composable
...@@ -365,7 +401,8 @@ fun MessageViewHolderContactBadge( ...@@ -365,7 +401,8 @@ fun MessageViewHolderContactBadge(
.preferredSize(50.dp) .preferredSize(50.dp)
.clickable(onClick = { .clickable(onClick = {
messageViewHolderActionListener.onContactClicked(message.from.first()) messageViewHolderActionListener.onContactClicked(message.from.first())
}), })
.testTag("messageViewHolderContactBadge${message.id}"),
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f) color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
) { ) {
...@@ -378,11 +415,20 @@ fun RowScope.MessageViewHolderTitleAndAbstract( ...@@ -378,11 +415,20 @@ fun RowScope.MessageViewHolderTitleAndAbstract(
message: MyMessage, message: MyMessage,
messageViewHolderActionListener: MessageViewHolderActionListener messageViewHolderActionListener: MessageViewHolderActionListener
) { ) {
Column(modifier = Modifier val context = AmbientContext.current
.weight(1f) Column(
.clickable(onClick = { modifier = Modifier
messageViewHolderActionListener.onMessageClicked(message) .weight(1f)
})) { .clickable(onClick = {
messageViewHolderActionListener.onMessageClicked(message)
})
.semantics {
this.contentDescription =
if (message.read) context.getString(R.string.read_message_conent_description)
else context.getString(R.string.unread_message_conent_description)
}
.testTag("messageViewHolderTitleAndAbstract${message.id}")
) {
Text( Text(
text = message.subject, text = message.subject,
style = typography.h6, style = typography.h6,
...@@ -391,9 +437,11 @@ fun RowScope.MessageViewHolderTitleAndAbstract( ...@@ -391,9 +437,11 @@ fun RowScope.MessageViewHolderTitleAndAbstract(
color = colorResource(id = R.color.grey_scale_color), color = colorResource(id = R.color.grey_scale_color),
fontWeight = if (message.read) FontWeight.Normal else FontWeight.Bold fontWeight = if (message.read) FontWeight.Normal else FontWeight.Bold
) )
Spacer(modifier = Modifier Spacer(
.fillMaxWidth() modifier = Modifier
.preferredHeight(4.dp)) .fillMaxWidth()
.preferredHeight(4.dp)
)
Text( Text(
text = message.body, text = message.body,
style = typography.body2, style = typography.body2,
...@@ -410,9 +458,13 @@ fun MessageViewHolderDateAndFlag( ...@@ -410,9 +458,13 @@ fun MessageViewHolderDateAndFlag(
messageViewHolderActionListener: MessageViewHolderActionListener messageViewHolderActionListener: MessageViewHolderActionListener
) { ) {
val locale = ConfigurationCompat.getLocales(AmbientConfiguration.current).get(0) val locale = ConfigurationCompat.getLocales(AmbientConfiguration.current).get(0)
val context = AmbientContext.current
Column(modifier = Modifier.fillMaxHeight()) { Column(modifier = Modifier.fillMaxHeight()) {
val sdf = SimpleDateFormat("MMM d", locale) val sdf = SimpleDateFormat("MMM d", locale)
Text(sdf.format(Date(message.date))) Text(
sdf.format(Date(message.date)),
modifier = Modifier.testTag("messageViewHolderDate${message.id}")
)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Image( Image(
vectorResource( vectorResource(
...@@ -425,6 +477,12 @@ fun MessageViewHolderDateAndFlag( ...@@ -425,6 +477,12 @@ fun MessageViewHolderDateAndFlag(
.clickable(onClick = { .clickable(onClick = {
messageViewHolderActionListener.onFlagClicked(message) messageViewHolderActionListener.onFlagClicked(message)
}) })
.semantics {
contentDescription =
if (message.flagged) context.getString(R.string.message_remove_star_action)
else context.getString(R.string.message_add_star_action)
}
.testTag("messageViewHolderFlag${message.id}")
) )
} }
} }
...@@ -435,17 +493,17 @@ fun DrawerFolderList( ...@@ -435,17 +493,17 @@ fun DrawerFolderList(
drawerActionClickListener: DrawerActionClickListener, drawerActionClickListener: DrawerActionClickListener,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
LazyColumnFor( LazyColumn(modifier = modifier) {
items = folders, items(items = folders,
modifier = modifier, itemContent = { folder ->
itemContent = { folder -> DrawerFolderViewHolder(
DrawerFolderViewHolder( image = R.drawable.ic_launcher_foreground,
image = R.drawable.ic_launcher_foreground, folder = folder,
folder = folder, drawerActionClickListener = drawerActionClickListener
drawerActionClickListener = drawerActionClickListener )
) }
} )
) }
} }
@Composable @Composable
...@@ -483,7 +541,8 @@ fun DrawerBodyAccountMode( ...@@ -483,7 +541,8 @@ fun DrawerBodyAccountMode(
Column(modifier = modifier) { Column(modifier = modifier) {
DrawerAccountList( DrawerAccountList(
accounts = accounts, accounts = accounts,
drawerActionClickListener = drawerActionClickListener drawerActionClickListener = drawerActionClickListener,
modifier = Modifier.testTag("drawerAccountList")
) )
DrawerActionRow( DrawerActionRow(
image = R.drawable.ic_baseline_add_24, image = R.drawable.ic_baseline_add_24,
...@@ -525,17 +584,17 @@ fun DrawerAccountList( ...@@ -525,17 +584,17 @@ fun DrawerAccountList(
drawerActionClickListener: DrawerActionClickListener, drawerActionClickListener: DrawerActionClickListener,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
LazyColumnFor( LazyColumn(modifier = modifier) {
items = accounts, items(items = accounts,
modifier = modifier, itemContent = { account ->
itemContent = { account -> DrawerAccountViewHolder(
DrawerAccountViewHolder( image = R.drawable.ic_launcher_foreground,
image = R.drawable.ic_launcher_foreground, account = account,
account = account, drawerActionClickListener = drawerActionClickListener
drawerActionClickListener = drawerActionClickListener )
) }
} )
) }
} }
@Composable @Composable
...@@ -563,7 +622,8 @@ fun DrawerAccountViewHolder( ...@@ -563,7 +622,8 @@ fun DrawerAccountViewHolder(
Spacer( Spacer(
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.preferredWidth(16.dp)) .preferredWidth(16.dp)
)
Text(account.email, Modifier.weight(1f)) Text(account.email, Modifier.weight(1f))
} }
} }
......
...@@ -15,26 +15,27 @@ import androidx.compose.ui.Modifier ...@@ -15,26 +15,27 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.AmbientContext import androidx.compose.ui.platform.AmbientContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.ignacio.composesample1.R import com.ignacio.composesample1.R
import com.ignacio.composesample1.data.FakeMailDataSource import com.ignacio.composesample1.data.FakeMailDataSource
import com.ignacio.composesample1.domain.MyMessage import com.ignacio.composesample1.domain.MyMessage
import com.ignacio.composesample1.ui.ComposeSample1Theme import com.ignacio.composesample1.ui.*
import com.ignacio.composesample1.ui.MyDropDownMenu
import com.ignacio.composesample1.ui.OptionsMenuItemClickListener
import com.ignacio.composesample1.ui.home.HomeViewModel import com.ignacio.composesample1.ui.home.HomeViewModel
import com.ignacio.composesample1.ui.typography
import java.util.* import java.util.*
@Composable @Composable
fun MessageViewScreen( fun MessageViewScreen(
optionsMenuItemClickListener: OptionsMenuItemClickListener, optionsMenuItemClickListener: OptionsMenuItemClickListener,
viewModel: HomeViewModel homeviewModel: HomeViewModel? = null
) { ) {
val viewModel = homeviewModel ?: getViewModel()
val message = viewModel.currentMessage val message = viewModel.currentMessage
message ?: return message ?: return
val context = AmbientContext.current val context = AmbientContext.current
...@@ -71,7 +72,7 @@ fun MessageViewMainLayout( ...@@ -71,7 +72,7 @@ fun MessageViewMainLayout(
messageViewMenuActionListener: MessageViewMenuActionListener, messageViewMenuActionListener: MessageViewMenuActionListener,
messageInfo: MessageViewMenuMessageInfo, messageInfo: MessageViewMenuMessageInfo,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable (Modifier) -> Unit content: @Composable (Modifier) -> Unit = {}
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
...@@ -98,7 +99,9 @@ fun MessageViewTopBar( ...@@ -98,7 +99,9 @@ fun MessageViewTopBar(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Image( Image(
vectorResource(id = R.drawable.pep_status_green), vectorResource(id = R.drawable.pep_status_green),
modifier = Modifier.preferredSize(32.dp) modifier = Modifier
.preferredSize(32.dp)
.testTag("messageViewTopBarBadge")
) )
Spacer(modifier = Modifier.preferredWidth(8.dp)) Spacer(modifier = Modifier.preferredWidth(8.dp))
Text( Text(
...@@ -111,7 +114,10 @@ fun MessageViewTopBar( ...@@ -111,7 +114,10 @@ fun MessageViewTopBar(
}, },
backgroundColor = colorResource(id = R.color.white), backgroundColor = colorResource(id = R.color.white),
navigationIcon = { navigationIcon = {
IconButton(onClick = { messageViewMenuActionListener.onNavigationClicked() }) { IconButton(
onClick = { messageViewMenuActionListener.onNavigationClicked() },
modifier = Modifier.testTag("messageViewNavigationIcon")
) {
Image(vectorResource(id = R.drawable.ic_clear_daynight)) Image(vectorResource(id = R.drawable.ic_clear_daynight))
} }
}, },
...@@ -133,9 +139,19 @@ fun MessageViewOptionsMenu( ...@@ -133,9 +139,19 @@ fun MessageViewOptionsMenu(
messageViewMenuActionListener: MessageViewMenuActionListener, messageViewMenuActionListener: MessageViewMenuActionListener,
menuMessageInfo: MessageViewMenuMessageInfo menuMessageInfo: MessageViewMenuMessageInfo
) { ) {
IconButton(onClick = { val context = AmbientContext.current
messageViewMenuActionListener.onFlagIconToggled() IconButton(
}) { onClick = {
messageViewMenuActionListener.onFlagIconToggled()
},
modifier = Modifier
.semantics {
contentDescription =
if (menuMessageInfo.flagged) context.getString(R.string.message_remove_star_action)
else context.getString(R.string.message_add_star_action)
}
.testTag("messageViewFlagMenuItem")
) {
Image( Image(
vectorResource( vectorResource(
id = id =
...@@ -144,9 +160,12 @@ fun MessageViewOptionsMenu( ...@@ -144,9 +160,12 @@ fun MessageViewOptionsMenu(
) )
) )
} }
IconButton(onClick = { IconButton(
messageViewMenuActionListener.onDeleteIconClicked() onClick = {
}) { messageViewMenuActionListener.onDeleteIconClicked()
},
modifier = Modifier.testTag("messageViewDeleteMenuItem")
) {
Image(vectorResource(id = R.drawable.ic_trash_can_daynight)) Image(vectorResource(id = R.drawable.ic_trash_can_daynight))
} }
...@@ -179,7 +198,8 @@ fun MessageContent( ...@@ -179,7 +198,8 @@ fun MessageContent(
Spacer( Spacer(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.preferredHeight(8.dp)) .preferredHeight(8.dp)
)
Spacer( Spacer(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
...@@ -189,13 +209,15 @@ fun MessageContent( ...@@ -189,13 +209,15 @@ fun MessageContent(
Spacer( Spacer(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.preferredHeight(8.dp)) .preferredHeight(8.dp)
)
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp)) { Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp)) {
MessageViewBodyHeader(message) MessageViewBodyHeader(message)
Spacer( Spacer(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.preferredHeight(8.dp)) .preferredHeight(8.dp)
)
Text(message.body) Text(message.body)
} }
} }
...@@ -211,7 +233,8 @@ fun ColumnScope.MessageViewBodyHeader(message: MyMessage) { ...@@ -211,7 +233,8 @@ fun ColumnScope.MessageViewBodyHeader(message: MyMessage) {
modifier = Modifier modifier = Modifier
.preferredSize(48.dp) .preferredSize(48.dp)
.clip(shape = CircleShape) .clip(shape = CircleShape)
.background(colorResource(id = R.color.grey_scale_color)), .background(colorResource(id = R.color.grey_scale_color))
.testTag("messageViewAvatar"),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
Spacer(Modifier.preferredWidth(16.dp)) Spacer(Modifier.preferredWidth(16.dp))
...@@ -220,7 +243,12 @@ fun ColumnScope.MessageViewBodyHeader(message: MyMessage) { ...@@ -220,7 +243,12 @@ fun ColumnScope.MessageViewBodyHeader(message: MyMessage) {
Text(stringResource(R.string.message_to_field, recicpients)) Text(stringResource(R.string.message_to_field, recicpients))
} }
} }
Text(Date(message.date).toString(), Modifier.align(Alignment.End)) Text(
Date(message.date).toString(),
Modifier
.align(Alignment.End)
.testTag("messageViewDate")
)
} }
@Preview @Preview
......
<resources> <resources>
<string name="app_name">ComposeSample 1</string> <string name="app_name">ComposeSample 1</string>
<string name="option_1_action">Option 1</string> <string name="option_1_action">Option 1</string>
<string name="option_2_action">Option 2</string> <string name="option_2_action">Option 2</string>
<string name="option_3_action">Option 3</string> <string name="option_3_action">Option 3</string>
<string name="search_action">Search</string> <string name="search_action">Search</string>
<string name="add_account_action">Add account</string> <string name="add_account_action">Add account</string>
<string name="settings_action">Settings</string> <string name="settings_action">Settings</string>
<string name="folders_title">Folders</string>
<string name="subtitle_dummy">Subtitle</string> <string name="subtitle_dummy">Subtitle</string>
<string name="secure_and_trusted">"Secure &amp; Trusted"</string> <string name="secure_and_trusted">"Secure &amp; Trusted"</string>
<string name="message_add_star_action">Add star</string> <string name="message_add_star_action">Add star</string>
<string name="message_remove_star_action">Remove star</string> <string name="message_remove_star_action">Remove star</string>
...@@ -15,4 +20,7 @@ ...@@ -15,4 +20,7 @@
<string name="message_to_field">To: %s</string> <string name="message_to_field">To: %s</string>
<string name="mark_as_unread_action">Mark as unread</string> <string name="mark_as_unread_action">Mark as unread</string>
<string name="mark_as_read_action">Mark as read</string> <string name="mark_as_read_action">Mark as read</string>
<string name="unread_message_conent_description">Unread message</string>
<string name="read_message_conent_description">Already read message</string>
</resources> </resources>
\ No newline at end of file
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext { ext {
compose_version = '1.0.0-alpha08' compose_version = '1.0.0-alpha09'
hilt_version = '2.30.1-alpha' hilt_version = '2.30.1-alpha'
hilt_androidx_version = '1.0.0-alpha02'
} }
repositories { repositories {
google() google()
jcenter() jcenter()
} }
dependencies { dependencies {
classpath "com.android.tools.build:gradle:7.0.0-alpha03" classpath 'com.android.tools.build:gradle:7.0.0-alpha04'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.21" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.21"
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
......