diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f770db0a4..430126a0e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,7 @@ updates: directory: "/" schedule: interval: "monthly" + open-pull-requests-limit: 10 - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 88be81258..2fef06ac9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,17 +12,17 @@ jobs: timeout-minutes: 30 steps: - name: Check out source code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Build project run: ./build.sh build - name: Run Android tests - run: ./build.sh android-tests-parallel 28 29 30 31 32 33 + run: ./build.sh android-tests-parallel 28 29 30 32 33 34 - name: Upload artifacts if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: build path: | diff --git a/.gitignore b/.gitignore index 66057054e..8dac68273 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ node_modules *xcuserdata* *.sketch crowdin.yml +kotlin-js-store diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc344341..7b4887f77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2.2.0] -- 2024-01-30 +### Added +- Add support for Android 14 (@iSoron, @hiqua) +- Allow user to change app language (@leondzn) + +### Fixed +- Implement workaround to make notifications non-dismissible in Android 14 (@iSoron, #1872) +- Fix splash screen background color in dark mode (@SIKV, #1888) + +## [2.1.3] -- 2023-08-28 +### Fixed +- Use text input on Samsung devices (@iSoron, #1719) +- Prevent crash if alarm permission is revoked (@iSoron) +- Adjust widget colors (@iSoron) +- Fix bug preventing screens from updating at midnight (@iSoron) +- Fix skip button in locales that use comma instead of dot (@iSoron, #1721) + ## [2.1.2] -- 2023-05-26 ### Fixed - Fix bug that caused widget to enter checkmark on wrong date (@iSoron, #1541) diff --git a/build.gradle.kts b/build.gradle.kts index d5daa6c76..b4e6235bf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,10 @@ plugins { - val kotlinVersion = "1.7.21" - id("com.android.application") version "7.4.2" apply (false) + val kotlinVersion = "1.9.22" + id("com.android.application") version "8.1.4" apply (false) id("org.jetbrains.kotlin.android") version kotlinVersion apply (false) id("org.jetbrains.kotlin.kapt") version kotlinVersion apply (false) id("org.jetbrains.kotlin.multiplatform") version kotlinVersion apply (false) - id("org.jlleitschuh.gradle.ktlint") version "11.4.2" + id("org.jlleitschuh.gradle.ktlint") version "11.6.1" } apply { diff --git a/build.sh b/build.sh index 3a9937a58..863e8681e 100755 --- a/build.sh +++ b/build.sh @@ -182,7 +182,7 @@ android_test() { OUT_INSTRUMENT=${ANDROID_OUTPUTS_DIR}/instrument-${API}.txt OUT_LOGCAT=${ANDROID_OUTPUTS_DIR}/logcat-${API}.txt FAILED_TESTS="" - for i in {1..5}; do + for i in {1..10}; do log_info "Running $size instrumented tests (attempt $i)..." $ADB shell am instrument \ -r -e coverage true -e size "$size" $FAILED_TESTS \ diff --git a/gradle.properties b/gradle.properties index b54c6c097..cfc7ec4b1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,3 +3,6 @@ org.gradle.daemon=true org.gradle.jvmargs=-Xms2048m -Xmx2048m android.useAndroidX=true android.enableJetifier=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8049c684f..e411586a5 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/uhabits-android/build.gradle.kts b/uhabits-android/build.gradle.kts index d8f6b23b9..7dbd220d7 100644 --- a/uhabits-android/build.gradle.kts +++ b/uhabits-android/build.gradle.kts @@ -18,8 +18,8 @@ */ plugins { - id("com.github.triplet.play") version "3.7.0" - id("com.android.application") version "7.4.2" + id("com.github.triplet.play") version "3.8.6" + id("com.android.application") version "8.1.4" id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.kapt") id("org.jlleitschuh.gradle.ktlint") @@ -29,15 +29,27 @@ tasks.compileLint { dependsOn("updateTranslators") } +/* +Added on top of kotlinOptions to work around this issue: +https://youtrack.jetbrains.com/issue/KTIJ-24311/task-current-target-is-17-and-kaptGenerateStubsProductionDebugKotlin-task-current-target-is-1.8-jvm-target-compatibility-should#focus=Comments-27-6798448.0-0 +Updating gradle might fix this, so try again in the future to remove this and run: +./gradlew --rerun-tasks :uhabits-android:kaptGenerateStubsReleaseKotlin +If this doesn't produce any warning, try to remove it. + */ +kotlin { + jvmToolchain(11) +} + android { - compileSdk = 32 + namespace = "org.isoron.uhabits" + compileSdk = 34 defaultConfig { versionCode = 20200 versionName = "2.2.0" minSdk = 28 - targetSdk = 32 + targetSdk = 34 applicationId = "org.isoron.uhabits" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -69,8 +81,11 @@ android { compileOptions { isCoreLibraryDesugaringEnabled = true - targetCompatibility(JavaVersion.VERSION_1_8) - sourceCompatibility(JavaVersion.VERSION_1_8) + targetCompatibility(JavaVersion.VERSION_11) + sourceCompatibility(JavaVersion.VERSION_11) + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() } buildFeatures { @@ -84,9 +99,9 @@ android { } dependencies { - val daggerVersion = "2.46" - val kotlinVersion = "1.7.21" - val kxCoroutinesVersion = "1.6.4" + val daggerVersion = "2.51.1" + val kotlinVersion = "1.9.22" + val kxCoroutinesVersion = "1.7.3" val ktorVersion = "1.6.8" val espressoVersion = "3.5.1" @@ -96,17 +111,17 @@ dependencies { androidTestImplementation("com.linkedin.dexmaker:dexmaker-mockito:2.28.3") androidTestImplementation("io.ktor:ktor-client-mock:$ktorVersion") androidTestImplementation("io.ktor:ktor-jackson:$ktorVersion") - androidTestImplementation("androidx.annotation:annotation:1.5.0") + androidTestImplementation("androidx.annotation:annotation:1.7.1") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0") androidTestImplementation("androidx.test:rules:1.5.0") - androidTestImplementation("org.mockito.kotlin:mockito-kotlin:2.2.11") + androidTestImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1") compileOnly("javax.annotation:jsr250-api:1.0") - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") - implementation("com.github.AppIntro:AppIntro:6.2.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + implementation("com.github.AppIntro:AppIntro:6.3.1") implementation("com.google.code.findbugs:jsr305:3.0.2") implementation("com.google.dagger:dagger:$daggerVersion") - implementation("com.google.guava:guava:31.1-android") + implementation("com.google.guava:guava:33.1.0-android") implementation("io.ktor:ktor-client-android:$ktorVersion") implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.ktor:ktor-client-jackson:$ktorVersion") @@ -114,17 +129,18 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kxCoroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kxCoroutinesVersion") - implementation("androidx.appcompat:appcompat:1.5.1") + implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.legacy:legacy-preference-v14:1.0.0") implementation("androidx.legacy:legacy-support-v4:1.0.0") - implementation("com.google.android.material:material:1.8.0") - implementation("com.opencsv:opencsv:5.7.1") + implementation("com.google.android.material:material:1.11.0") + implementation("com.opencsv:opencsv:5.9") + implementation("nl.dionsegijn:konfetti-xml:2.0.2") implementation(project(":uhabits-core")) kapt("com.google.dagger:dagger-compiler:$daggerVersion") kaptAndroidTest("com.google.dagger:dagger-compiler:$daggerVersion") testImplementation("com.google.dagger:dagger:$daggerVersion") testImplementation("junit:junit:4.13.2") - testImplementation("org.mockito.kotlin:mockito-kotlin:2.2.11") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1") } kapt { diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/common/views/FrequencyChartTest.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/common/views/FrequencyChartTest.kt index 0c4225f99..3fb97bb4c 100644 --- a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/common/views/FrequencyChartTest.kt +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/common/views/FrequencyChartTest.kt @@ -18,6 +18,7 @@ */ package org.isoron.uhabits.activities.common.views +import android.view.MotionEvent import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import org.isoron.uhabits.BaseViewTest @@ -52,7 +53,8 @@ class FrequencyChartTest : BaseViewTest() { @Test @Throws(Throwable::class) fun testRender_withDataOffset() { - view.onScroll(null, null, -dpToPixels(150), 0f) + val e = MotionEvent.obtain(0, 0, 0, 0f, 0f, 0) + view.onScroll(e, e, -dpToPixels(150), 0f) view.invalidate() assertRenders(view, BASE_PATH + "renderDataOffset.png") } diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/common/views/ScoreChartTest.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/common/views/ScoreChartTest.kt index 4f504d8be..83d695ca4 100644 --- a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/common/views/ScoreChartTest.kt +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/common/views/ScoreChartTest.kt @@ -18,6 +18,7 @@ */ package org.isoron.uhabits.activities.common.views +import android.view.MotionEvent import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import org.isoron.uhabits.BaseViewTest @@ -63,7 +64,8 @@ class ScoreChartTest : BaseViewTest() { @Test @Throws(Throwable::class) fun testRender_withDataOffset() { - view.onScroll(null, null, -dpToPixels(150), 0f) + val e = MotionEvent.obtain(0, 0, 0, 0f, 0f, 0) + view.onScroll(e, e, -dpToPixels(150), 0f) view.invalidate() assertRenders(view, BASE_PATH + "renderDataOffset.png") } diff --git a/uhabits-android/src/main/AndroidManifest.xml b/uhabits-android/src/main/AndroidManifest.xml index 9377e42c0..8c7758439 100644 --- a/uhabits-android/src/main/AndroidManifest.xml +++ b/uhabits-android/src/main/AndroidManifest.xml @@ -16,12 +16,13 @@ ~ You should have received a copy of the GNU General Public License along ~ with this program. If not, see . --> - + + + + - diff --git a/uhabits-android/src/main/java/com/android/datetimepicker/date/DayPickerView.java b/uhabits-android/src/main/java/com/android/datetimepicker/date/DayPickerView.java index b18c358b0..1433f849f 100644 --- a/uhabits-android/src/main/java/com/android/datetimepicker/date/DayPickerView.java +++ b/uhabits-android/src/main/java/com/android/datetimepicker/date/DayPickerView.java @@ -22,7 +22,6 @@ import java.util.Locale; import android.annotation.SuppressLint; import android.content.Context; -import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.util.AttributeSet; diff --git a/uhabits-android/src/main/java/com/android/datetimepicker/date/MonthView.java b/uhabits-android/src/main/java/com/android/datetimepicker/date/MonthView.java index b9df53cce..c3052fdde 100644 --- a/uhabits-android/src/main/java/com/android/datetimepicker/date/MonthView.java +++ b/uhabits-android/src/main/java/com/android/datetimepicker/date/MonthView.java @@ -23,14 +23,13 @@ import android.graphics.Paint.*; import android.os.*; import androidx.core.view.*; import androidx.core.view.accessibility.*; -import androidx.core.widget.*; + import android.text.format.*; import android.view.*; import android.view.accessibility.*; import androidx.customview.widget.ExploreByTouchHelper; -import com.android.*; import com.android.datetimepicker.*; import com.android.datetimepicker.date.MonthAdapter.*; diff --git a/uhabits-android/src/main/java/com/android/datetimepicker/time/AmPmCirclesView.java b/uhabits-android/src/main/java/com/android/datetimepicker/time/AmPmCirclesView.java index ad9f6ce2e..2cebe7cfa 100644 --- a/uhabits-android/src/main/java/com/android/datetimepicker/time/AmPmCirclesView.java +++ b/uhabits-android/src/main/java/com/android/datetimepicker/time/AmPmCirclesView.java @@ -23,7 +23,6 @@ import android.graphics.Paint.*; import android.util.*; import android.view.*; -import com.android.*; import com.android.datetimepicker.*; import org.isoron.uhabits.R; diff --git a/uhabits-android/src/main/java/com/android/datetimepicker/time/RadialPickerLayout.java b/uhabits-android/src/main/java/com/android/datetimepicker/time/RadialPickerLayout.java index e18ee1143..bbd171a07 100644 --- a/uhabits-android/src/main/java/com/android/datetimepicker/time/RadialPickerLayout.java +++ b/uhabits-android/src/main/java/com/android/datetimepicker/time/RadialPickerLayout.java @@ -28,7 +28,6 @@ import android.view.View.*; import android.view.accessibility.*; import android.widget.*; -import com.android.*; import com.android.datetimepicker.*; import org.isoron.uhabits.R; diff --git a/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidDataView.kt b/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidDataView.kt index 990314a06..ff5f518ff 100644 --- a/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidDataView.kt +++ b/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidDataView.kt @@ -44,21 +44,21 @@ class AndroidDataView( addUpdateListener(this@AndroidDataView) } - override fun onTouchEvent(event: MotionEvent?) = detector.onTouchEvent(event) - override fun onDown(e: MotionEvent?) = true - override fun onShowPress(e: MotionEvent?) = Unit + override fun onTouchEvent(event: MotionEvent) = detector.onTouchEvent(event) + override fun onDown(e: MotionEvent) = true + override fun onShowPress(e: MotionEvent) = Unit - override fun onSingleTapUp(e: MotionEvent?): Boolean { + override fun onSingleTapUp(e: MotionEvent): Boolean { return handleClick(e, true) } - override fun onLongPress(e: MotionEvent?) { + override fun onLongPress(e: MotionEvent) { handleClick(e) } override fun onScroll( e1: MotionEvent?, - e2: MotionEvent?, + e2: MotionEvent, dx: Float, dy: Float ): Boolean { @@ -80,7 +80,7 @@ class AndroidDataView( override fun onFling( e1: MotionEvent?, - e2: MotionEvent?, + e2: MotionEvent, velocityX: Float, velocityY: Float ): Boolean { @@ -100,7 +100,7 @@ class AndroidDataView( return false } - override fun onAnimationUpdate(animation: ValueAnimator?) { + override fun onAnimationUpdate(animation: ValueAnimator) { if (!scroller.isFinished) { scroller.computeScrollOffset() updateDataOffset() @@ -127,11 +127,11 @@ class AndroidDataView( } } - private fun handleClick(e: MotionEvent?, isSingleTap: Boolean = false): Boolean { + private fun handleClick(e: MotionEvent, isSingleTap: Boolean = false): Boolean { val x: Float val y: Float try { - val pointerId = e!!.getPointerId(0) + val pointerId = e.getPointerId(0) x = e.getX(pointerId) y = e.getY(pointerId) } catch (ex: RuntimeException) { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/CheckmarkDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/CheckmarkDialog.kt index 5df8ffd7b..13e5f55f6 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/CheckmarkDialog.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/CheckmarkDialog.kt @@ -33,17 +33,19 @@ import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL import org.isoron.uhabits.databinding.CheckmarkPopupBinding import org.isoron.uhabits.utils.InterfaceUtils.getFontAwesome +import org.isoron.uhabits.utils.getCenter import org.isoron.uhabits.utils.sres class CheckmarkDialog : AppCompatDialogFragment() { - var onToggle: (Int, String) -> Unit = { _, _ -> } + var onToggle: (Int, String, Float, Float) -> Unit = { _, _, _, _ -> } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val appComponent = (requireActivity().application as HabitsApplication).component val prefs = appComponent.preferences val view = CheckmarkPopupBinding.inflate(LayoutInflater.from(context)) + val color = requireArguments().getInt("color") arrayOf(view.yesBtn, view.skipBtn).forEach { - it.setTextColor(requireArguments().getInt("color")) + it.setTextColor(color) } arrayOf(view.noBtn, view.unknownBtn).forEach { it.setTextColor(view.root.sres.getColor(R.attr.contrast60)) @@ -62,7 +64,8 @@ class CheckmarkDialog : AppCompatDialogFragment() { } fun onClick(v: Int) { val notes = view.notes.text.toString().trim() - onToggle(v, notes) + val location = view.yesBtn.getCenter() + onToggle(v, notes, location.x, location.y) requireDialog().dismiss() } view.yesBtn.setOnClickListener { onClick(YES_MANUAL) } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/NumberDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/NumberDialog.kt index 7e10baa51..d8ba1ed59 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/NumberDialog.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/NumberDialog.kt @@ -2,17 +2,20 @@ package org.isoron.uhabits.activities.common.dialogs import android.app.Dialog import android.os.Bundle +import android.provider.Settings import android.text.method.DigitsKeyListener import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.View +import android.view.inputmethod.EditorInfo import androidx.appcompat.app.AppCompatDialogFragment import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.R import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.databinding.CheckmarkPopupBinding import org.isoron.uhabits.utils.InterfaceUtils +import org.isoron.uhabits.utils.getCenter import org.isoron.uhabits.utils.requestFocusWithKeyboard import org.isoron.uhabits.utils.sres import java.text.DecimalFormat @@ -22,7 +25,7 @@ import java.text.ParseException class NumberDialog : AppCompatDialogFragment() { - var onToggle: (Double, String) -> Unit = { _, _ -> } + var onToggle: (Double, String, Float, Float) -> Unit = { _, _, _, _ -> } var onDismiss: () -> Unit = {} private var originalNotes: String = "" @@ -65,7 +68,7 @@ class NumberDialog : AppCompatDialogFragment() { save() } view.skipBtnNumber.setOnClickListener { - view.value.setText((Entry.SKIP.toDouble() / 1000).toString()) + view.value.setText(DecimalFormat("#.###").format((Entry.SKIP.toDouble() / 1000))) save() } view.notes.setOnEditorActionListener { v, actionId, event -> @@ -86,6 +89,15 @@ class NumberDialog : AppCompatDialogFragment() { // https://stackoverflow.com/a/34256139 val separator = DecimalFormatSymbols.getInstance().decimalSeparator view.value.keyListener = DigitsKeyListener.getInstance("0123456789$separator") + + // https://github.com/flutter/flutter/issues/61175 + val currKeyboard = Settings.Secure.getString( + requireContext().contentResolver, + Settings.Secure.DEFAULT_INPUT_METHOD + ) + if (currKeyboard.contains("swiftkey") || currKeyboard.contains("samsung")) { + view.value.inputType = EditorInfo.TYPE_CLASS_TEXT + } } fun save() { @@ -93,12 +105,17 @@ class NumberDialog : AppCompatDialogFragment() { try { val numberFormat = NumberFormat.getInstance() val valueStr = view.value.text.toString() - value = numberFormat.parse(valueStr)!!.toDouble() + value = if (valueStr.isNotEmpty()) { + numberFormat.parse(valueStr)!!.toDouble() + } else { + Entry.UNKNOWN.toDouble() / 1000 + } } catch (e: ParseException) { // NOP } val notes = view.notes.text.toString() - onToggle(value, notes) + val location = view.saveBtn.getCenter() + onToggle(value, notes, location.x, location.y) requireDialog().dismiss() } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/views/RingView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/views/RingView.kt index 870097800..59225dd2f 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/views/RingView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/views/RingView.kt @@ -58,6 +58,7 @@ class RingView : View { private var em = 0f private var text: String? private var textSize: Float + private var isStrokedTextEnabled: Boolean = false private var enableFontAwesome = false private var internalDrawingCache: Bitmap? = null private var cacheCanvas: Canvas? = null @@ -131,6 +132,10 @@ class RingView : View { invalidate() } + fun setIsStrokedTextEnabled(isStroked: Boolean) { + this.isStrokedTextEnabled = isStroked + } + override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val activeCanvas: Canvas? @@ -159,6 +164,12 @@ class RingView : View { pRing!!.xfermode = null pRing!!.color = color pRing!!.textSize = textSize + + if (isStrokedTextEnabled) { + pRing!!.style = Paint.Style.STROKE + pRing!!.strokeWidth = textSize / 15f + } + if (enableFontAwesome) pRing!!.typeface = getFontAwesome(context) activeCanvas.drawText( text!!, diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/views/ScrollableChart.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/views/ScrollableChart.kt index 509acc067..e0d311a0f 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/views/ScrollableChart.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/views/ScrollableChart.kt @@ -65,7 +65,7 @@ abstract class ScrollableChart : View, GestureDetector.OnGestureListener, Animat } override fun onFling( - e1: MotionEvent, + e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float @@ -116,7 +116,7 @@ abstract class ScrollableChart : View, GestureDetector.OnGestureListener, Animat return BundleSavedState(superState, bundle) } - override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, dx: Float, dy: Float): Boolean { + override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float): Boolean { var dx = dx if (scrollerBucketSize == 0) return false if (abs(dx) > abs(dy)) { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt index b7ca3235f..21936d4ea 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt @@ -19,12 +19,17 @@ package org.isoron.uhabits.activities.habits.list +import android.Manifest.permission.POST_NOTIFICATIONS import android.content.Intent +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.os.Build import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat.checkSelfPermission import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import org.isoron.uhabits.BaseExceptionHandler @@ -56,6 +61,16 @@ class ListHabitsActivity : AppCompatActivity(), Preferences.Listener { lateinit var midnightTimer: MidnightTimer private val scope = CoroutineScope(Dispatchers.Main) + private var permissionAlreadyRequested = false + private val permissionLauncher = + registerForActivityResult(RequestPermission()) { isGranted: Boolean -> + if (isGranted) { + scheduleReminders() + } else { + Log.i("ListHabitsActivity", "POST_NOTIFICATIONS denied") + } + } + private lateinit var menu: ListHabitsMenu override fun onQuestionMarksChanged() { @@ -101,7 +116,26 @@ class ListHabitsActivity : AppCompatActivity(), Preferences.Listener { screen.onAttached() rootView.postInvalidate() midnightTimer.onResume() - appComponent.reminderScheduler.scheduleAll() + + if (appComponent.reminderScheduler.hasHabitsWithReminders()) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + scheduleReminders() + } else { + if (checkSelfPermission(this, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { + scheduleReminders() + } else { + // If we have not requested the permission yet, request it. Otherwide do + // nothing. This check is necessary to avoid an infinite onResume loop in case + // the user denies the permission. + if (!permissionAlreadyRequested) { + Log.i("ListHabitsActivity", "Requestion permission: POST_NOTIFICATIONS") + permissionLauncher.launch(POST_NOTIFICATIONS) + permissionAlreadyRequested = true + } + } + } + } + taskRunner.run { try { AutoBackup(this@ListHabitsActivity).run() @@ -117,6 +151,10 @@ class ListHabitsActivity : AppCompatActivity(), Preferences.Listener { super.onResume() } + private fun scheduleReminders() { + appComponent.reminderScheduler.scheduleAll() + } + override fun onCreateOptionsMenu(m: Menu): Boolean { menu.onCreate(menuInflater, m) return true @@ -127,6 +165,7 @@ class ListHabitsActivity : AppCompatActivity(), Preferences.Listener { return menu.onItemSelected(item) } + @Deprecated("Deprecated in Java") override fun onActivityResult(request: Int, result: Int, data: Intent?) { super.onActivityResult(request, result, data) screen.onResult(request, result, data) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt index 62fe3f00c..f0a542a0d 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt @@ -23,6 +23,7 @@ import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import android.widget.RelativeLayout +import nl.dionsegijn.konfetti.xml.KonfettiView import org.isoron.uhabits.R import org.isoron.uhabits.activities.common.views.ScrollableChart import org.isoron.uhabits.activities.common.views.TaskProgressBar @@ -69,6 +70,9 @@ class ListHabitsRootView @Inject constructor( val listView: HabitCardListView = habitCardListViewFactory.create() val llEmpty = EmptyListView(context) val tbar = buildToolbar() + val konfettiView = KonfettiView(context).apply { + translationZ = 10f + } val progressBar = TaskProgressBar(context, runner) val hintView: HintView val header = HeaderView(context, preferences, midnightTimer) @@ -80,6 +84,7 @@ class ListHabitsRootView @Inject constructor( val rootView = RelativeLayout(context).apply { background = sres.getDrawable(R.attr.windowBackgroundColor) + addAtTop(konfettiView) addAtTop(tbar) addBelow(header, tbar) addBelow(listView, header, height = MATCH_PARENT) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt index c525e624a..e1f0418a3 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt @@ -25,6 +25,9 @@ import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import dagger.Lazy +import nl.dionsegijn.konfetti.core.Party +import nl.dionsegijn.konfetti.core.Position +import nl.dionsegijn.konfetti.core.emitter.Emitter import org.isoron.platform.gui.toInt import org.isoron.uhabits.R import org.isoron.uhabits.activities.common.dialogs.CheckmarkDialog @@ -63,6 +66,7 @@ import org.isoron.uhabits.intents.IntentFactory import org.isoron.uhabits.tasks.ExportDBTaskFactory import org.isoron.uhabits.tasks.ImportDataTask import org.isoron.uhabits.tasks.ImportDataTaskFactory +import org.isoron.uhabits.utils.ColorUtils import org.isoron.uhabits.utils.copyTo import org.isoron.uhabits.utils.currentTheme import org.isoron.uhabits.utils.dismissCurrentAndShow @@ -72,6 +76,7 @@ import org.isoron.uhabits.utils.showSendEmailScreen import org.isoron.uhabits.utils.showSendFileScreen import java.io.File import java.io.IOException +import java.util.concurrent.TimeUnit import javax.inject.Inject const val RESULT_IMPORT_DATA = 101 @@ -218,6 +223,28 @@ class ListHabitsScreen activity.showSendFileScreen(filename) } + override fun showConfetti(color: PaletteColor, x: Float, y: Float) { + val baseColor = themeSwitcher.currentTheme!!.color(color).toInt() + rootView.get().konfettiView.start( + Party( + speed = 0f, + maxSpeed = 16f, + damping = 0.9f, + spread = 360, + angle = 0, + colors = listOf( + ColorUtils.changeHue(baseColor, 180f), + ColorUtils.changeHue(baseColor, 20f), + ColorUtils.changeHue(baseColor, -20f), + baseColor + ), + position = Position.Absolute(x, y), + emitter = Emitter(duration = 25, TimeUnit.MILLISECONDS).max(25), + timeToLive = 0 + ) + ) + } + override fun showSettingsScreen() { val intent = intentFactory.startSettingsActivity(activity) activity.startActivityForResult(intent, REQUEST_SETTINGS) @@ -240,7 +267,7 @@ class ListHabitsScreen putDouble("value", value) putString("notes", notes) } - dialog.onToggle = { v, n -> callback.onNumberPicked(v, n) } + dialog.onToggle = { v, n, x, y -> callback.onNumberPicked(v, n, x, y) } dialog.dismissCurrentAndShow(fm, "numberDialog") } @@ -258,7 +285,7 @@ class ListHabitsScreen putInt("value", selectedValue) putString("notes", notes) } - dialog.onToggle = { v, n -> callback.onNotesSaved(v, n) } + dialog.onToggle = { v, n, x, y -> callback.onNotesSaved(v, n, x, y) } dialog.dismissCurrentAndShow(fm, "checkmarkDialog") } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt index 8e5e9d21e..84bd001fc 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt @@ -20,6 +20,7 @@ package org.isoron.uhabits.activities.habits.list.views import android.content.Context +import android.graphics.PointF import android.graphics.text.LineBreaker.BREAK_STRATEGY_BALANCED import android.os.Build import android.os.Build.VERSION.SDK_INT @@ -154,7 +155,17 @@ class HabitCardView( checkmarkPanel = checkmarkPanelFactory.create().apply { onToggle = { timestamp, value, notes -> triggerRipple(timestamp) - habit?.let { behavior.onToggle(it, timestamp, value, notes) } + val location = getAbsoluteButtonLocation(timestamp) + habit?.let { + behavior.onToggle( + it, + timestamp, + value, + notes, + location.x, + location.y + ) + } } onEdit = { timestamp -> triggerRipple(timestamp) @@ -206,12 +217,27 @@ class HabitCardView( } fun triggerRipple(timestamp: Timestamp) { + val location = getRelativeButtonLocation(timestamp) + triggerRipple(location.x, location.y) + } + + private fun getRelativeButtonLocation(timestamp: Timestamp): PointF { val today = DateUtils.getTodayWithOffset() val offset = timestamp.daysUntil(today) - dataOffset val button = checkmarkPanel.buttons[offset] val y = button.height / 2.0f val x = checkmarkPanel.x + button.x + (button.width / 2).toFloat() - triggerRipple(x, y) + return PointF(x, y) + } + + private fun getAbsoluteButtonLocation(timestamp: Timestamp): PointF { + val containerLocation = IntArray(2) + this.getLocationOnScreen(containerLocation) + val relButtonLocation = getRelativeButtonLocation(timestamp) + return PointF( + containerLocation[0].toFloat() + relButtonLocation.x, + containerLocation[1].toFloat() - relButtonLocation.y + ) } override fun onAttachedToWindow() { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt index 21289a0bd..14baee148 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt @@ -179,7 +179,7 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener { putDouble("value", value) putString("notes", notes) } - dialog.onToggle = { v, n -> callback.onNumberPicked(v, n) } + dialog.onToggle = { v, n, x, y -> callback.onNumberPicked(v, n, x, y) } dialog.dismissCurrentAndShow(supportFragmentManager, "numberDialog") } @@ -196,7 +196,7 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener { putInt("value", selectedValue) putString("notes", notes) } - dialog.onToggle = { v, n -> callback.onNotesSaved(v, n) } + dialog.onToggle = { v, n, x, y -> callback.onNotesSaved(v, n, x, y) } dialog.dismissCurrentAndShow(supportFragmentManager, "checkmarkDialog") } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/BarCardView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/BarCardView.kt index 980f6c1d1..f9e857261 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/BarCardView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/BarCardView.kt @@ -38,7 +38,7 @@ class BarCardView(context: Context, attrs: AttributeSet) : LinearLayout(context, fun setState(state: BarCardState) { val androidColor = state.theme.color(state.color).toInt() - binding.chart.view = BarChart(state.theme, JavaLocalDateFormatter(Locale.US)).apply { + binding.chart.view = BarChart(state.theme, JavaLocalDateFormatter(Locale.getDefault())).apply { series = mutableListOf(state.entries.map { it.value / 1000.0 }) colors = mutableListOf(theme.color(state.color.paletteIndex)) axis = state.entries.map { it.timestamp.toLocalDate() } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt index e67f6a567..0fc1f43c8 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt @@ -53,6 +53,8 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis private var ringtoneManager: RingtoneManager? = null private lateinit var prefs: Preferences private var widgetUpdater: WidgetUpdater? = null + + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == RINGTONE_REQUEST_CODE) { ringtoneManager!!.update(data) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt index 089af4af2..047474d67 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt @@ -25,6 +25,7 @@ import android.app.AlarmManager.RTC_WAKEUP import android.app.PendingIntent import android.content.Context import android.content.Context.ALARM_SERVICE +import android.os.Build import android.util.Log import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.models.Habit @@ -56,6 +57,10 @@ class IntentScheduler ) return SchedulerResult.IGNORED } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !manager.canScheduleExactAlarms()) { + Log.e("IntentScheduler", "No permission to schedule exact alarms") + return SchedulerResult.IGNORED + } manager.setExactAndAllowWhileIdle(alarmType, timestamp, intent) return SchedulerResult.OK } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt index 38ba3a1b4..4937dbc74 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt @@ -66,7 +66,13 @@ class ReminderController @Inject constructor( } fun onDismiss(habit: Habit) { - notificationTray.cancel(habit) + if (preferences.shouldMakeNotificationsSticky()) { + // This is a workaround to keep sticky notifications non-dismissible in Android 14+. + // If the notification is dismissed, we immediately reshow it. + notificationTray.reshow(habit) + } else { + notificationTray.cancel(habit) + } } private fun showSnoozeDelayPicker(habit: Habit, context: Context) { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/tasks/AndroidTaskRunner.kt b/uhabits-android/src/main/java/org/isoron/uhabits/tasks/AndroidTaskRunner.kt index aa96604c2..70abc8f7f 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/tasks/AndroidTaskRunner.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/tasks/AndroidTaskRunner.kt @@ -60,12 +60,14 @@ class AndroidTaskRunner : TaskRunner { publishProgress(progress) } + @Deprecated("Deprecated in Java") override fun doInBackground(vararg params: Void?): Void? { if (isCancelled) return null task.doInBackground() return null } + @Deprecated("Deprecated in Java") override fun onPostExecute(aVoid: Void?) { if (isCancelled) return task.onPostExecute() @@ -74,6 +76,7 @@ class AndroidTaskRunner : TaskRunner { for (l in listeners) l.onTaskFinished(task) } + @Deprecated("Deprecated in Java") override fun onPreExecute() { if (isCancelled) return for (l in listeners) l.onTaskStarted(task) @@ -82,6 +85,7 @@ class AndroidTaskRunner : TaskRunner { task.onPreExecute() } + @Deprecated("Deprecated in Java") override fun onProgressUpdate(vararg values: Int?) { values[0]?.let { task.onProgressUpdate(it) } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/utils/ColorUtils.kt b/uhabits-android/src/main/java/org/isoron/uhabits/utils/ColorUtils.kt index c3806f5c6..b993a5bc2 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/utils/ColorUtils.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/utils/ColorUtils.kt @@ -36,6 +36,13 @@ object ColorUtils { return a or r or g or b } + fun changeHue(color: Int, delta: Float): Int { + val hsv = FloatArray(3) + Color.colorToHSV(color, hsv) + hsv[0] = (hsv[0] + delta).mod(360f) + return Color.HSVToColor(hsv) + } + @JvmStatic fun setAlpha(color: Int, newAlpha: Float): Int { val intAlpha = (newAlpha * 255).toInt() diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/utils/ViewExtensions.kt b/uhabits-android/src/main/java/org/isoron/uhabits/utils/ViewExtensions.kt index abf98970c..3c92e7e1b 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/utils/ViewExtensions.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/utils/ViewExtensions.kt @@ -26,6 +26,7 @@ import android.content.Intent import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.graphics.PointF import android.graphics.drawable.ColorDrawable import android.os.Handler import android.os.SystemClock @@ -135,7 +136,11 @@ fun Activity.startActivitySafely(intent: Intent) { } } -fun Activity.showSendEmailScreen(@StringRes toId: Int, @StringRes subjectId: Int, content: String?) { +fun Activity.showSendEmailScreen( + @StringRes toId: Int, + @StringRes subjectId: Int, + content: String? +) { val to = this.getString(toId) val subject = this.getString(subjectId) this.startActivity( @@ -232,3 +237,11 @@ fun View.requestFocusWithKeyboard() { dispatchTouchEvent(MotionEvent.obtain(time, time, MotionEvent.ACTION_UP, 0f, 0f, 0)) }, 250) } + +fun View.getCenter(): PointF { + val viewLocation = IntArray(2) + this.getLocationOnScreen(viewLocation) + viewLocation[0] += this.width / 2 + viewLocation[1] -= this.height / 2 + return PointF(viewLocation[0].toFloat(), viewLocation[1].toFloat()) +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/views/CheckmarkWidgetView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/views/CheckmarkWidgetView.kt index 6515542b0..72c95da64 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/views/CheckmarkWidgetView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/views/CheckmarkWidgetView.kt @@ -68,13 +68,13 @@ class CheckmarkWidgetView : HabitWidgetView { val fgColor: Int setShadowAlpha(0x4f) when (entryState) { - YES_MANUAL, SKIP -> { + YES_MANUAL, SKIP, YES_AUTO -> { bgColor = activeColor fgColor = res.getColor(R.attr.contrast0) backgroundPaint!!.color = bgColor frame!!.setBackgroundDrawable(background) } - YES_AUTO, NO, UNKNOWN -> { + NO, UNKNOWN -> { bgColor = res.getColor(R.attr.cardBgColor) fgColor = res.getColor(R.attr.contrast60) } @@ -87,12 +87,23 @@ class CheckmarkWidgetView : HabitWidgetView { ring.setColor(fgColor) ring.setBackgroundColor(bgColor) ring.setText(text) + ring.setIsStrokedTextEnabled(strokedTextEnabled) label.text = name label.setTextColor(fgColor) requestLayout() postInvalidate() } + private val strokedTextEnabled: Boolean + get() = if (isNumerical) { + false + } else { + when (entryState) { + YES_AUTO -> true + else -> false + } + } + private val text: String get() = if (isNumerical) { (max(0, entryValue) / 1000.0).toShortString() diff --git a/uhabits-android/src/main/res/layout/checkmark_popup.xml b/uhabits-android/src/main/res/layout/checkmark_popup.xml index e21c2fb54..59f5c81a1 100644 --- a/uhabits-android/src/main/res/layout/checkmark_popup.xml +++ b/uhabits-android/src/main/res/layout/checkmark_popup.xml @@ -36,7 +36,7 @@ android:layout_height="0dp" android:layout_weight="1" android:gravity="center" - android:inputType="textCapSentences" + android:inputType="textCapSentences|textMultiLine" android:textSize="@dimen/smallTextSize" android:padding="4dp" android:background="@color/transparent" diff --git a/uhabits-android/src/main/res/layout/widget_graph.xml b/uhabits-android/src/main/res/layout/widget_graph.xml index 6ee9cfb8b..6349717bd 100644 --- a/uhabits-android/src/main/res/layout/widget_graph.xml +++ b/uhabits-android/src/main/res/layout/widget_graph.xml @@ -44,6 +44,7 @@ android:layout_height="wrap_content" android:gravity="center" android:textSize="@dimen/smallTextSize" + android:maxLines="2" android:textColor="@color/white"/> diff --git a/uhabits-android/src/main/res/values-night/colors.xml b/uhabits-android/src/main/res/values-night/colors.xml new file mode 100644 index 000000000..e98c74022 --- /dev/null +++ b/uhabits-android/src/main/res/values-night/colors.xml @@ -0,0 +1,23 @@ + + + + + @color/grey_900 + \ No newline at end of file diff --git a/uhabits-android/src/main/res/values/colors.xml b/uhabits-android/src/main/res/values/colors.xml index 9476698d5..5a59d6681 100644 --- a/uhabits-android/src/main/res/values/colors.xml +++ b/uhabits-android/src/main/res/values/colors.xml @@ -89,4 +89,5 @@ #1976D2 + @color/grey_200 \ No newline at end of file diff --git a/uhabits-android/src/main/res/values/styles.xml b/uhabits-android/src/main/res/values/styles.xml index a3b2be8ba..87d053fe9 100644 --- a/uhabits-android/src/main/res/values/styles.xml +++ b/uhabits-android/src/main/res/values/styles.xml @@ -61,6 +61,7 @@ 0.25 true @color/grey_200 + @color/color_background @color/grey_800 false @color/white diff --git a/uhabits-android/src/main/res/xml/locales_config.xml b/uhabits-android/src/main/res/xml/locales_config.xml new file mode 100644 index 000000000..a0fefcb50 --- /dev/null +++ b/uhabits-android/src/main/res/xml/locales_config.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/uhabits-core/build.gradle.kts b/uhabits-core/build.gradle.kts index 26cece397..930b7819a 100644 --- a/uhabits-core/build.gradle.kts +++ b/uhabits-core/build.gradle.kts @@ -24,6 +24,7 @@ plugins { kotlin { jvm().withJava() + jvmToolchain(11) sourceSets { val commonMain by getting { @@ -43,14 +44,14 @@ kotlin { val jvmMain by getting { dependencies { implementation(kotlin("stdlib-jdk8")) - compileOnly("com.google.dagger:dagger:2.46") - implementation("com.google.guava:guava:31.1-android") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4") - implementation("androidx.annotation:annotation:1.5.0") + compileOnly("com.google.dagger:dagger:2.51.1") + implementation("com.google.guava:guava:33.1.0-android") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3") + implementation("androidx.annotation:annotation:1.7.1") implementation("com.google.code.findbugs:jsr305:3.0.2") - implementation("com.opencsv:opencsv:5.7.1") - implementation("commons-codec:commons-codec:1.15") - implementation("org.apache.commons:commons-lang3:3.12.0") + implementation("com.opencsv:opencsv:5.9") + implementation("commons-codec:commons-codec:1.16.0") + implementation("org.apache.commons:commons-lang3:3.14.0") } } @@ -58,11 +59,11 @@ kotlin { dependencies { implementation(kotlin("test")) implementation(kotlin("test-junit")) - implementation("org.xerial:sqlite-jdbc:3.40.0.0") + implementation("org.xerial:sqlite-jdbc:3.45.1.0") implementation("org.hamcrest:hamcrest:2.2") implementation("org.apache.commons:commons-io:1.3.2") - implementation("org.mockito.kotlin:mockito-kotlin:2.2.11") - implementation("org.junit.jupiter:junit-jupiter:5.8.1") + implementation("org.mockito.kotlin:mockito-kotlin:5.2.1") + implementation("org.junit.jupiter:junit-jupiter:5.10.1") } } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt index abbfc4c48..a06d01ec9 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt @@ -61,7 +61,7 @@ data class Habit( return if (isNumerical) { when (targetType) { NumericalHabitType.AT_LEAST -> value / 1000.0 >= targetValue - NumericalHabitType.AT_MOST -> value / 1000.0 <= targetValue + NumericalHabitType.AT_MOST -> value != Entry.UNKNOWN && value / 1000.0 <= targetValue } } else { value != Entry.NO && value != Entry.UNKNOWN diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/reminders/ReminderScheduler.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/reminders/ReminderScheduler.kt index 8318429f6..bae15bb00 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/reminders/ReminderScheduler.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/reminders/ReminderScheduler.kt @@ -115,6 +115,11 @@ class ReminderScheduler @Inject constructor( for (habit in reminderHabits) schedule(habit) } + @Synchronized + fun hasHabitsWithReminders(): Boolean { + return !habitList.getFiltered(HabitMatcher.WITH_ALARM).isEmpty + } + @Synchronized fun startListening() { commandRunner.addListener(this) diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/NotificationTray.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/NotificationTray.kt index 5239ed44b..da5bb018a 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/NotificationTray.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/NotificationTray.kt @@ -89,6 +89,12 @@ class NotificationTray @Inject constructor( } } + fun reshow(habit: Habit) { + active[habit]?.let { + taskRunner.execute(ShowNotificationTask(habit, it)) + } + } + interface SystemTray { fun removeNotification(notificationId: Int) fun showNotification( diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt index 3db3cdbaf..b66b08be6 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt @@ -20,9 +20,12 @@ package org.isoron.uhabits.core.ui.screens.habits.list import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CreateRepetitionCommand +import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitType +import org.isoron.uhabits.core.models.NumericalHabitType.AT_LEAST +import org.isoron.uhabits.core.models.NumericalHabitType.AT_MOST import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.preferences.Preferences @@ -52,8 +55,16 @@ open class ListHabitsBehavior @Inject constructor( val entry = habit.computedEntries.get(timestamp!!) if (habit.type == HabitType.NUMERICAL) { val oldValue = entry.value.toDouble() / 1000 - screen.showNumberPopup(oldValue, entry.notes) { newValue: Double, newNotes: String -> + screen.showNumberPopup(oldValue, entry.notes) { newValue: Double, newNotes: String, x: Float, y: Float -> val value = (newValue * 1000).roundToInt() + if (newValue != oldValue) { + if ( + (habit.targetType == AT_LEAST && newValue >= habit.targetValue) || + (habit.targetType == AT_MOST && newValue <= habit.targetValue) + ) { + screen.showConfetti(habit.color, x, y) + } + } commandRunner.run(CreateRepetitionCommand(habitList, habit, timestamp, value, newNotes)) } } else { @@ -61,7 +72,8 @@ open class ListHabitsBehavior @Inject constructor( entry.value, entry.notes, habit.color - ) { newValue, newNotes -> + ) { newValue: Int, newNotes: String, x: Float, y: Float -> + if (newValue != entry.value && newValue == YES_MANUAL) screen.showConfetti(habit.color, x, y) commandRunner.run(CreateRepetitionCommand(habitList, habit, timestamp, newValue, newNotes)) } } @@ -117,10 +129,11 @@ open class ListHabitsBehavior @Inject constructor( if (prefs.isFirstRun) onFirstRun() } - fun onToggle(habit: Habit, timestamp: Timestamp, value: Int, notes: String) { + fun onToggle(habit: Habit, timestamp: Timestamp, value: Int, notes: String, x: Float, y: Float) { commandRunner.run( CreateRepetitionCommand(habitList, habit, timestamp, value, notes) ) + if (value == YES_MANUAL) screen.showConfetti(habit.color, x, y) } enum class Message { @@ -144,12 +157,22 @@ open class ListHabitsBehavior @Inject constructor( } fun interface NumberPickerCallback { - fun onNumberPicked(newValue: Double, notes: String) + fun onNumberPicked( + newValue: Double, + notes: String, + x: Float, + y: Float + ) fun onNumberPickerDismissed() {} } fun interface CheckMarkDialogCallback { - fun onNotesSaved(value: Int, notes: String) + fun onNotesSaved( + value: Int, + notes: String, + x: Float, + y: Float + ) fun onNotesDismissed() {} } @@ -170,5 +193,6 @@ open class ListHabitsBehavior @Inject constructor( ) fun showSendBugReportToDeveloperScreen(log: String) fun showSendFileScreen(filename: String) + fun showConfetti(color: PaletteColor, x: Float, y: Float) } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt index cfbe5e0f3..0a28c801f 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt @@ -98,7 +98,7 @@ class HistoryCardPresenter( entry.value, entry.notes, habit.color - ) { newValue, newNotes -> + ) { newValue, newNotes, _: Float, _: Float -> commandRunner.run( CreateRepetitionCommand( habitList, @@ -135,7 +135,7 @@ class HistoryCardPresenter( screen.showNumberPopup( value = oldValue / 1000.0, notes = entry.notes - ) { newValue: Double, newNotes: String -> + ) { newValue: Double, newNotes: String, _: Float, _: Float -> val thousands = (newValue * 1000).roundToInt() commandRunner.run( CreateRepetitionCommand( diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/views/Themes.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/views/Themes.kt index 1b1d989e4..eef7f26d0 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/views/Themes.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/views/Themes.kt @@ -125,4 +125,30 @@ class WidgetTheme : LightTheme() { override val highContrastTextColor = Color.WHITE override val mediumContrastTextColor = Color.WHITE.withAlpha(0.50) override val lowContrastTextColor = Color.WHITE.withAlpha(0.10) + + override fun color(paletteIndex: Int): Color { + return when (paletteIndex) { + 0 -> Color(0xD32F2F) + 1 -> Color(0xE64A19) + 2 -> Color(0xF57C00) + 3 -> Color(0xFF8F00) + 4 -> Color(0xF9A825) + 5 -> Color(0xAFB42B) + 6 -> Color(0x7CB342) + 7 -> Color(0x388E3C) + 8 -> Color(0x00897B) + 9 -> Color(0x00ACC1) + 10 -> Color(0x039BE5) + 11 -> Color(0x1976D2) + 12 -> Color(0x6275f0) + 13 -> Color(0x5E35B1) + 14 -> Color(0x8E24AA) + 15 -> Color(0xD81B60) + 16 -> Color(0x5D4037) + 17 -> Color(0x757575) + 18 -> Color(0x757575) + 19 -> Color(0x9E9E9E) + else -> Color(0x000000) + } + } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/DateUtils.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/DateUtils.kt index b91b0e33a..aec974228 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/DateUtils.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/DateUtils.kt @@ -227,7 +227,7 @@ abstract class DateUtils { fun getStartOfTodayWithOffset(): Long = getStartOfDayWithOffset(getLocalTime()) @JvmStatic - fun millisecondsUntilTomorrowWithOffset(): Long = getStartOfTomorrowWithOffset() - getLocalTime() + fun millisecondsUntilTomorrowWithOffset(): Long = getStartOfTomorrowWithOffset() - applyTimezone(getLocalTime()) @JvmStatic fun getStartOfTodayCalendar(): GregorianCalendar = getCalendar(getStartOfToday()) diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/MidnightTimer.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/MidnightTimer.kt index 903099293..b63569533 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/MidnightTimer.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/MidnightTimer.kt @@ -19,6 +19,7 @@ package org.isoron.uhabits.core.utils import org.isoron.uhabits.core.AppScope +import org.isoron.uhabits.core.io.Logging import java.util.LinkedList import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService @@ -29,9 +30,10 @@ import javax.inject.Inject * A class that emits events when a new day starts. */ @AppScope -open class MidnightTimer @Inject constructor() { +open class MidnightTimer @Inject constructor(logging: Logging) { private val listeners: MutableList = LinkedList() private lateinit var executor: ScheduledExecutorService + private val logger = logging.getLogger("MidnightTimer") @Synchronized fun addListener(listener: MidnightListener) { @@ -39,7 +41,10 @@ open class MidnightTimer @Inject constructor() { } @Synchronized - fun onPause(): MutableList? = executor.shutdownNow() + fun onPause(): MutableList? { + logger.info("Pausing timer") + return executor.shutdownNow() + } @Synchronized fun onResume( @@ -47,9 +52,11 @@ open class MidnightTimer @Inject constructor() { testExecutor: ScheduledExecutorService? = null ) { executor = testExecutor ?: Executors.newSingleThreadScheduledExecutor() + val initialDelay = DateUtils.millisecondsUntilTomorrowWithOffset() + delayOffsetInMillis + logger.info("Scheduling refresh for $initialDelay ms from now") executor.scheduleAtFixedRate( { notifyListeners() }, - DateUtils.millisecondsUntilTomorrowWithOffset() + delayOffsetInMillis, + initialDelay, DateUtils.DAY_LENGTH, TimeUnit.MILLISECONDS ) @@ -60,6 +67,7 @@ open class MidnightTimer @Inject constructor() { @Synchronized private fun notifyListeners() { + logger.info("Midnight refresh") for (l in listeners) { l.atMidnight() } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt index ead82c1e9..26dd82df8 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt @@ -84,7 +84,7 @@ class ListHabitsBehaviorTest : BaseUnitTest() { eq(""), picker.capture() ) - picker.lastValue.onNumberPicked(100.0, "") + picker.lastValue.onNumberPicked(100.0, "", 0f, 0f) val today = getTodayWithOffset() assertThat(habit2.computedEntries.get(today).value, equalTo(100000)) } @@ -168,7 +168,9 @@ class ListHabitsBehaviorTest : BaseUnitTest() { habit = habit1, timestamp = getToday(), value = Entry.NO, - notes = "" + notes = "", + x = 0f, + y = 0f ) assertFalse(habit1.isCompletedToday()) } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/widgets/WidgetBehaviorTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/widgets/WidgetBehaviorTest.kt index 27a1c8cba..e173cdb5e 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/widgets/WidgetBehaviorTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/widgets/WidgetBehaviorTest.kt @@ -32,7 +32,7 @@ import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.reset import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever class WidgetBehaviorTest : BaseUnitTest() { @@ -61,7 +61,7 @@ class WidgetBehaviorTest : BaseUnitTest() { CreateRepetitionCommand(habitList, habit, today, Entry.YES_MANUAL, "") ) verify(notificationTray).cancel(habit) - verifyZeroInteractions(preferences) + verifyNoInteractions(preferences) } @Test @@ -71,7 +71,7 @@ class WidgetBehaviorTest : BaseUnitTest() { CreateRepetitionCommand(habitList, habit, today, Entry.NO, "") ) verify(notificationTray).cancel(habit) - verifyZeroInteractions(preferences) + verifyNoInteractions(preferences) } @Test @@ -113,7 +113,7 @@ class WidgetBehaviorTest : BaseUnitTest() { CreateRepetitionCommand(habitList, habit, today, 600, "") ) verify(notificationTray).cancel(habit) - verifyZeroInteractions(preferences) + verifyNoInteractions(preferences) } @Test @@ -126,6 +126,6 @@ class WidgetBehaviorTest : BaseUnitTest() { CreateRepetitionCommand(habitList, habit, today, 400, "") ) verify(notificationTray).cancel(habit) - verifyZeroInteractions(preferences) + verifyNoInteractions(preferences) } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/utils/MidnightTimerTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/utils/MidnightTimerTest.kt index b92507da0..c0eb3fe2d 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/utils/MidnightTimerTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/utils/MidnightTimerTest.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.isoron.uhabits.core.BaseUnitTest +import org.isoron.uhabits.core.io.StandardLogging import org.junit.Test import java.util.Calendar import java.util.TimeZone @@ -34,7 +35,7 @@ class MidnightTimerTest : BaseUnitTest() { ) val suspendedListener = suspendCoroutine { continuation -> - MidnightTimer().apply { + MidnightTimer(StandardLogging()).apply { addListener { continuation.resume(true) } // When onResume(1, executor) diff --git a/uhabits-server/build.gradle.kts b/uhabits-server/build.gradle.kts index a96d02131..9f2fda06a 100644 --- a/uhabits-server/build.gradle.kts +++ b/uhabits-server/build.gradle.kts @@ -22,7 +22,11 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar plugins { application id("kotlin") - id("com.github.johnrengelman.shadow") version "7.1.2" + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +kotlin { + jvmToolchain(17) } @@ -34,11 +38,9 @@ application { dependencies { val ktorVersion = "1.6.8" - val kotlinVersion = "1.7.21" - val logbackVersion = "1.4.5" - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22") implementation("io.ktor:ktor-server-netty:$ktorVersion") - implementation("ch.qos.logback:logback-classic:$logbackVersion") + implementation("ch.qos.logback:logback-classic:1.4.14") implementation("io.ktor:ktor-server-core:$ktorVersion") implementation("io.ktor:ktor-html-builder:$ktorVersion") implementation("io.ktor:ktor-jackson:$ktorVersion") @@ -47,7 +49,7 @@ dependencies { implementation("io.prometheus:simpleclient_httpserver:0.16.0") implementation("io.prometheus:simpleclient_hotspot:0.16.0") testImplementation("io.ktor:ktor-server-tests:$ktorVersion") - testImplementation("org.mockito.kotlin:mockito-kotlin:2.2.11") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1") testImplementation(kotlin("test")) testImplementation(kotlin("test-junit")) } @@ -57,3 +59,4 @@ tasks.withType { archiveClassifier.set("") archiveVersion.set("") } +