diff --git a/android/uhabits-android/src/androidTest/assets/views/widgets/HistoryWidget/render.png b/android/uhabits-android/src/androidTest/assets/views/widgets/HistoryWidget/render.png index 279f22d43..5b71e7300 100644 Binary files a/android/uhabits-android/src/androidTest/assets/views/widgets/HistoryWidget/render.png and b/android/uhabits-android/src/androidTest/assets/views/widgets/HistoryWidget/render.png differ diff --git a/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidCanvas.kt b/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidCanvas.kt index 472632690..4e4980c40 100644 --- a/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidCanvas.kt +++ b/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidCanvas.kt @@ -177,4 +177,8 @@ class AndroidCanvas : Canvas { val bmp = innerBitmap ?: throw UnsupportedOperationException() return AndroidImage(bmp) } + + override fun measureText(text: String): Double { + return textPaint.measureText(text) / innerDensity + } } diff --git a/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidDataView.kt b/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidDataView.kt index b1cd1daf5..21df11bff 100644 --- a/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidDataView.kt +++ b/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidDataView.kt @@ -45,7 +45,24 @@ class AndroidDataView( 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?) = false + + override fun onSingleTapUp(e: MotionEvent?): Boolean { + val x: Float + val y: Float + try { + val pointerId = e!!.getPointerId(0) + x = e.getX(pointerId) + y = e.getY(pointerId) + } catch (ex: RuntimeException) { + // Android often throws IllegalArgumentException here. Apparently, + // the pointer id may become invalid shortly after calling + // e.getPointerId. + return false + } + view?.onClick(x / canvas.innerDensity, y / canvas.innerDensity) + return true + } + override fun onLongPress(e: MotionEvent?) = Unit override fun onScroll( @@ -76,7 +93,16 @@ class AndroidDataView( velocityX: Float, velocityY: Float, ): Boolean { - scroller.fling(scroller.currX, scroller.currY, velocityX.toInt() / 2, 0, 0, 10000, 0, 0) + scroller.fling( + scroller.currX, + scroller.currY, + velocityX.toInt() / 2, + 0, + 0, + Integer.MAX_VALUE, + 0, + 0 + ) invalidate() scrollAnimator.duration = scroller.duration.toLong() scrollAnimator.start() @@ -99,11 +125,14 @@ class AndroidDataView( } private fun updateDataOffset() { - var newDataOffset: Int = scroller.currX / (view.dataColumnWidth * canvas.innerDensity).toInt() - newDataOffset = Math.max(0, newDataOffset) - if (newDataOffset != view.dataOffset) { - view.dataOffset = newDataOffset - postInvalidate() + view?.let { v -> + var newDataOffset: Int = + scroller.currX / (v.dataColumnWidth * canvas.innerDensity).toInt() + newDataOffset = Math.max(0, newDataOffset) + if (newDataOffset != v.dataOffset) { + v.dataOffset = newDataOffset + postInvalidate() + } } } } diff --git a/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidView.kt b/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidView.kt index 32cc8cb26..44b3b04ae 100644 --- a/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidView.kt +++ b/android/uhabits-android/src/main/java/org/isoron/platform/gui/AndroidView.kt @@ -27,7 +27,7 @@ open class AndroidView( attrs: AttributeSet? = null, ) : android.view.View(context, attrs) { - lateinit var view: T + var view: T? = null val canvas = AndroidCanvas() override fun onDraw(canvas: android.graphics.Canvas) { @@ -36,6 +36,6 @@ open class AndroidView( this.canvas.innerWidth = width this.canvas.innerHeight = height this.canvas.innerDensity = resources.displayMetrics.density.toDouble() - view.draw(this.canvas) + view?.draw(this.canvas) } } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/HistoryEditorDialog.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/HistoryEditorDialog.java deleted file mode 100644 index 96c1ecb26..000000000 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/HistoryEditorDialog.java +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright (C) 2016 Álinson Santos Xavier - * - * 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 . - */ - -package org.isoron.uhabits.activities.common.dialogs; - -import android.app.*; -import android.content.*; -import android.os.*; -import android.util.*; - -import androidx.annotation.Nullable; -import androidx.annotation.*; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.*; - -import org.isoron.uhabits.*; -import org.isoron.uhabits.core.commands.*; -import org.isoron.uhabits.core.models.*; -import org.isoron.uhabits.core.preferences.*; -import org.isoron.uhabits.core.tasks.*; -import org.isoron.uhabits.core.ui.callbacks.*; -import org.isoron.uhabits.core.ui.screens.habits.show.views.*; -import org.isoron.uhabits.core.ui.views.*; -import org.isoron.uhabits.utils.*; -import org.jetbrains.annotations.*; - -import java.util.*; - -public class HistoryEditorDialog extends AppCompatDialogFragment - implements DialogInterface.OnClickListener, CommandRunner.Listener -{ - @Nullable - private Habit habit; - - @Nullable - HistoryChart historyChart; - - @NonNull - private OnToggleCheckmarkListener onToggleCheckmarkListener; - - private HabitList habitList; - - private TaskRunner taskRunner; - - private Preferences prefs; - - private CommandRunner commandRunner; - - public HistoryEditorDialog() - { - this.onToggleCheckmarkListener = new OnToggleCheckmarkListener() - { - @Override - public void onToggleEntry(@NotNull Timestamp timestamp, int value) - { - } - }; - } - - @Override - public void onClick(DialogInterface dialog, int which) - { - dismiss(); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) - { - Context context = getActivity(); - - HabitsApplication app = - (HabitsApplication) getActivity().getApplicationContext(); - habitList = app.getComponent().getHabitList(); - taskRunner = app.getComponent().getTaskRunner(); - commandRunner = app.getComponent().getCommandRunner(); - prefs = app.getComponent().getPreferences(); - -// historyChart = new HistoryChart(context); -// historyChart.setOnToggleCheckmarkListener(onToggleCheckmarkListener); -// historyChart.setFirstWeekday(prefs.getFirstWeekday()); -// historyChart.setSkipEnabled(prefs.isSkipEnabled()); - -// if (savedInstanceState != null) -// { -// long id = savedInstanceState.getLong("habit", -1); -// if (id > 0) this.habit = habitList.getById(id); -// historyChart.onRestoreInstanceState( -// savedInstanceState.getParcelable("historyChart")); -// } -// -// int padding = -// (int) getDimension(getContext(), R.dimen.history_editor_padding); -// -// historyChart.setPadding(padding, 0, padding, 0); -// historyChart.setIsEditable(true); -// - AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder - .setTitle(R.string.history) -// .setView(historyChart) - .setPositiveButton(android.R.string.ok, this); -// - return builder.create(); - } - - @Override - public void onResume() - { - super.onResume(); - - DisplayMetrics metrics = getResources().getDisplayMetrics(); - int maxHeight = getResources().getDimensionPixelSize( - R.dimen.history_editor_max_height); - int width = metrics.widthPixels; - int height = Math.min(metrics.heightPixels, maxHeight); - - getDialog().getWindow().setLayout(width, height); - commandRunner.addListener(this); - refreshData(); - } - - @Override - public void onPause() - { - commandRunner.removeListener(this); - super.onPause(); - } - - @Override - public void onSaveInstanceState(Bundle outState) - { -// outState.putLong("habit", habit.getId()); -// outState.putParcelable("historyChart", historyChart.onSaveInstanceState()); - } - - public void setOnToggleCheckmarkListener(@NonNull OnToggleCheckmarkListener onToggleCheckmarkListener) - { - this.onToggleCheckmarkListener = onToggleCheckmarkListener; - } - - public void setHabit(@Nullable Habit habit) - { - this.habit = habit; - } - - private void refreshData() - { - if (habit == null) return; - taskRunner.execute(new RefreshTask()); - } - - @Override - public void onCommandFinished(@NonNull Command command) - { - refreshData(); - } - - private class RefreshTask implements Task - { - public List checkmarks; - - @Override - public void doInBackground() - { - HistoryCardViewModel model = new HistoryCardPresenter().present( - habit, - prefs.getFirstWeekday(), - prefs.isSkipEnabled(), - new LightTheme() - ); - checkmarks = model.getSeries(); - } - - @Override - public void onPostExecute() - { - if (getContext() == null || habit == null || historyChart == null) - return; - - int color = PaletteUtilsKt.toThemedAndroidColor(habit.getColor(), getContext()); -// historyChart.setColor(color); -// historyChart.setEntries(checkmarks); -// historyChart.setNumerical(habit.isNumerical()); - } - } -} diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/HistoryEditorDialog.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/HistoryEditorDialog.kt new file mode 100644 index 000000000..25af12e9b --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/HistoryEditorDialog.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ +package org.isoron.uhabits.activities.common.dialogs + +import android.app.Dialog +import android.os.Bundle +import androidx.appcompat.app.AppCompatDialogFragment +import org.isoron.platform.gui.AndroidDataView +import org.isoron.platform.time.JavaLocalDateFormatter +import org.isoron.uhabits.HabitsApplication +import org.isoron.uhabits.R +import org.isoron.uhabits.core.commands.Command +import org.isoron.uhabits.core.commands.CommandRunner +import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.preferences.Preferences +import org.isoron.uhabits.core.ui.screens.habits.show.views.HistoryCardPresenter +import org.isoron.uhabits.core.ui.views.HistoryChart +import org.isoron.uhabits.core.ui.views.LightTheme +import org.isoron.uhabits.core.ui.views.OnDateClickedListener +import org.isoron.uhabits.core.utils.DateUtils +import java.util.Locale +import kotlin.math.min + +class HistoryEditorDialog : AppCompatDialogFragment(), CommandRunner.Listener { + + private lateinit var commandRunner: CommandRunner + private lateinit var habit: Habit + private lateinit var preferences: Preferences + private lateinit var dataView: AndroidDataView + + private var chart: HistoryChart? = null + private var onDateClickedListener: OnDateClickedListener? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val component = (activity!!.application as HabitsApplication).component + commandRunner = component.commandRunner + habit = component.habitList.getById(arguments!!.getLong("habit"))!! + preferences = component.preferences + + chart = HistoryChart( + dateFormatter = JavaLocalDateFormatter(Locale.getDefault()), + firstWeekday = preferences.firstWeekday, + paletteColor = habit.color, + series = emptyList(), + theme = LightTheme(), + today = DateUtils.getTodayWithOffset().toLocalDate(), + onDateClickedListener = onDateClickedListener ?: OnDateClickedListener { }, + padding = 10.0, + ) + dataView = AndroidDataView(context!!, null) + dataView.view = chart!! + + return Dialog(context!!).apply { + val metrics = resources.displayMetrics + val maxHeight = resources.getDimensionPixelSize(R.dimen.history_editor_max_height) + setContentView(dataView) + window!!.setLayout(metrics.widthPixels, min(metrics.heightPixels, maxHeight)) + } + } + + override fun onResume() { + super.onResume() + commandRunner.addListener(this) + refreshData() + } + + override fun onPause() { + commandRunner.removeListener(this) + super.onPause() + } + + fun setOnDateClickedListener(listener: OnDateClickedListener) { + onDateClickedListener = listener + chart?.onDateClickedListener = listener + } + + private fun refreshData() { + val model = HistoryCardPresenter().present( + habit, + preferences.firstWeekday, + preferences.isSkipEnabled, + theme = LightTheme() + ) + chart?.series = model.series + dataView.postInvalidate() + } + + override fun onCommandFinished(command: Command) { + refreshData() + } +} diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt index 75f3145d7..324e7bdbf 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt @@ -31,6 +31,7 @@ import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.activities.AndroidThemeSwitcher import org.isoron.uhabits.activities.HabitsDirFinder import org.isoron.uhabits.activities.common.dialogs.ConfirmDeleteDialogFactory +import org.isoron.uhabits.activities.common.dialogs.HistoryEditorDialog import org.isoron.uhabits.activities.common.dialogs.NumberPickerFactory import org.isoron.uhabits.core.commands.Command import org.isoron.uhabits.core.commands.CommandRunner @@ -51,6 +52,7 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener { private lateinit var habit: Habit private lateinit var preferences: Preferences private lateinit var themeSwitcher: AndroidThemeSwitcher + private lateinit var behavior: ShowHabitBehavior private val scope = CoroutineScope(Dispatchers.Main) @@ -76,7 +78,7 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener { widgetUpdater = appComponent.widgetUpdater, ) - val behavior = ShowHabitBehavior( + behavior = ShowHabitBehavior( commandRunner = commandRunner, habit = habit, habitList = habitList, @@ -118,6 +120,9 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener { override fun onResume() { super.onResume() commandRunner.addListener(this) + supportFragmentManager.findFragmentByTag("historyEditor")?.let { + (it as HistoryEditorDialog).setOnDateClickedListener(behavior) + } refresh() } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitScreen.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitScreen.kt index 31d91fd42..cc134ff43 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitScreen.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitScreen.kt @@ -19,16 +19,18 @@ package org.isoron.uhabits.activities.habits.show +import android.os.Bundle +import android.view.HapticFeedbackConstants import org.isoron.uhabits.R import org.isoron.uhabits.activities.common.dialogs.ConfirmDeleteDialogFactory import org.isoron.uhabits.activities.common.dialogs.HistoryEditorDialog import org.isoron.uhabits.activities.common.dialogs.NumberPickerFactory import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback -import org.isoron.uhabits.core.ui.callbacks.OnToggleCheckmarkListener import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitBehavior import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitMenuBehavior +import org.isoron.uhabits.core.ui.views.OnDateClickedListener import org.isoron.uhabits.intents.IntentFactory import org.isoron.uhabits.utils.showMessage import org.isoron.uhabits.utils.showSendFileScreen @@ -43,7 +45,11 @@ class ShowHabitScreen( val widgetUpdater: WidgetUpdater, ) : ShowHabitBehavior.Screen, ShowHabitMenuBehavior.Screen { - override fun showNumberPicker(value: Double, unit: String, callback: ListHabitsBehavior.NumberPickerCallback) { + override fun showNumberPicker( + value: Double, + unit: String, + callback: ListHabitsBehavior.NumberPickerCallback, + ) { numberPickerFactory.create(value, unit, callback).show() } @@ -55,13 +61,19 @@ class ShowHabitScreen( activity.refresh() } - override fun showHistoryEditorDialog(listener: OnToggleCheckmarkListener) { + override fun showHistoryEditorDialog(listener: OnDateClickedListener) { val dialog = HistoryEditorDialog() - dialog.setHabit(habit) - dialog.setOnToggleCheckmarkListener(listener) + dialog.arguments = Bundle().apply { + putLong("habit", habit.id!!) + } + dialog.setOnDateClickedListener(listener) dialog.show(activity.supportFragmentManager, "historyEditor") } + override fun touchFeedback() { + activity.window.decorView.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + override fun showEditHabitScreen(habit: Habit) { activity.startActivity(intentFactory.startEditActivity(activity, habit)) } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/HistoryCardView.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/HistoryCardView.kt index d408b0d4c..3f151049e 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/HistoryCardView.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/HistoryCardView.kt @@ -40,7 +40,6 @@ class HistoryCardView(context: Context, attrs: AttributeSet) : LinearLayout(cont } fun update(data: HistoryCardViewModel) { - val androidColor = data.color.toThemedAndroidColor(context) binding.title.setTextColor(androidColor) binding.chart.view = HistoryChart( @@ -51,10 +50,6 @@ class HistoryCardView(context: Context, attrs: AttributeSet) : LinearLayout(cont series = data.series, firstWeekday = data.firstWeekday, ) - - // binding.historyChart.setSkipEnabled(data.isSkipEnabled) - // if (data.isNumerical) { - // binding.historyChart.setNumerical(true) - // } + binding.chart.postInvalidate() } } diff --git a/android/uhabits-android/src/main/res/values/dimens.xml b/android/uhabits-android/src/main/res/values/dimens.xml index 20b1fce66..a19364ac9 100644 --- a/android/uhabits-android/src/main/res/values/dimens.xml +++ b/android/uhabits-android/src/main/res/values/dimens.xml @@ -21,8 +21,7 @@ 20dp 48dp 48dp - 450dp - 8dp + 350dp 16sp 14sp 12sp diff --git a/android/uhabits-core/assets/test/views/HistoryChart/base.png b/android/uhabits-core/assets/test/views/HistoryChart/base.png index 949a40f35..0e8d41a7d 100644 Binary files a/android/uhabits-core/assets/test/views/HistoryChart/base.png and b/android/uhabits-core/assets/test/views/HistoryChart/base.png differ diff --git a/android/uhabits-core/assets/test/views/HistoryChart/scroll.png b/android/uhabits-core/assets/test/views/HistoryChart/scroll.png index b55b147b7..3dd0aba82 100644 Binary files a/android/uhabits-core/assets/test/views/HistoryChart/scroll.png and b/android/uhabits-core/assets/test/views/HistoryChart/scroll.png differ diff --git a/android/uhabits-core/assets/test/views/HistoryChart/small.png b/android/uhabits-core/assets/test/views/HistoryChart/small.png index 07b419c5c..c0081f0b1 100644 Binary files a/android/uhabits-core/assets/test/views/HistoryChart/small.png and b/android/uhabits-core/assets/test/views/HistoryChart/small.png differ diff --git a/android/uhabits-core/assets/test/views/HistoryChart/themeDark.png b/android/uhabits-core/assets/test/views/HistoryChart/themeDark.png index 89bedf070..fea8340e6 100644 Binary files a/android/uhabits-core/assets/test/views/HistoryChart/themeDark.png and b/android/uhabits-core/assets/test/views/HistoryChart/themeDark.png differ diff --git a/android/uhabits-core/assets/test/views/HistoryChart/themeWidget.png b/android/uhabits-core/assets/test/views/HistoryChart/themeWidget.png index 8c39deac3..984bda475 100644 Binary files a/android/uhabits-core/assets/test/views/HistoryChart/themeWidget.png and b/android/uhabits-core/assets/test/views/HistoryChart/themeWidget.png differ diff --git a/android/uhabits-core/assets/test/views/HistoryChart/weekday.png b/android/uhabits-core/assets/test/views/HistoryChart/weekday.png new file mode 100644 index 000000000..206ee16a4 Binary files /dev/null and b/android/uhabits-core/assets/test/views/HistoryChart/weekday.png differ diff --git a/android/uhabits-core/src/main/java/org/isoron/platform/gui/Canvas.kt b/android/uhabits-core/src/main/java/org/isoron/platform/gui/Canvas.kt index c72417289..c8218641d 100644 --- a/android/uhabits-core/src/main/java/org/isoron/platform/gui/Canvas.kt +++ b/android/uhabits-core/src/main/java/org/isoron/platform/gui/Canvas.kt @@ -51,6 +51,7 @@ interface Canvas { fun fillCircle(centerX: Double, centerY: Double, radius: Double) fun setTextAlign(align: TextAlign) fun toImage(): Image + fun measureText(test: String): Double /** * Fills entire canvas with the current color. diff --git a/android/uhabits-core/src/main/java/org/isoron/platform/gui/JavaCanvas.kt b/android/uhabits-core/src/main/java/org/isoron/platform/gui/JavaCanvas.kt index 919c11dec..98a2f047c 100644 --- a/android/uhabits-core/src/main/java/org/isoron/platform/gui/JavaCanvas.kt +++ b/android/uhabits-core/src/main/java/org/isoron/platform/gui/JavaCanvas.kt @@ -36,13 +36,18 @@ import kotlin.math.roundToInt class JavaCanvas( val image: BufferedImage, - val pixelScale: Double = 2.0 + val pixelScale: Double = 2.0, ) : Canvas { override fun toImage(): Image { return JavaImage(image) } + override fun measureText(text: String): Double { + val metrics = g2d.getFontMetrics(g2d.font) + return toDp(metrics.stringWidth(text)) + } + private val frc = FontRenderContext(null, true, true) private var fontSize = 12.0 private var font = Font.REGULAR @@ -121,7 +126,7 @@ class JavaCanvas( y: Double, width: Double, height: Double, - cornerRadius: Double + cornerRadius: Double, ) { g2d.fill( RoundRectangle2D.Double( @@ -184,7 +189,7 @@ class JavaCanvas( centerY: Double, radius: Double, startAngle: Double, - swipeAngle: Double + swipeAngle: Double, ) { g2d.fillArc( diff --git a/android/uhabits-core/src/main/java/org/isoron/platform/gui/View.kt b/android/uhabits-core/src/main/java/org/isoron/platform/gui/View.kt index dc775cc7f..45f9a8374 100644 --- a/android/uhabits-core/src/main/java/org/isoron/platform/gui/View.kt +++ b/android/uhabits-core/src/main/java/org/isoron/platform/gui/View.kt @@ -21,6 +21,8 @@ package org.isoron.platform.gui interface View { fun draw(canvas: Canvas) + fun onClick(x: Double, y: Double) { + } } interface DataView : View { diff --git a/android/uhabits-core/src/main/java/org/isoron/platform/time/Dates.kt b/android/uhabits-core/src/main/java/org/isoron/platform/time/Dates.kt index d3ef6af33..066f2a304 100644 --- a/android/uhabits-core/src/main/java/org/isoron/platform/time/Dates.kt +++ b/android/uhabits-core/src/main/java/org/isoron/platform/time/Dates.kt @@ -128,9 +128,14 @@ data class LocalDate(val daysSince2000: Int) { fun distanceTo(other: LocalDate): Int { return abs(daysSince2000 - other.daysSince2000) } + + override fun toString(): String { + return "LocalDate($year-$month-$day)" + } } interface LocalDateFormatter { + fun shortWeekdayName(weekday: DayOfWeek): String fun shortWeekdayName(date: LocalDate): String fun shortMonthName(date: LocalDate): String } diff --git a/android/uhabits-core/src/main/java/org/isoron/platform/time/JavaDates.kt b/android/uhabits-core/src/main/java/org/isoron/platform/time/JavaDates.kt index 91b59e427..56439fe84 100644 --- a/android/uhabits-core/src/main/java/org/isoron/platform/time/JavaDates.kt +++ b/android/uhabits-core/src/main/java/org/isoron/platform/time/JavaDates.kt @@ -56,6 +56,12 @@ class JavaLocalDateFormatter(private val locale: Locale) : LocalDateFormatter { return if (longName.length <= 3) longName else shortName } + override fun shortWeekdayName(weekday: DayOfWeek): String { + val cal = GregorianCalendar() + cal.set(DAY_OF_WEEK, weekday.daysSinceSunday - 1) + return shortWeekdayName(LocalDate(cal.get(YEAR), cal.get(MONTH) + 1, cal.get(DAY_OF_MONTH))) + } + override fun shortWeekdayName(date: LocalDate): String { val cal = date.toGregorianCalendar() return cal.getDisplayName(DAY_OF_WEEK, SHORT, locale) diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitBehavior.kt b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitBehavior.kt index f84f00185..420c40553 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitBehavior.kt +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitBehavior.kt @@ -18,14 +18,15 @@ */ package org.isoron.uhabits.core.ui.screens.habits.show +import org.isoron.platform.time.LocalDate import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CreateRepetitionCommand +import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList -import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.preferences.Preferences -import org.isoron.uhabits.core.ui.callbacks.OnToggleCheckmarkListener import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior +import org.isoron.uhabits.core.ui.views.OnDateClickedListener import kotlin.math.roundToInt class ShowHabitBehavior( @@ -34,7 +35,7 @@ class ShowHabitBehavior( private val habit: Habit, private val screen: Screen, private val preferences: Preferences, -) : OnToggleCheckmarkListener { +) : OnDateClickedListener { fun onScoreCardSpinnerPosition(position: Int) { preferences.scoreCardSpinnerPosition = position @@ -58,7 +59,9 @@ class ShowHabitBehavior( screen.showHistoryEditorDialog(this) } - override fun onToggleEntry(timestamp: Timestamp, value: Int) { + override fun onDateClicked(date: LocalDate) { + val timestamp = date.timestamp + screen.touchFeedback() if (habit.isNumerical) { val entries = habit.computedEntries val oldValue = entries.get(timestamp).value @@ -74,12 +77,18 @@ class ShowHabitBehavior( ) } } else { + val currentValue = habit.computedEntries.get(timestamp).value + val nextValue = if (preferences.isSkipEnabled) { + Entry.nextToggleValueWithSkip(currentValue) + } else { + Entry.nextToggleValueWithoutSkip(currentValue) + } commandRunner.run( CreateRepetitionCommand( habitList, habit, timestamp, - value, + nextValue, ), ) } @@ -89,11 +98,12 @@ class ShowHabitBehavior( fun showNumberPicker( value: Double, unit: String, - callback: ListHabitsBehavior.NumberPickerCallback + callback: ListHabitsBehavior.NumberPickerCallback, ) fun updateWidgets() fun refresh() - fun showHistoryEditorDialog(listener: OnToggleCheckmarkListener) + fun showHistoryEditorDialog(listener: OnDateClickedListener) + fun touchFeedback() } } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/views/HistoryChart.kt b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/views/HistoryChart.kt index 2b9442ced..6387a5eef 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/views/HistoryChart.kt +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/views/HistoryChart.kt @@ -28,15 +28,23 @@ import org.isoron.platform.time.LocalDate import org.isoron.platform.time.LocalDateFormatter import org.isoron.uhabits.core.models.PaletteColor import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min import kotlin.math.round +fun interface OnDateClickedListener { + fun onDateClicked(date: LocalDate) +} + class HistoryChart( - var today: LocalDate, - var paletteColor: PaletteColor, - var theme: Theme, var dateFormatter: LocalDateFormatter, var firstWeekday: DayOfWeek, + var paletteColor: PaletteColor, var series: List, + var theme: Theme, + var today: LocalDate, + var onDateClickedListener: OnDateClickedListener = OnDateClickedListener { }, + var padding: Double = 0.0, ) : DataView { enum class Square { @@ -46,38 +54,58 @@ class HistoryChart( HATCHED, } - // Style - var padding = 0.0 var squareSpacing = 1.0 override var dataOffset = 0 - private var squareSize = 0.0 - var lastPrintedMonth = "" - var lastPrintedYear = "" + private var squareSize = 0.0 + private var width = 0.0 + private var height = 0.0 + private var nColumns = 0 + private var topLeftOffset = 0 + private var topLeftDate = LocalDate(2020, 1, 1) + private var lastPrintedMonth = "" + private var lastPrintedYear = "" + private var headerOverflow = 0.0 override val dataColumnWidth: Double get() = squareSpacing + squareSize + override fun onClick(x: Double, y: Double) { + if (width <= 0.0) throw IllegalStateException("onClick must be called after draw(canvas)") + val col = ((x - padding) / squareSize).toInt() + val row = ((y - padding) / squareSize).toInt() + val offset = col * 7 + (row - 1) + if (row == 0 || col == nColumns) return + val clickedDate = topLeftDate.plus(offset) + if (clickedDate.isNewerThan(today)) return + onDateClickedListener.onDateClicked(clickedDate) + } + override fun draw(canvas: Canvas) { - val width = canvas.getWidth() - val height = canvas.getHeight() + width = canvas.getWidth() + height = canvas.getHeight() canvas.setColor(theme.cardBackgroundColor) canvas.fill() squareSize = round((height - 2 * padding) / 8.0) - canvas.setFontSize(height * 0.06) + canvas.setFontSize(min(14.0, height * 0.06)) + + val weekdayColumnWidth = DayOfWeek.values().map { weekday -> + canvas.measureText(dateFormatter.shortWeekdayName(weekday)) + squareSize * 0.15 + }.maxOrNull() ?: 0.0 - val nColumns = floor((width - 2 * padding) / squareSize).toInt() - 2 + nColumns = floor((width - 2 * padding - weekdayColumnWidth) / squareSize).toInt() val firstWeekdayOffset = ( today.dayOfWeek.daysSinceSunday - firstWeekday.daysSinceSunday + 7 ) % 7 - val topLeftOffset = (nColumns - 1 + dataOffset) * 7 + firstWeekdayOffset - val topLeftDate = today.minus(topLeftOffset) + topLeftOffset = (nColumns - 1 + dataOffset) * 7 + firstWeekdayOffset + topLeftDate = today.minus(topLeftOffset) lastPrintedYear = "" lastPrintedMonth = "" + headerOverflow = 0.0 // Draw main columns repeat(nColumns) { column -> @@ -93,7 +121,7 @@ class HistoryChart( canvas.setTextAlign(TextAlign.LEFT) canvas.drawText( dateFormatter.shortWeekdayName(date), - padding + nColumns * squareSize + squareSpacing * 3, + padding + nColumns * squareSize + squareSize * 0.15, padding + squareSize * (row + 1) + squareSize / 2 ) } @@ -143,9 +171,12 @@ class HistoryChart( canvas.setTextAlign(TextAlign.LEFT) canvas.drawText( headerText, - padding + column * squareSize, + headerOverflow + padding + column * squareSize, padding + squareSize / 2 ) + + headerOverflow += canvas.measureText(headerText) + 0.1 * squareSize + headerOverflow = max(0.0, headerOverflow - squareSize) } private fun drawSquare( diff --git a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/ui/views/HistoryChartTest.kt b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/ui/views/HistoryChartTest.kt index e97870872..44bd863ec 100644 --- a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/ui/views/HistoryChartTest.kt +++ b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/ui/views/HistoryChartTest.kt @@ -33,19 +33,27 @@ import org.isoron.uhabits.core.ui.views.HistoryChart.Square.HATCHED import org.isoron.uhabits.core.ui.views.HistoryChart.Square.OFF import org.isoron.uhabits.core.ui.views.HistoryChart.Square.ON import org.isoron.uhabits.core.ui.views.LightTheme +import org.isoron.uhabits.core.ui.views.OnDateClickedListener import org.isoron.uhabits.core.ui.views.WidgetTheme import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.reset +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions import java.util.Locale class HistoryChartTest { val base = "views/HistoryChart" + val dateClickedListener = mock(OnDateClickedListener::class.java) + val view = HistoryChart( today = LocalDate(2015, 1, 25), paletteColor = PaletteColor(7), theme = LightTheme(), dateFormatter = JavaLocalDateFormatter(Locale.US), firstWeekday = SUNDAY, + onDateClickedListener = dateClickedListener, series = listOf( 2, // today 2, 1, 2, 1, 2, 1, 2, @@ -71,16 +79,42 @@ class HistoryChartTest { } ) - // TODO: Label overflow - // TODO: onClick - // TODO: HistoryEditorDialog - // TODO: Remove excessive padding on widgets - @Test fun testDraw() = runBlocking { assertRenders(400, 200, "$base/base.png", view) } + @Test + fun testClick() = runBlocking { + assertRenders(400, 200, "$base/base.png", view) + + // Click top left date + view.onClick(20.0, 46.0) + verify(dateClickedListener).onDateClicked(LocalDate(2014, 10, 26)) + reset(dateClickedListener) + view.onClick(2.0, 28.0) + verify(dateClickedListener).onDateClicked(LocalDate(2014, 10, 26)) + reset(dateClickedListener) + + // Click date in the middle + view.onClick(163.0, 113.0) + verify(dateClickedListener).onDateClicked(LocalDate(2014, 12, 10)) + reset(dateClickedListener) + + // Click today + view.onClick(336.0, 37.0) + verify(dateClickedListener).onDateClicked(LocalDate(2015, 1, 25)) + reset(dateClickedListener) + + // Click header + view.onClick(160.0, 15.0) + verifyNoMoreInteractions(dateClickedListener) + + // Click right axis + view.onClick(360.0, 60.0) + verifyNoMoreInteractions(dateClickedListener) + } + @Test fun testDrawWeekDay() = runBlocking { view.firstWeekday = DayOfWeek.MONDAY