diff --git a/_ci/adb_demo_mode.sh b/_ci/adb_demo_mode.sh new file mode 100755 index 00000000..7c6678c0 --- /dev/null +++ b/_ci/adb_demo_mode.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +origin=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) || exit + +# shellcheck disable=SC1091 +. "${origin}/utils.sh" + +if [ $# -lt 1 ]; then + echo "Usage: $0 [on|off] [hhmm]" >&2 + exit +fi + +cmd=${1} + +hhmm=${2:-"1200"} + +# see available commands https://android.googlesource.com/platform/frameworks/base/+/master/packages/SystemUI/docs/demo_mode.md +case "${cmd,,}" in + on) + warn "Don't forget to switch phone in English language" + info "Enabling demo mode" + adb shell settings put global sysui_demo_allowed 1 + adb shell am broadcast -a com.android.systemui.demo -e command enter || exit + adb shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm "${hhmm}" + adb shell am broadcast -a com.android.systemui.demo -e command battery -e plugged false -e level 100 -e powersave false + adb shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4 + adb shell am broadcast -a com.android.systemui.demo -e command network -e mobile show -e datatype none -e level 4 -e fully true + adb shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false + adb shell cmd overlay enable com.android.internal.systemui.navbar.gestural + ;; +off) + info "Disabling demo mode" + adb shell am broadcast -a com.android.systemui.demo -e command exit + adb shell settings put global sysui_demo_allowed 0 + adb shell cmd overlay enable com.android.internal.systemui.navbar.threebutton + ;; +*) + step_error "Invalid command '${cmd}'" +esac + +step_done diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e42cc7b2..32c0ade2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -93,6 +93,7 @@ androidx-test-core = { module = "androidx.test:core", version = "1.6.1" } androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } +androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version = "2.3.0" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } diff --git a/tasks-app-android/build.gradle.kts b/tasks-app-android/build.gradle.kts index aa1141fe..c16d8fb6 100644 --- a/tasks-app-android/build.gradle.kts +++ b/tasks-app-android/build.gradle.kts @@ -76,6 +76,11 @@ android { manifestPlaceholders["crashlyticsEnabled"] = false } + create("demo") { + initWith(getByName("dev")) + applicationIdSuffix = ".demo" + } + create("store") { dimension = "target" } @@ -165,10 +170,15 @@ dependencies { implementation(projects.google.tasks) implementation(projects.tasksAppShared) + "demoImplementation"(projects.tasksCore) { + because("needed for prefilled content for screenshot generation") + } + debugImplementation(libs.androidx.ui.test.manifest) androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.uiautomator) } aboutLibraries { diff --git a/tasks-app-android/src/androidTest/java/net/opatry/tasks/app/test/ScreenshotOnFailureRule.kt b/tasks-app-android/src/androidTest/java/net/opatry/tasks/app/test/ScreenshotOnFailureRule.kt new file mode 100644 index 00000000..f71b04ae --- /dev/null +++ b/tasks-app-android/src/androidTest/java/net/opatry/tasks/app/test/ScreenshotOnFailureRule.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.app.test + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import java.io.File + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) +annotation class NoScreenshot + +private fun defaultScreenshotDir() = File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "test_failed_screenshots") + +class ScreenshotOnFailureRule(private val screenshotsDir: File = defaultScreenshotDir()) : TestWatcher() { + private val Description.allowScreenshot: Boolean + get() = getAnnotation(NoScreenshot::class.java) == null + + override fun failed(e: Throwable?, description: Description?) { + description?.let { testDescription -> + if (testDescription.allowScreenshot) { + try { + takeScreenshot(testDescription) + } catch (_: Exception) { + // ignore screenshot processing errors + } + } + } + super.failed(e, description) + } + + private fun takeScreenshot(testDescription: Description) { + val fileName = testDescription.displayName.take(150) + val outputFile = File(screenshotsDir, "$fileName.png").also { + it.parentFile?.mkdirs() + } + if (outputFile.exists()) { + outputFile.delete() + } + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + .takeScreenshot(outputFile) + } +} \ No newline at end of file diff --git a/tasks-app-android/src/androidTestDemo/kotlin/net/opatry/tasks/app/test/screenshot/StoreScreenshotTest.kt b/tasks-app-android/src/androidTestDemo/kotlin/net/opatry/tasks/app/test/screenshot/StoreScreenshotTest.kt new file mode 100644 index 00000000..e02dd0bd --- /dev/null +++ b/tasks-app-android/src/androidTestDemo/kotlin/net/opatry/tasks/app/test/screenshot/StoreScreenshotTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.app.test.screenshot + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog +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 androidx.compose.ui.test.runAndroidComposeUiTest +import androidx.compose.ui.test.waitUntilAtLeastOneExists +import androidx.compose.ui.test.waitUntilExactlyOneExists +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import net.opatry.tasks.app.MainActivity +import net.opatry.tasks.app.R +import net.opatry.tasks.app.test.ScreenshotOnFailureRule +import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.NOTES_FIELD +import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.TITLE_FIELD +import net.opatry.tasks.app.ui.component.TaskListScaffoldTestTag.ADD_TASK_FAB +import net.opatry.tasks.app.ui.component.TasksColumnTestTag.COMPLETED_TASKS_TOGGLE +import org.junit.Rule +import org.junit.Test +import java.io.File + + +@OptIn(ExperimentalTestApi::class) +class StoreScreenshotTest { + + @get:Rule + val screenshotOnFailureRule = ScreenshotOnFailureRule() + + private val targetContext: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private fun takeScreenshot(name: String) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val outputDir = File(instrumentation.targetContext.cacheDir, "store_screenshots").also(File::mkdirs) + val outputFile = File(outputDir, "$name.png") + if (outputFile.exists()) { + outputFile.delete() + } + UiDevice.getInstance(instrumentation) + .takeScreenshot(outputFile) + } + + private fun AndroidComposeUiTest<*>.pressBack() { + // FIXME how to "press back" with `runAndroidComposeUiTest` (without Espresso) + // `UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack()` + // UI Automator doesn't work for navigation (but does for IME dismiss) + activity?.onBackPressed() + } + + private fun dismissKeyboard() { + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack() + } + + private fun AndroidComposeUiTest.switchToNightMode(nightMode: Int) { + activity?.runOnUiThread { + activity?.delegate?.localNightMode = nightMode + activity?.recreate() + } + waitForIdle() + } + + /** + * This test should be executed with the `demo` flavor which stub content for store screenshots. + */ + @Test + fun storeScreenshotSequence() = runAndroidComposeUiTest { + val initialNightMode = activity?.delegate?.localNightMode ?: AppCompatDelegate.MODE_NIGHT_UNSPECIFIED + + waitForIdle() + takeScreenshot("initial_screen") + + switchToNightMode(AppCompatDelegate.MODE_NIGHT_NO) + + val defaultTaskTitle = targetContext.getString(R.string.demo_task_list_default) + waitUntilAtLeastOneExists(hasText(defaultTaskTitle)) + onNodeWithText(defaultTaskTitle) + .assertIsDisplayed() + + val homeTaskTitle = targetContext.getString(R.string.demo_task_list_home) + onNodeWithText(homeTaskTitle) + .assertIsDisplayed() + + val groceriesTaskTitle = targetContext.getString(R.string.demo_task_list_groceries) + onNodeWithText(groceriesTaskTitle) + .assertIsDisplayed() + + val workTaskTitle = targetContext.getString(R.string.demo_task_list_work) + onNodeWithText(workTaskTitle) + .assertIsDisplayed() + + takeScreenshot("task_lists_light") + + onNodeWithText(defaultTaskTitle) + .assertIsDisplayed() + .performClick() + val defaultTask1Title = targetContext.getString(R.string.demo_task_list_default_task1) + waitUntilAtLeastOneExists(hasText(defaultTask1Title)) + // FIXME unreliable, need to wait for something else? + takeScreenshot("my_tasks_light") + + waitUntilExactlyOneExists(hasTestTag(ADD_TASK_FAB)) + onNodeWithTag(ADD_TASK_FAB) + .assertIsDisplayed() + .performClick() + waitUntilExactlyOneExists(isDialog()) + + waitUntilExactlyOneExists(hasTestTag(TITLE_FIELD)) + onNodeWithTag(TITLE_FIELD) + .performTextInput("Wash the car ๐Ÿงฝ") + waitForIdle() + dismissKeyboard() + + waitUntilExactlyOneExists(hasTestTag(NOTES_FIELD)) + onNodeWithTag(NOTES_FIELD) + .performTextInput("Keys are in the drawer") + + dismissKeyboard() + + waitForIdle() + takeScreenshot("add_task_light") + + // FIXME how to dismiss bottom sheet without clicking on the button? (press back somehow? tap outside?) + // FIXME how to use Res strings from :tasks-app-shared? + onNodeWithText("Cancel") + .assertIsDisplayed() + .performClick() + // go back + pressBack() + waitUntilAtLeastOneExists(hasText(groceriesTaskTitle)) + + onNodeWithText(groceriesTaskTitle) + .assertIsDisplayed() + .performClick() + val groceriesTask1Title = targetContext.getString(R.string.demo_task_list_groceries_task1) + waitUntilAtLeastOneExists(hasText(groceriesTask1Title)) + onNodeWithTag(COMPLETED_TASKS_TOGGLE) + .assertIsDisplayed() + .performClick() + val groceriesTask3Title = targetContext.getString(R.string.demo_task_list_groceries_task3) + waitUntilAtLeastOneExists(hasText(groceriesTask3Title)) + takeScreenshot("groceries_light") + + pressBack() + waitUntilAtLeastOneExists(hasText(workTaskTitle)) + + onNodeWithText(workTaskTitle) + .assertIsDisplayed() + .performClick() + val workTask1Title = targetContext.getString(R.string.demo_task_list_work_task1) + waitUntilAtLeastOneExists(hasText(workTask1Title)) + takeScreenshot("work_light") + + pressBack() + waitUntilAtLeastOneExists(hasText(homeTaskTitle)) + + onNodeWithText(homeTaskTitle) + .assertIsDisplayed() + .performClick() + val homeTask1Title = targetContext.getString(R.string.demo_task_list_home_task1) + waitUntilAtLeastOneExists(hasText(homeTask1Title)) + takeScreenshot("home_light") + + switchToNightMode(AppCompatDelegate.MODE_NIGHT_YES) + waitUntilAtLeastOneExists(hasText(homeTask1Title)) + takeScreenshot("home_dark") + switchToNightMode(initialNightMode) + } +} \ No newline at end of file diff --git a/tasks-app-android/src/demo/assets/avatar.png b/tasks-app-android/src/demo/assets/avatar.png new file mode 100644 index 00000000..271795d7 Binary files /dev/null and b/tasks-app-android/src/demo/assets/avatar.png differ diff --git a/tasks-app-android/src/demo/google-services.json b/tasks-app-android/src/demo/google-services.json new file mode 100644 index 00000000..07760dd8 --- /dev/null +++ b/tasks-app-android/src/demo/google-services.json @@ -0,0 +1,23 @@ +{ + "project_info": { + "project_number": "44853535682", + "project_id": "tasks-app-c632e", + "storage_bucket": "tasks-app-c632e.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:44853535682:android:912604c7085bbeb11e2c5b", + "android_client_info": { + "package_name": "net.opatry.tasks.app.demo" + } + }, + "api_key": [ + { + "current_key": "" + } + ] + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/tasks-app-android/src/demo/java/net/opatry/tasks/app/init/FlavorCustomInit.kt b/tasks-app-android/src/demo/java/net/opatry/tasks/app/init/FlavorCustomInit.kt new file mode 100644 index 00000000..5ff7e638 --- /dev/null +++ b/tasks-app-android/src/demo/java/net/opatry/tasks/app/init/FlavorCustomInit.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2025 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.app.init + +import android.app.Application +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import net.opatry.tasks.app.R +import net.opatry.tasks.data.TaskDao +import net.opatry.tasks.data.TaskListDao +import net.opatry.tasks.data.UserDao +import net.opatry.tasks.data.entity.TaskEntity +import net.opatry.tasks.data.entity.TaskListEntity +import net.opatry.tasks.data.entity.UserEntity +import org.koin.android.ext.android.get +import kotlin.time.Duration.Companion.days + +object FlavorCustomInit { + fun Application.init() { + // TODO would be more elegant to use Room preloaded data mechanism. + // Was tested but didn't work first time, not investigated a lot. + // That being said, it's simpler to make evolution on pre-filled content programmatically. + // see https://developer.android.com/training/data-storage/room/prepopulate + val userDao = get() + val taskListDao = get() + val taskDao = get() + runBlocking { + withContext(Dispatchers.Default) { + userDao.setSignedInUser( + UserEntity( + remoteId = "demo", + email = "jane.do@acme.org", + name = "Jane Doe", + avatarUrl = "file:///android_asset/avatar.png", + isSignedIn = true, + ) + ) + val myTasksListId = taskListDao.insert( + TaskListEntity( + title = getString(R.string.demo_task_list_default), + lastUpdateDate = Clock.System.now(), + sorting = TaskListEntity.Sorting.DueDate, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "my_task1", + title = getString(R.string.demo_task_list_default_task1), + notes = getString(R.string.demo_task_list_default_task1_notes), + parentListLocalId = myTasksListId, + dueDate = Clock.System.now() + 1.days, + lastUpdateDate = Clock.System.now(), + position = "1", + isCompleted = false, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "my_task2", + title = getString(R.string.demo_task_list_default_task2), + parentListLocalId = myTasksListId, + dueDate = Clock.System.now() - 1.days, + lastUpdateDate = Clock.System.now(), + position = "2", + isCompleted = false, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "my_task3", + title = getString(R.string.demo_task_list_default_task3), + notes = getString(R.string.demo_task_list_default_task3_notes), + parentListLocalId = myTasksListId, + dueDate = null, + lastUpdateDate = Clock.System.now(), + position = "3", + isCompleted = true, + completionDate = Clock.System.now() - 1.days, + ) + ) + val groceriesListId = taskListDao.insert( + TaskListEntity( + title = getString(R.string.demo_task_list_groceries), + lastUpdateDate = Clock.System.now(), + sorting = TaskListEntity.Sorting.Title, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "groceries_task1", + title = getString(R.string.demo_task_list_groceries_task1), + parentListLocalId = groceriesListId, + dueDate = null, + lastUpdateDate = Clock.System.now(), + position = "1", + isCompleted = false, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "groceries_task2", + title = getString(R.string.demo_task_list_groceries_task2), + parentListLocalId = groceriesListId, + dueDate = null, + lastUpdateDate = Clock.System.now(), + position = "2", + isCompleted = false, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "groceries_task3", + title = getString(R.string.demo_task_list_groceries_task3), + parentListLocalId = groceriesListId, + dueDate = null, + lastUpdateDate = Clock.System.now(), + position = "3", + isCompleted = true, + completionDate = Clock.System.now() - 2.days, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "groceries_task4", + title = getString(R.string.demo_task_list_groceries_task4), + parentListLocalId = groceriesListId, + dueDate = null, + lastUpdateDate = Clock.System.now(), + position = "4", + isCompleted = true, + completionDate = Clock.System.now(), + ) + ) + val homeListId = taskListDao.insert( + TaskListEntity( + title = getString(R.string.demo_task_list_home), + lastUpdateDate = Clock.System.now(), + sorting = TaskListEntity.Sorting.UserDefined, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "home_task1", + title = getString(R.string.demo_task_list_home_task1), + parentListLocalId = homeListId, + dueDate = null, + lastUpdateDate = Clock.System.now(), + position = "1", + isCompleted = false, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "home_task2", + title = getString(R.string.demo_task_list_home_task2), + parentListLocalId = homeListId, + dueDate = null, + lastUpdateDate = Clock.System.now(), + position = "2", + isCompleted = false, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "home_task3", + title = getString(R.string.demo_task_list_home_task3), + notes = getString(R.string.demo_task_list_home_task3_notes), + parentListLocalId = homeListId, + dueDate = Clock.System.now() + 1.days, + lastUpdateDate = Clock.System.now(), + position = "3", + isCompleted = false, + ) + ) + val workListId = taskListDao.insert( + TaskListEntity( + title = getString(R.string.demo_task_list_work), + lastUpdateDate = Clock.System.now(), + sorting = TaskListEntity.Sorting.UserDefined, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "work_task1", + title = getString(R.string.demo_task_list_work_task1), + notes = getString(R.string.demo_task_list_work_task1_notes), + parentListLocalId = workListId, + dueDate = Clock.System.now() - 1.days, + lastUpdateDate = Clock.System.now(), + position = "1", + isCompleted = false, + ) + ) + val teamMeetingTaskId = taskDao.insert( + TaskEntity( + remoteId = "work_task2", + title = getString(R.string.demo_task_list_work_task2), + notes = getString(R.string.demo_task_list_work_task2_notes), + parentListLocalId = workListId, + dueDate = Clock.System.now(), + lastUpdateDate = Clock.System.now(), + position = "2", + isCompleted = false, + ) + ) + taskDao.insert( + TaskEntity( + remoteId = "work_task3", + title = getString(R.string.demo_task_list_work_task3), + notes = getString(R.string.demo_task_list_work_task3_notes), + parentListLocalId = workListId, + parentTaskLocalId = teamMeetingTaskId, + parentTaskRemoteId = "work_task2", + dueDate = null, + lastUpdateDate = Clock.System.now(), + position = "1", + isCompleted = false, + ) + ) + } + } + } +} \ No newline at end of file diff --git a/tasks-app-android/src/demo/res/values/strings.xml b/tasks-app-android/src/demo/res/values/strings.xml new file mode 100644 index 00000000..903a8af9 --- /dev/null +++ b/tasks-app-android/src/demo/res/values/strings.xml @@ -0,0 +1,50 @@ + + + + ๐Ÿ“ธ Taskfolio + + ๐Ÿ‘ฉโ€๐Ÿ’ผ My Tasks + Plan weekend getaway ๐Ÿ–๏ธ + "โ€ข Find accommodations\nโ€ข Check weather"๏ธ + Finish reading book ๐Ÿ“–๏ธ + Organize desk ๐Ÿ“๏ธ + "โ€ข Sort out papers\nโ€ข Set up new monitor" + ๐Ÿ›’ Groceries + Milk ๐Ÿฅ› + Cheese ๐Ÿง€ + Carrots ๐Ÿฅ• + Bread ๐Ÿฅ– + Whole wheat + ๐Ÿก Home + Fix leaking faucet ๐Ÿšฐ + Mow the lawn ๐ŸŒฑ + Garage appointment ๐Ÿ› ๏ธ ๐Ÿš— + "โ€ข Oil change\nโ€ข Check brakesโ€ฆ\nโ€ข Tire rotation" + ๐Ÿ’ผ Work + ๐Ÿ“‘ Finish Acme report + Include last quarterโ€™s stats + Attend team meeting + Discuss project timelines + Review project proposals + Focus on budget analysis + \ No newline at end of file diff --git a/tasks-app-android/src/dev/java/net/opatry/tasks/app/init/FlavorCustomInit.kt b/tasks-app-android/src/dev/java/net/opatry/tasks/app/init/FlavorCustomInit.kt new file mode 100644 index 00000000..26384d13 --- /dev/null +++ b/tasks-app-android/src/dev/java/net/opatry/tasks/app/init/FlavorCustomInit.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.app.init + +import android.app.Application + +object FlavorCustomInit { + fun Application.init() { + // Nothing custom is needed for this flavor + } +} \ No newline at end of file diff --git a/tasks-app-android/src/main/java/net/opatry/tasks/app/TasksApplication.kt b/tasks-app-android/src/main/java/net/opatry/tasks/app/TasksApplication.kt index e79947b0..0960e1c1 100644 --- a/tasks-app-android/src/main/java/net/opatry/tasks/app/TasksApplication.kt +++ b/tasks-app-android/src/main/java/net/opatry/tasks/app/TasksApplication.kt @@ -35,6 +35,7 @@ import org.koin.android.ext.koin.androidContext import org.koin.androix.startup.KoinStartup import org.koin.core.annotation.KoinExperimentalAPI import org.koin.dsl.koinConfiguration +import net.opatry.tasks.app.init.FlavorCustomInit.init as flavorInit private const val GCP_CLIENT_ID = "191682949161-esokhlfh7uugqptqnu3su9vgqmvltv95.apps.googleusercontent.com" @@ -60,6 +61,7 @@ open class TasksApplication : Application(), KoinStartup { override fun onCreate() { super.onCreate() + flavorInit() initNetworkMonitor(this) } } \ No newline at end of file diff --git a/tasks-app-android/src/store/java/net/opatry/tasks/app/init/FlavorCustomInit.kt b/tasks-app-android/src/store/java/net/opatry/tasks/app/init/FlavorCustomInit.kt new file mode 100644 index 00000000..26384d13 --- /dev/null +++ b/tasks-app-android/src/store/java/net/opatry/tasks/app/init/FlavorCustomInit.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.app.init + +import android.app.Application + +object FlavorCustomInit { + fun Application.init() { + // Nothing custom is needed for this flavor + } +} \ No newline at end of file diff --git a/tasks-app-shared/src/androidMain/kotlin/net/opatry/tasks/app/di/platformModule.android.kt b/tasks-app-shared/src/androidMain/kotlin/net/opatry/tasks/app/di/platformModule.android.kt index 183e3266..f6bd1480 100644 --- a/tasks-app-shared/src/androidMain/kotlin/net/opatry/tasks/app/di/platformModule.android.kt +++ b/tasks-app-shared/src/androidMain/kotlin/net/opatry/tasks/app/di/platformModule.android.kt @@ -40,7 +40,7 @@ actual fun platformModule(target: String): Module = module { val context = get() val appContext = context.applicationContext val dbFile = appContext.getDatabasePath("tasks${dbNameSuffix}.db") - if (target == "test" && dbFile.exists()) { + if (target in arrayOf("test", "demo") && dbFile.exists()) { dbFile.delete() } Room.databaseBuilder(appContext, dbFile.absolutePath) diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskEditorBottomSheet.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskEditorBottomSheet.kt index 2cdbb92e..a17fe9c5 100644 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskEditorBottomSheet.kt +++ b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskEditorBottomSheet.kt @@ -70,6 +70,7 @@ import androidx.compose.ui.unit.dp import kotlinx.datetime.LocalDate import net.opatry.tasks.app.presentation.model.TaskListUIModel import net.opatry.tasks.app.presentation.model.TaskUIModel +import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.BOTTOM_SHEET import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.CANCEL_BUTTON import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.DUE_DATE_CHIP import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.NOTES_FIELD @@ -94,6 +95,7 @@ import org.jetbrains.compose.resources.stringResource @VisibleForTesting object TaskEditorBottomSheetTestTag { + const val BOTTOM_SHEET = "TASK_EDITOR_BOTTOM_SHEET" const val SHEET_TITLE = "TASK_EDITOR_BOTTOM_SHEET_TITLE" const val TITLE_FIELD = "TASK_EDITOR_TITLE_FIELD" const val TITLE_FIELD_ERROR_MESSAGE = "TASK_EDITOR_TITLE_FIELD_ERROR_MESSAGE" @@ -122,6 +124,7 @@ fun TaskEditorBottomSheet( onValidate: (TaskListUIModel, String, String, LocalDate?) -> Unit, ) { ModalBottomSheet( + modifier = Modifier.testTag(BOTTOM_SHEET), sheetState = sheetState, onDismissRequest = onDismiss ) {