Merge branch 'dev' into android_lint_baseline

pull/1785/head
Alinson S. Xavier 2 years ago
commit 985234cdf3
Signed by: isoron
GPG Key ID: 0DA8E4B9E1109DCA

@ -4,6 +4,7 @@ updates:
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 10
- package-ecosystem: "github-actions"
directory: "/"
schedule:

@ -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: |

1
.gitignore vendored

@ -17,3 +17,4 @@ node_modules
*xcuserdata*
*.sketch
crowdin.yml
kotlin-js-store

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

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

@ -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 \

@ -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

@ -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

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

@ -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")
}

@ -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")
}

@ -16,12 +16,13 @@
~ You should have received a copy of the GNU General Public License along
~ with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.isoron.uhabits">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".HabitsApplication"
@ -29,6 +30,7 @@
android:backupAgent=".HabitsBackupAgent"
android:icon="@mipmap/ic_launcher"
android:label="@string/main_activity_title"
android:localeConfig="@xml/locales_config"
android:supportsRtl="true"
android:theme="@style/AppBaseTheme">

@ -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;

@ -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.*;

@ -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;

@ -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;

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

@ -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) }

@ -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()
}
}

@ -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!!,

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

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

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

@ -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")
}

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

@ -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")
}

@ -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() }

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

@ -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
}

@ -66,8 +66,14 @@ class ReminderController @Inject constructor(
}
fun onDismiss(habit: 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) {
context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))

@ -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) }
}

@ -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()

@ -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())
}

@ -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()

@ -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"

@ -44,6 +44,7 @@
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="@dimen/smallTextSize"
android:maxLines="2"
android:textColor="@color/white"/>
</LinearLayout>

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
~
~ This file is part of Loop Habit Tracker.
~
~ Loop Habit Tracker is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by the
~ Free Software Foundation, either version 3 of the License, or (at your
~ option) any later version.
~
~ Loop Habit Tracker is distributed in the hope that it will be useful, but
~ WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
~ or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along
~ with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<resources>
<color name="color_background">@color/grey_900</color>
</resources>

@ -89,4 +89,5 @@
</array>
<color name="ic_launcher_background">#1976D2</color>
<color name="color_background">@color/grey_200</color>
</resources>

@ -61,6 +61,7 @@
<item name="widgetShadowAlpha">0.25</item>
<item name="windowActionModeOverlay">true</item>
<item name="windowBackgroundColor">@color/grey_200</item>
<item name="android:colorBackground">@color/color_background</item>
<item name="android:textColorAlertDialogListItem">@color/grey_800</item>
<item name="singleLineTitle">false</item>
<item name="dialogFormLabelColor">@color/white</item>

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" /> <!-- English -->
<locale android:name="af-ZA" /> <!-- Afrikaans -->
<locale android:name="ar-SA" /> <!-- Arabic -->
<locale android:name="bg-BG" /> <!-- Bulgarian -->
<locale android:name="ca-ES" /> <!-- Catalan -->
<locale android:name="cs-CZ" /> <!-- Czech -->
<locale android:name="da-DK" /> <!-- Danish -->
<locale android:name="de-DE" /> <!-- German -->
<locale android:name="el-GR" /> <!-- Greek -->
<locale android:name="eo-UY" /> <!-- Esperanto -->
<locale android:name="es-ES" /> <!-- Spanish -->
<locale android:name="eu-ES" /> <!-- Basque -->
<locale android:name="fa-IR" /> <!-- Farsi -->
<locale android:name="fi-FI" /> <!-- Finnish -->
<locale android:name="fr-FR" /> <!-- French -->
<locale android:name="hi-IN" /> <!-- Hindi -->
<locale android:name="hr-HR" /> <!-- Croatian -->
<locale android:name="hu-HU" /> <!-- Hungarian -->
<locale android:name="hy-AM" /> <!-- Armenian -->
<locale android:name="in-ID" /> <!-- Indonesian -->
<locale android:name="it-IT" /> <!-- Italian -->
<locale android:name="iw-IL" /> <!-- Hebrew -->
<locale android:name="ja-JP" /> <!-- Japanese -->
<locale android:name="ko-KR" /> <!-- Korean -->
<locale android:name="nl-NL" /> <!-- Dutch -->
<locale android:name="no-NO" /> <!-- Norwegian -->
<locale android:name="pl-PL" /> <!-- Polish -->
<locale android:name="pt-BR" /> <!-- Portuguese (Brazil) -->
<locale android:name="pt-PT" /> <!-- Portuguese (Portugal) -->
<locale android:name="ro-RO" /> <!-- Romanian -->
<locale android:name="ru-RU" /> <!-- Russian -->
<locale android:name="sk-SK" /> <!-- Slovak -->
<locale android:name="sl-SL" /> <!-- Slovenian -->
<locale android:name="sr-CS" /> <!-- Serbian (Latin) -->
<locale android:name="sr-SP" /> <!-- Serbian (Cyrillic) -->
<locale android:name="sv-SE" /> <!-- Swedish -->
<locale android:name="ta-IN" /> <!-- Tamil -->
<locale android:name="te-IN" /> <!-- Telugu -->
<locale android:name="tr-TR" /> <!-- Turkish -->
<locale android:name="ug-CN" /> <!-- Uyghur -->
<locale android:name="uk-UA" /> <!-- Ukrainian -->
<locale android:name="vi-VN" /> <!-- Vietnamese -->
<locale android:name="zh-CN" /> <!-- Chinese (Simplified) -->
<locale android:name="zh-TW" /> <!-- Chinese (Traditional) -->
</locale-config>

@ -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")
}
}
}

@ -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

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

@ -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(

@ -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)
}
}

@ -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(

@ -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)
}
}
}

@ -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())

@ -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<MidnightListener> = 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<Runnable>? = executor.shutdownNow()
fun onPause(): MutableList<Runnable>? {
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()
}

@ -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())
}

@ -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)
}
}

@ -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<Boolean> { continuation ->
MidnightTimer().apply {
MidnightTimer(StandardLogging()).apply {
addListener { continuation.resume(true) }
// When
onResume(1, executor)

@ -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<ShadowJar> {
archiveClassifier.set("")
archiveVersion.set("")
}

Loading…
Cancel
Save