diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 405d776..814ccf2 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -2,42 +2,46 @@ name: Eventually Builds on: [push, pull_request] +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + jobs: core: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Setup JDK 11 - uses: actions/setup-java@v3 + - name: Setup JDK 21 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: - java-version: 11 - distribution: adopt + java-version: 21 + distribution: temurin - - name: Run QA - run: | - mkdir ${{ github.workspace }}/target - ./gradlew core:qa + - name: Setup Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - - name: Push Coverage Result - uses: codecov/codecov-action@v3 - with: - flags: kotlin + - name: Run QA + run: ./gradlew core:qa android: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Setup JDK 11 - uses: actions/setup-java@v3 + - name: Setup JDK 21 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: - java-version: 11 - distribution: adopt + java-version: 21 + distribution: temurin + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Run QA - run: | - mkdir ${{ github.workspace }}/target - ./gradlew client-android:qa + run: ./gradlew client-android:qa client-android:assembleDebug client-android:assembleRelease diff --git a/.gitignore b/.gitignore index 005fc48..4d4cdb6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ libraries/tools/kotlin-test-js-runner/lib/ local.properties *.hprof client-android/release +.local +.kotlin diff --git a/build.gradle.kts b/build.gradle.kts index c8d5e4f..ca4ca0d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,36 +1,20 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat -buildscript { - repositories { - jcenter() - google() - } - - dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21") - classpath("com.android.tools.build:gradle:7.4.1") - } -} - plugins { - id("io.gitlab.arturbosch.detekt") version "1.22.0" + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.detekt) jacoco } -allprojects { - repositories { - jcenter() - mavenCentral() - google() - } -} - subprojects { version = "1.1.1-SNAPSHOT" -} -allprojects { - tasks.withType { + tasks.withType().configureEach { testLogging { exceptionFormat = TestExceptionFormat.FULL showCauses = true diff --git a/client-android/build.gradle.kts b/client-android/build.gradle.kts index 02bc723..7320e21 100644 --- a/client-android/build.gradle.kts +++ b/client-android/build.gradle.kts @@ -1,67 +1,21 @@ -plugins { - id("com.android.application") - id("kotlin-android") - kotlin("kapt") -} - -dependencies { - implementation(project(":core")) +import java.time.Duration - implementation("com.google.code.gson:gson:2.10.1") - implementation("org.jetbrains.kotlin:kotlin-stdlib:1.7.21") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.navigation:navigation-fragment-ktx:2.5.3") - implementation("androidx.navigation:navigation-ui-ktx:2.5.3") - implementation("androidx.legacy:legacy-support-v4:1.0.0") - implementation("androidx.recyclerview:recyclerview:1.2.1") - implementation("androidx.preference:preference-ktx:1.2.0") - implementation("com.google.android.material:material:1.8.0") - implementation("androidx.room:room-runtime:2.5.0") - implementation("androidx.room:room-ktx:2.5.0") - implementation("androidx.lifecycle:lifecycle-common-java8:2.5.1") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1") - implementation("androidx.work:work-runtime-ktx:2.8.0") - implementation("ru.cleverpumpkin:crunchycalendar:2.3.0") - implementation("ca.antonious:materialdaypicker:0.7.4") - implementation("io.noties.markwon:core:4.6.2") - implementation("io.noties.markwon:ext-strikethrough:4.6.2") - implementation("io.noties.markwon:ext-tables:4.6.2") - implementation("io.noties.markwon:ext-tasklist:4.6.2") - implementation("com.github.skydoves:colorpickerview:2.2.4") - - kapt("androidx.room:room-compiler:2.5.0") - - api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") - api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") - - testImplementation("io.mockk:mockk:1.13.3") - testImplementation("org.robolectric:robolectric:4.9.2") - testImplementation("junit:junit:4.13.2") - testImplementation("androidx.test:core:1.5.0") - - androidTestImplementation("androidx.test:runner:1.5.2") - androidTestImplementation("androidx.test:rules:1.5.0") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - androidTestImplementation("androidx.room:room-testing:2.5.0") - androidTestImplementation("androidx.arch.core:core-testing:2.1.0") - - debugImplementation("androidx.fragment:fragment-testing:1.5.5") { - exclude(group = "androidx.test", module = "monitor") - } +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) } android { - compileSdk = 33 + namespace = "eventually.client" + compileSdk = 37 defaultConfig { - minSdk = 26 - targetSdk = 33 - applicationId = "eventually.client" + minSdk = 26 + targetSdk = 37 versionCode = 3 versionName = "1.1.1-SNAPSHOT" @@ -69,6 +23,10 @@ android { } buildTypes { + getByName("debug") { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + } getByName("release") { isMinifyEnabled = true proguardFiles( @@ -79,30 +37,85 @@ android { } buildFeatures { - dataBinding = true - viewBinding = true + buildConfig = true + compose = true } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = "1.8" - } - packagingOptions { - resources { - excludes += setOf("META-INF/atomicfu.kotlin_module") - } + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } testOptions { unitTests.isIncludeAndroidResources = true } - namespace = "eventually.client" +} + +kotlin { + jvmToolchain(21) +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + +dependencies { + implementation(project(":core")) + + implementation(libs.gson) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.lifecycle.common.java8) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.lifecycle.service) + implementation(libs.markdown.renderer.m3) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.activity.compose) + implementation(libs.composepreference) + implementation(libs.colorpicker.compose) + implementation(libs.calendar.compose) { + exclude(group = "androidx.compose.ui", module = "ui-tooling") + } + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + ksp(libs.androidx.room.compiler) + + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.coroutines.android) + + testImplementation(libs.mockk) + testImplementation(libs.robolectric) + testImplementation(libs.junit4) + testImplementation(libs.androidx.test.core) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) + + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.room.testing) + androidTestImplementation(libs.androidx.arch.core.testing) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) +} + +tasks.withType().configureEach { + timeout.set(Duration.ofMinutes(15)) } tasks.register("qa") { - dependsOn("check") + dependsOn("check", "compileDebugAndroidTestKotlin") } diff --git a/client-android/proguard-rules.pro b/client-android/proguard-rules.pro index 481bb43..ac43731 100644 --- a/client-android/proguard-rules.pro +++ b/client-android/proguard-rules.pro @@ -18,4 +18,7 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +-keep class eventually.client.ui.navigation.Route { *; } +-keep class eventually.client.ui.navigation.Route$* { *; } \ No newline at end of file diff --git a/client-android/schemas/eventually.client.persistence.notifications.NotificationEntityDatabase/1.json b/client-android/schemas/eventually.client.persistence.notifications.NotificationEntityDatabase/1.json new file mode 100644 index 0000000..b17f00c --- /dev/null +++ b/client-android/schemas/eventually.client.persistence.notifications.NotificationEntityDatabase/1.json @@ -0,0 +1,58 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "7240a1045dc9200ce86f4d4ed2417cc7", + "entities": [ + { + "tableName": "notifications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task` INTEGER NOT NULL, `instance` TEXT NOT NULL, `type` TEXT NOT NULL, `hash` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "task", + "columnName": "task", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7240a1045dc9200ce86f4d4ed2417cc7')" + ] + } +} \ No newline at end of file diff --git a/client-android/schemas/eventually.client.persistence.schedules.TaskScheduleEntityDatabase/1.json b/client-android/schemas/eventually.client.persistence.schedules.TaskScheduleEntityDatabase/1.json new file mode 100644 index 0000000..b4f6c4f --- /dev/null +++ b/client-android/schemas/eventually.client.persistence.schedules.TaskScheduleEntityDatabase/1.json @@ -0,0 +1,46 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "9bf3e237a764c15a4311e47bd9dedef7", + "entities": [ + { + "tableName": "schedules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`task` INTEGER NOT NULL, `instances` TEXT NOT NULL, `dismissed` TEXT NOT NULL, PRIMARY KEY(`task`))", + "fields": [ + { + "fieldPath": "task", + "columnName": "task", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instances", + "columnName": "instances", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dismissed", + "columnName": "dismissed", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "task" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9bf3e237a764c15a4311e47bd9dedef7')" + ] + } +} \ No newline at end of file diff --git a/client-android/schemas/eventually.client.persistence.tasks.TaskEntityDatabase/2.json b/client-android/schemas/eventually.client.persistence.tasks.TaskEntityDatabase/2.json new file mode 100644 index 0000000..0a7e7a4 --- /dev/null +++ b/client-android/schemas/eventually.client.persistence.tasks.TaskEntityDatabase/2.json @@ -0,0 +1,76 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "6aeb5644984f1ed79143f32f24d65f91", + "entities": [ + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `goal` TEXT NOT NULL, `schedule` TEXT NOT NULL, `contextSwitch` INTEGER NOT NULL, `isActive` INTEGER NOT NULL, `color` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "goal", + "columnName": "goal", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schedule", + "columnName": "schedule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextSwitch", + "columnName": "contextSwitch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6aeb5644984f1ed79143f32f24d65f91')" + ] + } +} \ No newline at end of file diff --git a/client-android/src/androidTest/java/eventually/test/client/activities/ActivityModels.kt b/client-android/src/androidTest/java/eventually/test/client/activities/ActivityModels.kt index cb69ea9..ee913f5 100644 --- a/client-android/src/androidTest/java/eventually/test/client/activities/ActivityModels.kt +++ b/client-android/src/androidTest/java/eventually/test/client/activities/ActivityModels.kt @@ -4,10 +4,13 @@ import android.app.Application import androidx.room.Room import androidx.test.core.app.ApplicationProvider import eventually.client.persistence.notifications.NotificationEntityDatabase +import eventually.client.persistence.notifications.NotificationRepository import eventually.client.persistence.notifications.NotificationViewModel import eventually.client.persistence.schedules.TaskScheduleEntityDatabase +import eventually.client.persistence.schedules.TaskScheduleRepository import eventually.client.persistence.schedules.TaskScheduleViewModel import eventually.client.persistence.tasks.TaskEntityDatabase +import eventually.client.persistence.tasks.TaskRepository import eventually.client.persistence.tasks.TaskViewModel object ActivityModels { @@ -42,7 +45,11 @@ object ActivityModels { tasks.clearAllTables() schedules.clearAllTables() notifications.clearAllTables() - f(TaskViewModel(application), TaskScheduleViewModel(application), NotificationViewModel(application)) + f( + TaskViewModel(TaskRepository(application)), + TaskScheduleViewModel(TaskScheduleRepository(application)), + NotificationViewModel(NotificationRepository(application)) + ) } finally { tasks.clearAllTables() schedules.clearAllTables() diff --git a/client-android/src/androidTest/java/eventually/test/client/activities/MainActivitySpec.kt b/client-android/src/androidTest/java/eventually/test/client/activities/MainActivitySpec.kt new file mode 100644 index 0000000..6b1e9b0 --- /dev/null +++ b/client-android/src/androidTest/java/eventually/test/client/activities/MainActivitySpec.kt @@ -0,0 +1,97 @@ +package eventually.test.client.activities + +import android.Manifest +import android.content.Context +import android.os.Build +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ActivityScenario.launch +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import eventually.client.R +import eventually.client.activities.MainActivity +import eventually.core.model.Task +import eventually.test.client.activities.ActivityModels.withTaskViewModel +import eventually.test.client.await +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.instanceOf +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.time.Duration + +@RunWith(AndroidJUnit4::class) +class MainActivitySpec { + @Test + fun createNewTasks() { + withTaskViewModel { model -> + assertThat(model.tasks.await(), equalTo(emptyList())) + + launch(MainActivity::class.java) + + val expectedName = "test-name" + val expectedGoal = "test-goal" + val expectedDescription = "test-description" + + composeRule.onNodeWithContentDescription(string(R.string.create_task_hint)).performClick() + + composeRule.onNodeWithContentDescription(string(R.string.section_expand_tooltip)) + .performScrollTo() + .performClick() + + textField(string(R.string.task_details_field_title_name_hint)).performTextInput(expectedName) + textField(string(R.string.task_details_field_title_goal_hint)).performTextInput(expectedGoal) + textField(string(R.string.task_details_field_title_description_hint)).performTextInput(expectedDescription) + + composeRule.onNodeWithText(string(R.string.new_task_action)).performScrollTo().performClick() + + composeRule.onNodeWithText(string(R.string.main_top_app_bar_subtitle_summary)).assertExists() + + val tasks = model.tasks.await() + assertThat(tasks.size, equalTo(1)) + + val task = tasks.first() + assertThat(task.name, equalTo(expectedName)) + assertThat(task.goal, equalTo(expectedGoal)) + assertThat(task.description, equalTo(expectedDescription)) + assertThat(task.contextSwitch, equalTo(Duration.ofMinutes(15))) + assertThat(task.isActive, equalTo(true)) + assertThat(task.schedule, instanceOf(Task.Schedule.Once::class.java)) + } + } + + private fun string(id: Int): String = + ApplicationProvider.getApplicationContext().getString(id) + + private fun textField(text: String): SemanticsNodeInteraction = + composeRule.onNode( + hasSetTextAction() and (hasText(text) or hasAnyDescendant(hasText(text))), + useUnmergedTree = true + ).performScrollTo() + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + *if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf(Manifest.permission.POST_NOTIFICATIONS) + } else { + emptyArray() + } + ) + + @get:Rule + val composeRule = createEmptyComposeRule() +} diff --git a/client-android/src/androidTest/java/eventually/test/client/activities/NewTaskActivitySpec.kt b/client-android/src/androidTest/java/eventually/test/client/activities/NewTaskActivitySpec.kt deleted file mode 100644 index 70748ac..0000000 --- a/client-android/src/androidTest/java/eventually/test/client/activities/NewTaskActivitySpec.kt +++ /dev/null @@ -1,59 +0,0 @@ -package eventually.test.client.activities - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.core.app.ActivityScenario.launch -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.action.ViewActions.scrollTo -import androidx.test.espresso.action.ViewActions.typeText -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.ext.junit.runners.AndroidJUnit4 -import eventually.client.R -import eventually.client.activities.NewTaskActivity -import eventually.core.model.Task -import eventually.test.client.activities.ActivityModels.withTaskViewModel -import eventually.test.client.await -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.CoreMatchers.instanceOf -import org.hamcrest.MatcherAssert.assertThat -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import java.time.Duration - -@RunWith(AndroidJUnit4::class) -class NewTaskActivitySpec { - @Test - fun createNewTasks() { - withTaskViewModel { model -> - assertThat(model.tasks.await(), equalTo(emptyList())) - - launch(NewTaskActivity::class.java) - - val expectedName = "test-name" - val expectedGoal = "test-goal" - val expectedDescription = "test-description" - - onView(withId(R.id.expand_extra_fields)).perform(scrollTo(), click()) - - onView(withId(R.id.name_text_input)).perform(scrollTo(), typeText(expectedName)) - onView(withId(R.id.goal_text_input)).perform(scrollTo(), typeText(expectedGoal)) - onView(withId(R.id.description_text_input)).perform(scrollTo(), typeText(expectedDescription)) - onView(withId(R.id.execute_operation)).perform(scrollTo(), click()) - - val tasks = model.tasks.await() - assertThat(tasks.size, equalTo(1)) - - val task = tasks.first() - assertThat(task.name, equalTo(expectedName)) - assertThat(task.goal, equalTo(expectedGoal)) - assertThat(task.description, equalTo(expectedDescription)) - assertThat(task.contextSwitch, equalTo(Duration.ofMinutes(15))) - assertThat(task.isActive, equalTo(true)) - assertThat(task.schedule, instanceOf(Task.Schedule.Once::class.java)) - } - } - - @get:Rule - var instantTaskExecutorRule = InstantTaskExecutorRule() -} diff --git a/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationEntityDaoSpec.kt b/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationEntityDaoSpec.kt index af0d357..1717c3a 100644 --- a/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationEntityDaoSpec.kt +++ b/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationEntityDaoSpec.kt @@ -43,6 +43,16 @@ class NotificationEntityDaoSpec { } } + @Test + fun retrieveAllNotificationEntities() { + withDao { dao -> + assertThat(runBlocking { dao.getAll() }, equalTo(emptyList())) + + runBlocking { dao.put(entity) } + assertThat(runBlocking { dao.getAll() }, equalTo(listOf(entity))) + } + } + @Test fun retrieveNotificationEntitiesBasedOnQuery() { withDao { dao -> diff --git a/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationRepositorySpec.kt b/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationRepositorySpec.kt index e0279c1..06519e5 100644 --- a/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationRepositorySpec.kt +++ b/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationRepositorySpec.kt @@ -37,6 +37,16 @@ class NotificationRepositorySpec { assertThat(repo.notifications.await(), equalTo(listOf(entity))) } + @Test + fun retrieveAllNotificationEntities() { + val repo = createRepo() + + assertThat(runBlocking { repo.all() }, equalTo(emptyList())) + + runBlocking { repo.put(task, instance, entity.type) } + assertThat(runBlocking { repo.all() }, equalTo(listOf(entity))) + } + @Test fun createNotificationEntitiesFromExistingEntity() { val repo = createRepo() @@ -136,6 +146,8 @@ class NotificationRepositorySpec { override fun get(): LiveData> = data + override suspend fun getAll(): List = entities.values.toList() + override suspend fun get(task: Int, instance: UUID, type: String): NotificationEntity? { return entities.values.find { it.task == task && it.instance == instance && it.type == type } } diff --git a/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationViewModelSpec.kt b/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationViewModelSpec.kt index d3f0040..d98880e 100644 --- a/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationViewModelSpec.kt +++ b/client-android/src/androidTest/java/eventually/test/client/persistence/notifications/NotificationViewModelSpec.kt @@ -6,6 +6,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import eventually.client.persistence.notifications.NotificationEntity import eventually.client.persistence.notifications.NotificationEntityDatabase +import eventually.client.persistence.notifications.NotificationRepository import eventually.client.persistence.notifications.NotificationViewModel import eventually.core.model.Task import eventually.core.model.Task.Schedule.Repeating.Interval.Companion.toInterval @@ -31,7 +32,7 @@ class NotificationViewModelSpec { withModel { model -> assertThat(model.notifications.await(), equalTo(emptyList())) - runBlocking { model.put(task, instance, entity.type).await() } + runBlocking { model.put(task, instance, entity.type) } eventually { assertThat( @@ -47,7 +48,7 @@ class NotificationViewModelSpec { withModel { model -> assertThat(model.notifications.await(), equalTo(emptyList())) - runBlocking { model.put(entity).await() } + runBlocking { model.put(entity) } eventually { assertThat( @@ -64,15 +65,15 @@ class NotificationViewModelSpec { assertThat(model.notifications.await(), equalTo(emptyList())) runBlocking { - model.put(task, instance, entity.type).await() + model.put(task, instance, entity.type) assertThat( - model.get(entity.task, entity.instance, entity.type).await()?.copy(id = entity.id), + model.get(entity.task, entity.instance, entity.type)?.copy(id = entity.id), equalTo(entity) ) assertThat( - model.get(entity.task, entity.instance, "execution").await(), + model.get(entity.task, entity.instance, "execution"), equalTo(null) ) } @@ -84,7 +85,7 @@ class NotificationViewModelSpec { withModel { model -> assertThat(model.notifications.await(), equalTo(emptyList())) - val notificationId = runBlocking { model.put(task, instance, entity.type).await() } + val notificationId = runBlocking { model.put(task, instance, entity.type) } eventually { assertThat( @@ -119,8 +120,8 @@ class NotificationViewModelSpec { withModel { model -> assertThat(model.notifications.await(), equalTo(emptyList())) - val contextNotificationId = runBlocking { model.put(task, instance, "context").await() } - val executionNotificationId = runBlocking { model.put(task, instance, "execution").await() } + val contextNotificationId = runBlocking { model.put(task, instance, "context") } + val executionNotificationId = runBlocking { model.put(task, instance, "execution") } eventually { assertThat( @@ -184,7 +185,7 @@ class NotificationViewModelSpec { private fun withModel(f: (model: NotificationViewModel) -> Unit) { val application = ApplicationProvider.getApplicationContext() - val model = NotificationViewModel(application) + val model = NotificationViewModel(NotificationRepository(application)) val db = NotificationEntityDatabase.getInstance(application) try { diff --git a/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleEntityDaoSpec.kt b/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleEntityDaoSpec.kt index 986ad55..fc4901d 100644 --- a/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleEntityDaoSpec.kt +++ b/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleEntityDaoSpec.kt @@ -59,6 +59,16 @@ class TaskScheduleEntityDaoSpec { } } + @Test + fun retrieveAllScheduleEntities() { + withDao { dao -> + assertThat(runBlocking { dao.getAll() }, equalTo(emptyList())) + + runBlocking { dao.put(entity) } + assertThat(runBlocking { dao.getAll() }, equalTo(listOf(entity))) + } + } + private val instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) private val instance1 = TaskInstance(instant = instant) private val instance2 = TaskInstance(instant = instant.plusSeconds(42)).copy(postponed = Duration.ofMinutes(5)) diff --git a/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleRepositorySpec.kt b/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleRepositorySpec.kt index ca6e151..fe9680f 100644 --- a/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleRepositorySpec.kt +++ b/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleRepositorySpec.kt @@ -75,6 +75,16 @@ class TaskScheduleRepositorySpec { assertThat(repo.schedules.await(), equalTo(emptyList())) } + @Test + fun retrieveAllSchedules() { + val repo = createRepo() + + assertThat(runBlocking { repo.all() }, equalTo(emptyList())) + + runBlocking { repo.put(schedule) } + assertThat(runBlocking { repo.all() }, equalTo(listOf(entity))) + } + private val instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) private val instance1 = TaskInstance(instant = instant) private val instance2 = TaskInstance(instant = instant.plusSeconds(42)).copy(postponed = Duration.ofMinutes(5)) @@ -130,6 +140,8 @@ class TaskScheduleRepositorySpec { override fun get(): LiveData> = data + override suspend fun getAll(): List = entities.values.toList() + override suspend fun put(entity: TaskScheduleEntity) { entities[entity.task] = entity data.value = entities.toList().map { it.second } diff --git a/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleViewModelSpec.kt b/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleViewModelSpec.kt index 683fd30..6e18f8b 100644 --- a/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleViewModelSpec.kt +++ b/client-android/src/androidTest/java/eventually/test/client/persistence/schedules/TaskScheduleViewModelSpec.kt @@ -6,6 +6,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import eventually.client.persistence.schedules.TaskScheduleEntity import eventually.client.persistence.schedules.TaskScheduleEntityDatabase +import eventually.client.persistence.schedules.TaskScheduleRepository import eventually.client.persistence.schedules.TaskScheduleViewModel import eventually.core.model.Task import eventually.core.model.Task.Schedule.Repeating.Interval.Companion.toInterval @@ -33,7 +34,7 @@ class TaskScheduleViewModelSpec { withModel { model -> assertThat(model.schedules.await(), equalTo(emptyList())) - runBlocking { model.put(schedule).await() } + runBlocking { model.put(schedule) } eventually { assertThat(model.schedules.await(), equalTo(listOf(entity))) @@ -46,7 +47,7 @@ class TaskScheduleViewModelSpec { withModel { model -> assertThat(model.schedules.await(), equalTo(emptyList())) - runBlocking { model.put(entity).await() } + runBlocking { model.put(entity) } eventually { assertThat(model.schedules.await(), equalTo(listOf(entity))) @@ -59,7 +60,7 @@ class TaskScheduleViewModelSpec { withModel { model -> assertThat(model.schedules.await(), equalTo(emptyList())) - runBlocking { model.put(schedule).await() } + runBlocking { model.put(schedule) } eventually { assertThat(model.schedules.await(), equalTo(listOf(entity))) @@ -79,7 +80,7 @@ class TaskScheduleViewModelSpec { withModel { model -> assertThat(model.schedules.await(), equalTo(emptyList())) - runBlocking { model.put(schedule).await() } + runBlocking { model.put(schedule) } eventually { assertThat(model.schedules.await(), equalTo(listOf(entity))) @@ -143,7 +144,7 @@ class TaskScheduleViewModelSpec { private fun withModel(f: (model: TaskScheduleViewModel) -> Unit) { val application = ApplicationProvider.getApplicationContext() - val model = TaskScheduleViewModel(application) + val model = TaskScheduleViewModel(TaskScheduleRepository(application)) val db = TaskScheduleEntityDatabase.getInstance(application) try { diff --git a/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskEntityDaoSpec.kt b/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskEntityDaoSpec.kt index dbb46f2..59ae2ba 100644 --- a/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskEntityDaoSpec.kt +++ b/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskEntityDaoSpec.kt @@ -75,7 +75,17 @@ class TaskEntityDaoSpec { } assertThat(dao.get().await().size, equalTo(5)) - assertThat(dao.goals().await().sorted(), equalTo(listOf("goal-1", "goal-2", "goal-3"))) + assertThat(runBlocking { dao.getGoals() }.sorted(), equalTo(listOf("goal-1", "goal-2", "goal-3"))) + } + } + + @Test + fun retrieveAllTaskEntities() { + withDao { dao -> + assertThat(runBlocking { dao.getAll() }, equalTo(emptyList())) + + val taskId = runBlocking { dao.put(entity) } + assertThat(runBlocking { dao.getAll() }, equalTo(listOf(entity.copy(id = taskId.toInt())))) } } diff --git a/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskRepositorySpec.kt b/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskRepositorySpec.kt index e361022..9c9ddc3 100644 --- a/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskRepositorySpec.kt +++ b/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskRepositorySpec.kt @@ -3,7 +3,6 @@ package eventually.test.client.persistence.tasks import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.map import androidx.test.ext.junit.runners.AndroidJUnit4 import eventually.client.persistence.tasks.TaskEntity import eventually.client.persistence.tasks.TaskEntityDao @@ -78,7 +77,17 @@ class TaskRepositorySpec { } assertThat(repo.tasks.await().size, equalTo(5)) - assertThat(repo.goals.await().sorted(), equalTo(listOf("goal-1", "goal-2", "goal-3"))) + assertThat(runBlocking { repo.goals() }.sorted(), equalTo(listOf("goal-1", "goal-2", "goal-3"))) + } + + @Test + fun retrieveAllTasks() { + val repo = createRepo() + + assertThat(runBlocking { repo.all() }, equalTo(emptyList())) + + val taskId = runBlocking { repo.put(task) } + assertThat(runBlocking { repo.all() }, equalTo(listOf(task.copy(id = taskId.toInt())))) } @get:Rule @@ -105,7 +114,9 @@ class TaskRepositorySpec { override fun get(): LiveData> = data - override fun goals(): LiveData> = data.map { it.map { t -> t.goal }.distinct() } + override suspend fun getAll(): List = entities.values.toList() + + override suspend fun getGoals(): List = entities.values.map { it.goal }.distinct() override suspend fun put(entity: TaskEntity): Long { val id = entities[entity.id]?.id ?: (entities.size + 1) diff --git a/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskViewModelSpec.kt b/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskViewModelSpec.kt index 20aa4b5..106393b 100644 --- a/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskViewModelSpec.kt +++ b/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskViewModelSpec.kt @@ -5,6 +5,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import eventually.client.persistence.tasks.TaskEntityDatabase +import eventually.client.persistence.tasks.TaskRepository import eventually.client.persistence.tasks.TaskViewModel import eventually.core.model.Task import eventually.core.model.Task.Schedule.Repeating.Interval.Companion.toInterval @@ -28,7 +29,7 @@ class TaskViewModelSpec { withModel { model -> assertThat(model.tasks.await(), equalTo(emptyList())) - val taskId = runBlocking { model.put(task).await() } + val taskId = runBlocking { model.put(task) } eventually { assertThat(model.tasks.await(), equalTo(listOf(task.copy(id = taskId.toInt())))) @@ -41,7 +42,7 @@ class TaskViewModelSpec { withModel { model -> assertThat(model.tasks.await(), equalTo(emptyList())) - val taskId = runBlocking { model.put(task).await() } + val taskId = runBlocking { model.put(task) } eventually { assertThat(model.tasks.await(), equalTo(listOf(task.copy(id = taskId.toInt())))) @@ -61,7 +62,7 @@ class TaskViewModelSpec { withModel { model -> assertThat(model.tasks.await(), equalTo(emptyList())) - val taskId = runBlocking { model.put(task).await() } + val taskId = runBlocking { model.put(task) } eventually { assertThat(model.tasks.await(), equalTo(listOf(task.copy(id = taskId.toInt())))) @@ -81,15 +82,15 @@ class TaskViewModelSpec { assertThat(model.tasks.await(), equalTo(emptyList())) runBlocking { - model.put(task.copy(goal = "goal-1")).await() - model.put(task.copy(goal = "goal-2")).await() - model.put(task.copy(goal = "goal-3")).await() - model.put(task.copy(goal = "goal-2")).await() - model.put(task.copy(goal = "goal-3")).await() + model.put(task.copy(goal = "goal-1")) + model.put(task.copy(goal = "goal-2")) + model.put(task.copy(goal = "goal-3")) + model.put(task.copy(goal = "goal-2")) + model.put(task.copy(goal = "goal-3")) } assertThat(model.tasks.await().size, equalTo(5)) - assertThat(model.goals.await().sorted(), equalTo(listOf("goal-1", "goal-2", "goal-3"))) + assertThat(runBlocking { model.goals() }.sorted(), equalTo(listOf("goal-1", "goal-2", "goal-3"))) } } @@ -112,7 +113,7 @@ class TaskViewModelSpec { private fun withModel(f: (model: TaskViewModel) -> Unit) { val application = ApplicationProvider.getApplicationContext() - val model = TaskViewModel(application) + val model = TaskViewModel(TaskRepository(application)) val db = TaskEntityDatabase.getInstance(application) try { diff --git a/client-android/src/main/AndroidManifest.xml b/client-android/src/main/AndroidManifest.xml index 8ee6f0a..0d9a90b 100644 --- a/client-android/src/main/AndroidManifest.xml +++ b/client-android/src/main/AndroidManifest.xml @@ -3,10 +3,17 @@ - + + + + + android:exported="false" + android:foregroundServiceType="specialUse"> + + + - + + + + + + android:theme="@style/AppTheme" + android:windowSoftInputMode="adjustResize"> - - - - - - - - - - - - - - diff --git a/client-android/src/main/java/eventually/client/EventuallyApplication.kt b/client-android/src/main/java/eventually/client/EventuallyApplication.kt new file mode 100644 index 0000000..f732fdb --- /dev/null +++ b/client-android/src/main/java/eventually/client/EventuallyApplication.kt @@ -0,0 +1,7 @@ +package eventually.client + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class EventuallyApplication : Application() diff --git a/client-android/src/main/java/eventually/client/activities/AboutActivity.kt b/client-android/src/main/java/eventually/client/activities/AboutActivity.kt deleted file mode 100644 index dae17ee..0000000 --- a/client-android/src/main/java/eventually/client/activities/AboutActivity.kt +++ /dev/null @@ -1,28 +0,0 @@ -package eventually.client.activities - -import android.graphics.Typeface -import android.os.Bundle -import android.text.style.StyleSpan -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity -import eventually.client.BuildConfig -import eventually.client.R -import eventually.client.activities.helpers.Common -import eventually.client.activities.helpers.Common.renderAsSpannable - -class AboutActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_about) - - findViewById(R.id.app_version).text = - getString(R.string.about_version) - .renderAsSpannable( - Common.StyledString( - placeholder = "%1\$s", - content = BuildConfig.VERSION_NAME, - style = StyleSpan(Typeface.BOLD) - ) - ) - } -} diff --git a/client-android/src/main/java/eventually/client/activities/MainActivity.kt b/client-android/src/main/java/eventually/client/activities/MainActivity.kt index 4adcf92..3ea512c 100644 --- a/client-android/src/main/java/eventually/client/activities/MainActivity.kt +++ b/client-android/src/main/java/eventually/client/activities/MainActivity.kt @@ -1,159 +1,343 @@ package eventually.client.activities +import android.Manifest +import android.app.AlarmManager import android.app.NotificationManager -import android.app.SearchManager +import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.SharedPreferences +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.graphics.Color +import android.net.Uri +import android.os.Build import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.SearchView +import android.os.IBinder +import android.provider.Settings as AndroidSettings +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.adapter.FragmentStateAdapter -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.appbar.MaterialToolbar -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.lifecycleScope +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import dagger.hilt.android.AndroidEntryPoint +import eventually.client.BuildConfig import eventually.client.R -import eventually.client.activities.fragments.TaskCalendarFragment -import eventually.client.activities.fragments.TaskListFragment -import eventually.client.activities.fragments.TaskListItemAdapter -import eventually.client.activities.fragments.TaskSummaryFragment +import eventually.client.activities.helpers.TaskManagement +import eventually.client.persistence.DataExport +import eventually.client.persistence.notifications.NotificationViewModel +import eventually.client.persistence.schedules.TaskScheduleViewModel +import eventually.client.persistence.tasks.TaskViewModel +import eventually.client.scheduling.AlarmManagerExtensions.canUseExactAlarms import eventually.client.scheduling.NotificationManagerExtensions.createInstanceNotificationChannels +import eventually.client.scheduling.NotificationManagerExtensions.deleteInstanceNotifications import eventually.client.scheduling.SchedulerService -import eventually.client.settings.Settings -import eventually.client.settings.Settings.getStatsEnabled +import eventually.client.serialization.Extras.requireTaskId +import eventually.client.settings.Settings.defaultPreferences +import eventually.client.settings.Settings.getDateTimeFormat +import eventually.client.settings.Settings.getFirstDayOfWeek +import eventually.client.settings.Settings.getPostponeLength +import eventually.client.settings.Settings.getShowAllInstances +import eventually.client.ui.navigation.Route +import eventually.client.ui.navigation.Route.Companion.onTop +import eventually.client.ui.navigation.Route.Companion.openTask +import eventually.client.ui.screens.AboutScreen +import eventually.client.ui.screens.MainActions +import eventually.client.ui.screens.MainScreen +import eventually.client.ui.screens.NewTaskScreen +import eventually.client.ui.screens.SettingsScreen +import eventually.client.ui.screens.Stats +import eventually.client.ui.screens.StatsScreen +import eventually.client.ui.screens.TaskDetailsActions +import eventually.client.ui.screens.TaskDetailsScreen +import eventually.client.ui.screens.rememberMainSettings +import eventually.client.ui.theme.EventuallyTheme +import eventually.core.model.Task +import eventually.core.model.TaskInstance +import eventually.core.model.TaskSchedule +import eventually.core.model.TaskSummary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.Instant -class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceChangeListener { - private lateinit var pager: ViewPager2 +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + private val taskViewModel: TaskViewModel by viewModels() + private val taskScheduleViewModel: TaskScheduleViewModel by viewModels() + private val notificationViewModel: NotificationViewModel by viewModels() - private lateinit var notificationManager: NotificationManager + private val preferences by lazy { defaultPreferences() } - private lateinit var topAppBar: MaterialToolbar + private var service: SchedulerService? by mutableStateOf(null) + + private var exactAlarmPrompt: Boolean by mutableStateOf(false) + + private var exactAlarmPromptShown: Boolean = false + + private var requestedTask: Int? by mutableStateOf(null) + + private val notificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) {} + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + this@MainActivity.service = (service as SchedulerService.SchedulerBinder).service + } + + override fun onServiceDisconnected(name: ComponentName?) { + this@MainActivity.service = null + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + exactAlarmPromptShown = savedInstanceState?.getBoolean(StateExactAlarmPromptShown) ?: false + + if (savedInstanceState == null) { + requestedTask = intent.requestedTask() + } - pager = findViewById(R.id.pager) - pager.adapter = object : FragmentStateAdapter(this) { - override fun getItemCount(): Int = 3 + enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)) - override fun createFragment(position: Int): Fragment { - return when (Position(position)) { - Position.Summary -> TaskSummaryFragment() - Position.List -> TaskListFragment() - Position.Calendar -> TaskCalendarFragment() + setContent { + EventuallyTheme { + val backStack = rememberNavBackStack(Route.Main) + + LaunchedEffect(requestedTask) { + requestedTask?.let { task -> + backStack.openTask(task) + requestedTask = null + } } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + transitionSpec = { ScreenTransition }, + popTransitionSpec = { ScreenTransition }, + entryProvider = entryProvider { + entry { MainEntry(backStack = backStack) } + entry { route -> NewTaskEntry(route = route, backStack = backStack) } + entry { route -> TaskDetailsEntry(route = route, backStack = backStack) } + entry { route -> + SettingsScreen(preferences = preferences, onBack = { backStack.onTop(route, ::back) }) + } + entry { route -> StatsEntry(route = route, backStack = backStack) } + entry { AboutScreen(version = BuildConfig.VERSION_NAME) } + } + ) } } - val preferences = PreferenceManager.getDefaultSharedPreferences(this) - preferences.registerOnSharedPreferenceChangeListener(this@MainActivity) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createInstanceNotificationChannels(applicationContext) - topAppBar = findViewById(R.id.topAppBar) + if (savedInstanceState == null) { + requestNotificationPermissionIfNeeded() + } - topAppBar.subtitle = getString(R.string.main_top_app_bar_subtitle_summary) - topAppBar.menu.findItem(R.id.task_search).isVisible = false - topAppBar.menu.findItem(R.id.stats).isVisible = preferences.getStatsEnabled() + startForegroundService(Intent(this, SchedulerService::class.java)) + evaluate() + } - topAppBar.setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.task_search -> true - R.id.task_add -> { - startActivity(Intent(this@MainActivity, NewTaskActivity::class.java)) - true - } - R.id.settings -> { - startActivity(Intent(this@MainActivity, SettingsActivity::class.java)) - true - } - R.id.stats -> { - startActivity(Intent(this@MainActivity, StatsActivity::class.java)) - true - } - R.id.about -> { - startActivity(Intent(this@MainActivity, AboutActivity::class.java)) - true - } - else -> false - } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + intent.requestedTask()?.let { requestedTask = it } + } + + override fun onStart() { + super.onStart() + val intent = Intent(this, SchedulerService::class.java) + bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + } + + override fun onStop() { + super.onStop() + unbindService(serviceConnection) + } + + override fun onResume() { + super.onResume() + promptForExactAlarmsIfNeeded() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(StateExactAlarmPromptShown, exactAlarmPromptShown) + } + + @Composable + private fun MainEntry(backStack: MutableList) { + val tasks by taskViewModel.tasks.observeAsState() + val summary by (service?.summary ?: NoSummary).observeAsState() + val schedules by (service?.schedules ?: NoSchedules).observeAsState() + + val actions = remember(backStack) { + val push = { route: Route -> backStack.onTop(Route.Main) { backStack.add(route) } } + + MainActions( + createTask = { push(Route.NewTask()) }, + createTaskOn = { date -> push(Route.NewTask(date = date.toEpochMilli())) }, + openTask = { task -> push(Route.TaskDetails(task)) }, + openSettings = { push(Route.Settings) }, + openStats = { push(Route.Stats) }, + openAbout = { push(Route.About) }, + openExactAlarmSettings = ::openExactAlarmSettings, + evaluate = ::evaluate + ) } - val searchView = (topAppBar.menu.findItem(R.id.task_search).actionView as SearchView) - val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager - searchView.setSearchableInfo(searchManager.getSearchableInfo(componentName)) - searchView.setOnQueryTextListener( - object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - val listView = this@MainActivity.findViewById(R.id.recycler_view) - (listView.adapter as TaskListItemAdapter).filter.filter(query) - return false - } + MainScreen( + tasks = tasks, + summary = summary ?: TaskSummary.empty(), + schedules = schedules ?: emptyMap(), + settings = rememberMainSettings(preferences), + exactAlarmPrompt = exactAlarmPrompt, + onExactAlarmPromptShown = { + exactAlarmPromptShown = true + exactAlarmPrompt = false + }, + actions = actions + ) + } - override fun onQueryTextChange(newText: String?): Boolean { - val listView = this@MainActivity.findViewById(R.id.recycler_view) - (listView.adapter as TaskListItemAdapter).filter.filter(searchView.query) - return false - } - } + @Composable + private fun NewTaskEntry(route: Route.NewTask, backStack: MutableList) { + val goals by produceState(initialValue = emptyList()) { value = taskViewModel.goals() } + val default = remember(route) { route.date?.takeIf { it > 0 }?.let(Instant::ofEpochMilli) ?: Instant.now() } + + NewTaskScreen( + goals = goals, + default = default, + dateTimeFormat = preferences.getDateTimeFormat(), + firstDayOfWeek = preferences.getFirstDayOfWeek(), + onCreate = { task -> + if (backStack.remove(route)) createTask(task) + }, + onBack = { backStack.onTop(route, ::back) } ) - searchView.isIconified = false - - val tabs = findViewById(R.id.tabs) - TabLayoutMediator(tabs, pager) { tab, position -> - val icon = when (Position(position)) { - Position.Summary -> R.drawable.ic_summary - Position.List -> R.drawable.ic_list - Position.Calendar -> R.drawable.ic_calendar - } + } - tab.icon = ContextCompat.getDrawable(this, icon) - }.attach() + @Composable + private fun TaskDetailsEntry(route: Route.TaskDetails, backStack: MutableList) { + val tasks by taskViewModel.tasks.observeAsState() + val schedules by (service?.schedules ?: NoSchedules).observeAsState() - tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { - override fun onTabUnselected(tab: TabLayout.Tab?) = Unit + val loaded = tasks != null && schedules != null - override fun onTabSelected(tab: TabLayout.Tab?) { - val position = Position(tab?.position ?: -1) + val actions = remember(route, backStack) { + TaskDetailsActions( + update = ::updateTask, + delete = { task -> + if (backStack.removeAll { it is Route.TaskDetails && it.task == task.id }) deleteTask(task) + }, + dismiss = { instance -> dismissInstance(route.task, instance) }, + postpone = { instance -> postponeInstance(route.task, instance) }, + undo = { instant -> TaskManagement.undoDismissTaskInstance(this, route.task, instant) } + ) + } - if (position == Position.List) { - topAppBar.menu.findItem(R.id.task_search).isVisible = true - } else { - topAppBar.menu.findItem(R.id.task_search).isVisible = false - topAppBar.collapseActionView() - } + TaskDetailsScreen( + task = if (loaded) tasks?.firstOrNull { it.id == route.task } else null, + schedule = schedules?.get(route.task), + goals = tasks.orEmpty().map { it.goal }, + dateTimeFormat = preferences.getDateTimeFormat(), + firstDayOfWeek = preferences.getFirstDayOfWeek(), + showAllInstances = preferences.getShowAllInstances(), + actions = actions, + onBack = { backStack.onTop(route, ::back) } + ) + } - if (position == Position.Summary) { - evaluate() - } + @Composable + private fun StatsEntry(route: Route.Stats, backStack: MutableList) { + val tasks by taskViewModel.tasks.observeAsState() + val schedules by taskScheduleViewModel.schedules.observeAsState() + val notifications by notificationViewModel.notifications.observeAsState() + val lastEvaluation by (service?.lastEvaluation ?: NoEvaluation).observeAsState() + val nextEvaluation by (service?.nextEvaluation ?: NoEvaluation).observeAsState() + val evaluations by (service?.evaluations ?: NoEvaluations).observeAsState() - topAppBar.subtitle = getString( - when (position) { - Position.Summary -> R.string.main_top_app_bar_subtitle_summary - Position.List -> R.string.main_top_app_bar_subtitle_list - Position.Calendar -> R.string.main_top_app_bar_subtitle_calendar - } - ) - } + StatsScreen( + stats = Stats( + tasks = tasks, + schedules = schedules, + notifications = notifications, + lastEvaluation = lastEvaluation, + nextEvaluation = nextEvaluation, + evaluations = evaluations + ), + onImport = ::importData, + onForceEvaluation = ::evaluate, + onBack = { backStack.onTop(route, ::back) } + ) + } - override fun onTabReselected(tab: TabLayout.Tab?) = Unit - }) + private fun back() { + onBackPressedDispatcher.onBackPressed() + } + private fun createTask(task: Task) { + TaskManagement.putTask(this, task) + Toast.makeText(this, getString(R.string.toast_task_created), Toast.LENGTH_SHORT).show() + } - notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createInstanceNotificationChannels(applicationContext) + private fun updateTask(task: Task) { + TaskManagement.putTask(this, task) + Toast.makeText(this, getString(R.string.toast_task_updated), Toast.LENGTH_SHORT).show() + } - startForegroundService(Intent(this, SchedulerService::class.java)) - evaluate() + private fun deleteTask(task: Task) { + TaskManagement.deleteTask(this, task.id) + Toast.makeText(this, getString(R.string.toast_task_deleted), Toast.LENGTH_SHORT).show() + } + + private fun dismissInstance(task: Int, instance: TaskInstance) { + getSystemService(NotificationManager::class.java).deleteInstanceNotifications(task, instance.id) + TaskManagement.dismissTaskInstance(this, task, instance.id) } - override fun onSharedPreferenceChanged(updated: SharedPreferences, key: String?) { - if (key == Settings.Keys.StatsEnabled) { - topAppBar.menu.findItem(R.id.stats).isVisible = updated.getStatsEnabled() + private fun postponeInstance(task: Int, instance: TaskInstance) { + getSystemService(NotificationManager::class.java).deleteInstanceNotifications(task, instance.id) + TaskManagement.postponeTaskInstance(this, task, instance.id, preferences.getPostponeLength()) + } + + private fun importData(data: DataExport) { + lifecycleScope.launch(Dispatchers.IO) { + withContext(NonCancellable) { + data.tasks.forEach { taskViewModel.put(it) } + data.schedules.forEach { taskScheduleViewModel.put(it) } + + val intent = Intent(applicationContext, SchedulerService::class.java) + intent.action = SchedulerService.ActionReload + + applicationContext.startService(intent) + } } } @@ -164,19 +348,56 @@ class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceCh startService(intent) } - private sealed class Position { - object Summary : Position() - object List : Position() - object Calendar : Position() + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return - companion object { - operator fun invoke(position: Int): Position = - when (position) { - 0 -> Summary - 1 -> List - 2 -> Calendar - else -> throw IllegalStateException("Unexpected position encountered: [$position]") - } + val granted = ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + + if (!granted) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } + + private fun promptForExactAlarmsIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + if (exactAlarmPromptShown) return + + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + if (alarmManager.canUseExactAlarms()) return + + exactAlarmPrompt = true + } + + private fun openExactAlarmSettings() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + + val intent = Intent(AndroidSettings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply { + data = Uri.fromParts("package", packageName, null) + } + runCatching { startActivity(intent) } + } + + companion object { + const val ExtraTask: String = "eventually.client.activities.MainActivity.extra_task" + + fun Intent.requestedTask(): Int? = + runCatching { + val fromHistory = flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 + if (!fromHistory && hasExtra(ExtraTask)) requireTaskId(ExtraTask) else null + }.getOrNull() + + private const val StateExactAlarmPromptShown: String = + "eventually.client.activities.MainActivity.state_exact_alarm_prompt_shown" + + private val ScreenTransition: ContentTransform + get() = fadeIn(tween()) togetherWith fadeOut(tween()) + + private val NoSummary: LiveData = MutableLiveData() + private val NoSchedules: LiveData> = MutableLiveData() + private val NoEvaluation: LiveData = MutableLiveData() + private val NoEvaluations: LiveData = MutableLiveData() + } } diff --git a/client-android/src/main/java/eventually/client/activities/NewTaskActivity.kt b/client-android/src/main/java/eventually/client/activities/NewTaskActivity.kt deleted file mode 100644 index bfa424c..0000000 --- a/client-android/src/main/java/eventually/client/activities/NewTaskActivity.kt +++ /dev/null @@ -1,68 +0,0 @@ -package eventually.client.activities - -import android.os.Bundle -import android.widget.Button -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.databinding.DataBindingUtil -import androidx.lifecycle.ViewModelProvider -import com.google.android.material.appbar.MaterialToolbar -import eventually.client.R -import eventually.client.activities.helpers.TaskDetails.initTaskDetails -import eventually.client.activities.helpers.TaskManagement -import eventually.client.databinding.ActivityNewTaskBinding -import eventually.client.persistence.tasks.TaskViewModel -import java.time.Instant - -class NewTaskActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val taskViewModel = ViewModelProvider(this).get(TaskViewModel::class.java) - - val date = when (val date = intent.extras?.getLong(ExtraDate)) { - null -> null - else -> when { - date > 0 -> Instant.ofEpochMilli(date) - else -> null - } - } - - taskViewModel.goals.observe(this) { goals -> - taskViewModel.goals.removeObservers(this) - - val binding: ActivityNewTaskBinding = DataBindingUtil.setContentView(this, R.layout.activity_new_task) - - findViewById(R.id.topAppBar).setNavigationOnClickListener { - onBackPressedDispatcher.onBackPressed() - } - - val fields = initTaskDetails( - binding = binding.details, - task = null, - default = date ?: Instant.now(), - operation = getString(R.string.new_task_action), - goals = goals - ) - - val button = findViewById