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
) {