diff --git a/gradle.properties b/gradle.properties index cfc7ec4b1..2035e4f83 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,3 +6,4 @@ android.enableJetifier=true android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false android.nonFinalResIds=false +org.gradle.java.installations.auto-download=true diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt index 20474090a..ad237f630 100644 --- a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt @@ -53,7 +53,7 @@ class HabitCardViewTest : BaseViewTest() { .getByInterval(today.minus(300), today) .map { it.value }.toIntArray() - view = component.getHabitCardViewFactory().create().apply { + view = component.getHabitCardViewFactory().createHabitCard().apply { habit = habit1 values = entries score = habit1.scores[today].value diff --git a/uhabits-android/src/main/AndroidManifest.xml b/uhabits-android/src/main/AndroidManifest.xml index 8c7758439..1f3aba1aa 100644 --- a/uhabits-android/src/main/AndroidManifest.xml +++ b/uhabits-android/src/main/AndroidManifest.xml @@ -42,6 +42,22 @@ android:value=".activities.habits.list.ListHabitsActivity" /> + + + + + + + + @@ -72,6 +88,14 @@ android:value=".activities.habits.list.ListHabitsActivity" /> + + + + @@ -112,6 +136,15 @@ + + + + + + diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt index 4f00f4d1f..b8421c5c1 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt @@ -79,6 +79,10 @@ class HabitsApplication : Application() { val habitList = component.habitList for (h in habitList) h.recompute() + val habitGroupList = component.habitGroupList + for (hgr in habitGroupList) hgr.recompute() + habitGroupList.attachHabitsToGroups() + widgetUpdater = component.widgetUpdater.apply { startListening() scheduleStartDayWidgetUpdate() diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt index 953ee554c..4692799c4 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt @@ -20,6 +20,7 @@ package org.isoron.uhabits.activities import org.isoron.uhabits.AndroidDirFinder import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior +import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupMenuPresenter import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitMenuPresenter import java.io.File import javax.inject.Inject @@ -33,3 +34,13 @@ constructor( return androidDirFinder.getFilesDir("CSV")!! } } + +class HabitGroupsDirFinder @Inject +constructor( + private val androidDirFinder: AndroidDirFinder +) : ShowHabitGroupMenuPresenter.System, ListHabitsBehavior.DirFinder { + + override fun getCSVOutputDir(): File { + return androidDirFinder.getFilesDir("CSV")!! + } +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt index 5479ab78c..207f39195 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt @@ -44,8 +44,9 @@ import org.isoron.uhabits.activities.common.dialogs.WeekdayPickerDialog import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CreateHabitCommand import org.isoron.uhabits.core.commands.EditHabitCommand +import org.isoron.uhabits.core.commands.RefreshParentGroupCommand import org.isoron.uhabits.core.models.Frequency -import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.HabitType import org.isoron.uhabits.core.models.NumericalHabitType import org.isoron.uhabits.core.models.PaletteColor @@ -76,6 +77,7 @@ class EditHabitActivity : AppCompatActivity() { var habitId = -1L lateinit var habitType: HabitType + var parentGroup: HabitGroup? = null var unit = "" var color = PaletteColor(11) var androidColor = 0 @@ -98,10 +100,21 @@ class EditHabitActivity : AppCompatActivity() { binding.toolbar.applyToolbarInsets() setContentView(binding.root) + if (intent.hasExtra("groupId")) { + val groupId = intent.getLongExtra("groupId", -1L) + parentGroup = component.habitGroupList.getById(groupId) + color = parentGroup!!.color + } + if (intent.hasExtra("habitId")) { binding.toolbar.title = getString(R.string.edit_habit) - habitId = intent.getLongExtra("habitId", -1) - val habit = component.habitList.getById(habitId)!! + habitId = intent.getLongExtra("habitId", -1L) + val habitList = if (parentGroup != null) { + parentGroup!!.habitList + } else { + component.habitList + } + val habit = habitList.getById(habitId)!! habitType = habit.type color = habit.color freqNum = habit.frequency.numerator @@ -262,9 +275,14 @@ class EditHabitActivity : AppCompatActivity() { val component = (application as HabitsApplication).component val habit = component.modelFactory.buildHabit() - var original: Habit? = null - if (habitId >= 0) { - original = component.habitList.getById(habitId)!! + val habitList = if (parentGroup != null) { + parentGroup!!.habitList + } else { + component.habitList + } + + if (habitId > 0) { + val original = habitList.getById(habitId)!! habit.copyFrom(original) } @@ -285,21 +303,31 @@ class EditHabitActivity : AppCompatActivity() { habit.unit = binding.unitInput.text.trim().toString() } habit.type = habitType + habit.group = parentGroup + habit.groupId = parentGroup?.id + habit.groupUUID = parentGroup?.uuid - val command = if (habitId >= 0) { + val command = if (habitId > 0) { EditHabitCommand( - component.habitList, + habitList, habitId, habit ) } else { CreateHabitCommand( component.modelFactory, - component.habitList, + habitList, habit ) } component.commandRunner.run(command) + + if (habit.groupId != null) { + val habitGroupList = component.habitGroupList + val refreshCommand = RefreshParentGroupCommand(habit, habitGroupList) + component.commandRunner.run(refreshCommand) + } + finish() } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitGroupActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitGroupActivity.kt new file mode 100644 index 000000000..03761f26c --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitGroupActivity.kt @@ -0,0 +1,232 @@ +package org.isoron.uhabits.activities.habits.edit + +import android.content.res.ColorStateList +import android.graphics.Color +import android.os.Bundle +import android.text.Html +import android.text.Spanned +import android.text.format.DateFormat +import android.view.View +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.DialogFragment +import com.android.datetimepicker.time.RadialPickerLayout +import com.android.datetimepicker.time.TimePickerDialog +import org.isoron.platform.gui.toInt +import org.isoron.uhabits.HabitsApplication +import org.isoron.uhabits.R +import org.isoron.uhabits.activities.AndroidThemeSwitcher +import org.isoron.uhabits.activities.common.dialogs.ColorPickerDialogFactory +import org.isoron.uhabits.activities.common.dialogs.WeekdayPickerDialog +import org.isoron.uhabits.core.commands.CommandRunner +import org.isoron.uhabits.core.commands.CreateHabitGroupCommand +import org.isoron.uhabits.core.commands.EditHabitGroupCommand +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.core.models.PaletteColor +import org.isoron.uhabits.core.models.Reminder +import org.isoron.uhabits.core.models.WeekdayList +import org.isoron.uhabits.databinding.ActivityEditHabitGroupBinding +import org.isoron.uhabits.utils.ColorUtils +import org.isoron.uhabits.utils.dismissCurrentAndShow +import org.isoron.uhabits.utils.formatTime +import org.isoron.uhabits.utils.toFormattedString + +class EditHabitGroupActivity : AppCompatActivity() { + + private lateinit var themeSwitcher: AndroidThemeSwitcher + private lateinit var binding: ActivityEditHabitGroupBinding + private lateinit var commandRunner: CommandRunner + + var habitGroupId = -1L + var color = PaletteColor(11) + var androidColor = 0 + var reminderHour = -1 + var reminderMin = -1 + var reminderDays: WeekdayList = WeekdayList.EVERY_DAY + + override fun onCreate(state: Bundle?) { + super.onCreate(state) + + val component = (application as HabitsApplication).component + themeSwitcher = AndroidThemeSwitcher(this, component.preferences) + themeSwitcher.apply() + + binding = ActivityEditHabitGroupBinding.inflate(layoutInflater) + setContentView(binding.root) + + if (intent.hasExtra("habitGroupId")) { + binding.toolbar.title = getString(R.string.edit_habit_group) + habitGroupId = intent.getLongExtra("habitGroupId", -1) + val hgr = component.habitGroupList.getById(habitGroupId)!! + color = hgr.color + hgr.reminder?.let { + reminderHour = it.hour + reminderMin = it.minute + reminderDays = it.days + } + binding.nameInput.setText(hgr.name) + binding.questionInput.setText(hgr.question) + binding.notesInput.setText(hgr.description) + } + + if (state != null) { + habitGroupId = state.getLong("habitGroupId") + color = PaletteColor(state.getInt("paletteColor")) + reminderHour = state.getInt("reminderHour") + reminderMin = state.getInt("reminderMin") + reminderDays = WeekdayList(state.getInt("reminderDays")) + } + + updateColors() + + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.elevation = 10.0f + + val colorPickerDialogFactory = ColorPickerDialogFactory(this) + binding.colorButton.setOnClickListener { + val picker = colorPickerDialogFactory.create(color, themeSwitcher.currentTheme) + picker.setListener { paletteColor -> + this.color = paletteColor + updateColors() + } + picker.dismissCurrentAndShow(supportFragmentManager, "colorPicker") + } + + populateReminder() + binding.reminderTimePicker.setOnClickListener { + val currentHour = if (reminderHour >= 0) reminderHour else 8 + val currentMin = if (reminderMin >= 0) reminderMin else 0 + val is24HourMode = DateFormat.is24HourFormat(this) + val dialog = TimePickerDialog.newInstance( + object : TimePickerDialog.OnTimeSetListener { + override fun onTimeSet(view: RadialPickerLayout?, hourOfDay: Int, minute: Int) { + reminderHour = hourOfDay + reminderMin = minute + populateReminder() + } + + override fun onTimeCleared(view: RadialPickerLayout?) { + reminderHour = -1 + reminderMin = -1 + reminderDays = WeekdayList.EVERY_DAY + populateReminder() + } + }, + currentHour, + currentMin, + is24HourMode, + androidColor + ) + dialog.dismissCurrentAndShow(supportFragmentManager, "timePicker") + } + + binding.reminderDatePicker.setOnClickListener { + val dialog = WeekdayPickerDialog() + + dialog.setListener { days: WeekdayList -> + reminderDays = days + if (reminderDays.isEmpty) reminderDays = WeekdayList.EVERY_DAY + populateReminder() + } + dialog.setSelectedDays(reminderDays) + dialog.dismissCurrentAndShow(supportFragmentManager, "dayPicker") + } + + binding.buttonSave.setOnClickListener { + if (validate()) save() + } + + for (fragment in supportFragmentManager.fragments) { + (fragment as DialogFragment).dismiss() + } + } + + private fun save() { + val component = (application as HabitsApplication).component + val hgr = component.modelFactory.buildHabitGroup() + + var original: HabitGroup? = null + if (habitGroupId >= 0) { + original = component.habitGroupList.getById(habitGroupId)!! + hgr.copyFrom(original) + } + + hgr.name = binding.nameInput.text.trim().toString() + hgr.question = binding.questionInput.text.trim().toString() + hgr.description = binding.notesInput.text.trim().toString() + hgr.color = color + if (reminderHour >= 0) { + hgr.reminder = Reminder(reminderHour, reminderMin, reminderDays) + } else { + hgr.reminder = null + } + + val command = if (habitGroupId >= 0) { + EditHabitGroupCommand( + component.habitGroupList, + habitGroupId, + hgr + ) + } else { + CreateHabitGroupCommand( + component.modelFactory, + component.habitGroupList, + hgr + ) + } + component.commandRunner.run(command) + finish() + } + + private fun validate(): Boolean { + var isValid = true + if (binding.nameInput.text.isEmpty()) { + binding.nameInput.error = getFormattedValidationError(R.string.validation_cannot_be_blank) + isValid = false + } + return isValid + } + + private fun populateReminder() { + if (reminderHour < 0) { + binding.reminderTimePicker.text = getString(R.string.reminder_off) + binding.reminderDatePicker.visibility = View.GONE + binding.reminderDivider.visibility = View.GONE + } else { + val time = formatTime(this, reminderHour, reminderMin) + binding.reminderTimePicker.text = time + binding.reminderDatePicker.visibility = View.VISIBLE + binding.reminderDivider.visibility = View.VISIBLE + binding.reminderDatePicker.text = reminderDays.toFormattedString(this) + } + } + + private fun updateColors() { + androidColor = themeSwitcher.currentTheme.color(color).toInt() + binding.colorButton.backgroundTintList = ColorStateList.valueOf(androidColor) + if (!themeSwitcher.isNightMode) { + val darkerAndroidColor = ColorUtils.mixColors(Color.BLACK, androidColor, 0.15f) + window.statusBarColor = darkerAndroidColor + binding.toolbar.setBackgroundColor(androidColor) + } + } + + private fun getFormattedValidationError(@StringRes resId: Int): Spanned { + val html = "${getString(resId)}" + return Html.fromHtml(html) + } + + override fun onSaveInstanceState(state: Bundle) { + super.onSaveInstanceState(state) + with(state) { + putLong("habitGroupId", habitGroupId) + putInt("paletteColor", color.paletteIndex) + putInt("androidColor", androidColor) + putInt("reminderHour", reminderHour) + putInt("reminderMin", reminderMin) + putInt("reminderDays", reminderDays.toInteger()) + } + } +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt index 350509207..603335c37 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt @@ -29,7 +29,7 @@ import org.isoron.uhabits.core.models.HabitType import org.isoron.uhabits.databinding.SelectHabitTypeBinding import org.isoron.uhabits.intents.IntentFactory -class HabitTypeDialog : AppCompatDialogFragment() { +class HabitTypeDialog(val groupId: Long? = null) : AppCompatDialogFragment() { override fun getTheme() = R.style.Translucent override fun onCreateView( @@ -40,17 +40,27 @@ class HabitTypeDialog : AppCompatDialogFragment() { val binding = SelectHabitTypeBinding.inflate(inflater, container, false) binding.buttonYesNo.setOnClickListener { - val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.YES_NO.value) + val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.YES_NO.value, groupId) startActivity(intent) dismiss() } binding.buttonMeasurable.setOnClickListener { - val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.NUMERICAL.value) + val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.NUMERICAL.value, groupId) startActivity(intent) dismiss() } + binding.buttonHabitGroup.setOnClickListener { + val intent = IntentFactory().startEditGroupActivity(requireActivity()) + startActivity(intent) + dismiss() + } + + if (groupId != null) { + binding.buttonHabitGroup.visibility = View.GONE + } + binding.background.setOnClickListener { dismiss() } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/HabitGroupPickerDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/HabitGroupPickerDialog.kt new file mode 100644 index 000000000..aea309d83 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/HabitGroupPickerDialog.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2016-2021 Á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.habits.list + +import android.app.Activity +import android.os.Bundle +import android.widget.ArrayAdapter +import android.widget.ListView +import android.widget.TextView +import org.isoron.uhabits.HabitsApplication +import org.isoron.uhabits.R +import org.isoron.uhabits.activities.AndroidThemeSwitcher +import org.isoron.uhabits.core.commands.AddToGroupCommand +import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroupList +import org.isoron.uhabits.core.models.HabitList +import org.isoron.uhabits.inject.HabitsApplicationComponent + +class HabitGroupPickerDialog : Activity() { + + private lateinit var habitGroupList: HabitGroupList + private lateinit var habitList: HabitList + private lateinit var selected: List + private lateinit var component: HabitsApplicationComponent + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + component = (applicationContext as HabitsApplication).component + AndroidThemeSwitcher(this, component.preferences).apply() + habitList = component.habitList + habitGroupList = component.habitGroupList + + val selectedIds = intent.getLongArrayExtra("selected")!! + selected = selectedIds.map { id -> + habitList.getById(id) ?: habitGroupList.getHabitByID(id)!! + } + + val groupIds = ArrayList() + val groupNames = ArrayList() + + for (hgr in habitGroupList) { + if (hgr.isArchived) continue + groupIds.add(hgr.id!!) + groupNames.add(hgr.name) + } + + if (groupNames.isEmpty()) { + setContentView(R.layout.widget_empty_activity) + findViewById(R.id.message).setText(R.string.no_habit_groups) + return + } + + setContentView(R.layout.widget_configure_activity) + val listView = findViewById(R.id.listView) + + with(listView) { + adapter = ArrayAdapter( + context, + android.R.layout.simple_list_item_1, + groupNames + ) + setOnItemClickListener { parent, view, position, id -> + performTransfer(groupIds[position]) + } + } + } + private fun performTransfer(toGroupId: Long) { + val hgr = habitGroupList.getById(toGroupId)!! + selected = selected.filter { it.groupId != hgr.id } + component.commandRunner.run( + AddToGroupCommand(habitList, hgr, selected) + ) + finish() + } +} 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 d16f7d702..586811032 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 @@ -179,10 +179,20 @@ class ListHabitsActivity : AppCompatActivity(), Preferences.Listener { val habitId = intent.extras?.getLong("habit") val timestamp = intent.extras?.getLong("timestamp") if (habitId != null && timestamp != null) { - val habit = appComponent.habitList.getById(habitId)!! + val habit = appComponent.habitList.getById(habitId) ?: appComponent.habitGroupList.getHabitByID(habitId)!! component.listHabitsBehavior.onEdit(habit, Timestamp(timestamp)) } } + intent.getLongExtra("CLEAR_NOTIFICATION_HABIT_ID", -1).takeIf { it != -1L }?.let { id -> + val dismissHabit = appComponent.habitList.getById(id) ?: appComponent.habitGroupList.getHabitByID(id) + if (dismissHabit != null) { + appComponent.reminderController.onDismiss(dismissHabit) + } else { + val dismissHabitGroup = appComponent.habitGroupList.getById(id)!! + appComponent.reminderController.onDismiss(dismissHabitGroup) + } + } + intent = null } 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 f0a542a0d..35de927d4 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 @@ -148,7 +148,7 @@ class ListHabitsRootView @Inject constructor( private fun updateEmptyView() { if (listAdapter.itemCount == 0) { - if (listAdapter.hasNoHabit()) { + if (listAdapter.hasNoHabit() && listAdapter.hasNoHabitGroup()) { llEmpty.showEmpty() } else { llEmpty.showDone() 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 e1f0418a3..8feb12748 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 @@ -41,10 +41,12 @@ import org.isoron.uhabits.core.commands.ChangeHabitColorCommand import org.isoron.uhabits.core.commands.Command import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CreateHabitCommand +import org.isoron.uhabits.core.commands.DeleteHabitGroupsCommand import org.isoron.uhabits.core.commands.DeleteHabitsCommand import org.isoron.uhabits.core.commands.EditHabitCommand import org.isoron.uhabits.core.commands.UnarchiveHabitsCommand import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.tasks.TaskRunner @@ -164,8 +166,8 @@ class ListHabitsScreen activity.startActivity(intent) } - override fun showSelectHabitTypeDialog() { - val dialog = HabitTypeDialog() + override fun showSelectHabitTypeDialog(groupId: Long?) { + val dialog = HabitTypeDialog(groupId) dialog.show(activity.supportFragmentManager, "habitType") } @@ -178,6 +180,16 @@ class ListHabitsScreen activity.startActivity(intent) } + override fun showEditHabitGroupScreen(selected: List) { + val intent = intentFactory.startEditGroupActivity(activity, selected[0]) + activity.startActivity(intent) + } + + override fun showHabitGroupPickerDialog(selected: List) { + val intent = intentFactory.startHabitGroupPickerActivity(activity, selected) + activity.startActivity(intent) + } + override fun showFAQScreen() { val intent = intentFactory.viewFAQ(activity) activity.startActivity(intent) @@ -188,6 +200,11 @@ class ListHabitsScreen activity.startActivity(intent) } + override fun showHabitGroupScreen(hgr: HabitGroup) { + val intent = intentFactory.startShowHabitGroupActivity(activity, hgr) + activity.startActivity(intent) + } + fun showImportScreen() { val intent = intentFactory.openDocument() activity.startActivityForResult(intent, REQUEST_OPEN_DOCUMENT) @@ -312,6 +329,12 @@ class ListHabitsScreen command.selected.size ) } + is DeleteHabitGroupsCommand -> { + return activity.resources.getQuantityString( + R.plurals.toast_habits_deleted, + command.selected.size + ) + } is EditHabitCommand -> { return activity.resources.getQuantityString(R.plurals.toast_habits_changed, 1) } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt index ce5bc45a0..35c5563bc 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt @@ -75,14 +75,18 @@ class ListHabitsSelectionMenu @Inject constructor( val itemColor = menu.findItem(R.id.action_color) val itemArchive = menu.findItem(R.id.action_archive_habit) val itemUnarchive = menu.findItem(R.id.action_unarchive_habit) + val itemRemoveFromGroup = menu.findItem(R.id.action_remove_from_group) + val itemAddToGroup = menu.findItem(R.id.action_add_to_group) val itemNotify = menu.findItem(R.id.action_notify) itemColor.isVisible = true itemEdit.isVisible = behavior.canEdit() itemArchive.isVisible = behavior.canArchive() itemUnarchive.isVisible = behavior.canUnarchive() + itemRemoveFromGroup.isVisible = behavior.areSubHabits() + itemAddToGroup.isVisible = behavior.areHabits() itemNotify.isVisible = prefs.isDeveloper - activeActionMode?.title = listAdapter.selected.size.toString() + activeActionMode?.title = (listAdapter.selectedHabits.size + listAdapter.selectedHabitGroups.size).toString() return true } override fun onDestroyActionMode(mode: ActionMode?) { @@ -106,6 +110,16 @@ class ListHabitsSelectionMenu @Inject constructor( return true } + R.id.action_remove_from_group -> { + behavior.onRemoveFromGroup() + return true + } + + R.id.action_add_to_group -> { + behavior.onAddToGroup() + return true + } + R.id.action_delete -> { behavior.onDeleteHabits() return true @@ -117,7 +131,7 @@ class ListHabitsSelectionMenu @Inject constructor( } R.id.action_notify -> { - for (h in listAdapter.selected) + for (h in listAdapter.selectedHabits) notificationTray.show(h, DateUtils.getToday(), 0) return true } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/AddButtonView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/AddButtonView.kt new file mode 100644 index 000000000..0034d2b94 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/AddButtonView.kt @@ -0,0 +1,73 @@ +package org.isoron.uhabits.activities.habits.list.views + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.text.TextPaint +import android.view.View +import android.view.View.MeasureSpec.EXACTLY +import org.isoron.uhabits.R +import org.isoron.uhabits.activities.habits.list.ListHabitsActivity +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.utils.getFontAwesome +import org.isoron.uhabits.utils.sp +import org.isoron.uhabits.utils.sres +import org.isoron.uhabits.utils.toMeasureSpec + +class AddButtonView( + context: Context, + var habitGroup: HabitGroup? +) : View(context), + View.OnClickListener { + + private var drawer = Drawer() + + init { + setOnClickListener(this) + } + + override fun onClick(v: View) { + (context as ListHabitsActivity).component.listHabitsMenu.behavior.onCreateHabit(habitGroup!!.id) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + drawer.draw(canvas) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val height = resources.getDimensionPixelSize(R.dimen.checkmarkHeight) + val width = resources.getDimensionPixelSize(R.dimen.checkmarkWidth) + super.onMeasure( + width.toMeasureSpec(EXACTLY), + height.toMeasureSpec(EXACTLY) + ) + } + + private inner class Drawer { + private val rect = RectF() + private val highContrastColor = sres.getColor(R.attr.contrast80) + + private val paint = TextPaint().apply { + typeface = getFontAwesome() + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + fun draw(canvas: Canvas) { + paint.color = highContrastColor + val id = R.string.fa_plus + paint.textSize = sp(12.0f) + paint.strokeWidth = 0f + paint.style = Paint.Style.FILL + + val label = resources.getString(id) + val em = paint.measureText("m") + + rect.set(0f, 0f, width.toFloat(), height.toFloat()) + rect.offset(0f, 0.4f * em) + canvas.drawText(label, rect.centerX(), rect.centerY(), paint) + } + } +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/CollapseButtonView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/CollapseButtonView.kt new file mode 100644 index 000000000..ab11baf3d --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/CollapseButtonView.kt @@ -0,0 +1,105 @@ +package org.isoron.uhabits.activities.habits.list.views + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.text.TextPaint +import android.view.View +import org.isoron.uhabits.R +import org.isoron.uhabits.activities.habits.list.ListHabitsActivity +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.core.models.ModelObservable +import org.isoron.uhabits.utils.getFontAwesome +import org.isoron.uhabits.utils.sp +import org.isoron.uhabits.utils.sres +import org.isoron.uhabits.utils.toMeasureSpec + +class CollapseButtonView( + context: Context, + var habitGroup: HabitGroup? +) : View(context), + View.OnClickListener, + ModelObservable.Listener { + + private var drawer = Drawer() + + var collapsed = false + set(value) { + field = value + drawer.rotate() + invalidate() + } + + init { + setOnClickListener(this) + } + + override fun onClick(v: View) { + collapsed = !collapsed + habitGroup!!.collapsed = collapsed + (context as ListHabitsActivity).component.listHabitsMenu.behavior.onPreferencesChanged() + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + drawer.draw(canvas) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val height = resources.getDimensionPixelSize(R.dimen.checkmarkHeight) + val width = resources.getDimensionPixelSize(R.dimen.checkmarkWidth) + super.onMeasure( + width.toMeasureSpec(MeasureSpec.EXACTLY), + height.toMeasureSpec(MeasureSpec.EXACTLY) + ) + } + + private inner class Drawer { + private val rect = RectF() + private val highContrastColor = sres.getColor(R.attr.contrast100) + + private var rotationAngle = 0f + private var offset_y = 0.4f + private var offset_x = 0f + private val paint = TextPaint().apply { + typeface = getFontAwesome() + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + fun rotate() { + if (collapsed) { + rotationAngle = 90f + offset_y = 0f + offset_x = -0.4f + } else { + rotationAngle = 0f + offset_y = 0.4f + offset_x = 0f + } + } + + fun draw(canvas: Canvas) { + paint.color = highContrastColor + val id = R.string.fa_angle_down + paint.textSize = sp(12.0f) + paint.strokeWidth = 0f + paint.style = Paint.Style.FILL + + val label = resources.getString(id) + val em = paint.measureText("m") + + rect.set(0f, 0f, width.toFloat(), height.toFloat()) + rect.offset(offset_x * em, offset_y * em) + + canvas.save() // Save the current state of the canvas + canvas.rotate(rotationAngle, rect.centerX(), rect.centerY()) // Rotate the canvas + canvas.drawText(label, rect.centerX(), rect.centerY(), paint) + canvas.restore() + } + } + + override fun onModelChange() {} +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt index 06412103b..fcff247a2 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt @@ -18,10 +18,12 @@ */ package org.isoron.uhabits.activities.habits.list.views +import android.annotation.SuppressLint import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Adapter import org.isoron.uhabits.activities.habits.list.MAX_CHECKMARK_COUNT import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitMatcher import org.isoron.uhabits.core.models.ModelObservable @@ -38,7 +40,7 @@ import javax.inject.Inject * Provides data that backs a [HabitCardListView]. * * - * The data if fetched and cached by a [HabitCardListCache]. This adapter + * The data is fetched and cached by a [HabitCardListCache]. This adapter * also holds a list of items that have been selected. */ @ActivityScope @@ -46,14 +48,16 @@ class HabitCardListAdapter @Inject constructor( private val cache: HabitCardListCache, private val preferences: Preferences, private val midnightTimer: MidnightTimer -) : RecyclerView.Adapter(), +) : Adapter(), HabitCardListCache.Listener, MidnightTimer.MidnightListener, ListHabitsMenuBehavior.Adapter, ListHabitsSelectionMenuBehavior.Adapter { val observable: ModelObservable = ModelObservable() private var listView: HabitCardListView? = null - val selected: LinkedList = LinkedList() + val selectedHabits: LinkedList = LinkedList() + val selectedHabitGroups: LinkedList = LinkedList() + override fun atMidnight() { cache.refreshAllHabits() } @@ -66,17 +70,27 @@ class HabitCardListAdapter @Inject constructor( return cache.hasNoHabit() } + fun hasNoHabitGroup(): Boolean { + return cache.hasNoHabitGroup() + } + /** * Sets all items as not selected. */ + @SuppressLint("NotifyDataSetChanged") override fun clearSelection() { - selected.clear() + selectedHabits.clear() + selectedHabitGroups.clear() notifyDataSetChanged() observable.notifyListeners() } - override fun getSelected(): List { - return ArrayList(selected) + override fun getSelectedHabits(): List { + return ArrayList(selectedHabits) + } + + override fun getSelectedHabitGroups(): List { + return ArrayList(selectedHabitGroups) } /** @@ -90,12 +104,20 @@ class HabitCardListAdapter @Inject constructor( return cache.getHabitByPosition(position) } + fun getHabit(position: Int): Habit? { + return cache.getHabitByPosition(position) + } + + fun getHabitGroup(position: Int): HabitGroup? { + return cache.getHabitGroupByPosition(position) + } + override fun getItemCount(): Int { - return cache.habitCount + return cache.itemCount } override fun getItemId(position: Int): Long { - return getItem(position)!!.id!! + return cache.getIdByPosition(position)!! } /** @@ -104,7 +126,7 @@ class HabitCardListAdapter @Inject constructor( * @return true if selection is empty, false otherwise */ val isSelectionEmpty: Boolean - get() = selected.isEmpty() + get() = selectedHabits.isEmpty() && selectedHabitGroups.isEmpty() val isSortable: Boolean get() = cache.primaryOrder == HabitList.Order.BY_POSITION @@ -122,11 +144,18 @@ class HabitCardListAdapter @Inject constructor( ) { if (listView == null) return val habit = cache.getHabitByPosition(position) - val score = cache.getScore(habit!!.id!!) - val checkmarks = cache.getCheckmarks(habit.id!!) - val notes = cache.getNotes(habit.id!!) - val selected = selected.contains(habit) - listView!!.bindCardView(holder, habit, score, checkmarks, notes, selected) + if (habit != null) { + val score = cache.getScore(habit.id!!) + val checkmarks = cache.getCheckmarks(habit.id!!) + val notes = cache.getNotes(habit.id!!) + val selected = selectedHabits.contains(habit) + listView!!.bindCardView(holder, habit, score, checkmarks, notes, selected) + } else { + val habitGroup = cache.getHabitGroupByPosition(position) + val score = cache.getScore(habitGroup!!.id!!) + val selected = selectedHabitGroups.contains(habitGroup) + listView!!.bindGroupCardView(holder, habitGroup, score, selected) + } } override fun onViewAttachedToWindow(holder: HabitCardViewHolder) { @@ -141,8 +170,22 @@ class HabitCardListAdapter @Inject constructor( parent: ViewGroup, viewType: Int ): HabitCardViewHolder { - val view = listView!!.createHabitCardView() - return HabitCardViewHolder(view) + if (viewType == 0) { + val view = listView!!.createHabitCardView() + return HabitCardViewHolder(view, null) + } else { + val view = listView!!.createHabitGroupCardView() + return HabitCardViewHolder(null, view) + } + } + + // function to override getItemViewType and return the type of the view. The view can either be a HabitCardView or a HabitGroupCardView + override fun getItemViewType(position: Int): Int { + return if (cache.getHabitByPosition(position) != null) { + 0 + } else { + 1 + } } /** @@ -193,6 +236,10 @@ class HabitCardListAdapter @Inject constructor( for (habit in selected) cache.remove(habit.id!!) } + override fun performRemoveHabitGroup(selected: List) { + for (hgr in selected) cache.remove(hgr.id!!) + } + /** * Changes the order of habits on the adapter. * @@ -249,11 +296,19 @@ class HabitCardListAdapter @Inject constructor( * * @param position position of the item to be toggled */ + @SuppressLint("NotifyDataSetChanged") fun toggleSelection(position: Int) { - val h = getItem(position) ?: return - val k = selected.indexOf(h) - if (k < 0) selected.add(h) else selected.remove(h) - notifyDataSetChanged() + val h = cache.getHabitByPosition(position) + val hgr = cache.getHabitGroupByPosition(position) + if (h != null) { + val k = selectedHabits.indexOf(h) + if (k < 0) selectedHabits.add(h) else selectedHabits.remove(h) + notifyDataSetChanged() + } else if (hgr != null) { + val k = selectedHabitGroups.indexOf(hgr) + if (k < 0) selectedHabitGroups.add(hgr) else selectedHabitGroups.remove(hgr) + notifyDataSetChanged() + } } init { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListController.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListController.kt index a70805139..cf5055f90 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListController.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListController.kt @@ -50,12 +50,22 @@ class HabitCardListController @Inject constructor( if (from == to) return cancelSelection() - val habitFrom = adapter.getItem(from) - val habitTo = adapter.getItem(to) - if (habitFrom == null || habitTo == null) return + val habitFrom = adapter.getHabit(from) + val habitTo = adapter.getHabit(to) + if (habitFrom != null) { + if (habitTo != null) { + adapter.performReorder(from, to) + behavior.onReorderHabit(habitFrom, habitTo) + } + return + } + var hgrFrom = adapter.getHabitGroup(from)!! + if (hgrFrom.parent != null) hgrFrom = hgrFrom.parent!! + var hgrTo = adapter.getHabitGroup(to) ?: return + if (hgrTo.parent != null) hgrTo = hgrTo.parent!! adapter.performReorder(from, to) - behavior.onReorderHabit(habitFrom, habitTo) + behavior.onReorderHabitGroup(hgrFrom, hgrTo) } override fun onItemClick(position: Int) { @@ -114,8 +124,15 @@ class HabitCardListController @Inject constructor( */ internal inner class NormalMode : Mode { override fun onItemClick(position: Int) { - val habit = adapter.getItem(position) ?: return - behavior.onClickHabit(habit) + val habit = adapter.getHabit(position) + if (habit != null) { + behavior.onClickHabit(habit) + } else { + val hgr = adapter.getHabitGroup(position) + if (hgr != null) { + behavior.onClickHabitGroup(hgr) + } + } } override fun onItemLongClick(position: Int): Boolean { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt index fd6df425f..a656b4d48 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt @@ -36,6 +36,7 @@ import dagger.Lazy import org.isoron.uhabits.R import org.isoron.uhabits.activities.common.views.BundleSavedState import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.inject.ActivityContext import javax.inject.Inject @@ -62,8 +63,13 @@ class HabitCardListView( set(value) { field = value attachedHolders - .map { it.itemView as HabitCardView } - .forEach { it.dataOffset = value } + .forEach { + if (it.itemView is HabitCardView) { + (it.itemView as HabitCardView).dataOffset = value + } else { + (it.itemView as HabitGroupCardView).dataOffset = value + } + } } private val attachedHolders = mutableListOf() @@ -79,7 +85,11 @@ class HabitCardListView( } fun createHabitCardView(): HabitCardView { - return cardViewFactory.create() + return cardViewFactory.createHabitCard() + } + + fun createHabitGroupCardView(): HabitGroupCardView { + return cardViewFactory.createHabitGroupCard() } fun bindCardView( @@ -110,8 +120,28 @@ class HabitCardListView( return cardView } + fun bindGroupCardView( + holder: HabitCardViewHolder, + habitGroup: HabitGroup, + score: Double, + selected: Boolean + ): View { + val cardView = holder.itemView as HabitGroupCardView + cardView.habitGroup = habitGroup + cardView.isSelected = selected + cardView.score = score + + val detector = GestureDetector(context, CardViewGestureDetector(holder)) + cardView.setOnTouchListener { _, ev -> + detector.onTouchEvent(ev) + true + } + + return cardView + } + fun attachCardView(holder: HabitCardViewHolder) { - (holder.itemView as HabitCardView).dataOffset = dataOffset + (holder.itemView as? HabitCardView)?.dataOffset = dataOffset attachedHolders.add(holder) } 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 84bd001fc..cac6cb1b2 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 @@ -55,7 +55,8 @@ class HabitCardViewFactory private val numberPanelFactory: NumberPanelViewFactory, private val behavior: ListHabitsBehavior ) { - fun create() = HabitCardView(context, checkmarkPanelFactory, numberPanelFactory, behavior) + fun createHabitCard() = HabitCardView(context, checkmarkPanelFactory, numberPanelFactory, behavior) + fun createHabitGroupCard() = HabitGroupCardView(context, behavior) } class HabitCardView( @@ -265,6 +266,15 @@ class HabitCardView( } scoreRing.apply { setColor(c) +// if (h.isSubHabit()) { + val rightMargin = dp(8f).toInt() + val ringSize = dp(15f).toInt() + val leftMargin = if (h.isSubHabit() == true) dp(30f).toInt() else dp(8f).toInt() + layoutParams = LinearLayout.LayoutParams(ringSize, ringSize).apply { + setMargins(leftMargin, 0, rightMargin, 0) + gravity = Gravity.CENTER + } +// } } checkmarkPanel.apply { color = c diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt index e01159360..84fc26a2b 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt @@ -21,4 +21,4 @@ package org.isoron.uhabits.activities.habits.list.views import androidx.recyclerview.widget.RecyclerView -class HabitCardViewHolder(itemView: HabitCardView) : RecyclerView.ViewHolder(itemView) +class HabitCardViewHolder(itemView1: HabitCardView?, itemView2: HabitGroupCardView?) : RecyclerView.ViewHolder(itemView1 ?: itemView2!!) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardView.kt new file mode 100644 index 000000000..6f5efd87a --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardView.kt @@ -0,0 +1,172 @@ +package org.isoron.uhabits.activities.habits.list.views + +import android.content.Context +import android.graphics.Typeface +import android.graphics.text.LineBreaker.BREAK_STRATEGY_BALANCED +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import android.os.Handler +import android.os.Looper +import android.text.TextUtils +import android.view.Gravity +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import org.isoron.platform.gui.toInt +import org.isoron.uhabits.R +import org.isoron.uhabits.activities.common.views.RingView +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.core.models.ModelObservable +import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior +import org.isoron.uhabits.inject.ActivityContext +import org.isoron.uhabits.utils.currentTheme +import org.isoron.uhabits.utils.dp +import org.isoron.uhabits.utils.sres + +class HabitGroupCardView( + @ActivityContext context: Context, + private val behavior: ListHabitsBehavior +) : FrameLayout(context), + ModelObservable.Listener { + + var dataOffset = 0 + + var habitGroup: HabitGroup? = null + set(newHabitGroup) { + if (isAttachedToWindow) { + field?.observable?.removeListener(this) + newHabitGroup?.observable?.addListener(this) + } + field = newHabitGroup + if (newHabitGroup != null) copyAttributesFrom(newHabitGroup) + addButtonView.habitGroup = newHabitGroup + collapseButtonView.habitGroup = newHabitGroup + } + + var score + get() = scoreRing.getPercentage().toDouble() + set(value) { + scoreRing.setPercentage(value.toFloat()) + scoreRing.setPrecision(1.0f / 16) + } + + var addButtonView: AddButtonView + var collapseButtonView: CollapseButtonView + private var innerFrame: LinearLayout + private var label: TextView + private var scoreRing: RingView + + private var currentToggleTaskId = 0 + + init { + scoreRing = RingView(context).apply { + val thickness = dp(3f) + val margin = dp(8f).toInt() + val ringSize = dp(15f).toInt() + layoutParams = LinearLayout.LayoutParams(ringSize, ringSize).apply { + setMargins(margin, 0, margin, 0) + gravity = Gravity.CENTER + } + setThickness(thickness) + } + + label = TextView(context).apply { + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) + if (SDK_INT >= Build.VERSION_CODES.Q) { + breakStrategy = BREAK_STRATEGY_BALANCED + } + setTypeface(typeface, Typeface.BOLD) + } + + addButtonView = AddButtonView(context, habitGroup) + collapseButtonView = CollapseButtonView(context, habitGroup) + + innerFrame = LinearLayout(context).apply { + gravity = Gravity.CENTER_VERTICAL + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + elevation = dp(1f) + + addView(scoreRing) + addView(label) + addView(addButtonView) + addView(collapseButtonView) + + setOnTouchListener { v, event -> + v.background.setHotspot(event.x, event.y) + false + } + } + + clipToPadding = false + layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) + val margin = dp(3f).toInt() + setPadding(margin, 0, margin, margin) + addView(innerFrame) + } + + override fun onModelChange() { + Handler(Looper.getMainLooper()).post { + habitGroup?.let { copyAttributesFrom(it) } + } + } + + override fun setSelected(isSelected: Boolean) { + super.setSelected(isSelected) + updateBackground(isSelected) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + habitGroup?.observable?.addListener(this) + } + + override fun onDetachedFromWindow() { + habitGroup?.observable?.removeListener(this) + super.onDetachedFromWindow() + } + + private fun copyAttributesFrom(hgr: HabitGroup) { + fun getActiveColor(hgr: HabitGroup): Int { + return when (hgr.isArchived) { + true -> sres.getColor(R.attr.contrast60) + false -> currentTheme().color(hgr.color).toInt() + } + } + + val c = getActiveColor(hgr) + label.apply { + text = hgr.name + setTextColor(c) + } + scoreRing.apply { + setColor(c) + } + + collapseButtonView.collapsed = hgr.collapsed + + if (collapseButtonView.collapsed) { + addButtonView.visibility = GONE + } else { + addButtonView.visibility = VISIBLE + } + } + + private fun updateBackground(isSelected: Boolean) { + val background = when (isSelected) { + true -> R.drawable.selected_box + false -> R.drawable.ripple + } + innerFrame.setBackgroundResource(background) + } + + companion object { + fun (() -> Unit).delay(delayInMillis: Long) { + Handler(Looper.getMainLooper()).postDelayed(this, delayInMillis) + } + } +} 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 a7d07db53..a3c197415 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 @@ -18,7 +18,6 @@ */ package org.isoron.uhabits.activities.habits.show -import android.content.ContentUris import android.os.Bundle import android.view.HapticFeedbackConstants import android.view.Menu @@ -75,8 +74,15 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener { super.onCreate(savedInstanceState) val appComponent = (applicationContext as HabitsApplication).component - val habitList = appComponent.habitList - habit = habitList.getById(ContentUris.parseId(intent.data!!))!! + val habitGroupList = appComponent.habitGroupList + val groupId = intent.getLongExtra("groupId", -1L) + val habitList = if (groupId > 0) { + habitGroupList.getById(groupId)!!.habitList + } else { + appComponent.habitList + } + val id = intent.getLongExtra("habitId", -1L) + habit = habitList.getById(id)!! preferences = appComponent.preferences commandRunner = appComponent.commandRunner widgetUpdater = appComponent.widgetUpdater diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupActivity.kt new file mode 100644 index 000000000..d204f7a48 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupActivity.kt @@ -0,0 +1,148 @@ +package org.isoron.uhabits.activities.habits.show + +import android.content.ContentUris +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.isoron.uhabits.HabitsApplication +import org.isoron.uhabits.R +import org.isoron.uhabits.activities.AndroidThemeSwitcher +import org.isoron.uhabits.activities.common.dialogs.ConfirmDeleteDialog +import org.isoron.uhabits.core.commands.Command +import org.isoron.uhabits.core.commands.CommandRunner +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.core.preferences.Preferences +import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback +import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupMenuPresenter +import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupPresenter +import org.isoron.uhabits.intents.IntentFactory +import org.isoron.uhabits.utils.dismissCurrentAndShow +import org.isoron.uhabits.utils.dismissCurrentDialog +import org.isoron.uhabits.utils.showMessage +import org.isoron.uhabits.utils.showSendFileScreen +import org.isoron.uhabits.widgets.WidgetUpdater + +class ShowHabitGroupActivity : AppCompatActivity(), CommandRunner.Listener { + + private lateinit var commandRunner: CommandRunner + private lateinit var menu: ShowHabitGroupMenu + private lateinit var view: ShowHabitGroupView + private lateinit var habitGroup: HabitGroup + private lateinit var preferences: Preferences + private lateinit var themeSwitcher: AndroidThemeSwitcher + private lateinit var widgetUpdater: WidgetUpdater + + private val scope = CoroutineScope(Dispatchers.Main) + private lateinit var presenter: ShowHabitGroupPresenter + private val screen = Screen() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val appComponent = (applicationContext as HabitsApplication).component + val habitGroupList = appComponent.habitGroupList + habitGroup = habitGroupList.getById(ContentUris.parseId(intent.data!!))!! + preferences = appComponent.preferences + commandRunner = appComponent.commandRunner + widgetUpdater = appComponent.widgetUpdater + + themeSwitcher = AndroidThemeSwitcher(this, preferences) + themeSwitcher.apply() + + presenter = ShowHabitGroupPresenter( + commandRunner = commandRunner, + habitGroup = habitGroup, + preferences = preferences, + screen = screen + ) + + view = ShowHabitGroupView(this) + + val menuPresenter = ShowHabitGroupMenuPresenter( + commandRunner = commandRunner, + habitGroup = habitGroup, + habitGroupList = habitGroupList, + screen = screen + ) + + menu = ShowHabitGroupMenu( + activity = this, + presenter = menuPresenter, + preferences = preferences + ) + + view.setListener(presenter) + setContentView(view) + } + + override fun onCreateOptionsMenu(m: Menu): Boolean { + return menu.onCreateOptionsMenu(m) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return menu.onOptionsItemSelected(item) + } + + override fun onResume() { + super.onResume() + commandRunner.addListener(this) + screen.refresh() + } + + override fun onPause() { + dismissCurrentDialog() + commandRunner.removeListener(this) + super.onPause() + } + + override fun onCommandFinished(command: Command) { + screen.refresh() + } + + inner class Screen : ShowHabitGroupMenuPresenter.Screen, ShowHabitGroupPresenter.Screen { + override fun updateWidgets() { + widgetUpdater.updateWidgets() + } + + override fun refresh() { + scope.launch { + view.setState( + ShowHabitGroupPresenter.buildState( + habitGroup = habitGroup, + preferences = preferences, + theme = themeSwitcher.currentTheme + ) + ) + } + } + + override fun showEditHabitGroupScreen(habitGroup: HabitGroup) { + startActivity(IntentFactory().startEditGroupActivity(this@ShowHabitGroupActivity, habitGroup)) + } + + override fun showMessage(m: ShowHabitGroupMenuPresenter.Message?) { + when (m) { + ShowHabitGroupMenuPresenter.Message.COULD_NOT_EXPORT -> { + showMessage(resources.getString(R.string.could_not_export)) + } + else -> {} + } + } + + override fun showSendFileScreen(filename: String) { + this@ShowHabitGroupActivity.showSendFileScreen(filename) + } + + override fun showDeleteConfirmationScreen(callback: OnConfirmedCallback) { + ConfirmDeleteDialog(this@ShowHabitGroupActivity, callback, 1).dismissCurrentAndShow() + } + + override fun close() { + this@ShowHabitGroupActivity.finish() + } + } +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupMenu.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupMenu.kt new file mode 100644 index 000000000..1dc4bbd03 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupMenu.kt @@ -0,0 +1,32 @@ +package org.isoron.uhabits.activities.habits.show + +import android.view.Menu +import android.view.MenuItem +import org.isoron.uhabits.R +import org.isoron.uhabits.core.preferences.Preferences +import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupMenuPresenter + +class ShowHabitGroupMenu( + val activity: ShowHabitGroupActivity, + val presenter: ShowHabitGroupMenuPresenter, + val preferences: Preferences +) { + fun onCreateOptionsMenu(menu: Menu): Boolean { + activity.menuInflater.inflate(R.menu.show_habit_group, menu) + return true + } + + fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_edit_habit_group -> { + presenter.onEditHabitGroup() + return true + } + R.id.action_delete -> { + presenter.onDeleteHabitGroup() + return true + } + } + return false + } +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupView.kt new file mode 100644 index 000000000..1a51347b6 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupView.kt @@ -0,0 +1,56 @@ +package org.isoron.uhabits.activities.habits.show + +import android.content.Context +import android.view.LayoutInflater +import android.widget.FrameLayout +import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupPresenter +import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupState +import org.isoron.uhabits.databinding.ShowHabitGroupBinding +import org.isoron.uhabits.utils.setupToolbar + +class ShowHabitGroupView(context: Context) : FrameLayout(context) { + private val binding = ShowHabitGroupBinding.inflate(LayoutInflater.from(context)) + + init { + addView(binding.root) + } + + fun setState(data: ShowHabitGroupState) { + setupToolbar( + binding.toolbar, + title = data.title, + color = data.color, + theme = data.theme + ) + binding.subtitleCard.setState(data.subtitle) + binding.overviewCard.setState(data.overview) + binding.notesCard.setState(data.notes) + binding.targetCard.setState(data.target) + binding.streakCard.setState(data.streaks) + binding.scoreCard.setState(data.scores) + binding.barCard.setState(data.bar) + binding.frequencyCard.setState(data.frequency) + if (!data.isBoolean) { + binding.streakCard.visibility = GONE + if (!data.isNumerical) { + binding.barCard.visibility = GONE + } + } + if (!data.isNumerical) { + binding.targetCard.visibility = GONE + } + if (data.isEmpty) { + binding.targetCard.visibility = GONE + binding.barCard.visibility = GONE + binding.streakCard.visibility = GONE + binding.overviewCard.visibility = GONE + binding.scoreCard.visibility = GONE + binding.frequencyCard.visibility = GONE + binding.noSubHabitsCard.visibility = VISIBLE + } + } + + fun setListener(presenter: ShowHabitGroupPresenter) { + binding.scoreCard.setListener(presenter.scoreCardPresenter) + } +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt index da7f957f3..c3dc68e82 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt @@ -24,6 +24,7 @@ import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.io.GenericImporter import org.isoron.uhabits.core.io.Logging +import org.isoron.uhabits.core.models.HabitGroupList import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.preferences.Preferences @@ -50,6 +51,7 @@ interface HabitsApplicationComponent { val genericImporter: GenericImporter val habitCardListCache: HabitCardListCache val habitList: HabitList + val habitGroupList: HabitGroupList val intentFactory: IntentFactory val intentParser: IntentParser val logging: Logging diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt index c7b6843d0..fd687604e 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt @@ -26,9 +26,11 @@ import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.database.Database import org.isoron.uhabits.core.database.DatabaseOpener import org.isoron.uhabits.core.io.Logging +import org.isoron.uhabits.core.models.HabitGroupList import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.sqlite.SQLModelFactory +import org.isoron.uhabits.core.models.sqlite.SQLiteHabitGroupList import org.isoron.uhabits.core.models.sqlite.SQLiteHabitList import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.WidgetPreferences @@ -61,9 +63,10 @@ class HabitsModule(dbFile: File) { sys: IntentScheduler, commandRunner: CommandRunner, habitList: HabitList, + habitGroupList: HabitGroupList, widgetPreferences: WidgetPreferences ): ReminderScheduler { - return ReminderScheduler(commandRunner, habitList, sys, widgetPreferences) + return ReminderScheduler(commandRunner, habitList, habitGroupList, sys, widgetPreferences) } @Provides @@ -97,6 +100,12 @@ class HabitsModule(dbFile: File) { return list } + @Provides + @AppScope + fun getHabitGroupList(list: SQLiteHabitGroupList): HabitGroupList { + return list + } + @Provides @AppScope fun getDatabaseOpener(opener: AndroidDatabaseOpener): DatabaseOpener { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt index 71b2938e7..2515d5554 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt @@ -25,10 +25,14 @@ import android.net.Uri import org.isoron.uhabits.R import org.isoron.uhabits.activities.about.AboutActivity import org.isoron.uhabits.activities.habits.edit.EditHabitActivity +import org.isoron.uhabits.activities.habits.edit.EditHabitGroupActivity +import org.isoron.uhabits.activities.habits.list.HabitGroupPickerDialog import org.isoron.uhabits.activities.habits.show.ShowHabitActivity +import org.isoron.uhabits.activities.habits.show.ShowHabitGroupActivity import org.isoron.uhabits.activities.intro.IntroActivity import org.isoron.uhabits.activities.settings.SettingsActivity import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import javax.inject.Inject class IntentFactory @@ -60,9 +64,16 @@ class IntentFactory fun startSettingsActivity(context: Context) = Intent(context, SettingsActivity::class.java) - fun startShowHabitActivity(context: Context, habit: Habit) = - Intent(context, ShowHabitActivity::class.java).apply { - data = Uri.parse(habit.uriString) + fun startShowHabitActivity(context: Context, habit: Habit): Intent { + val intent = Intent(context, ShowHabitActivity::class.java) + intent.putExtra("habitId", habit.id) + intent.putExtra("groupId", habit.groupId) + return intent + } + + fun startShowHabitGroupActivity(context: Context, habitGroup: HabitGroup) = + Intent(context, ShowHabitGroupActivity::class.java).apply { + data = Uri.parse(habitGroup.uriString) } fun viewFAQ(context: Context) = @@ -92,12 +103,33 @@ class IntentFactory val intent = startEditActivity(context) intent.putExtra("habitId", habit.id) intent.putExtra("habitType", habit.type) + if (habit.groupId != null) intent.putExtra("groupId", habit.groupId) return intent } - fun startEditActivity(context: Context, habitType: Int): Intent { + fun startEditActivity(context: Context, habitType: Int, groupId: Long?): Intent { val intent = startEditActivity(context) intent.putExtra("habitType", habitType) + if (groupId != null) { + intent.putExtra("groupId", groupId) + } + return intent + } + + fun startEditGroupActivity(context: Context): Intent { + return Intent(context, EditHabitGroupActivity::class.java) + } + + fun startEditGroupActivity(context: Context, habitGroup: HabitGroup): Intent { + val intent = startEditGroupActivity(context) + intent.putExtra("habitGroupId", habitGroup.id) + return intent + } + + fun startHabitGroupPickerActivity(context: Context, selected: List): Intent { + val intent = Intent(context, HabitGroupPickerDialog::class.java) + val ids = selected.map { it.id!! }.toLongArray() + intent.putExtra("selected", ids) return intent } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentParser.kt b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentParser.kt index c3981baf2..fd1fc3206 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentParser.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentParser.kt @@ -24,6 +24,7 @@ import android.content.Intent import android.net.Uri import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroupList import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.utils.DateUtils @@ -31,7 +32,10 @@ import javax.inject.Inject @AppScope class IntentParser -@Inject constructor(private val habits: HabitList) { +@Inject constructor( + private val habits: HabitList, + private val habitGroups: HabitGroupList +) { fun parseCheckmarkIntent(intent: Intent): CheckmarkIntentData { val uri = intent.data ?: throw IllegalArgumentException("uri is null") @@ -45,6 +49,7 @@ class IntentParser private fun parseHabit(uri: Uri): Habit { return habits.getById(parseId(uri)) + ?: habitGroups.getHabitByID(parseId(uri)) ?: throw IllegalArgumentException("habit not found") } 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 047474d67..55791ad1e 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 @@ -29,6 +29,7 @@ import android.os.Build import android.util.Log import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.reminders.ReminderScheduler.SchedulerResult import org.isoron.uhabits.core.reminders.ReminderScheduler.SystemScheduler import org.isoron.uhabits.core.utils.DateFormats @@ -75,6 +76,16 @@ class IntentScheduler return schedule(reminderTime, intent, RTC_WAKEUP) } + override fun scheduleShowReminder( + reminderTime: Long, + habitGroup: HabitGroup, + timestamp: Long + ): SchedulerResult { + val intent = pendingIntents.showReminder(habitGroup, reminderTime, timestamp) + logReminderScheduled(habitGroup, reminderTime) + return schedule(reminderTime, intent, RTC_WAKEUP) + } + override fun scheduleWidgetUpdate(updateTime: Long): SchedulerResult { val intent = pendingIntents.updateWidgets() return schedule(updateTime, intent, RTC) @@ -94,4 +105,15 @@ class IntentScheduler String.format("Setting alarm (%s): %s", time, name) ) } + + private fun logReminderScheduled(habitGroup: HabitGroup, reminderTime: Long) { + val min = min(5, habitGroup.name.length) + val name = habitGroup.name.substring(0, min) + val df = DateFormats.getBackupDateFormat() + val time = df.format(Date(reminderTime)) + Log.i( + "ReminderHelper", + String.format("Setting alarm (%s): %s", time, name) + ) + } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt b/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt index 038cfe3a4..45a70cd14 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt @@ -31,8 +31,10 @@ import android.net.Uri import android.os.Build import org.isoron.uhabits.activities.habits.list.ListHabitsActivity import org.isoron.uhabits.activities.habits.show.ShowHabitActivity +import org.isoron.uhabits.activities.habits.show.ShowHabitGroupActivity import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.inject.AppContext import org.isoron.uhabits.receivers.ReminderReceiver @@ -69,6 +71,17 @@ class PendingIntentFactory FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT ) + fun dismissNotification(habitGroup: HabitGroup): PendingIntent = + getBroadcast( + context, + 0, + Intent(context, ReminderReceiver::class.java).apply { + action = WidgetReceiver.ACTION_DISMISS_REMINDER + data = Uri.parse(habitGroup.uriString) + }, + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + fun removeRepetition(habit: Habit, timestamp: Timestamp?): PendingIntent = getBroadcast( context, @@ -92,6 +105,17 @@ class PendingIntentFactory ) .getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)!! + fun showHabitGroup(habitGroup: HabitGroup): PendingIntent = + androidx.core.app.TaskStackBuilder + .create(context) + .addNextIntentWithParentStack( + intentFactory.startShowHabitGroupActivity( + context, + habitGroup + ) + ) + .getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)!! + fun showHabitTemplate(): PendingIntent { return getActivity( context, @@ -101,6 +125,15 @@ class PendingIntentFactory ) } + fun showHabitGroupTemplate(): PendingIntent { + return getActivity( + context, + 0, + Intent(context, ShowHabitGroupActivity::class.java), + getIntentTemplateFlags() + ) + } + fun showHabitFillIn(habit: Habit) = Intent().apply { data = Uri.parse(habit.uriString) @@ -123,6 +156,23 @@ class PendingIntentFactory FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT ) + fun showReminder( + habitGroup: HabitGroup, + reminderTime: Long?, + timestamp: Long + ): PendingIntent = + getBroadcast( + context, + (habitGroup.id!! % Integer.MAX_VALUE).toInt() + 1, + Intent(context, ReminderReceiver::class.java).apply { + action = ReminderReceiver.ACTION_SHOW_REMINDER + data = Uri.parse(habitGroup.uriString) + putExtra("timestamp", timestamp) + putExtra("reminderTime", reminderTime) + }, + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + fun snoozeNotification(habit: Habit): PendingIntent = getBroadcast( context, @@ -134,6 +184,17 @@ class PendingIntentFactory FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT ) + fun snoozeNotification(habitGroup: HabitGroup): PendingIntent = + getBroadcast( + context, + 0, + Intent(context, ReminderReceiver::class.java).apply { + data = Uri.parse(habitGroup.uriString) + action = ReminderReceiver.ACTION_SNOOZE_REMINDER + }, + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + fun toggleCheckmark(habit: Habit, timestamp: Long?): PendingIntent = getBroadcast( context, @@ -185,6 +246,26 @@ class PendingIntentFactory putExtra("timestamp", timestamp.unixTime) } + fun showHabitList(): PendingIntent { + return getActivity( + context, + 1, + Intent(context, ListHabitsActivity::class.java), + getIntentTemplateFlags() + ) + } + + fun showHabitListWithNotificationClear(id: Long): PendingIntent { + return getActivity( + context, + 0, + Intent(context, ListHabitsActivity::class.java).apply { + putExtra("CLEAR_NOTIFICATION_HABIT_ID", id) + }, + getIntentTemplateFlags() + ) + } + private fun getIntentTemplateFlags(): Int { var flags = 0 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt index a63982358..bb28b5ad3 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt @@ -35,6 +35,7 @@ import androidx.core.app.NotificationManagerCompat import org.isoron.uhabits.R import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.ui.NotificationTray @@ -90,6 +91,34 @@ class AndroidNotificationTray active.add(notificationId) } + override fun showNotification( + habitGroup: HabitGroup, + notificationId: Int, + timestamp: Timestamp, + reminderTime: Long + ) { + val notificationManager = NotificationManagerCompat.from(context) + val notification = buildNotification(habitGroup, reminderTime, timestamp) + createAndroidNotificationChannel(context) + try { + notificationManager.notify(notificationId, notification) + } catch (e: RuntimeException) { + // Some Xiaomi phones produce a RuntimeException if custom notification sounds are used. + Log.i( + "AndroidNotificationTray", + "Failed to show notification. Retrying without sound." + ) + val n = buildNotification( + habitGroup, + reminderTime, + timestamp, + disableSound = true + ) + notificationManager.notify(notificationId, n) + } + active.add(notificationId) + } + fun buildNotification( habit: Habit, reminderTime: Long, @@ -163,6 +192,58 @@ class AndroidNotificationTray return builder.build() } + fun buildNotification( + habitGroup: HabitGroup, + reminderTime: Long, + timestamp: Timestamp, + disableSound: Boolean = false + ): Notification { + val enterAction = Action( + R.drawable.ic_action_check, + context.getString(R.string.enter), + pendingIntents.showHabitListWithNotificationClear(habitGroup.id!!) + ) + + val wearableBg = decodeResource(context.resources, R.drawable.stripe) + + // Even though the set of actions is the same on the phone and + // on the watch, Pebble requires us to add them to the + // WearableExtender. + val wearableExtender = WearableExtender().setBackground(wearableBg) + + val defaultText = context.getString(R.string.default_reminder_question) + val builder = Builder(context, REMINDERS_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(habitGroup.name) + .setContentText(if (habitGroup.question.isBlank()) defaultText else habitGroup.question) + .setContentIntent(pendingIntents.showHabitGroup(habitGroup)) + .setDeleteIntent(pendingIntents.dismissNotification(habitGroup)) + .setSound(null) + .setWhen(reminderTime) + .setShowWhen(true) + .setOngoing(preferences.shouldMakeNotificationsSticky()) + + wearableExtender.addAction(enterAction) + builder.addAction(enterAction) + + if (!disableSound) { + builder.setSound(ringtoneManager.getURI()) + } + + if (SDK_INT < Build.VERSION_CODES.S) { + val snoozeAction = Action( + R.drawable.ic_action_snooze, + context.getString(R.string.snooze), + pendingIntents.snoozeNotification(habitGroup) + ) + wearableExtender.addAction(snoozeAction) + builder.addAction(snoozeAction) + } + + builder.extend(wearableExtender) + return builder.build() + } + companion object { private const val REMINDERS_CHANNEL_ID = "REMINDERS" fun createAndroidNotificationChannel(context: Context) { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt index f7e532984..23a9ab9db 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt @@ -33,6 +33,7 @@ import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.R import org.isoron.uhabits.activities.AndroidThemeSwitcher import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.ui.views.DarkTheme import org.isoron.uhabits.core.ui.views.LightTheme import org.isoron.uhabits.receivers.ReminderController @@ -41,6 +42,7 @@ import java.util.Calendar class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener { private var habit: Habit? = null + private var habitGroup: HabitGroup? = null private var reminderController: ReminderController? = null private var dialog: AlertDialog? = null private var androidColor: Int = 0 @@ -58,10 +60,13 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener { if (data == null) { finish() } else { - habit = appComponent.habitList.getById(ContentUris.parseId(data)) + val id = ContentUris.parseId(data) + habit = appComponent.habitList.getById(id) ?: appComponent.habitGroupList.getHabitByID(id) + habitGroup = appComponent.habitGroupList.getById(id) } - if (habit == null) finish() - androidColor = themeSwitcher.currentTheme.color(habit!!.color).toInt() + if (habit == null && habitGroup == null) finish() + val color = habit?.color ?: habitGroup!!.color + androidColor = themeSwitcher.currentTheme.color(color).toInt() reminderController = appComponent.reminderController dialog = AlertDialog.Builder(this) .setTitle(R.string.select_snooze_delay) @@ -87,7 +92,11 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener { val calendar = Calendar.getInstance() val dialog = TimePickerDialog.newInstance( { view: RadialPickerLayout?, hour: Int, minute: Int -> - reminderController!!.onSnoozeTimePicked(habit, hour, minute) + if (habit != null) { + reminderController!!.onSnoozeTimePicked(habit, hour, minute) + } else { + reminderController!!.onSnoozeTimePicked(habitGroup, hour, minute) + } finish() }, calendar[Calendar.HOUR_OF_DAY], @@ -101,7 +110,11 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener { override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) { val snoozeValues = resources.getIntArray(R.array.snooze_picker_values) if (snoozeValues[position] >= 0) { - reminderController!!.onSnoozeDelayPicked(habit!!, snoozeValues[position]) + if (habit != null) { + reminderController!!.onSnoozeDelayPicked(habit!!, snoozeValues[position]) + } else { + reminderController!!.onSnoozeDelayPicked(habitGroup!!, snoozeValues[position]) + } finish() } else { showTimePicker() 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 4937dbc74..e3fd3ee6a 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 @@ -23,6 +23,7 @@ import android.content.Intent import android.net.Uri import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.reminders.ReminderScheduler @@ -50,21 +51,45 @@ class ReminderController @Inject constructor( reminderScheduler.scheduleAll() } + fun onShowReminder( + habitGroup: HabitGroup, + timestamp: Timestamp, + reminderTime: Long + ) { + notificationTray.show(habitGroup, timestamp, reminderTime) + reminderScheduler.scheduleAll() + } + fun onSnoozePressed(habit: Habit, context: Context) { showSnoozeDelayPicker(habit, context) } + fun onSnoozePressed(habitGroup: HabitGroup, context: Context) { + showSnoozeDelayPicker(habitGroup, context) + } + fun onSnoozeDelayPicked(habit: Habit, delayInMinutes: Int) { reminderScheduler.snoozeReminder(habit, delayInMinutes.toLong()) notificationTray.cancel(habit) } + fun onSnoozeDelayPicked(habitGroup: HabitGroup, delayInMinutes: Int) { + reminderScheduler.snoozeReminder(habitGroup, delayInMinutes.toLong()) + notificationTray.cancel(habitGroup) + } + fun onSnoozeTimePicked(habit: Habit?, hour: Int, minute: Int) { val time: Long = getUpcomingTimeInMillis(hour, minute) reminderScheduler.scheduleAtTime(habit!!, time) notificationTray.cancel(habit) } + fun onSnoozeTimePicked(habitGroup: HabitGroup?, hour: Int, minute: Int) { + val time: Long = getUpcomingTimeInMillis(hour, minute) + reminderScheduler.scheduleAtTime(habitGroup!!, time) + notificationTray.cancel(habitGroup) + } + fun onDismiss(habit: Habit) { if (preferences.shouldMakeNotificationsSticky()) { // This is a workaround to keep sticky notifications non-dismissible in Android 14+. @@ -75,6 +100,16 @@ class ReminderController @Inject constructor( } } + fun onDismiss(habitGroup: HabitGroup) { + 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(habitGroup) + } else { + notificationTray.cancel(habitGroup) + } + } + private fun showSnoozeDelayPicker(habit: Habit, context: Context) { context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)) val intent = Intent(context, SnoozeDelayPickerActivity::class.java) @@ -82,4 +117,12 @@ class ReminderController @Inject constructor( intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } + + private fun showSnoozeDelayPicker(habitGroup: HabitGroup, context: Context) { + context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)) + val intent = Intent(context, SnoozeDelayPickerActivity::class.java) + intent.data = Uri.parse(habitGroup.uriString) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt index 6eb10dc7e..a5e6db51a 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt @@ -27,6 +27,7 @@ import android.os.Build.VERSION.SDK_INT import android.util.Log import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayWithOffset @@ -44,52 +45,81 @@ class ReminderReceiver : BroadcastReceiver() { val app = context.applicationContext as HabitsApplication val appComponent = app.component val habits = appComponent.habitList + val habitGroups = appComponent.habitGroupList val reminderController = appComponent.reminderController Log.i(TAG, String.format("Received intent: %s", intent.toString())) var habit: Habit? = null + var habitGroup: HabitGroup? = null + var id: Long? = null + var type: String? = null val today: Long = getStartOfTodayWithOffset() val data = intent.data - if (data != null) habit = habits.getById(ContentUris.parseId(data)) + if (data != null) { + type = data.pathSegments[0] + id = ContentUris.parseId(data) + when (type) { + "habit" -> habit = habits.getById(id) ?: habitGroups.getHabitByID(id) + "habitgroup" -> habitGroup = habitGroups.getById(id) + } + } val timestamp = intent.getLongExtra("timestamp", today) val reminderTime = intent.getLongExtra("reminderTime", today) try { when (intent.action) { ACTION_SHOW_REMINDER -> { - if (habit == null) return + if (id == null) return Log.d( "ReminderReceiver", String.format( - "onShowReminder habit=%d timestamp=%d reminderTime=%d", - habit.id, + "onShowReminder %s=%d timestamp=%d reminderTime=%d", + type, + id, timestamp, reminderTime ) ) - reminderController.onShowReminder( - habit, - Timestamp(timestamp), - reminderTime - ) + if (habit != null) { + reminderController.onShowReminder( + habit, + Timestamp(timestamp), + reminderTime + ) + } else { + reminderController.onShowReminder( + habitGroup!!, + Timestamp(timestamp), + reminderTime + ) + } } ACTION_DISMISS_REMINDER -> { - if (habit == null) return - Log.d("ReminderReceiver", String.format("onDismiss habit=%d", habit.id)) - reminderController.onDismiss(habit) + if (id == null) return + Log.d("ReminderReceiver", String.format("onDismiss %s=%d", type, id)) + if (habit != null) { + reminderController.onDismiss(habit) + } else { + reminderController.onDismiss(habitGroup!!) + } } ACTION_SNOOZE_REMINDER -> { - if (habit == null) return + if (id == null) return if (SDK_INT < Build.VERSION_CODES.S) { Log.d( "ReminderReceiver", - String.format("onSnoozePressed habit=%d", habit.id) + String.format("onSnoozePressed %s=%d", type, id) ) - reminderController.onSnoozePressed(habit, context) + if (habit != null) { + reminderController.onSnoozePressed(habit, context) + } else { + reminderController.onSnoozePressed(habitGroup!!, context) + } } else { Log.w( "ReminderReceiver", String.format( - "onSnoozePressed habit=%d, should be deactivated in recent versions.", - habit.id + "onSnoozePressed %s=%d, should be deactivated in recent versions.", + type, + id ) ) } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.kt index 1a41d0f4f..a22fbb4ae 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.kt @@ -27,15 +27,17 @@ import android.widget.RemoteViews import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.R import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.core.models.HabitGroupList import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitNotFoundException import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.WidgetPreferences import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels -import java.util.ArrayList abstract class BaseWidgetProvider : AppWidgetProvider() { private lateinit var habits: HabitList + private lateinit var habitGroups: HabitGroupList lateinit var preferences: Preferences private set private lateinit var widgetPrefs: WidgetPreferences @@ -112,12 +114,26 @@ abstract class BaseWidgetProvider : AppWidgetProvider() { val selectedIds = widgetPrefs.getHabitIdsFromWidgetId(widgetId) val selectedHabits = ArrayList(selectedIds.size) for (id in selectedIds) { - val h = habits.getById(id) ?: throw HabitNotFoundException() - selectedHabits.add(h) + val h = habits.getById(id) ?: habitGroups.getHabitByID(id) + if (h != null) { + selectedHabits.add(h) + } } return selectedHabits } + protected fun getHabitGroupsFromWidgetId(widgetId: Int): List { + val selectedIds = widgetPrefs.getHabitIdsFromWidgetId(widgetId) + val selectedHabitGroups = ArrayList(selectedIds.size) + for (id in selectedIds) { + val hgr = habitGroups.getById(id) + if (hgr != null) { + selectedHabitGroups.add(hgr) + } + } + return selectedHabitGroups + } + protected abstract fun getWidgetFromId( context: Context, id: Int @@ -159,6 +175,7 @@ abstract class BaseWidgetProvider : AppWidgetProvider() { private fun updateDependencies(context: Context) { val app = context.applicationContext as HabitsApplication habits = app.component.habitList + habitGroups = app.component.habitGroupList preferences = app.component.preferences widgetPrefs = app.component.widgetPreferences } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidget.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidget.kt index 88c6b7655..9ae960695 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidget.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidget.kt @@ -25,32 +25,51 @@ import android.view.View import org.isoron.platform.gui.toInt import org.isoron.uhabits.activities.common.views.FrequencyChart import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.core.ui.screens.habits.show.views.FrequencyCardPresenter import org.isoron.uhabits.core.ui.views.WidgetTheme import org.isoron.uhabits.widgets.views.GraphWidgetView -class FrequencyWidget( +class FrequencyWidget private constructor( context: Context, widgetId: Int, - private val habit: Habit, + private val habit: Habit?, + private val habitGroup: HabitGroup?, private val firstWeekday: Int, - stacked: Boolean = false + stacked: Boolean ) : BaseWidget(context, widgetId, stacked) { + + constructor(context: Context, widgetId: Int, habit: Habit, firstWeekday: Int, stacked: Boolean = false) : this(context, widgetId, habit, null, firstWeekday, stacked) + constructor(context: Context, widgetId: Int, habitGroup: HabitGroup, firstWeekday: Int, stacked: Boolean = false) : this(context, widgetId, null, habitGroup, firstWeekday, stacked) + override val defaultHeight: Int = 200 override val defaultWidth: Int = 200 - override fun getOnClickPendingIntent(context: Context): PendingIntent = - pendingIntentFactory.showHabit(habit) + override fun getOnClickPendingIntent(context: Context): PendingIntent { + return if (habit != null) { + pendingIntentFactory.showHabit(habit) + } else { + pendingIntentFactory.showHabitGroup(habitGroup!!) + } + } override fun refreshData(v: View) { val widgetView = v as GraphWidgetView - widgetView.setTitle(habit.name) + widgetView.setTitle(habit?.name ?: habitGroup!!.name) widgetView.setBackgroundAlpha(preferedBackgroundAlpha) if (preferedBackgroundAlpha >= 255) widgetView.setShadowAlpha(0x4f) + val color = habit?.color ?: habitGroup!!.color + val isNumerical = habit?.isNumerical ?: true + val frequency = if (habit != null) { + habit.originalEntries.computeWeekdayFrequency(habit.isNumerical) + } else { + FrequencyCardPresenter.getFrequenciesFromHabitGroup(habitGroup!!) + } (widgetView.dataView as FrequencyChart).apply { setFirstWeekday(firstWeekday) - setColor(WidgetTheme().color(habit.color).toInt()) - setIsNumerical(habit.isNumerical) - setFrequency(habit.originalEntries.computeWeekdayFrequency(habit.isNumerical)) + setColor(WidgetTheme().color(color).toInt()) + setIsNumerical(isNumerical) + setFrequency(frequency) } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.kt index 698f002f4..dc2f18fd2 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.kt @@ -24,15 +24,29 @@ import android.content.Context class FrequencyWidgetProvider : BaseWidgetProvider() { override fun getWidgetFromId(context: Context, id: Int): BaseWidget { val habits = getHabitsFromWidgetId(id) - return if (habits.size == 1) { - FrequencyWidget( - context, - id, - habits[0], - preferences.firstWeekdayInt - ) + if (habits.isNotEmpty()) { + return if (habits.size == 1) { + FrequencyWidget( + context, + id, + habits[0], + preferences.firstWeekdayInt + ) + } else { + StackWidget(context, id, StackWidgetType.FREQUENCY, habits) + } } else { - StackWidget(context, id, StackWidgetType.FREQUENCY, habits) + val habitGroups = getHabitGroupsFromWidgetId(id) + return if (habitGroups.size == 1) { + FrequencyWidget( + context, + id, + habitGroups[0], + preferences.firstWeekdayInt + ) + } else { + StackWidget(context, id, StackWidgetType.FREQUENCY, habitGroups, true) + } } } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidget.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidget.kt index 8be9dfed5..b6786cb66 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidget.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidget.kt @@ -25,42 +25,62 @@ import android.view.View import org.isoron.platform.gui.toInt import org.isoron.uhabits.activities.common.views.ScoreChart import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.ui.screens.habits.show.views.ScoreCardPresenter import org.isoron.uhabits.core.ui.views.WidgetTheme import org.isoron.uhabits.widgets.views.GraphWidgetView -class ScoreWidget( +class ScoreWidget private constructor( context: Context, id: Int, - private val habit: Habit, - stacked: Boolean = false + private val habit: Habit?, + private val habitGroup: HabitGroup?, + stacked: Boolean ) : BaseWidget(context, id, stacked) { + constructor(context: Context, id: Int, habit: Habit, stacked: Boolean = false) : this(context, id, habit, null, stacked) + constructor(context: Context, id: Int, habitGroup: HabitGroup, stacked: Boolean = false) : this(context, id, null, habitGroup, stacked) + override val defaultHeight: Int = 300 override val defaultWidth: Int = 300 - override fun getOnClickPendingIntent(context: Context): PendingIntent = - pendingIntentFactory.showHabit(habit) + override fun getOnClickPendingIntent(context: Context): PendingIntent { + return if (habit != null) { + pendingIntentFactory.showHabit(habit) + } else { + pendingIntentFactory.showHabitGroup(habitGroup!!) + } + } override fun refreshData(view: View) { - val viewModel = ScoreCardPresenter.buildState( - habit = habit, - firstWeekday = prefs.firstWeekdayInt, - spinnerPosition = prefs.scoreCardSpinnerPosition, - theme = WidgetTheme() - ) + val viewModel = if (habit != null) { + ScoreCardPresenter.buildState( + habit = habit, + firstWeekday = prefs.firstWeekdayInt, + spinnerPosition = prefs.scoreCardSpinnerPosition, + theme = WidgetTheme() + ) + } else { + ScoreCardPresenter.buildState( + habitGroup = habitGroup!!, + firstWeekday = prefs.firstWeekdayInt, + spinnerPosition = prefs.scoreCardSpinnerPosition, + theme = WidgetTheme() + ) + } val widgetView = view as GraphWidgetView widgetView.setBackgroundAlpha(preferedBackgroundAlpha) if (preferedBackgroundAlpha >= 255) widgetView.setShadowAlpha(0x4f) + val color = habit?.color ?: habitGroup!!.color (widgetView.dataView as ScoreChart).apply { setIsTransparencyEnabled(true) setBucketSize(viewModel.bucketSize) - setColor(WidgetTheme().color(habit.color).toInt()) + setColor(WidgetTheme().color(color).toInt()) setScores(viewModel.scores) } } override fun buildView() = GraphWidgetView(context, ScoreChart(context)).apply { - setTitle(habit.name) + setTitle(habit?.name ?: habitGroup!!.name) } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.kt index 160054da1..794413219 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.kt @@ -23,10 +23,19 @@ import android.content.Context class ScoreWidgetProvider : BaseWidgetProvider() { override fun getWidgetFromId(context: Context, id: Int): BaseWidget { val habits = getHabitsFromWidgetId(id) - return if (habits.size == 1) { - ScoreWidget(context, id, habits[0]) + if (habits.isNotEmpty()) { + return if (habits.size == 1) { + ScoreWidget(context, id, habits[0]) + } else { + StackWidget(context, id, StackWidgetType.SCORE, habits) + } } else { - StackWidget(context, id, StackWidgetType.SCORE, habits) + val habitGroups = getHabitGroupsFromWidgetId(id) + return if (habitGroups.size == 1) { + ScoreWidget(context, id, habitGroups[0]) + } else { + StackWidget(context, id, StackWidgetType.SCORE, habitGroups, true) + } } } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidget.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidget.kt index 6c59c1e1d..47e906538 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidget.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidget.kt @@ -28,14 +28,20 @@ import android.view.View import android.widget.RemoteViews import org.isoron.platform.utils.StringUtils import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup -class StackWidget( +class StackWidget private constructor( context: Context, widgetId: Int, private val widgetType: StackWidgetType, - private val habits: List, - stacked: Boolean = true + private val habits: List?, + private val habitGroups: List?, + stacked: Boolean ) : BaseWidget(context, widgetId, stacked) { + + constructor(context: Context, widgetId: Int, widgetType: StackWidgetType, habits: List, stacked: Boolean = true) : this(context, widgetId, widgetType, habits, null, stacked) + constructor(context: Context, widgetId: Int, widgetType: StackWidgetType, habitGroups: List, stacked: Boolean = true, isHabitGroups: Boolean = false) : this(context, widgetId, widgetType, null, habitGroups, stacked) + override val defaultHeight: Int = 0 override val defaultWidth: Int = 0 @@ -55,7 +61,11 @@ class StackWidget( val remoteViews = RemoteViews(context.packageName, StackWidgetType.getStackWidgetLayoutId(widgetType)) val serviceIntent = Intent(context, StackWidgetService::class.java) - val habitIds = StringUtils.joinLongs(habits.map { it.id!! }.toLongArray()) + val habitIds = if (habits != null) { + StringUtils.joinLongs(habits.map { it.id!! }.toLongArray()) + } else { + StringUtils.joinLongs(habitGroups!!.map { it.id!! }.toLongArray()) + } serviceIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) serviceIntent.putExtra(StackWidgetService.WIDGET_TYPE, widgetType.value) @@ -73,9 +83,14 @@ class StackWidget( StackWidgetType.getStackWidgetAdapterViewId(widgetType), StackWidgetType.getStackWidgetEmptyViewId(widgetType) ) + val pendingIntentTemplate = if (habits != null) { + StackWidgetType.getPendingIntentTemplate(pendingIntentFactory, widgetType, habits) + } else { + StackWidgetType.getPendingIntentTemplate(pendingIntentFactory, widgetType, true) + } remoteViews.setPendingIntentTemplate( StackWidgetType.getStackWidgetAdapterViewId(widgetType), - StackWidgetType.getPendingIntentTemplate(pendingIntentFactory, widgetType, habits) + pendingIntentTemplate ) return remoteViews } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidgetType.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidgetType.kt index 1e3a5d217..b8929f5f6 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidgetType.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidgetType.kt @@ -24,7 +24,6 @@ import org.isoron.uhabits.R import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.intents.PendingIntentFactory -import java.lang.IllegalStateException enum class StackWidgetType(val value: Int) { CHECKMARK(0), FREQUENCY(1), SCORE(2), // habit strength widget @@ -95,6 +94,17 @@ enum class StackWidgetType(val value: Int) { } } + fun getPendingIntentTemplate( + factory: PendingIntentFactory, + widgetType: StackWidgetType, + isHabitGroups: Boolean + ): PendingIntent { + return when (widgetType) { + CHECKMARK, HISTORY, STREAKS, TARGET -> throw RuntimeException() + FREQUENCY, SCORE -> factory.showHabitGroupTemplate() + } + } + fun getIntentFillIn( factory: PendingIntentFactory, widgetType: StackWidgetType, diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/activities/HabitPickerDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/activities/HabitPickerDialog.kt index 67ad8ae2d..2f2dc9682 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/activities/HabitPickerDialog.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/activities/HabitPickerDialog.kt @@ -25,7 +25,6 @@ import android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID import android.content.Intent import android.os.Bundle import android.widget.ArrayAdapter -import android.widget.Button import android.widget.ListView import android.widget.TextView import org.isoron.uhabits.HabitsApplication @@ -44,6 +43,10 @@ class NumericalHabitPickerDialog : HabitPickerDialog() { override fun getEmptyMessage() = R.string.no_numerical_habits } +class HabitAndGroupPickerDialog : HabitPickerDialog() { + override fun shouldShowGroups(): Boolean = true +} + open class HabitPickerDialog : Activity() { private var widgetId = 0 @@ -52,6 +55,8 @@ open class HabitPickerDialog : Activity() { protected open fun shouldHideNumerical() = false protected open fun shouldHideBoolean() = false + + protected open fun shouldShowGroups() = false protected open fun getEmptyMessage() = R.string.no_habits override fun onCreate(savedInstanceState: Bundle?) { @@ -59,6 +64,7 @@ open class HabitPickerDialog : Activity() { val component = (applicationContext as HabitsApplication).component AndroidThemeSwitcher(this, component.preferences).apply() val habitList = component.habitList + val habitGroupList = component.habitGroupList widgetPreferences = component.widgetPreferences widgetUpdater = component.widgetUpdater widgetId = intent.extras?.getInt(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID) ?: 0 @@ -73,6 +79,21 @@ open class HabitPickerDialog : Activity() { habitNames.add(h.name) } + for (hgr in habitGroupList) { + if (hgr.isArchived) continue + if (shouldShowGroups()) { + habitIds.add(hgr.id!!) + habitNames.add(hgr.name) + } + + for (h in hgr.habitList) { + if (h.isArchived) continue + if (!h.isNumerical and shouldHideBoolean()) continue + habitIds.add(h.id!!) + habitNames.add(h.name) + } + } + if (habitNames.isEmpty()) { setContentView(R.layout.widget_empty_activity) findViewById(R.id.message).setText(getEmptyMessage()) @@ -81,7 +102,6 @@ open class HabitPickerDialog : Activity() { setContentView(R.layout.widget_configure_activity) val listView = findViewById(R.id.listView) - val saveButton = findViewById