Move uhabits-core to top level; all Java files to uhabits-core:jvmMain/jvmTest

This commit is contained in:
2021-01-03 13:21:02 -06:00
parent 1137088e20
commit 9fd36d8d53
225 changed files with 444 additions and 116 deletions

View File

@@ -0,0 +1,106 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.gui
enum class TextAlign {
LEFT, CENTER, RIGHT
}
enum class Font {
REGULAR,
BOLD,
FONT_AWESOME
}
interface Canvas {
fun setColor(color: Color)
fun drawLine(x1: Double, y1: Double, x2: Double, y2: Double)
fun drawText(text: String, x: Double, y: Double)
fun fillRect(x: Double, y: Double, width: Double, height: Double)
fun fillRoundRect(x: Double, y: Double, width: Double, height: Double, cornerRadius: Double)
fun drawRect(x: Double, y: Double, width: Double, height: Double)
fun getHeight(): Double
fun getWidth(): Double
fun setFont(font: Font)
fun setFontSize(size: Double)
fun setStrokeWidth(size: Double)
fun fillArc(
centerX: Double,
centerY: Double,
radius: Double,
startAngle: Double,
swipeAngle: Double
)
fun fillCircle(centerX: Double, centerY: Double, radius: Double)
fun setTextAlign(align: TextAlign)
fun toImage(): Image
fun measureText(test: String): Double
/**
* Fills entire canvas with the current color.
*/
fun fill() {
fillRect(0.0, 0.0, getWidth(), getHeight())
}
fun drawTestImage() {
// Draw transparent background
setColor(Color(0.1, 0.1, 0.1, 0.5))
fillRect(0.0, 0.0, 500.0, 400.0)
// Draw center rectangle
setColor(Color(0x606060))
setStrokeWidth(25.0)
drawRect(100.0, 100.0, 300.0, 200.0)
// Draw squares, circles and arcs
setColor(Color.YELLOW)
setStrokeWidth(1.0)
drawRect(0.0, 0.0, 100.0, 100.0)
fillCircle(50.0, 50.0, 30.0)
drawRect(0.0, 100.0, 100.0, 100.0)
fillArc(50.0, 150.0, 30.0, 90.0, 135.0)
drawRect(0.0, 200.0, 100.0, 100.0)
fillArc(50.0, 250.0, 30.0, 90.0, -135.0)
drawRect(0.0, 300.0, 100.0, 100.0)
fillArc(50.0, 350.0, 30.0, 45.0, 90.0)
// Draw two red crossing lines
setColor(Color.RED)
setStrokeWidth(2.0)
drawLine(0.0, 0.0, 500.0, 400.0)
drawLine(500.0, 0.0, 0.0, 400.0)
// Draw text
setFont(Font.BOLD)
setFontSize(50.0)
setColor(Color.GREEN)
setTextAlign(TextAlign.CENTER)
drawText("HELLO", 250.0, 100.0)
setTextAlign(TextAlign.RIGHT)
drawText("HELLO", 250.0, 150.0)
setTextAlign(TextAlign.LEFT)
drawText("HELLO", 250.0, 200.0)
// Draw FontAwesome icon
setFont(Font.FONT_AWESOME)
drawText(FontAwesome.CHECK, 250.0, 300.0)
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.gui
data class Color(
val red: Double,
val green: Double,
val blue: Double,
val alpha: Double,
) {
val luminosity: Double
get() {
return 0.21 * red + 0.72 * green + 0.07 * blue
}
constructor(rgb: Int) : this(
((rgb shr 16) and 0xFF) / 255.0,
((rgb shr 8) and 0xFF) / 255.0,
((rgb shr 0) and 0xFF) / 255.0,
1.0
)
fun blendWith(other: Color, weight: Double): Color {
return Color(
red * (1 - weight) + other.red * weight,
green * (1 - weight) + other.green * weight,
blue * (1 - weight) + other.blue * weight,
alpha * (1 - weight) + other.alpha * weight
)
}
fun contrast(other: Color): Double {
val l1 = this.luminosity
val l2 = other.luminosity
val relativeLuminosity = (l1 + 0.05) / (l2 + 0.05)
return if (relativeLuminosity >= 1) relativeLuminosity else 1 / relativeLuminosity
}
fun withAlpha(newAlpha: Double) = Color(red, green, blue, newAlpha)
companion object {
val TRANSPARENT = Color(0.0, 0.0, 0.0, 0.0)
val RED = Color(1.0, 0.0, 0.0, 1.0)
val GREEN = Color(0.0, 1.0, 0.0, 1.0)
val BLUE = Color(1.0, 0.0, 1.0, 1.0)
val YELLOW = Color(1.0, 1.0, 0.0, 1.0)
val MAGENTA = Color(1.0, 0.0, 1.0, 1.0)
val CYAN = Color(0.0, 1.0, 1.0, 1.0)
val WHITE = Color(1.0, 1.0, 1.0, 1.0)
val BLACK = Color(0.0, 0.0, 0.0, 1.0)
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.gui
class FontAwesome {
companion object {
val CHECK = "\uf00c"
val TIMES = "\uf00d"
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.gui
import kotlin.math.abs
import kotlin.math.min
interface Image {
val width: Int
val height: Int
fun getPixel(x: Int, y: Int): Color
fun setPixel(x: Int, y: Int, color: Color)
suspend fun export(path: String)
fun diff(other: Image) {
if (width != other.width) error("Width must match: $width !== ${other.width}")
if (height != other.height) error("Height must match: $height !== ${other.height}")
for (x in 0 until width) {
for (y in 0 until height) {
val p1 = getPixel(x, y)
var l = 1.0
for (dx in -2..2) {
if (x + dx < 0 || x + dx >= width) continue
for (dy in -2..2) {
if (y + dy < 0 || y + dy >= height) continue
val p2 = other.getPixel(x + dx, y + dy)
l = min(l, abs(p1.luminosity - p2.luminosity))
}
}
setPixel(x, y, Color(l, l, l, 1.0))
}
}
}
val averageLuminosity: Double
get() {
var luminosity = 0.0
for (x in 0 until width) {
for (y in 0 until height) {
luminosity += getPixel(x, y).luminosity
}
}
return luminosity / (width * height)
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.gui
interface View {
fun draw(canvas: Canvas)
fun onClick(x: Double, y: Double) {
}
}
interface DataView : View {
var dataOffset: Int
val dataColumnWidth: Double
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.io
import org.isoron.platform.gui.Image
interface FileOpener {
/**
* Opens a file which was shipped bundled with the application, such as a
* migration file.
*
* The path is relative to the assets folder. For example, to open
* assets/main/migrations/09.sql you should provide migrations/09.sql
* as the path.
*
* This function always succeed, even if the file does not exist.
*/
fun openResourceFile(path: String): ResourceFile
/**
* Opens a file which was not shipped with the application, such as
* databases and logs.
*
* The path is relative to the user folder. For example, if the application
* stores the user data at /home/user/.loop/ and you wish to open the file
* /home/user/.loop/crash.log, you should provide crash.log as the path.
*
* This function always succeed, even if the file does not exist.
*/
fun openUserFile(path: String): UserFile
}
/**
* Represents a file that was created after the application was installed, as a
* result of some user action, such as databases and logs.
*/
interface UserFile {
/**
* Deletes the user file. If the file does not exist, nothing happens.
*/
suspend fun delete()
/**
* Returns true if the file exists.
*/
suspend fun exists(): Boolean
/**
* Returns the lines of the file. If the file does not exist, throws an
* exception.
*/
suspend fun lines(): List<String>
}
/**
* Represents a file that was shipped with the application, such as migration
* files or database templates.
*/
interface ResourceFile {
/**
* Copies the resource file to the specified user file. If the user file
* already exists, it is replaced. If not, a new file is created.
*/
suspend fun copyTo(dest: UserFile)
/**
* Returns the lines of the resource file. If the file does not exist,
* throws an exception.
*/
suspend fun lines(): List<String>
/**
* Returns true if the file exists.
*/
suspend fun exists(): Boolean
/**
* Loads resource file as an image.
*/
suspend fun toImage(): Image
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.time
import kotlin.math.abs
import kotlin.math.ceil
enum class DayOfWeek(val daysSinceSunday: Int) {
SUNDAY(0),
MONDAY(1),
TUESDAY(2),
WEDNESDAY(3),
THURSDAY(4),
FRIDAY(5),
SATURDAY(6),
}
data class LocalDate(val daysSince2000: Int) {
var yearCache = -1
var monthCache = -1
var dayCache = -1
constructor(year: Int, month: Int, day: Int) :
this(daysSince2000(year, month, day))
val dayOfWeek: DayOfWeek
get() {
return when (daysSince2000 % 7) {
0 -> DayOfWeek.SATURDAY
1 -> DayOfWeek.SUNDAY
2 -> DayOfWeek.MONDAY
3 -> DayOfWeek.TUESDAY
4 -> DayOfWeek.WEDNESDAY
5 -> DayOfWeek.THURSDAY
else -> DayOfWeek.FRIDAY
}
}
val year: Int
get() {
if (yearCache < 0) updateYearMonthDayCache()
return yearCache
}
val month: Int
get() {
if (monthCache < 0) updateYearMonthDayCache()
return monthCache
}
val day: Int
get() {
if (dayCache < 0) updateYearMonthDayCache()
return dayCache
}
private fun updateYearMonthDayCache() {
var currYear = 2000
var currDay = 0
while (true) {
val currYearLength = if (isLeapYear(currYear)) 366 else 365
if (daysSince2000 < currDay + currYearLength) {
yearCache = currYear
break
} else {
currYear++
currDay += currYearLength
}
}
var currMonth = 1
val monthOffset = if (isLeapYear(currYear)) leapOffset else nonLeapOffset
while (true) {
if (daysSince2000 < currDay + monthOffset[currMonth]) {
monthCache = currMonth
break
} else {
currMonth++
}
}
currDay += monthOffset[currMonth - 1]
dayCache = daysSince2000 - currDay + 1
}
fun isOlderThan(other: LocalDate): Boolean {
return daysSince2000 < other.daysSince2000
}
fun isNewerThan(other: LocalDate): Boolean {
return daysSince2000 > other.daysSince2000
}
fun plus(days: Int): LocalDate {
return LocalDate(daysSince2000 + days)
}
fun minus(days: Int): LocalDate {
return LocalDate(daysSince2000 - days)
}
fun distanceTo(other: LocalDate): Int {
return abs(daysSince2000 - other.daysSince2000)
}
override fun toString(): String {
return "LocalDate($year-$month-$day)"
}
}
interface LocalDateFormatter {
fun shortWeekdayName(weekday: DayOfWeek): String
fun shortWeekdayName(date: LocalDate): String
fun shortMonthName(date: LocalDate): String
}
private fun isLeapYear(year: Int): Boolean {
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
val leapOffset = arrayOf(
0, 31, 60, 91, 121, 152, 182,
213, 244, 274, 305, 335, 366
)
val nonLeapOffset = arrayOf(
0, 31, 59, 90, 120, 151, 181,
212, 243, 273, 304, 334, 365
)
private fun daysSince2000(year: Int, month: Int, day: Int): Int {
var result = 365 * (year - 2000)
result += ceil((year - 2000) / 4.0).toInt()
result -= ceil((year - 2000) / 100.0).toInt()
result += ceil((year - 2000) / 400.0).toInt()
if (isLeapYear(year)) {
result += leapOffset[month - 1]
} else {
result += nonLeapOffset[month - 1]
}
result += (day - 1)
return result
}

View File

@@ -0,0 +1,214 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.gui
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.JavaFileOpener
import org.isoron.platform.io.JavaResourceFile
import java.awt.BasicStroke
import java.awt.RenderingHints.KEY_ANTIALIASING
import java.awt.RenderingHints.KEY_FRACTIONALMETRICS
import java.awt.RenderingHints.KEY_TEXT_ANTIALIASING
import java.awt.RenderingHints.VALUE_ANTIALIAS_ON
import java.awt.RenderingHints.VALUE_FRACTIONALMETRICS_ON
import java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON
import java.awt.font.FontRenderContext
import java.awt.geom.RoundRectangle2D
import java.awt.image.BufferedImage
import kotlin.math.roundToInt
class JavaCanvas(
val image: BufferedImage,
val pixelScale: Double = 2.0,
) : Canvas {
override fun toImage(): Image {
return JavaImage(image)
}
override fun measureText(text: String): Double {
val metrics = g2d.getFontMetrics(g2d.font)
return toDp(metrics.stringWidth(text))
}
private val frc = FontRenderContext(null, true, true)
private var fontSize = 12.0
private var font = Font.REGULAR
private var textAlign = TextAlign.CENTER
val widthPx = image.width
val heightPx = image.height
val g2d = image.createGraphics()
private val NOTO_REGULAR_FONT = createFont("fonts/NotoSans-Regular.ttf")
private val NOTO_BOLD_FONT = createFont("fonts/NotoSans-Bold.ttf")
private val FONT_AWESOME_FONT = createFont("fonts/FontAwesome.ttf")
init {
g2d.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON)
g2d.setRenderingHint(KEY_TEXT_ANTIALIASING, VALUE_TEXT_ANTIALIAS_ON)
g2d.setRenderingHint(KEY_FRACTIONALMETRICS, VALUE_FRACTIONALMETRICS_ON)
updateFont()
}
private fun toPixel(x: Double): Int {
return (pixelScale * x).toInt()
}
private fun toDp(x: Int): Double {
return x / pixelScale
}
override fun setColor(color: Color) {
g2d.color = java.awt.Color(
color.red.toFloat(),
color.green.toFloat(),
color.blue.toFloat(),
color.alpha.toFloat()
)
}
override fun drawLine(x1: Double, y1: Double, x2: Double, y2: Double) {
g2d.drawLine(toPixel(x1), toPixel(y1), toPixel(x2), toPixel(y2))
}
override fun drawText(text: String, x: Double, y: Double) {
updateFont()
val bounds = g2d.font.getStringBounds(text, frc)
val bWidth = bounds.width.roundToInt()
val bHeight = bounds.height.roundToInt()
val bx = bounds.x.roundToInt()
val by = bounds.y.roundToInt()
if (textAlign == TextAlign.CENTER) {
g2d.drawString(
text,
toPixel(x) - bx - bWidth / 2,
toPixel(y) - by - bHeight / 2
)
} else if (textAlign == TextAlign.LEFT) {
g2d.drawString(
text,
toPixel(x) - bx,
toPixel(y) - by - bHeight / 2
)
} else {
g2d.drawString(
text,
toPixel(x) - bx - bWidth,
toPixel(y) - by - bHeight / 2
)
}
}
override fun fillRect(x: Double, y: Double, width: Double, height: Double) {
g2d.fillRect(toPixel(x), toPixel(y), toPixel(width), toPixel(height))
}
override fun fillRoundRect(
x: Double,
y: Double,
width: Double,
height: Double,
cornerRadius: Double,
) {
g2d.fill(
RoundRectangle2D.Double(
toPixel(x).toDouble(),
toPixel(y).toDouble(),
toPixel(width).toDouble(),
toPixel(height).toDouble(),
toPixel(cornerRadius).toDouble(),
toPixel(cornerRadius).toDouble(),
)
)
}
override fun drawRect(x: Double, y: Double, width: Double, height: Double) {
g2d.drawRect(toPixel(x), toPixel(y), toPixel(width), toPixel(height))
}
override fun getHeight(): Double {
return toDp(heightPx)
}
override fun getWidth(): Double {
return toDp(widthPx)
}
override fun setFont(font: Font) {
this.font = font
updateFont()
}
override fun setFontSize(size: Double) {
fontSize = size
updateFont()
}
override fun setStrokeWidth(size: Double) {
g2d.stroke = BasicStroke((size * pixelScale).toFloat())
}
private fun updateFont() {
val size = (fontSize * pixelScale).toFloat()
g2d.font = when (font) {
Font.REGULAR -> NOTO_REGULAR_FONT.deriveFont(size)
Font.BOLD -> NOTO_BOLD_FONT.deriveFont(size)
Font.FONT_AWESOME -> FONT_AWESOME_FONT.deriveFont(size)
}
}
override fun fillCircle(centerX: Double, centerY: Double, radius: Double) {
g2d.fillOval(
toPixel(centerX - radius),
toPixel(centerY - radius),
toPixel(radius * 2),
toPixel(radius * 2)
)
}
override fun fillArc(
centerX: Double,
centerY: Double,
radius: Double,
startAngle: Double,
swipeAngle: Double,
) {
g2d.fillArc(
toPixel(centerX - radius),
toPixel(centerY - radius),
toPixel(radius * 2),
toPixel(radius * 2),
startAngle.roundToInt(),
swipeAngle.roundToInt()
)
}
override fun setTextAlign(align: TextAlign) {
this.textAlign = align
}
private fun createFont(path: String) = runBlocking<java.awt.Font> {
val file = JavaFileOpener().openResourceFile(path) as JavaResourceFile
if (!file.exists()) throw RuntimeException("File not found: ${file.path}")
java.awt.Font.createFont(0, file.stream())
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.gui
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
class JavaImage(val bufferedImage: BufferedImage) : Image {
override fun setPixel(x: Int, y: Int, color: Color) {
bufferedImage.setRGB(
x,
y,
java.awt.Color(
color.red.toFloat(),
color.green.toFloat(),
color.blue.toFloat()
).rgb
)
}
override suspend fun export(path: String) {
val file = File(path)
file.parentFile.mkdirs()
ImageIO.write(bufferedImage, "png", file)
}
override val width: Int
get() = bufferedImage.width
override val height: Int
get() = bufferedImage.height
override fun getPixel(x: Int, y: Int): Color {
return Color(bufferedImage.getRGB(x, y))
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.io
import org.isoron.platform.gui.Image
import org.isoron.platform.gui.JavaImage
import java.io.InputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import javax.imageio.ImageIO
@Suppress("NewApi")
class JavaResourceFile(val path: String) : ResourceFile {
private val javaPath: Path
get() {
val mainPath = Paths.get("assets/main/$path")
val testPath = Paths.get("assets/test/$path")
if (Files.exists(mainPath)) return mainPath
else return testPath
}
override suspend fun exists(): Boolean {
return Files.exists(javaPath)
}
override suspend fun lines(): List<String> {
return Files.readAllLines(javaPath)
}
override suspend fun copyTo(dest: UserFile) {
if (dest.exists()) dest.delete()
val destPath = (dest as JavaUserFile).path
destPath.toFile().parentFile?.mkdirs()
Files.copy(javaPath, destPath)
}
fun stream(): InputStream {
return Files.newInputStream(javaPath)
}
override suspend fun toImage(): Image {
return JavaImage(ImageIO.read(stream()))
}
}
@Suppress("NewApi")
class JavaUserFile(val path: Path) : UserFile {
override suspend fun lines(): List<String> {
return Files.readAllLines(path)
}
override suspend fun exists(): Boolean {
return Files.exists(path)
}
override suspend fun delete() {
Files.delete(path)
}
}
@Suppress("NewApi")
class JavaFileOpener : FileOpener {
override fun openUserFile(path: String): UserFile {
val path = Paths.get("/tmp/$path")
return JavaUserFile(path)
}
override fun openResourceFile(path: String): ResourceFile {
return JavaResourceFile(path)
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.time
import java.util.Calendar.DAY_OF_MONTH
import java.util.Calendar.DAY_OF_WEEK
import java.util.Calendar.HOUR_OF_DAY
import java.util.Calendar.LONG
import java.util.Calendar.MILLISECOND
import java.util.Calendar.MINUTE
import java.util.Calendar.MONTH
import java.util.Calendar.SECOND
import java.util.Calendar.SHORT
import java.util.Calendar.YEAR
import java.util.GregorianCalendar
import java.util.Locale
import java.util.TimeZone
fun LocalDate.toGregorianCalendar(): GregorianCalendar {
val cal = GregorianCalendar()
cal.timeZone = TimeZone.getTimeZone("GMT")
cal.set(MILLISECOND, 0)
cal.set(SECOND, 0)
cal.set(MINUTE, 0)
cal.set(HOUR_OF_DAY, 0)
cal.set(YEAR, this.year)
cal.set(MONTH, this.month - 1)
cal.set(DAY_OF_MONTH, this.day)
return cal
}
class JavaLocalDateFormatter(private val locale: Locale) : LocalDateFormatter {
override fun shortMonthName(date: LocalDate): String {
val cal = date.toGregorianCalendar()
val longName = cal.getDisplayName(MONTH, LONG, locale)
val shortName = cal.getDisplayName(MONTH, SHORT, locale)
// For some locales, such as Japan, SHORT name is exceedingly short
return if (longName.length <= 3) longName else shortName
}
override fun shortWeekdayName(weekday: DayOfWeek): String {
val cal = GregorianCalendar()
cal.set(DAY_OF_WEEK, weekday.daysSinceSunday - 1)
return shortWeekdayName(LocalDate(cal.get(YEAR), cal.get(MONTH) + 1, cal.get(DAY_OF_MONTH)))
}
override fun shortWeekdayName(date: LocalDate): String {
val cal = date.toGregorianCalendar()
return cal.getDisplayName(DAY_OF_WEEK, SHORT, locale)
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core
import javax.inject.Scope
@Scope
annotation class AppScope

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core
const val DATABASE_FILENAME = "uhabits.db"
const val DATABASE_VERSION = 24

View File

@@ -0,0 +1,32 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
data class ArchiveHabitsCommand(
val habitList: HabitList,
val selected: List<Habit>,
) : Command {
override fun run() {
for (h in selected) h.isArchived = true
habitList.update(selected)
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.PaletteColor
data class ChangeHabitColorCommand(
val habitList: HabitList,
val selected: List<Habit>,
val newColor: PaletteColor,
) : Command {
override fun run() {
for (h in selected) h.color = newColor
habitList.update(selected)
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
interface Command {
fun run()
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.tasks.Task
import org.isoron.uhabits.core.tasks.TaskRunner
import java.util.LinkedList
import javax.inject.Inject
@AppScope
open class CommandRunner
@Inject constructor(
private val taskRunner: TaskRunner,
) {
private val listeners: LinkedList<Listener> = LinkedList()
open fun run(command: Command) {
taskRunner.execute(
object : Task {
override fun doInBackground() {
command.run()
}
override fun onPostExecute() {
notifyListeners(command)
}
}
)
}
fun addListener(l: Listener) {
listeners.add(l)
}
fun notifyListeners(command: Command) {
for (l in listeners) l.onCommandFinished(command)
}
fun removeListener(l: Listener) {
listeners.remove(l)
}
interface Listener {
fun onCommandFinished(command: Command)
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
data class CreateHabitCommand(
val modelFactory: ModelFactory,
val habitList: HabitList,
val model: Habit,
) : Command {
override fun run() {
val habit = modelFactory.buildHabit()
habit.copyFrom(model)
habitList.add(habit)
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.Timestamp
data class CreateRepetitionCommand(
val habitList: HabitList,
val habit: Habit,
val timestamp: Timestamp,
val value: Int,
) : Command {
override fun run() {
val entries = habit.originalEntries
entries.add(Entry(timestamp, value))
habit.recompute()
habitList.resort()
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
data class DeleteHabitsCommand(
val habitList: HabitList,
val selected: List<Habit>,
) : Command {
override fun run() {
for (h in selected) habitList.remove(h)
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.HabitNotFoundException
data class EditHabitCommand(
val habitList: HabitList,
val habitId: Long,
val modified: Habit,
) : Command {
override fun run() {
val habit = habitList.getById(habitId) ?: throw HabitNotFoundException()
habit.copyFrom(modified)
habitList.update(habit)
habit.observable.notifyListeners()
habit.recompute()
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
data class UnarchiveHabitsCommand(
val habitList: HabitList,
val selected: List<Habit>,
) : Command {
override fun run() {
for (h in selected) h.isArchived = false
habitList.update(selected)
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.database
@Retention(AnnotationRetention.RUNTIME)
annotation class Column(val name: String = "")

View File

@@ -0,0 +1,62 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.database
import java.io.Closeable
interface Cursor : Closeable {
override fun close()
/**
* Moves the cursor forward one row from its current position. Returns
* true if the current position is valid, or false if the cursor is already
* past the last row. The cursor start at position -1, so this method must
* be called first.
*/
fun moveToNext(): Boolean
/**
* Retrieves the value of the designated column in the current row of this
* Cursor as an Integer. If the value is null, returns null. The first
* column has index zero.
*/
fun getInt(index: Int): Int?
/**
* Retrieves the value of the designated column in the current row of this
* Cursor as a Long. If the value is null, returns null. The first
* column has index zero.
*/
fun getLong(index: Int): Long?
/**
* Retrieves the value of the designated column in the current row of this
* Cursor as a Double. If the value is null, returns null. The first
* column has index zero.
*/
fun getDouble(index: Int): Double?
/**
* Retrieves the value of the designated column in the current row of this
* Cursor as a String. If the value is null, returns null. The first
* column has index zero.
*/
fun getString(index: Int): String?
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.database
import java.io.File
interface Database {
fun query(q: String, vararg params: String): Cursor
fun query(q: String, callback: ProcessCallback) {
query(q).use { c ->
c.moveToNext()
callback.process(c)
}
}
fun update(
tableName: String,
values: Map<String, Any?>,
where: String,
vararg params: String,
): Int
fun insert(tableName: String, values: Map<String, Any?>): Long?
fun delete(tableName: String, where: String, vararg params: String)
fun execute(query: String, vararg params: Any)
fun beginTransaction()
fun setTransactionSuccessful()
fun endTransaction()
fun close()
val version: Int
val file: File?
interface ProcessCallback {
fun process(cursor: Cursor)
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.database
import java.io.File
interface DatabaseOpener {
fun open(file: File): Database
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*
*
*/
package org.isoron.uhabits.core.database
import java.sql.ResultSet
import java.sql.SQLException
class JdbcCursor(private val resultSet: ResultSet) : Cursor {
override fun close() {
try {
resultSet.close()
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun moveToNext(): Boolean {
return try {
resultSet.next()
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun getInt(index: Int): Int? {
return try {
val value = resultSet.getInt(index + 1)
if (resultSet.wasNull()) null else value
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun getLong(index: Int): Long? {
return try {
val value = resultSet.getLong(index + 1)
if (resultSet.wasNull()) null else value
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun getDouble(index: Int): Double? {
return try {
val value = resultSet.getDouble(index + 1)
if (resultSet.wasNull()) null else value
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun getString(index: Int): String? {
return try {
val value = resultSet.getString(index + 1)
if (resultSet.wasNull()) null else value
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
}

View File

@@ -0,0 +1,166 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.database
import org.apache.commons.lang3.StringUtils
import java.io.File
import java.sql.Connection
import java.sql.PreparedStatement
import java.sql.SQLException
import java.sql.Types
import java.util.ArrayList
class JdbcDatabase(private val connection: Connection) : Database {
private var transactionSuccessful = false
override fun query(q: String, vararg params: String): Cursor {
return try {
val st = buildStatement(q, params)
JdbcCursor(st.executeQuery())
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun update(
tableName: String,
values: Map<String, Any?>,
where: String,
vararg params: String,
): Int {
return try {
val fields = ArrayList<String?>()
val valuesStr = ArrayList<String>()
for ((key, value) in values) {
fields.add("$key=?")
valuesStr.add(value.toString())
}
valuesStr.addAll(listOf(*params))
val query = String.format(
"update %s set %s where %s",
tableName,
StringUtils.join(fields, ", "),
where
)
val st = buildStatement(query, valuesStr.toTypedArray())
st.executeUpdate()
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun insert(tableName: String, values: Map<String, Any?>): Long? {
return try {
val fields = ArrayList<String?>()
val params = ArrayList<Any?>()
val questionMarks = ArrayList<String?>()
for ((key, value) in values) {
fields.add(key)
params.add(value)
questionMarks.add("?")
}
val query = String.format(
"insert into %s(%s) values(%s)",
tableName,
StringUtils.join(fields, ", "),
StringUtils.join(questionMarks, ", ")
)
val st = buildStatement(query, params.toTypedArray())
st.execute()
var id: Long? = null
val keys = st.generatedKeys
if (keys.next()) id = keys.getLong(1)
id
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun delete(tableName: String, where: String, vararg params: String) {
val query = String.format("delete from %s where %s", tableName, where)
execute(query, *params)
}
override fun execute(query: String, vararg params: Any) {
try {
buildStatement(query, params).execute()
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
private fun buildStatement(query: String, params: Array<out Any?>): PreparedStatement {
val st = connection.prepareStatement(query)
var index = 1
for (param in params) {
when (param) {
null -> st.setNull(index++, Types.INTEGER)
is Int -> st.setInt(index++, param)
is Double -> st.setDouble(index++, param)
is String -> st.setString(index++, param)
is Long -> st.setLong(index++, param)
else -> throw IllegalArgumentException()
}
}
return st
}
@Synchronized
override fun beginTransaction() {
try {
connection.autoCommit = false
transactionSuccessful = false
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
@Synchronized
override fun setTransactionSuccessful() {
transactionSuccessful = true
}
@Synchronized
override fun endTransaction() {
try {
if (transactionSuccessful) connection.commit() else connection.rollback()
connection.autoCommit = true
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun close() {
try {
connection.close()
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override val version: Int
get() {
query("PRAGMA user_version").use { c ->
c.moveToNext()
return c.getInt(0)!!
}
}
override val file: File?
get() = null
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.database
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.util.Locale
class MigrationHelper(
private val db: Database,
) {
fun migrateTo(newVersion: Int) {
try {
for (v in db.version + 1..newVersion) {
val fname = String.format(Locale.US, "/migrations/%02d.sql", v)
for (command in SQLParser.parse(open(fname))) db.execute(command)
}
} catch (e: Exception) {
throw RuntimeException(e)
}
}
private fun open(fname: String): InputStream {
val resource = javaClass.getResourceAsStream(fname)
if (resource != null) return resource
// Workaround for bug in Android Studio / IntelliJ. Removing this
// causes unit tests to fail when run from within the IDE, although
// everything works fine from the command line.
val file = File("uhabits-core/src/main/resources/$fname")
if (file.exists()) return FileInputStream(file)
throw RuntimeException("resource not found: $fname")
}
}

View File

@@ -0,0 +1,255 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.database
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.tuple.ImmutablePair
import org.apache.commons.lang3.tuple.Pair
import java.lang.reflect.Field
import java.util.ArrayList
import java.util.HashMap
import java.util.LinkedList
class Repository<T>(
private val klass: Class<T>,
private val db: Database,
) {
/**
* Returns the record that has the id provided. If no record is found, returns null.
*/
fun find(id: Long): T? {
return findFirst(String.format("where %s=?", getIdName()), id.toString())
}
/**
* Returns all records matching the given SQL query.
*
* The query should only contain the "where" part of the SQL query, and optionally the "order
* by" part. "Group by" is not allowed. If no matching records are found, returns an empty list.
*/
fun findAll(query: String, vararg params: String): List<T> {
db.query(buildSelectQuery() + query, *params).use { c -> return cursorToMultipleRecords(c) }
}
/**
* Returns the first record matching the given SQL query. See findAll for more details about
* the parameters.
*/
fun findFirst(query: String, vararg params: String): T? {
db.query(buildSelectQuery() + query, *params).use { c ->
return if (!c.moveToNext()) null else cursorToSingleRecord(c)
}
}
/**
* Executes the given SQL query on the repository.
*
* The query can be of any kind. For example, complex deletes and updates are allowed. The
* repository does not perform any checks to guarantee that the query is valid, however the
* underlying database might.
*/
fun execSQL(query: String, vararg params: Any) {
db.execute(query, *params)
}
/**
* Executes the given callback inside a database transaction.
*
* If the callback terminates without throwing any exceptions, the transaction is considered
* successful. If any exceptions are thrown, the transaction is aborted. Nesting transactions
* is not allowed.
*/
fun executeAsTransaction(callback: Runnable) {
db.beginTransaction()
try {
callback.run()
db.setTransactionSuccessful()
} catch (e: Exception) {
throw RuntimeException(e)
} finally {
db.endTransaction()
}
}
/**
* Saves the record on the database.
*
* If the id of the given record is null, it is assumed that the record has not been inserted
* in the repository yet. The record will be inserted, a new id will be automatically generated,
* and the id of the given record will be updated.
*
* If the given record has a non-null id, then an update will be performed instead. That is,
* the previous record will be overwritten by the one provided.
*/
fun save(record: T) {
try {
val fields = getFields()
val columns = getColumnNames()
val values: MutableMap<String, Any?> = HashMap()
for (i in fields.indices) values[columns[i]] = fields[i][record]
var id = getIdField()[record] as Long?
var affectedRows = 0
if (id != null) {
affectedRows = db.update(getTableName(), values, "${getIdName()}=?", id.toString())
}
if (id == null || affectedRows == 0) {
id = db.insert(getTableName(), values)
getIdField()[record] = id
}
} catch (e: Exception) {
throw RuntimeException(e)
}
}
/**
* Removes the given record from the repository. The id of the given record is also set to null.
*/
fun remove(record: T) {
try {
val id = getIdField()[record] as Long?
db.delete(getTableName(), "${getIdName()}=?", id.toString())
getIdField()[record] = null
} catch (e: Exception) {
throw RuntimeException(e)
}
}
private fun cursorToMultipleRecords(c: Cursor): List<T> {
val records: MutableList<T> = LinkedList()
while (c.moveToNext()) records.add(cursorToSingleRecord(c))
return records
}
@Suppress("UNCHECKED_CAST")
private fun cursorToSingleRecord(cursor: Cursor): T {
return try {
val constructor = klass.declaredConstructors[0]
constructor.isAccessible = true
val record = constructor.newInstance() as T
var index = 0
for (field in getFields()) copyFieldFromCursor(record, field, cursor, index++)
record
} catch (e: Exception) {
throw RuntimeException(e)
}
}
private fun copyFieldFromCursor(record: T, field: Field, c: Cursor, index: Int) {
when {
field.type.isAssignableFrom(java.lang.Integer::class.java) -> field[record] = c.getInt(index)
field.type.isAssignableFrom(java.lang.Long::class.java) -> field[record] = c.getLong(index)
field.type.isAssignableFrom(java.lang.Double::class.java) -> field[record] = c.getDouble(index)
field.type.isAssignableFrom(java.lang.String::class.java) -> field[record] = c.getString(index)
else -> throw RuntimeException("Type not supported: ${field.type.name} ${field.name}")
}
}
private fun buildSelectQuery(): String {
return String.format("select %s from %s ", StringUtils.join(getColumnNames(), ", "), getTableName())
}
private val fieldColumnPairs: List<Pair<Field, Column>>
get() {
val fields: MutableList<Pair<Field, Column>> = ArrayList()
for (f in klass.declaredFields) {
for (annotation in f.annotations) {
if (annotation !is Column) continue
fields.add(ImmutablePair(f, annotation))
}
}
return fields
}
private var cacheFields: Array<Field>? = null
private fun getFields(): Array<Field> {
if (cacheFields == null) {
val fields: MutableList<Field> = ArrayList()
val columns = fieldColumnPairs
for (pair in columns) fields.add(pair.left)
cacheFields = fields.toTypedArray()
}
return cacheFields!!
}
private var cacheColumnNames: Array<String>? = null
private fun getColumnNames(): Array<String> {
if (cacheColumnNames == null) {
val names: MutableList<String> = ArrayList()
val columns = fieldColumnPairs
for (pair in columns) {
var cname = pair.right.name
if (cname.isEmpty()) cname = pair.left.name
if (names.contains(cname)) throw RuntimeException("duplicated column : $cname")
names.add(cname)
}
cacheColumnNames = names.toTypedArray()
}
return cacheColumnNames!!
}
private var cacheTableName: String? = null
private fun getTableName(): String {
if (cacheTableName == null) {
val name = getTableAnnotation().name
if (name.isEmpty()) throw RuntimeException("Table name is empty")
cacheTableName = name
}
return cacheTableName!!
}
private var cacheIdName: String? = null
private fun getIdName(): String {
if (cacheIdName == null) {
val id = getTableAnnotation().id
if (id.isEmpty()) throw RuntimeException("Table id is empty")
cacheIdName = id
}
return cacheIdName!!
}
private var cacheIdField: Field? = null
private fun getIdField(): Field {
if (cacheIdField == null) {
val fields = getFields()
val idName = getIdName()
for (f in fields) if (f.name == idName) {
cacheIdField = f
break
}
if (cacheIdField == null) throw RuntimeException("Field not found: $idName")
}
return cacheIdField!!
}
private fun getTableAnnotation(): Table {
var t: Table? = null
for (annotation in klass.annotations) {
if (annotation !is Table) continue
t = annotation
break
}
if (t == null) throw RuntimeException("Table annotation not found")
return t
}
}

View File

@@ -0,0 +1,131 @@
/*
* Copyright (C) 2014 Markus Pfeiffer
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.isoron.uhabits.core.database
import java.io.BufferedInputStream
import java.io.InputStream
import java.util.ArrayList
internal class Tokenizer(
private val mStream: InputStream,
) {
private var mIsNext = false
private var mCurrent = 0
operator fun hasNext(): Boolean {
if (!mIsNext) {
mIsNext = true
mCurrent = mStream.read()
}
return mCurrent != -1
}
operator fun next(): Int {
if (!mIsNext) {
mCurrent = mStream.read()
}
mIsNext = false
return mCurrent
}
fun skip(s: String?): Boolean {
if (s == null || s.isEmpty()) {
return false
}
if (s[0].toInt() != mCurrent) {
return false
}
val len = s.length
mStream.mark(len - 1)
for (n in 1 until len) {
val value = mStream.read()
if (value != s[n].toInt()) {
mStream.reset()
return false
}
}
return true
}
}
object SQLParser {
private const val STATE_NONE = 0
private const val STATE_STRING = 1
private const val STATE_COMMENT = 2
private const val STATE_COMMENT_BLOCK = 3
fun parse(stream: InputStream): List<String> {
val buffer = BufferedInputStream(stream)
val commands: MutableList<String> = ArrayList()
val sb = StringBuffer()
try {
val tokenizer = Tokenizer(buffer)
var state = STATE_NONE
while (tokenizer.hasNext()) {
val c = tokenizer.next().toChar()
if (state == STATE_COMMENT_BLOCK) {
if (tokenizer.skip("*/")) {
state = STATE_NONE
}
continue
} else if (state == STATE_COMMENT) {
if (isNewLine(c)) {
state = STATE_NONE
}
continue
} else if (state == STATE_NONE && tokenizer.skip("/*")) {
state = STATE_COMMENT_BLOCK
continue
} else if (state == STATE_NONE && tokenizer.skip("--")) {
state = STATE_COMMENT
continue
} else if (state == STATE_NONE && c == ';') {
val command = sb.toString().trim { it <= ' ' }
commands.add(command)
sb.setLength(0)
continue
} else if (state == STATE_NONE && c == '\'') {
state = STATE_STRING
} else if (state == STATE_STRING && c == '\'') {
state = STATE_NONE
}
if (state == STATE_NONE || state == STATE_STRING) {
if (state == STATE_NONE && isWhitespace(c)) {
if (sb.length > 0 && sb[sb.length - 1] != ' ') {
sb.append(' ')
}
} else {
sb.append(c)
}
}
}
} finally {
buffer.close()
}
if (sb.isNotEmpty()) {
commands.add(sb.toString().trim { it <= ' ' })
}
return commands
}
private fun isNewLine(c: Char): Boolean {
return c == '\r' || c == '\n'
}
private fun isWhitespace(c: Char): Boolean {
return c == '\r' || c == '\n' || c == '\t' || c == ' '
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.database
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Table(val name: String, val id: String = "id")

View File

@@ -0,0 +1,21 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.database
class UnsupportedDatabaseVersionException : RuntimeException()

View File

@@ -0,0 +1,26 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.io
import java.io.File
abstract class AbstractImporter {
abstract fun canHandle(file: File): Boolean
abstract fun importHabitsFromFile(file: File)
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.io
import java.io.File
import javax.inject.Inject
/**
* A GenericImporter decides which implementation of AbstractImporter is able to
* handle a given file and delegates to it the task of importing the data.
*/
class GenericImporter
@Inject constructor(
loopDBImporter: LoopDBImporter,
rewireDBImporter: RewireDBImporter,
tickmateDBImporter: TickmateDBImporter,
habitBullCSVImporter: HabitBullCSVImporter,
) : AbstractImporter() {
var importers: List<AbstractImporter> = listOf(
loopDBImporter,
rewireDBImporter,
tickmateDBImporter,
habitBullCSVImporter,
)
override fun canHandle(file: File): Boolean {
for (importer in importers) {
if (importer.canHandle(file)) {
return true
}
}
return false
}
override fun importHabitsFromFile(file: File) {
for (importer in importers) {
if (importer.canHandle(file)) {
importer.importHabitsFromFile(file)
}
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.io
import com.opencsv.CSVReader
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Frequency
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.utils.DateUtils
import java.io.BufferedReader
import java.io.File
import java.io.FileReader
import java.util.HashMap
import javax.inject.Inject
/**
* Class that imports data from HabitBull CSV files.
*/
class HabitBullCSVImporter
@Inject constructor(
private val habitList: HabitList,
private val modelFactory: ModelFactory,
) : AbstractImporter() {
override fun canHandle(file: File): Boolean {
val reader = BufferedReader(FileReader(file))
val line = reader.readLine()
return line.startsWith("HabitName,HabitDescription,HabitCategory")
}
override fun importHabitsFromFile(file: File) {
val reader = CSVReader(FileReader(file))
val map = HashMap<String, Habit>()
for (line in reader) {
val name = line[0]
if (name == "HabitName") continue
val description = line[1]
val dateString = line[3].split("-").toTypedArray()
val year = dateString[0].toInt()
val month = dateString[1].toInt()
val day = dateString[2].toInt()
val date = DateUtils.getStartOfTodayCalendar()
date[year, month - 1] = day
val timestamp = Timestamp(date.timeInMillis)
val value = line[4].toInt()
if (value != 1) continue
var h = map[name]
if (h == null) {
h = modelFactory.buildHabit()
h.name = name
h.description = description ?: ""
h.frequency = Frequency.DAILY
habitList.add(h)
map[name] = h
}
h.originalEntries.add(Entry(timestamp, Entry.YES_MANUAL))
}
}
}

View File

@@ -0,0 +1,232 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.io
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.EntryList
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.Score
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.utils.DateFormats
import org.isoron.uhabits.core.utils.DateUtils
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.FileWriter
import java.io.IOException
import java.io.Writer
import java.util.ArrayList
import java.util.LinkedList
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Class that exports the application data to CSV files.
*/
class HabitsCSVExporter(
private val allHabits: HabitList,
private val selectedHabits: List<Habit>,
dir: File
) {
private val generatedDirs = LinkedList<String>()
private val generatedFilenames = LinkedList<String>()
private val exportDirName: String = dir.absolutePath + "/"
private val delimiter = ","
fun writeArchive(): String {
writeHabits()
val zipFilename = writeZipFile()
cleanup()
return zipFilename
}
private fun addFileToZip(zos: ZipOutputStream, filename: String) {
val fis = FileInputStream(File(exportDirName + filename))
val ze = ZipEntry(filename)
zos.putNextEntry(ze)
var length: Int
val bytes = ByteArray(1024)
while (fis.read(bytes).also { length = it } >= 0) zos.write(bytes, 0, length)
zos.closeEntry()
fis.close()
}
private fun cleanup() {
for (filename in generatedFilenames) File(exportDirName + filename).delete()
for (filename in generatedDirs) File(exportDirName + filename).delete()
File(exportDirName).delete()
}
private fun sanitizeFilename(name: String): String {
val s = name.replace("[^ a-zA-Z0-9\\._-]+".toRegex(), "")
return s.substring(0, Math.min(s.length, 100))
}
private fun writeHabits() {
val filename = "Habits.csv"
File(exportDirName).mkdirs()
val out = FileWriter(exportDirName + filename)
generatedFilenames.add(filename)
allHabits.writeCSV(out)
out.close()
for (h in selectedHabits) {
val sane = sanitizeFilename(h.name)
var habitDirName = String.format(Locale.US, "%03d %s", allHabits.indexOf(h) + 1, sane)
habitDirName = habitDirName.trim() + "/"
File(exportDirName + habitDirName).mkdirs()
generatedDirs.add(habitDirName)
writeScores(habitDirName, h)
writeEntries(habitDirName, h.computedEntries)
}
writeMultipleHabits()
}
private fun writeScores(habitDirName: String, habit: Habit) {
val path = habitDirName + "Scores.csv"
val out = FileWriter(exportDirName + path)
generatedFilenames.add(path)
val dateFormat = DateFormats.getCSVDateFormat()
val today = DateUtils.getTodayWithOffset()
var oldest = today
val known = habit.computedEntries.getKnown()
if (known.isNotEmpty()) oldest = known[known.size - 1].timestamp
for ((timestamp1, value) in habit.scores.getByInterval(oldest, today)) {
val timestamp = dateFormat.format(timestamp1.unixTime)
val score = String.format(Locale.US, "%.4f", value)
out.write(String.format("%s,%s\n", timestamp, score))
}
out.close()
}
private fun writeEntries(habitDirName: String, entries: EntryList) {
val filename = habitDirName + "Checkmarks.csv"
val out = FileWriter(exportDirName + filename)
generatedFilenames.add(filename)
val dateFormat = DateFormats.getCSVDateFormat()
for ((timestamp, value) in entries.getKnown()) {
val date = dateFormat.format(timestamp.toJavaDate())
out.write(String.format(Locale.US, "%s,%d\n", date, value))
}
out.close()
}
/**
* Writes a scores file and a checkmarks file containing scores and checkmarks of every habit.
* The first column corresponds to the date. Subsequent columns correspond to a habit.
* Habits are taken from the list of selected habits.
* Dates are determined from the oldest repetition date to the newest repetition date found in
* the list of habits.
*/
private fun writeMultipleHabits() {
val scoresFileName = "Scores.csv"
val checksFileName = "Checkmarks.csv"
generatedFilenames.add(scoresFileName)
generatedFilenames.add(checksFileName)
val scoresWriter = FileWriter(exportDirName + scoresFileName)
val checksWriter = FileWriter(exportDirName + checksFileName)
writeMultipleHabitsHeader(scoresWriter)
writeMultipleHabitsHeader(checksWriter)
val timeframe = getTimeframe()
val oldest = timeframe[0]
val newest = DateUtils.getToday()
val checkmarks: MutableList<ArrayList<Entry>> = ArrayList()
val scores: MutableList<ArrayList<Score>> = ArrayList()
for (habit in selectedHabits) {
checkmarks.add(ArrayList(habit.computedEntries.getByInterval(oldest, newest)))
scores.add(ArrayList(habit.scores.getByInterval(oldest, newest)))
}
val days = oldest.daysUntil(newest)
val dateFormat = DateFormats.getCSVDateFormat()
for (i in 0..days) {
val day = newest.minus(i).toJavaDate()
val date = dateFormat.format(day)
val sb = StringBuilder()
sb.append(date).append(delimiter)
checksWriter.write(sb.toString())
scoresWriter.write(sb.toString())
for (j in selectedHabits.indices) {
checksWriter.write(checkmarks[j][i].toString())
checksWriter.write(delimiter)
val score = String.format(Locale.US, "%.4f", scores[j][i].value)
scoresWriter.write(score)
scoresWriter.write(delimiter)
}
checksWriter.write("\n")
scoresWriter.write("\n")
}
scoresWriter.close()
checksWriter.close()
}
/**
* Writes the first row, containing header information, using the given writer.
* This consists of the date title and the names of the selected habits.
*
* @param out the writer to use
* @throws IOException if there was a problem writing
*/
@Throws(IOException::class)
private fun writeMultipleHabitsHeader(out: Writer) {
out.write("Date$delimiter")
for (habit in selectedHabits) {
out.write(habit.name)
out.write(delimiter)
}
out.write("\n")
}
/**
* Gets the overall timeframe of the selected habits.
* The timeframe is an array containing the oldest timestamp among the habits and the
* newest timestamp among the habits.
* Both timestamps are in milliseconds.
*
* @return the timeframe containing the oldest timestamp and the newest timestamp
*/
private fun getTimeframe(): Array<Timestamp> {
var oldest = Timestamp.ZERO.plus(1000000)
var newest = Timestamp.ZERO
for (habit in selectedHabits) {
val entries = habit.originalEntries.getKnown()
if (entries.isEmpty()) continue
val currNew = entries[0].timestamp
val currOld = entries[entries.size - 1].timestamp
oldest = if (currOld.isOlderThan(oldest)) currOld else oldest
newest = if (currNew.isNewerThan(newest)) currNew else newest
}
return arrayOf(oldest, newest)
}
private fun writeZipFile(): String {
val dateFormat = DateFormats.getCSVDateFormat()
val date = dateFormat.format(DateUtils.getStartOfToday())
val zipFilename = String.format("%s/Loop Habits CSV %s.zip", exportDirName, date)
val fos = FileOutputStream(zipFilename)
val zos = ZipOutputStream(fos)
for (filename in generatedFilenames) addFileToZip(zos, filename)
zos.close()
fos.close()
return zipFilename
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.io
interface Logging {
fun getLogger(name: String): Logger
}
interface Logger {
fun info(msg: String)
fun debug(msg: String)
fun error(msg: String)
fun error(exception: Exception)
}
class StandardLogging : Logging {
override fun getLogger(name: String): Logger {
return StandardLogger(name)
}
}
class StandardLogger(val name: String) : Logger {
override fun info(msg: String) {
println("[$name] $msg")
}
override fun debug(msg: String) {
println("[$name] $msg")
}
override fun error(msg: String) {
println("[$name] $msg")
}
override fun error(exception: Exception) {
exception.printStackTrace()
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.io
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.DATABASE_VERSION
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.CreateRepetitionCommand
import org.isoron.uhabits.core.commands.EditHabitCommand
import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.database.MigrationHelper
import org.isoron.uhabits.core.database.Repository
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.models.sqlite.records.EntryRecord
import org.isoron.uhabits.core.models.sqlite.records.HabitRecord
import org.isoron.uhabits.core.utils.isSQLite3File
import java.io.File
import javax.inject.Inject
/**
* Class that imports data from database files exported by Loop Habit Tracker.
*/
class LoopDBImporter
@Inject constructor(
@AppScope val habitList: HabitList,
@AppScope val modelFactory: ModelFactory,
@AppScope val opener: DatabaseOpener,
@AppScope val runner: CommandRunner,
@AppScope logging: Logging,
) : AbstractImporter() {
private val logger = logging.getLogger("LoopDBImporter")
override fun canHandle(file: File): Boolean {
if (!file.isSQLite3File()) return false
val db = opener.open(file)!!
var canHandle = true
val c = db.query("select count(*) from SQLITE_MASTER where name='Habits' or name='Repetitions'")
if (!c.moveToNext() || c.getInt(0) != 2) {
logger.error("Cannot handle file: tables not found")
canHandle = false
}
if (db.version > DATABASE_VERSION) {
logger.error("Cannot handle file: incompatible version: ${db.version} > $DATABASE_VERSION")
canHandle = false
}
c.close()
db.close()
return canHandle
}
override fun importHabitsFromFile(file: File) {
val db = opener.open(file)!!
val helper = MigrationHelper(db)
helper.migrateTo(DATABASE_VERSION)
val habitsRepository = Repository(HabitRecord::class.java, db)
val entryRepository = Repository(EntryRecord::class.java, db)
for (habitRecord in habitsRepository.findAll("order by position")) {
var habit = habitList.getByUUID(habitRecord.uuid)
val entryRecords = entryRepository.findAll("where habit = ?", habitRecord.id.toString())
var command: Command
if (habit == null) {
habit = modelFactory.buildHabit()
habitRecord.id = null
habitRecord.copyTo(habit)
command = CreateHabitCommand(modelFactory, habitList, habit)
command.run()
} else {
val modified = modelFactory.buildHabit()
habitRecord.id = habit.id
habitRecord.copyTo(modified)
command = EditHabitCommand(habitList, habit.id!!, modified)
command.run()
}
// Reload saved version of the habit
habit = habitList.getByUUID(habitRecord.uuid)
for (r in entryRecords) {
val t = Timestamp(r.timestamp)
val (_, value) = habit!!.originalEntries.get(t)
if (value != r.value) CreateRepetitionCommand(habitList, habit, t, r.value).run()
}
runner.notifyListeners(command)
}
db.close()
}
}

View File

@@ -0,0 +1,171 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.io
import org.isoron.uhabits.core.database.Cursor
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Frequency
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.Reminder
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.models.WeekdayList
import org.isoron.uhabits.core.utils.DateUtils
import org.isoron.uhabits.core.utils.isSQLite3File
import java.io.File
import javax.inject.Inject
/**
* Class that imports database files exported by Rewire.
*/
class RewireDBImporter
@Inject constructor(
private val habitList: HabitList,
private val modelFactory: ModelFactory,
private val opener: DatabaseOpener
) : AbstractImporter() {
override fun canHandle(file: File): Boolean {
if (!file.isSQLite3File()) return false
val db = opener.open(file)
val c = db.query(
"select count(*) from SQLITE_MASTER " +
"where name='CHECKINS' or name='UNIT'"
)
val result = c.moveToNext() && c.getInt(0) == 2
c.close()
db.close()
return result
}
override fun importHabitsFromFile(file: File) {
val db = opener.open(file)
db.beginTransaction()
createHabits(db)
db.setTransactionSuccessful()
db.endTransaction()
db.close()
}
private fun createHabits(db: Database) {
var c: Cursor? = null
try {
c = db.query(
"select _id, name, description, schedule, " +
"active_days, repeating_count, days, period " +
"from habits"
)
if (!c.moveToNext()) return
do {
val id = c.getInt(0)!!
val name = c.getString(1)
val description = c.getString(2)
val schedule = c.getInt(3)!!
val activeDays = c.getString(4)
val repeatingCount = c.getInt(5)!!
val days = c.getInt(6)!!
val periodIndex = c.getInt(7)!!
val habit = modelFactory.buildHabit()
habit.name = name!!
habit.description = description ?: ""
val periods = intArrayOf(7, 31, 365)
var numerator: Int
var denominator: Int
when (schedule) {
0 -> {
numerator = activeDays!!.split(",").toTypedArray().size
denominator = 7
}
1 -> {
numerator = days
denominator = periods[periodIndex]
}
2 -> {
numerator = 1
denominator = repeatingCount
}
else -> throw IllegalStateException()
}
habit.frequency = Frequency(numerator, denominator)
habitList.add(habit)
createReminder(db, habit, id)
createCheckmarks(db, habit, id)
} while (c.moveToNext())
} finally {
c?.close()
}
}
private fun createCheckmarks(
db: Database,
habit: Habit,
rewireHabitId: Int
) {
var c: Cursor? = null
try {
c = db.query(
"select distinct date from checkins where habit_id=? and type=2",
rewireHabitId.toString(),
)
if (!c.moveToNext()) return
do {
val date = c.getString(0)
val year = date!!.substring(0, 4).toInt()
val month = date.substring(4, 6).toInt()
val day = date.substring(6, 8).toInt()
val cal = DateUtils.getStartOfTodayCalendar()
cal[year, month - 1] = day
habit.originalEntries.add(Entry(Timestamp(cal), Entry.YES_MANUAL))
} while (c.moveToNext())
} finally {
c?.close()
}
}
private fun createReminder(db: Database, habit: Habit, rewireHabitId: Int) {
var c: Cursor? = null
try {
c = db.query(
"select time, active_days from reminders where habit_id=? limit 1",
rewireHabitId.toString(),
)
if (!c.moveToNext()) return
val rewireReminder = c.getInt(0)!!
if (rewireReminder <= 0 || rewireReminder >= 1440) return
val reminderDays = BooleanArray(7)
val activeDays = c.getString(1)!!.split(",").toTypedArray()
for (d in activeDays) {
val idx = (d.toInt() + 1) % 7
reminderDays[idx] = true
}
val hour = rewireReminder / 60
val minute = rewireReminder % 60
val days = WeekdayList(reminderDays)
val reminder = Reminder(hour, minute, days)
habit.reminder = reminder
habitList.update(habit)
} finally {
c?.close()
}
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.io
import org.isoron.uhabits.core.database.Cursor
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Frequency
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.utils.DateUtils
import org.isoron.uhabits.core.utils.isSQLite3File
import java.io.File
import javax.inject.Inject
/**
* Class that imports data from database files exported by Tickmate.
*/
class TickmateDBImporter @Inject constructor(
private val habitList: HabitList,
private val modelFactory: ModelFactory,
private val opener: DatabaseOpener
) : AbstractImporter() {
override fun canHandle(file: File): Boolean {
if (!file.isSQLite3File()) return false
val db = opener.open(file)
val c = db.query(
"select count(*) from SQLITE_MASTER " +
"where name='tracks' or name='track2groups'"
)
val result = c.moveToNext() && c.getInt(0) == 2
c.close()
db.close()
return result
}
override fun importHabitsFromFile(file: File) {
val db = opener.open(file)
db.beginTransaction()
createHabits(db)
db.setTransactionSuccessful()
db.endTransaction()
db.close()
}
private fun createCheckmarks(
db: Database,
habit: Habit,
tickmateTrackId: Int
) {
var c: Cursor? = null
try {
c = db.query(
"select distinct year, month, day from ticks where _track_id=?",
tickmateTrackId.toString(),
)
if (!c.moveToNext()) return
do {
val year = c.getInt(0)!!
val month = c.getInt(1)!!
val day = c.getInt(2)!!
val cal = DateUtils.getStartOfTodayCalendar()
cal[year, month] = day
habit.originalEntries.add(Entry(Timestamp(cal), Entry.YES_MANUAL))
} while (c.moveToNext())
} finally {
c?.close()
}
}
private fun createHabits(db: Database) {
var c: Cursor? = null
try {
c = db.query("select _id, name, description from tracks")
if (!c.moveToNext()) return
do {
val id = c.getInt(0)!!
val name = c.getString(1)
val description = c.getString(2)
val habit = modelFactory.buildHabit()
habit.name = name!!
habit.description = description ?: ""
habit.frequency = Frequency.DAILY
habitList.add(habit)
createCheckmarks(db, habit, id)
} while (c.moveToNext())
} finally {
c?.close()
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
data class Entry(
val timestamp: Timestamp,
val value: Int,
) {
companion object {
/**
* Value indicating that the habit is not applicable for this timestamp.
*/
const val SKIP = 3
/**
* Value indicating that the user has performed the habit at this timestamp.
*/
const val YES_MANUAL = 2
/**
* Value indicating that the user did not perform the habit, but they were not
* expected to, because of the frequency of the habit.
*/
const val YES_AUTO = 1
/**
* Value indicating that the user did not perform the habit, even though they were
* expected to perform it.
*/
const val NO = 0
/**
* Value indicating that no data is available for the given timestamp.
*/
const val UNKNOWN = -1
fun nextToggleValueWithSkip(value: Int): Int {
return when (value) {
NO, UNKNOWN, YES_AUTO -> YES_MANUAL
YES_MANUAL -> SKIP
SKIP -> NO
else -> NO
}
}
fun nextToggleValueWithoutSkip(value: Int): Int {
return when (value) {
NO, UNKNOWN, YES_AUTO -> YES_MANUAL
else -> NO
}
}
}
}

View File

@@ -0,0 +1,297 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
import org.isoron.uhabits.core.models.Entry.Companion.SKIP
import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN
import org.isoron.uhabits.core.models.Entry.Companion.YES_AUTO
import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL
import org.isoron.uhabits.core.utils.DateUtils
import java.util.ArrayList
import java.util.Calendar
import javax.annotation.concurrent.ThreadSafe
import kotlin.collections.set
import kotlin.math.max
import kotlin.math.min
@ThreadSafe
open class EntryList {
private val entriesByTimestamp: HashMap<Timestamp, Entry> = HashMap()
/**
* Returns the entry corresponding to the given timestamp. If no entry with such timestamp
* has been previously added, returns Entry(timestamp, UNKNOWN).
*/
@Synchronized
open fun get(timestamp: Timestamp): Entry {
return entriesByTimestamp[timestamp] ?: Entry(timestamp, UNKNOWN)
}
/**
* Returns one entry for each day in the given interval. The first element corresponds to the
* newest entry, and the last element corresponds to the oldest. The interval endpoints are
* included.
*/
@Synchronized
open fun getByInterval(from: Timestamp, to: Timestamp): List<Entry> {
val result = mutableListOf<Entry>()
if (from.isNewerThan(to)) return result
var current = to
while (current >= from) {
result.add(get(current))
current = current.minus(1)
}
return result
}
/**
* Adds the given entry to the list. If another entry with the same timestamp already exists,
* replaces it.
*/
@Synchronized
open fun add(entry: Entry) {
entriesByTimestamp[entry.timestamp] = entry
}
/**
* Returns all entries whose values are known, sorted by timestamp. The first element
* corresponds to the newest entry, and the last element corresponds to the oldest.
*/
@Synchronized
open fun getKnown(): List<Entry> {
return entriesByTimestamp.values.sortedBy { it.timestamp }.reversed()
}
/**
* Replaces all entries in this list by entries computed automatically from another list.
*
* For boolean habits, this function creates additional entries (with value YES_AUTO) according
* to the frequency of the habit. For numerical habits, this function simply copies all entries.
*/
@Synchronized
open fun recomputeFrom(
originalEntries: EntryList,
frequency: Frequency,
isNumerical: Boolean,
) {
clear()
val original = originalEntries.getKnown()
if (isNumerical) {
original.forEach { add(it) }
} else {
val intervals = buildIntervals(frequency, original)
snapIntervalsTogether(intervals)
val computed = buildEntriesFromInterval(original, intervals)
computed.filter { it.value != UNKNOWN }.forEach { add(it) }
}
}
/**
* Removes all known entries.
*/
@Synchronized
open fun clear() {
entriesByTimestamp.clear()
}
/**
* Returns the total number of successful entries for each month, grouped by day of week.
* <p>
* The checkmarks are returned in a HashMap. The key is the timestamp for
* the first day of the month, at midnight (00:00). The value is an integer
* array with 7 entries. The first entry contains the total number of
* successful checkmarks during the specified month that occurred on a Saturday. The
* second entry corresponds to Sunday, and so on. If there are no
* successful checkmarks during a certain month, the value is null.
*
* @return total number of checkmarks by month versus day of week
*/
@Synchronized
fun computeWeekdayFrequency(isNumerical: Boolean): HashMap<Timestamp, Array<Int>> {
val entries = getKnown()
val map = hashMapOf<Timestamp, Array<Int>>()
for ((originalTimestamp, value) in entries) {
val weekday = originalTimestamp.weekday
val truncatedTimestamp = Timestamp(
originalTimestamp.toCalendar().apply {
set(Calendar.DAY_OF_MONTH, 1)
}.timeInMillis
)
var list = map[truncatedTimestamp]
if (list == null) {
list = arrayOf(0, 0, 0, 0, 0, 0, 0)
map[truncatedTimestamp] = list
}
if (isNumerical) {
list[weekday] += value
} else if (value == YES_MANUAL) {
list[weekday] += 1
}
}
return map
}
data class Interval(val begin: Timestamp, val center: Timestamp, val end: Timestamp) {
val length: Int
get() = begin.daysUntil(end) + 1
}
/**
* Converts a list of intervals into a list of entries. Entries that fall outside of any
* interval receive value UNKNOWN. Entries that fall within an interval but do not appear
* in [original] receive value YES_AUTO. Entries provided in [original] are copied over.
*
* The intervals should be sorted by timestamp. The first element in the list should
* correspond to the newest interval.
*/
companion object {
fun buildEntriesFromInterval(
original: List<Entry>,
intervals: List<Interval>,
): List<Entry> {
val result = arrayListOf<Entry>()
if (original.isEmpty()) return result
var from = original[0].timestamp
var to = original[0].timestamp
for (e in original) {
if (e.timestamp < from) from = e.timestamp
if (e.timestamp > to) to = e.timestamp
}
for (interval in intervals) {
if (interval.begin < from) from = interval.begin
if (interval.end > to) to = interval.end
}
// Create unknown entries
var current = to
while (current >= from) {
result.add(Entry(current, UNKNOWN))
current = current.minus(1)
}
// Create YES_AUTO entries
intervals.forEach { interval ->
current = interval.end
while (current >= interval.begin) {
val offset = current.daysUntil(to)
result[offset] = Entry(current, YES_AUTO)
current = current.minus(1)
}
}
// Copy original entries
original.forEach { entry ->
val offset = entry.timestamp.daysUntil(to)
if (result[offset].value == UNKNOWN || entry.value == SKIP || entry.value == YES_MANUAL) {
result[offset] = entry
}
}
return result
}
/**
* Starting from the second newest interval, this function tries to slide the
* intervals backwards into the past, so that gaps are eliminated and
* streaks are maximized.
*
* The intervals should be sorted by timestamp. The first element in the list should
* correspond to the newest interval.
*/
fun snapIntervalsTogether(intervals: ArrayList<Interval>) {
for (i in 1 until intervals.size) {
val curr = intervals[i]
val next = intervals[i - 1]
val gapNextToCurrent = next.begin.daysUntil(curr.end)
val gapCenterToEnd = curr.center.daysUntil(curr.end)
if (gapNextToCurrent >= 0) {
val shift = min(gapCenterToEnd, gapNextToCurrent + 1)
intervals[i] = Interval(
curr.begin.minus(shift),
curr.center,
curr.end.minus(shift)
)
}
}
}
fun buildIntervals(
freq: Frequency,
entries: List<Entry>,
): ArrayList<Interval> {
val filtered = entries.filter { it.value == YES_MANUAL }
val num = freq.numerator
val den = freq.denominator
val intervals = arrayListOf<Interval>()
for (i in num - 1 until filtered.size) {
val (begin, _) = filtered[i]
val (center, _) = filtered[i - num + 1]
if (begin.daysUntil(center) < den) {
val end = begin.plus(den - 1)
intervals.add(Interval(begin, center, end))
}
}
return intervals
}
}
}
/**
* Given a list of entries, truncates the timestamp of each entry (according to the field given),
* groups the entries according to this truncated timestamp, then creates a new entry (t,v) for
* each group, where t is the truncated timestamp and v is the sum of the values of all entries in
* the group.
*
* For numerical habits, non-positive entry values are converted to zero. For boolean habits, each
* YES_MANUAL value is converted to 1000 and all other values are converted to zero.
*
* The returned list is sorted by timestamp, with the newest entry coming first and the oldest entry
* coming last. If the original list has gaps in it (for example, weeks or months without any
* entries), then the list produced by this method will also have gaps.
*
* The argument [firstWeekday] is only relevant when truncating by week.
*/
fun List<Entry>.groupedSum(
truncateField: DateUtils.TruncateField,
firstWeekday: Int = Calendar.SATURDAY,
isNumerical: Boolean,
): List<Entry> {
return this.map { (timestamp, value) ->
if (isNumerical) {
Entry(timestamp, max(0, value))
} else {
Entry(timestamp, if (value == YES_MANUAL) 1000 else 0)
}
}.groupBy { entry ->
entry.timestamp.truncate(
truncateField,
firstWeekday,
)
}.entries.map { (timestamp, entries) ->
Entry(timestamp, entries.sumOf { it.value })
}.sortedBy { (timestamp, _) ->
- timestamp.unixTime
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
data class Frequency(
var numerator: Int,
var denominator: Int,
) {
init {
if (numerator == denominator) {
denominator = 1
numerator = 1
}
}
fun toDouble(): Double {
return numerator.toDouble() / denominator
}
companion object {
@JvmField
val DAILY = Frequency(1, 1)
@JvmField
val THREE_TIMES_PER_WEEK = Frequency(3, 7)
@JvmField
val TWO_TIMES_PER_WEEK = Frequency(2, 7)
@JvmField
val WEEKLY = Frequency(1, 7)
}
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
import org.isoron.uhabits.core.utils.DateUtils
import java.util.UUID
data class Habit(
var color: PaletteColor = PaletteColor(8),
var description: String = "",
var frequency: Frequency = Frequency.DAILY,
var id: Long? = null,
var isArchived: Boolean = false,
var name: String = "",
var position: Int = 0,
var question: String = "",
var reminder: Reminder? = null,
var targetType: Int = AT_LEAST,
var targetValue: Double = 0.0,
var type: Int = YES_NO_HABIT,
var unit: String = "",
var uuid: String? = null,
val computedEntries: EntryList,
val originalEntries: EntryList,
val scores: ScoreList,
val streaks: StreakList,
) {
init {
if (uuid == null) this.uuid = UUID.randomUUID().toString().replace("-", "")
}
var observable = ModelObservable()
val isNumerical: Boolean
get() = type == NUMBER_HABIT
val uriString: String
get() = "content://org.isoron.uhabits/habit/$id"
fun hasReminder(): Boolean = reminder != null
fun isCompletedToday(): Boolean {
val today = DateUtils.getTodayWithOffset()
val value = computedEntries.get(today).value
return if (isNumerical) {
if (targetType == AT_LEAST) {
value / 1000.0 >= targetValue
} else {
value / 1000.0 <= targetValue
}
} else {
value != Entry.NO && value != Entry.UNKNOWN
}
}
fun recompute() {
computedEntries.recomputeFrom(
originalEntries = originalEntries,
frequency = frequency,
isNumerical = isNumerical,
)
val to = DateUtils.getTodayWithOffset().plus(30)
val entries = computedEntries.getKnown()
var from = entries.lastOrNull()?.timestamp ?: to
if (from.isNewerThan(to)) from = to
scores.recompute(
frequency = frequency,
isNumerical = isNumerical,
targetValue = targetValue,
computedEntries = computedEntries,
from = from,
to = to,
)
streaks.recompute(
computedEntries,
from,
to,
)
}
fun copyFrom(other: Habit) {
this.color = other.color
this.description = other.description
this.frequency = other.frequency
// this.id should not be copied
this.isArchived = other.isArchived
this.name = other.name
this.position = other.position
this.question = other.question
this.reminder = other.reminder
this.targetType = other.targetType
this.targetValue = other.targetValue
this.type = other.type
this.unit = other.unit
this.uuid = other.uuid
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Habit) return false
if (color != other.color) return false
if (description != other.description) return false
if (frequency != other.frequency) return false
if (id != other.id) return false
if (isArchived != other.isArchived) return false
if (name != other.name) return false
if (position != other.position) return false
if (question != other.question) return false
if (reminder != other.reminder) return false
if (targetType != other.targetType) return false
if (targetValue != other.targetValue) return false
if (type != other.type) return false
if (unit != other.unit) return false
if (uuid != other.uuid) return false
return true
}
override fun hashCode(): Int {
var result = color.hashCode()
result = 31 * result + description.hashCode()
result = 31 * result + frequency.hashCode()
result = 31 * result + (id?.hashCode() ?: 0)
result = 31 * result + isArchived.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + position
result = 31 * result + question.hashCode()
result = 31 * result + (reminder?.hashCode() ?: 0)
result = 31 * result + targetType
result = 31 * result + targetValue.hashCode()
result = 31 * result + type
result = 31 * result + unit.hashCode()
result = 31 * result + (uuid?.hashCode() ?: 0)
return result
}
companion object {
const val AT_LEAST = 0
const val AT_MOST = 1
const val NUMBER_HABIT = 1
const val YES_NO_HABIT = 0
}
}

View File

@@ -0,0 +1,269 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models;
import androidx.annotation.*;
import com.opencsv.*;
import java.io.*;
import java.util.*;
import javax.annotation.concurrent.*;
/**
* An ordered collection of {@link Habit}s.
*/
@ThreadSafe
public abstract class HabitList implements Iterable<Habit>
{
private final ModelObservable observable;
@NonNull
protected final HabitMatcher filter;
/**
* Creates a new HabitList.
* <p>
* Depending on the implementation, this list can either be empty or be
* populated by some pre-existing habits, for example, from a certain
* database.
*/
public HabitList()
{
observable = new ModelObservable();
filter = new HabitMatcherBuilder().setArchivedAllowed(true).build();
}
protected HabitList(@NonNull HabitMatcher filter)
{
observable = new ModelObservable();
this.filter = filter;
}
/**
* Inserts a new habit in the list.
* <p>
* If the id of the habit is null, the list will assign it a new id, which
* is guaranteed to be unique in the scope of the list. If id is not null,
* the caller should make sure that the list does not already contain
* another habit with same id, otherwise a RuntimeException will be thrown.
*
* @param habit the habit to be inserted
* @throws IllegalArgumentException if the habit is already on the list.
*/
public abstract void add(@NonNull Habit habit)
throws IllegalArgumentException;
/**
* Returns the habit with specified id.
*
* @param id the id of the habit
* @return the habit, or null if none exist
*/
@Nullable
public abstract Habit getById(long id);
/**
* Returns the habit with specified UUID.
*
* @param uuid the UUID of the habit
* @return the habit, or null if none exist
*/
@Nullable
public abstract Habit getByUUID(String uuid);
/**
* Returns the habit that occupies a certain position.
*
* @param position the position of the desired habit
* @return the habit at that position
* @throws IndexOutOfBoundsException when the position is invalid
*/
@NonNull
public abstract Habit getByPosition(int position);
/**
* Returns the list of habits that match a given condition.
*
* @param matcher the matcher that checks the condition
* @return the list of matching habits
*/
@NonNull
public abstract HabitList getFiltered(HabitMatcher matcher);
public ModelObservable getObservable()
{
return observable;
}
public abstract Order getPrimaryOrder();
public abstract Order getSecondaryOrder();
/**
* Changes the order of the elements on the list.
*
* @param order the new order criterion
*/
public abstract void setPrimaryOrder(@NonNull Order order);
/**
* Changes the previous order of the elements on the list.
*
* @param order the new order criterion
*/
public abstract void setSecondaryOrder(@NonNull Order order);
/**
* Returns the index of the given habit in the list, or -1 if the list does
* not contain the habit.
*
* @param h the habit
* @return the index of the habit, or -1 if not in the list
*/
public abstract int indexOf(@NonNull Habit h);
public boolean isEmpty()
{
return size() == 0;
}
/**
* Removes the given habit from the list.
* <p>
* If the given habit is not in the list, does nothing.
*
* @param h the habit to be removed.
*/
public abstract void remove(@NonNull Habit h);
/**
* Removes all the habits from the list.
*/
public void removeAll()
{
List<Habit> copy = new LinkedList<>();
for (Habit h : this) copy.add(h);
for (Habit h : copy) remove(h);
observable.notifyListeners();
}
/**
* Changes the position of a habit in the list.
*
* @param from the habit that should be moved
* @param to the habit that currently occupies the desired position
*/
public abstract void reorder(@NonNull Habit from, @NonNull Habit to);
public void repair()
{
}
/**
* Returns the number of habits in this list.
*
* @return number of habits
*/
public abstract int size();
/**
* Notifies the list that a certain list of habits has been modified.
* <p>
* Depending on the implementation, this operation might trigger a write to
* disk, or do nothing at all. To make sure that the habits get persisted,
* this operation must be called.
*
* @param habits the list of habits that have been modified.
*/
public abstract void update(List<Habit> habits);
/**
* Notifies the list that a certain habit has been modified.
* <p>
* See {@link #update(List)} for more details.
*
* @param habit the habit that has been modified.
*/
public void update(@NonNull Habit habit)
{
update(Collections.singletonList(habit));
}
/**
* Writes the list of habits to the given writer, in CSV format. There is
* one line for each habit, containing the fields name, description,
* frequency numerator, frequency denominator and color. The color is
* written in HTML format (#000000).
*
* @param out the writer that will receive the result
* @throws IOException if write operations fail
*/
public void writeCSV(@NonNull Writer out) throws IOException
{
String header[] = {
"Position",
"Name",
"Question",
"Description",
"NumRepetitions",
"Interval",
"Color"
};
CSVWriter csv = new CSVWriter(out);
csv.writeNext(header, false);
for (Habit habit : this)
{
Frequency freq = habit.getFrequency();
String[] cols = {
String.format("%03d", indexOf(habit) + 1),
habit.getName(),
habit.getQuestion(),
habit.getDescription(),
Integer.toString(freq.getNumerator()),
Integer.toString(freq.getDenominator()),
habit.getColor().toCsvColor(),
};
csv.writeNext(cols, false);
}
csv.close();
}
public abstract void resort();
public enum Order
{
BY_NAME_ASC,
BY_NAME_DESC,
BY_COLOR_ASC,
BY_COLOR_DESC,
BY_SCORE_ASC,
BY_SCORE_DESC,
BY_STATUS_ASC,
BY_STATUS_DESC,
BY_POSITION
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
data class HabitMatcher(
val isArchivedAllowed: Boolean = false,
val isReminderRequired: Boolean = false,
val isCompletedAllowed: Boolean = true,
) {
fun matches(habit: Habit): Boolean {
if (!isArchivedAllowed && habit.isArchived) return false
if (isReminderRequired && !habit.hasReminder()) return false
if (!isCompletedAllowed && habit.isCompletedToday()) return false
return true
}
companion object {
@JvmField
val WITH_ALARM = HabitMatcherBuilder()
.setArchivedAllowed(true)
.setReminderRequired(true)
.build()
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
class HabitMatcherBuilder {
private var archivedAllowed = false
private var reminderRequired = false
private var completedAllowed = true
fun build(): HabitMatcher {
return HabitMatcher(
isArchivedAllowed = archivedAllowed,
isReminderRequired = reminderRequired,
isCompletedAllowed = completedAllowed,
)
}
fun setArchivedAllowed(archivedAllowed: Boolean): HabitMatcherBuilder {
this.archivedAllowed = archivedAllowed
return this
}
fun setCompletedAllowed(completedAllowed: Boolean): HabitMatcherBuilder {
this.completedAllowed = completedAllowed
return this
}
fun setReminderRequired(reminderRequired: Boolean): HabitMatcherBuilder {
this.reminderRequired = reminderRequired
return this
}
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
class HabitNotFoundException : RuntimeException()

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
import org.isoron.uhabits.core.database.Repository
import org.isoron.uhabits.core.models.sqlite.records.EntryRecord
import org.isoron.uhabits.core.models.sqlite.records.HabitRecord
/**
* Interface implemented by factories that provide concrete implementations of
* the core model classes.
*/
interface ModelFactory {
fun buildHabit(): Habit {
val scores = buildScoreList()
val streaks = buildStreakList()
val habit = Habit(
scores = scores,
streaks = streaks,
originalEntries = buildOriginalEntries(),
computedEntries = buildComputedEntries(),
)
return habit
}
fun buildComputedEntries(): EntryList
fun buildOriginalEntries(): EntryList
fun buildHabitList(): HabitList
fun buildScoreList(): ScoreList
fun buildStreakList(): StreakList
fun buildHabitListRepository(): Repository<HabitRecord>
fun buildRepetitionListRepository(): Repository<EntryRecord>
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models;
import java.util.*;
import javax.annotation.concurrent.*;
/**
* A ModelObservable allows objects to subscribe themselves to it and receive
* notifications whenever the model is changed.
*/
@ThreadSafe
public class ModelObservable
{
private List<Listener> listeners;
/**
* Creates a new ModelObservable with no listeners.
*/
public ModelObservable()
{
super();
listeners = new LinkedList<>();
}
/**
* Adds the given listener to the observable.
*
* @param l the listener to be added.
*/
public synchronized void addListener(Listener l)
{
listeners.add(l);
}
/**
* Notifies every listener that the model has changed.
* <p>
* Only models should call this method.
*/
public synchronized void notifyListeners()
{
for (Listener l : listeners) l.onModelChange();
}
/**
* Removes the given listener.
* <p>
* The listener will no longer be notified when the model changes. If the
* given listener is not subscribed to this observable, does nothing.
*
* @param l the listener to be removed
*/
public synchronized void removeListener(Listener l)
{
listeners.remove(l);
}
/**
* Interface implemented by objects that want to be notified when the model
* changes.
*/
public interface Listener
{
/**
* Called whenever the model associated to this observable has been
* modified.
*/
void onModelChange();
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
data class PaletteColor(val paletteIndex: Int) {
fun toCsvColor(): String {
return arrayOf(
"#D32F2F", // 0 red
"#E64A19", // 1 deep orange
"#F57C00", // 2 orange
"#FF8F00", // 3 amber
"#F9A825", // 4 yellow
"#AFB42B", // 5 lime
"#7CB342", // 6 light green
"#388E3C", // 7 green
"#00897B", // 8 teal
"#00ACC1", // 9 cyan
"#039BE5", // 10 light blue
"#1976D2", // 11 blue
"#303F9F", // 12 indigo
"#5E35B1", // 13 deep purple
"#8E24AA", // 14 purple
"#D81B60", // 15 pink
"#5D4037", // 16 brown
"#303030", // 17 dark grey
"#757575", // 18 grey
"#aaaaaa" // 19 light grey
)[paletteIndex]
}
fun compareTo(other: PaletteColor): Int {
return paletteIndex.compareTo(other.paletteIndex)
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
import org.isoron.uhabits.core.utils.DateUtils
data class Reminder(
val hour: Int,
val minute: Int,
val days: WeekdayList,
) {
val timeInMillis: Long
get() = DateUtils.getUpcomingTimeInMillis(hour, minute)
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
import kotlin.math.sqrt
data class Score(
val timestamp: Timestamp,
val value: Double,
) {
companion object {
/**
* Given the frequency of the habit, the previous score, and the value of
* the current checkmark, computes the current score for the habit.
*
* The frequency of the habit is the number of repetitions divided by the
* length of the interval. For example, a habit that should be repeated 3
* times in 8 days has frequency 3.0 / 8.0 = 0.375.
*/
@JvmStatic
fun compute(
frequency: Double,
previousScore: Double,
checkmarkValue: Double,
): Double {
val multiplier = Math.pow(0.5, sqrt(frequency) / 13.0)
var score = previousScore * multiplier
score += checkmarkValue * (1 - multiplier)
return score
}
}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
import org.isoron.uhabits.core.models.Score.Companion.compute
import java.util.ArrayList
import java.util.HashMap
import javax.annotation.concurrent.ThreadSafe
import kotlin.math.min
@ThreadSafe
class ScoreList {
private val map = HashMap<Timestamp, Score>()
/**
* Returns the score for a given day. If the timestamp given happens before the first
* repetition of the habit or after the last computed score, returns a score with value zero.
*/
@Synchronized
operator fun get(timestamp: Timestamp): Score {
return map[timestamp] ?: Score(timestamp, 0.0)
}
/**
* Returns the list of scores that fall within the given interval.
*
* There is exactly one score per day in the interval. The endpoints of the interval are
* included. The list is ordered by timestamp (decreasing). That is, the first score
* corresponds to the newest timestamp, and the last score corresponds to the oldest timestamp.
*/
@Synchronized
fun getByInterval(
fromTimestamp: Timestamp,
toTimestamp: Timestamp,
): List<Score> {
val result: MutableList<Score> = ArrayList()
if (fromTimestamp.isNewerThan(toTimestamp)) return result
var current = toTimestamp
while (!current.isOlderThan(fromTimestamp)) {
result.add(get(current))
current = current.minus(1)
}
return result
}
/**
* Recomputes all scores between the provided [from] and [to] timestamps.
*/
@Synchronized
fun recompute(
frequency: Frequency,
isNumerical: Boolean,
targetValue: Double,
computedEntries: EntryList,
from: Timestamp,
to: Timestamp,
) {
map.clear()
if (computedEntries.getKnown().isEmpty()) return
if (from.isNewerThan(to)) return
var rollingSum = 0.0
var numerator = frequency.numerator
var denominator = frequency.denominator
val freq = frequency.toDouble()
val values = computedEntries.getByInterval(from, to).map { it.value }.toIntArray()
// For non-daily boolean habits, we double the numerator and the denominator to smooth
// out irregular repetition schedules (for example, weekly habits performed on different
// days of the week)
if (!isNumerical && freq < 1.0) {
numerator *= 2
denominator *= 2
}
var previousValue = 0.0
for (i in values.indices) {
val offset = values.size - i - 1
if (isNumerical) {
rollingSum += values[offset]
if (offset + denominator < values.size) {
rollingSum -= values[offset + denominator]
}
val percentageCompleted = min(1.0, rollingSum / 1000 / targetValue)
previousValue = compute(freq, previousValue, percentageCompleted)
} else {
if (values[offset] == Entry.YES_MANUAL) {
rollingSum += 1.0
}
if (offset + denominator < values.size) {
if (values[offset + denominator] == Entry.YES_MANUAL) {
rollingSum -= 1.0
}
}
if (values[offset] != Entry.SKIP) {
val percentageCompleted = Math.min(1.0, rollingSum / numerator)
previousValue = compute(freq, previousValue, percentageCompleted)
}
}
val timestamp = from.plus(i)
map[timestamp] = Score(timestamp, previousValue)
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
import java.lang.Long.signum
data class Streak(
val start: Timestamp,
val end: Timestamp,
) {
fun compareLonger(other: Streak): Int {
return if (length != other.length) signum(length - other.length.toLong())
else compareNewer(other)
}
fun compareNewer(other: Streak): Int {
return end.compareTo(other.end)
}
val length: Int
get() = start.daysUntil(end) + 1
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
import javax.annotation.concurrent.ThreadSafe
import kotlin.math.min
@ThreadSafe
class StreakList {
private val list = ArrayList<Streak>()
@Synchronized
fun getBest(limit: Int): List<Streak> {
list.sortWith { s1: Streak, s2: Streak -> s2.compareLonger(s1) }
return list.subList(0, min(list.size, limit)).apply {
sortWith { s1: Streak, s2: Streak -> s2.compareNewer(s1) }
}.toList()
}
@Synchronized
fun recompute(
computedEntries: EntryList,
from: Timestamp,
to: Timestamp,
) {
list.clear()
val timestamps = computedEntries
.getByInterval(from, to)
.filter { it.value > 0 }
.map { it.timestamp }
.toTypedArray()
if (timestamps.isEmpty()) return
var begin = timestamps[0]
var end = timestamps[0]
for (i in 1 until timestamps.size) {
val current = timestamps[i]
if (current == begin.minus(1)) {
begin = current
} else {
list.add(Streak(begin, end))
begin = current
end = current
}
}
list.add(Streak(begin, end))
}
}

View File

@@ -0,0 +1,159 @@
/*
* Copyright (C) 2015-2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models;
import org.isoron.platform.time.LocalDate;
import org.apache.commons.lang3.builder.*;
import org.isoron.uhabits.core.utils.*;
import java.util.*;
import static java.util.Calendar.*;
public final class Timestamp implements Comparable<Timestamp> {
public static final long DAY_LENGTH = 86400000;
public static final Timestamp ZERO = new Timestamp(0);
private final long unixTime;
public static Timestamp fromLocalDate(LocalDate date) {
return new Timestamp(946684800000L + date.getDaysSince2000() * 86400000L);
}
public Timestamp(long unixTime) {
if (unixTime < 0)
throw new IllegalArgumentException(
"Invalid unix time: " + unixTime);
if (unixTime % DAY_LENGTH != 0)
unixTime = (unixTime / DAY_LENGTH) * DAY_LENGTH;
this.unixTime = unixTime;
}
public Timestamp(GregorianCalendar cal) {
this(cal.getTimeInMillis());
}
public static Timestamp from(int year, int javaMonth, int day) {
GregorianCalendar cal = DateUtils.getStartOfTodayCalendar();
cal.set(year, javaMonth, day, 0, 0, 0);
return new Timestamp(cal.getTimeInMillis());
}
public long getUnixTime() {
return unixTime;
}
public LocalDate toLocalDate() {
long millisSince2000 = unixTime - 946684800000L;
int daysSince2000 = (int) (millisSince2000 / 86400000);
return new LocalDate(daysSince2000);
}
/**
* Returns -1 if this timestamp is older than the given timestamp, 1 if this
* timestamp is newer, or zero if they are equal.
*/
@Override
public int compareTo(Timestamp other) {
return Long.signum(this.unixTime - other.unixTime);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Timestamp timestamp = (Timestamp) o;
return new EqualsBuilder()
.append(unixTime, timestamp.unixTime)
.isEquals();
}
@Override
public int hashCode() {
return new HashCodeBuilder(17, 37).append(unixTime).toHashCode();
}
/**
* Given two timestamps, returns whichever timestamp is the oldest one.
*/
public static Timestamp oldest(Timestamp first, Timestamp second) {
return first.unixTime < second.unixTime ? first : second;
}
public Timestamp minus(int days) {
return plus(-days);
}
public Timestamp plus(int days) {
return new Timestamp(unixTime + DAY_LENGTH * days);
}
/**
* Returns the number of days between this timestamp and the given one. If
* the other timestamp equals this one, returns zero. If the other timestamp
* is older than this one, returns a negative number.
*/
public int daysUntil(Timestamp other) {
return (int) ((other.unixTime - this.unixTime) / DAY_LENGTH);
}
public boolean isNewerThan(Timestamp other) {
return compareTo(other) > 0;
}
public boolean isOlderThan(Timestamp other) {
return compareTo(other) < 0;
}
public Date toJavaDate() {
return new Date(unixTime);
}
public GregorianCalendar toCalendar() {
GregorianCalendar day =
new GregorianCalendar(TimeZone.getTimeZone("GMT"));
day.setTimeInMillis(unixTime);
return day;
}
@Override
public String toString() {
return DateFormats.getCSVDateFormat().format(new Date(unixTime));
}
/**
* Returns an integer corresponding to the day of the week. Saturday maps
* to 0, Sunday maps to 1, and so on.
*/
public int getWeekday() {
return toCalendar().get(DAY_OF_WEEK) % 7;
}
Timestamp truncate(DateUtils.TruncateField field, int firstWeekday) {
return new Timestamp(DateUtils.truncate(field, unixTime, firstWeekday));
}
}

View File

@@ -0,0 +1,101 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models;
import org.apache.commons.lang3.builder.*;
import java.util.*;
import static org.isoron.uhabits.core.utils.StringUtils.defaultToStringStyle;
public final class WeekdayList
{
public static final WeekdayList EVERY_DAY = new WeekdayList(127);
private final boolean[] weekdays;
public WeekdayList(int packedList)
{
weekdays = new boolean[7];
int current = 1;
for (int i = 0; i < 7; i++)
{
if ((packedList & current) != 0) weekdays[i] = true;
current = current << 1;
}
}
public WeekdayList(boolean weekdays[])
{
this.weekdays = Arrays.copyOf(weekdays, 7);
}
public boolean isEmpty()
{
for (boolean d : weekdays) if (d) return false;
return true;
}
public boolean[] toArray()
{
return Arrays.copyOf(weekdays, 7);
}
public int toInteger()
{
int packedList = 0;
int current = 1;
for (int i = 0; i < 7; i++)
{
if (weekdays[i]) packedList |= current;
current = current << 1;
}
return packedList;
}
@Override
public boolean equals(Object o)
{
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
WeekdayList that = (WeekdayList) o;
return new EqualsBuilder().append(weekdays, that.weekdays).isEquals();
}
@Override
public int hashCode()
{
return new HashCodeBuilder(17, 37).append(weekdays).toHashCode();
}
@Override
public String toString()
{
return new ToStringBuilder(this, defaultToStringStyle())
.append("weekdays", weekdays)
.toString();
}
}

View File

@@ -0,0 +1,294 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models.memory;
import androidx.annotation.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.utils.*;
import java.util.*;
import static org.isoron.uhabits.core.models.HabitList.Order.*;
/**
* In-memory implementation of {@link HabitList}.
*/
public class MemoryHabitList extends HabitList
{
@NonNull
private LinkedList<Habit> list = new LinkedList<>();
@NonNull
private Order primaryOrder = Order.BY_POSITION;
@NonNull
private Order secondaryOrder = Order.BY_NAME_ASC;
private Comparator<Habit> comparator = getComposedComparatorByOrder(primaryOrder, secondaryOrder);
@Nullable
private MemoryHabitList parent = null;
public MemoryHabitList()
{
super();
}
protected MemoryHabitList(@NonNull HabitMatcher matcher,
Comparator<Habit> comparator,
@NonNull MemoryHabitList parent)
{
super(matcher);
this.parent = parent;
this.comparator = comparator;
this.primaryOrder = parent.primaryOrder;
this.secondaryOrder = parent.secondaryOrder;
parent.getObservable().addListener(this::loadFromParent);
loadFromParent();
}
@Override
public synchronized void add(@NonNull Habit habit)
throws IllegalArgumentException
{
throwIfHasParent();
if (list.contains(habit))
throw new IllegalArgumentException("habit already added");
Long id = habit.getId();
if (id != null && getById(id) != null)
throw new RuntimeException("duplicate id");
if (id == null) habit.setId((long) list.size());
list.addLast(habit);
resort();
}
@Override
public synchronized Habit getById(long id)
{
for (Habit h : list)
{
if (h.getId() == null) throw new IllegalStateException();
if (h.getId() == id) return h;
}
return null;
}
@Override
public synchronized Habit getByUUID(String uuid)
{
for (Habit h : list) if (h.getUuid().equals(uuid)) return h;
return null;
}
@NonNull
@Override
public synchronized Habit getByPosition(int position)
{
return list.get(position);
}
@NonNull
@Override
public synchronized HabitList getFiltered(HabitMatcher matcher)
{
return new MemoryHabitList(matcher, comparator, this);
}
@Override
public synchronized Order getPrimaryOrder()
{
return primaryOrder;
}
@Override
public synchronized Order getSecondaryOrder()
{
return secondaryOrder;
}
@Override
public synchronized void setPrimaryOrder(@NonNull Order order)
{
this.primaryOrder = order;
this.comparator = getComposedComparatorByOrder(this.primaryOrder, this.secondaryOrder);
resort();
}
@Override
public void setSecondaryOrder(@NonNull Order order)
{
this.secondaryOrder = order;
this.comparator = getComposedComparatorByOrder(this.primaryOrder, this.secondaryOrder);
resort();
}
private Comparator<Habit> getComposedComparatorByOrder(Order firstOrder, Order secondOrder)
{
return (h1, h2) -> {
int firstResult = getComparatorByOrder(firstOrder).compare(h1, h2);
if (firstResult != 0 || secondOrder == null) {
return firstResult;
}
return getComparatorByOrder(secondOrder).compare(h1, h2);
};
}
private Comparator<Habit> getComparatorByOrder(Order order) {
Comparator<Habit> nameComparatorAsc = (h1, h2) ->
h1.getName().compareTo(h2.getName());
Comparator<Habit> nameComparatorDesc = (h1, h2) ->
nameComparatorAsc.compare(h2, h1);
Comparator<Habit> colorComparatorAsc = (h1, h2) ->
h1.getColor().compareTo(h2.getColor());
Comparator<Habit> colorComparatorDesc = (h1, h2) ->
colorComparatorAsc.compare(h2, h1);
Comparator<Habit> scoreComparatorDesc = (h1, h2) ->
{
Timestamp today = DateUtils.getTodayWithOffset();
return Double.compare(
h1.getScores().get(today).getValue(),
h2.getScores().get(today).getValue());
};
Comparator<Habit> scoreComparatorAsc = (h1, h2) ->
scoreComparatorDesc.compare(h2, h1);
Comparator<Habit> positionComparator = (h1, h2) ->
Integer.compare(h1.getPosition(), h2.getPosition());
Comparator<Habit> statusComparatorDesc = (h1, h2) ->
{
if (h1.isCompletedToday() != h2.isCompletedToday()) {
return h1.isCompletedToday() ? -1 : 1;
}
if (h1.isNumerical() != h2.isNumerical()) {
return h1.isNumerical() ? -1 : 1;
}
Timestamp today = DateUtils.getTodayWithOffset();
Integer v1 = h1.getComputedEntries().get(today).getValue();
Integer v2 = h2.getComputedEntries().get(today).getValue();
return v2.compareTo(v1);
};
Comparator<Habit> statusComparatorAsc = (h1, h2) -> statusComparatorDesc.compare(h2, h1);
if (order == BY_POSITION) return positionComparator;
if (order == BY_NAME_ASC) return nameComparatorAsc;
if (order == BY_NAME_DESC) return nameComparatorDesc;
if (order == BY_COLOR_ASC) return colorComparatorAsc;
if (order == BY_COLOR_DESC) return colorComparatorDesc;
if (order == BY_SCORE_DESC) return scoreComparatorDesc;
if (order == BY_SCORE_ASC) return scoreComparatorAsc;
if (order == BY_STATUS_DESC) return statusComparatorDesc;
if (order == BY_STATUS_ASC) return statusComparatorAsc;
throw new IllegalStateException();
}
@Override
public synchronized int indexOf(@NonNull Habit h)
{
return list.indexOf(h);
}
@NonNull
@Override
public synchronized Iterator<Habit> iterator()
{
return new ArrayList<>(list).iterator();
}
@Override
public synchronized void remove(@NonNull Habit habit)
{
throwIfHasParent();
list.remove(habit);
getObservable().notifyListeners();
}
@Override
public synchronized void reorder(@NonNull Habit from, @NonNull Habit to)
{
throwIfHasParent();
if (primaryOrder != BY_POSITION) throw new IllegalStateException(
"cannot reorder automatically sorted list");
if (indexOf(from) < 0) throw new IllegalArgumentException(
"list does not contain (from) habit");
int toPos = indexOf(to);
if (toPos < 0) throw new IllegalArgumentException(
"list does not contain (to) habit");
list.remove(from);
list.add(toPos, from);
int position = 0;
for(Habit h : list)
h.setPosition(position++);
getObservable().notifyListeners();
}
@Override
public synchronized int size()
{
return list.size();
}
@Override
public synchronized void update(List<Habit> habits)
{
resort();
}
private void throwIfHasParent()
{
if (parent != null) throw new IllegalStateException(
"Filtered lists cannot be modified directly. " +
"You should modify the parent list instead.");
}
private synchronized void loadFromParent()
{
if (parent == null) throw new IllegalStateException();
list.clear();
for (Habit h : parent) if (filter.matches(h)) list.add(h);
resort();
}
public synchronized void resort()
{
if (comparator != null) Collections.sort(list, comparator);
getObservable().notifyListeners();
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models.memory
import org.isoron.uhabits.core.models.EntryList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.ScoreList
import org.isoron.uhabits.core.models.StreakList
class MemoryModelFactory : ModelFactory {
override fun buildComputedEntries() = EntryList()
override fun buildOriginalEntries() = EntryList()
override fun buildHabitList() = MemoryHabitList()
override fun buildScoreList() = ScoreList()
override fun buildStreakList() = StreakList()
override fun buildHabitListRepository() = throw NotImplementedError()
override fun buildRepetitionListRepository() = throw NotImplementedError()
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* Provides in-memory implementation of core models.
*/
package org.isoron.uhabits.core.models.memory;

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*
*
*/
package org.isoron.uhabits.core.models.sqlite
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.Repository
import org.isoron.uhabits.core.models.EntryList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.ScoreList
import org.isoron.uhabits.core.models.StreakList
import org.isoron.uhabits.core.models.sqlite.records.EntryRecord
import org.isoron.uhabits.core.models.sqlite.records.HabitRecord
import javax.inject.Inject
/**
* Factory that provides models backed by an SQLite database.
*/
class SQLModelFactory
@Inject constructor(
val database: Database,
) : ModelFactory {
override fun buildOriginalEntries() = SQLiteEntryList(database)
override fun buildComputedEntries() = EntryList()
override fun buildHabitList() = SQLiteHabitList(this)
override fun buildScoreList() = ScoreList()
override fun buildStreakList() = StreakList()
override fun buildHabitListRepository() =
Repository(HabitRecord::class.java, database)
override fun buildRepetitionListRepository() =
Repository(EntryRecord::class.java, database)
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models.sqlite
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.Repository
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.EntryList
import org.isoron.uhabits.core.models.Frequency
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.models.sqlite.records.EntryRecord
class SQLiteEntryList(database: Database) : EntryList() {
val repository = Repository(EntryRecord::class.java, database)
var habitId: Long? = null
var isLoaded = false
private fun loadRecords() {
if (isLoaded) return
val habitId = habitId ?: throw IllegalStateException("habitId must be set")
val records = repository.findAll(
"where habit = ? order by timestamp",
habitId.toString()
)
for (rec in records) super.add(rec.toEntry())
isLoaded = true
}
override fun get(timestamp: Timestamp): Entry {
loadRecords()
return super.get(timestamp)
}
override fun getByInterval(from: Timestamp, to: Timestamp): List<Entry> {
loadRecords()
return super.getByInterval(from, to)
}
override fun add(entry: Entry) {
loadRecords()
val habitId = habitId ?: throw IllegalStateException("habitId must be set")
// Remove existing rows
repository.execSQL(
"delete from repetitions where habit = ? and timestamp = ?",
habitId.toString(),
entry.timestamp.unixTime.toString()
)
// Add new row
val record = EntryRecord().apply { copyFrom(entry) }
record.habitId = habitId
repository.save(record)
// Add to memory list
super.add(entry)
}
override fun getKnown(): List<Entry> {
loadRecords()
return super.getKnown()
}
override fun recomputeFrom(originalEntries: EntryList, frequency: Frequency, isNumerical: Boolean) {
throw UnsupportedOperationException()
}
override fun clear() {
super.clear()
repository.execSQL(
"delete from repetitions where habit = ?",
habitId.toString()
)
}
}

View File

@@ -0,0 +1,296 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*
*
*/
package org.isoron.uhabits.core.models.sqlite;
import androidx.annotation.*;
import org.isoron.uhabits.core.database.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.models.memory.*;
import org.isoron.uhabits.core.models.sqlite.records.*;
import java.util.*;
import javax.inject.*;
/**
* Implementation of a {@link HabitList} that is backed by SQLite.
*/
public class SQLiteHabitList extends HabitList
{
@NonNull
private final Repository<HabitRecord> repository;
@NonNull
private final ModelFactory modelFactory;
@NonNull
private final MemoryHabitList list;
private boolean loaded = false;
@Inject
public SQLiteHabitList(@NonNull ModelFactory modelFactory)
{
super();
this.modelFactory = modelFactory;
this.list = new MemoryHabitList();
this.repository = modelFactory.buildHabitListRepository();
}
private void loadRecords()
{
if (loaded) return;
loaded = true;
list.removeAll();
List<HabitRecord> records = repository.findAll("order by position");
int expectedPosition = 0;
boolean shouldRebuildOrder = false;
for (HabitRecord rec : records)
{
if (rec.position != expectedPosition) shouldRebuildOrder = true;
expectedPosition++;
Habit h = modelFactory.buildHabit();
rec.copyTo(h);
((SQLiteEntryList) h.getOriginalEntries()).setHabitId(h.getId());
list.add(h);
}
if(shouldRebuildOrder) rebuildOrder();
}
@Override
public synchronized void add(@NonNull Habit habit)
{
loadRecords();
habit.setPosition(size());
HabitRecord record = new HabitRecord();
record.copyFrom(habit);
repository.save(record);
habit.setId(record.id);
((SQLiteEntryList) habit.getOriginalEntries()).setHabitId(record.id);
list.add(habit);
getObservable().notifyListeners();
}
@Override
@Nullable
public synchronized Habit getById(long id)
{
loadRecords();
return list.getById(id);
}
@Override
@Nullable
public synchronized Habit getByUUID(String uuid)
{
loadRecords();
return list.getByUUID(uuid);
}
@Override
@NonNull
public synchronized Habit getByPosition(int position)
{
loadRecords();
return list.getByPosition(position);
}
@NonNull
@Override
public synchronized HabitList getFiltered(HabitMatcher filter)
{
loadRecords();
return list.getFiltered(filter);
}
@Override
@NonNull
public Order getPrimaryOrder()
{
return list.getPrimaryOrder();
}
@Override
public Order getSecondaryOrder()
{
return list.getSecondaryOrder();
}
@Override
public synchronized void setPrimaryOrder(@NonNull Order order)
{
list.setPrimaryOrder(order);
getObservable().notifyListeners();
}
@Override
public synchronized void setSecondaryOrder(@NonNull Order order)
{
list.setSecondaryOrder(order);
getObservable().notifyListeners();
}
@Override
public synchronized int indexOf(@NonNull Habit h)
{
loadRecords();
return list.indexOf(h);
}
@Override
public synchronized Iterator<Habit> iterator()
{
loadRecords();
return list.iterator();
}
private synchronized void rebuildOrder()
{
List<HabitRecord> records = repository.findAll("order by position");
repository.executeAsTransaction(() ->
{
int pos = 0;
for (HabitRecord r : records)
{
if (r.position != pos)
{
r.position = pos;
repository.save(r);
}
pos++;
}
});
}
@Override
public synchronized void remove(@NonNull Habit habit)
{
loadRecords();
reorder(habit, list.getByPosition(size() - 1));
list.remove(habit);
HabitRecord record = repository.find(habit.getId());
if (record == null) throw new RuntimeException("habit not in database");
repository.executeAsTransaction(() ->
{
habit.getOriginalEntries().clear();
repository.remove(record);
});
getObservable().notifyListeners();
}
@Override
public synchronized void removeAll()
{
list.removeAll();
repository.execSQL("delete from habits");
repository.execSQL("delete from repetitions");
getObservable().notifyListeners();
}
@Override
public synchronized void reorder(@NonNull Habit from, @NonNull Habit to)
{
loadRecords();
list.reorder(from, to);
HabitRecord fromRecord = repository.find(from.getId());
HabitRecord toRecord = repository.find(to.getId());
if (fromRecord == null)
throw new RuntimeException("habit not in database");
if (toRecord == null)
throw new RuntimeException("habit not in database");
if (toRecord.position < fromRecord.position)
{
repository.execSQL("update habits set position = position + 1 " +
"where position >= ? and position < ?",
toRecord.position, fromRecord.position);
}
else
{
repository.execSQL("update habits set position = position - 1 " +
"where position > ? and position <= ?",
fromRecord.position, toRecord.position);
}
fromRecord.position = toRecord.position;
repository.save(fromRecord);
getObservable().notifyListeners();
}
@Override
public synchronized void repair()
{
loadRecords();
rebuildOrder();
getObservable().notifyListeners();
}
@Override
public synchronized int size()
{
loadRecords();
return list.size();
}
@Override
public synchronized void update(List<Habit> habits)
{
loadRecords();
list.update(habits);
for (Habit h : habits)
{
HabitRecord record = repository.find(h.getId());
if (record == null) continue;
record.copyFrom(h);
repository.save(record);
}
getObservable().notifyListeners();
}
@Override
public void resort()
{
list.resort();
getObservable().notifyListeners();
}
public synchronized void reload()
{
loaded = false;
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*
*
*/
/**
* Provides SQLite implementations of the core models.
*/
package org.isoron.uhabits.core.models.sqlite;

View File

@@ -0,0 +1,57 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*
*
*/
package org.isoron.uhabits.core.models.sqlite.records;
import org.isoron.uhabits.core.database.*;
import org.isoron.uhabits.core.models.*;
/**
* The SQLite database record corresponding to a {@link Entry}.
*/
@Table(name = "Repetitions")
public class EntryRecord
{
public HabitRecord habit;
@Column(name = "habit")
public Long habitId;
@Column
public Long timestamp;
@Column
public Integer value;
@Column
public Long id;
public void copyFrom(Entry entry)
{
timestamp = entry.getTimestamp().getUnixTime();
value = entry.getValue();
}
public Entry toEntry()
{
return new Entry(new Timestamp(timestamp), value);
}
}

View File

@@ -0,0 +1,141 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*
*
*/
package org.isoron.uhabits.core.models.sqlite.records;
import org.isoron.uhabits.core.database.*;
import org.isoron.uhabits.core.models.*;
/**
* The SQLite database record corresponding to a {@link Habit}.
*/
@Table(name = "habits")
public class HabitRecord
{
@Column
public String description;
@Column
public String question;
@Column
public String name;
@Column(name = "freq_num")
public Integer freqNum;
@Column(name = "freq_den")
public Integer freqDen;
@Column
public Integer color;
@Column
public Integer position;
@Column(name = "reminder_hour")
public Integer reminderHour;
@Column(name = "reminder_min")
public Integer reminderMin;
@Column(name = "reminder_days")
public Integer reminderDays;
@Column
public Integer highlight;
@Column
public Integer archived;
@Column
public Integer type;
@Column(name = "target_value")
public Double targetValue;
@Column(name = "target_type")
public Integer targetType;
@Column
public String unit;
@Column
public Long id;
@Column
public String uuid;
public void copyFrom(Habit model)
{
this.id = model.getId();
this.name = model.getName();
this.description = model.getDescription();
this.highlight = 0;
this.color = model.getColor().getPaletteIndex();
this.archived = model.isArchived() ? 1 : 0;
this.type = model.getType();
this.targetType = model.getTargetType();
this.targetValue = model.getTargetValue();
this.unit = model.getUnit();
this.position = model.getPosition();
this.question = model.getQuestion();
this.uuid = model.getUuid();
Frequency freq = model.getFrequency();
this.freqNum = freq.getNumerator();
this.freqDen = freq.getDenominator();
this.reminderDays = 0;
this.reminderMin = null;
this.reminderHour = null;
if (model.hasReminder())
{
Reminder reminder = model.getReminder();
this.reminderHour = reminder.getHour();
this.reminderMin = reminder.getMinute();
this.reminderDays = reminder.getDays().toInteger();
}
}
public void copyTo(Habit habit)
{
habit.setId(this.id);
habit.setName(this.name);
habit.setDescription(this.description);
habit.setQuestion(this.question);
habit.setFrequency(new Frequency(this.freqNum, this.freqDen));
habit.setColor(new PaletteColor(this.color));
habit.setArchived(this.archived != 0);
habit.setType(this.type);
habit.setTargetType(this.targetType);
habit.setTargetValue(this.targetValue);
habit.setUnit(this.unit);
habit.setPosition(this.position);
habit.setUuid(this.uuid);
if (reminderHour != null && reminderMin != null)
{
habit.setReminder(new Reminder(reminderHour, reminderMin,
new WeekdayList(reminderDays)));
}
}
}

View File

@@ -0,0 +1,439 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.preferences;
import androidx.annotation.*;
import org.isoron.platform.time.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.ui.*;
import org.isoron.uhabits.core.utils.*;
import java.util.*;
public class Preferences
{
@NonNull
private final Storage storage;
@NonNull
private List<Listener> listeners;
@Nullable
private Boolean shouldReverseCheckmarks = null;
public Preferences(@NonNull Storage storage)
{
this.storage = storage;
listeners = new LinkedList<>();
storage.onAttached(this);
}
public void addListener(Listener listener)
{
listeners.add(listener);
}
public Integer getDefaultHabitColor(int fallbackColor)
{
return storage.getInt("pref_default_habit_palette_color",
fallbackColor);
}
public HabitList.Order getDefaultPrimaryOrder()
{
String name = storage.getString("pref_default_order", "BY_POSITION");
try
{
return HabitList.Order.valueOf(name);
}
catch (IllegalArgumentException e)
{
setDefaultPrimaryOrder(HabitList.Order.BY_POSITION);
return HabitList.Order.BY_POSITION;
}
}
public HabitList.Order getDefaultSecondaryOrder() {
String name = storage.getString("pref_default_secondary_order", "BY_NAME_ASC");
try
{
return HabitList.Order.valueOf(name);
}
catch (IllegalArgumentException e)
{
setDefaultSecondaryOrder(HabitList.Order.BY_NAME_ASC);
return HabitList.Order.BY_POSITION;
}
}
public void setDefaultPrimaryOrder(HabitList.Order order)
{
storage.putString("pref_default_order", order.name());
}
public void setDefaultSecondaryOrder(HabitList.Order order)
{
storage.putString("pref_default_secondary_order", order.name());
}
public int getScoreCardSpinnerPosition()
{
return Math.min(4, Math.max(0, storage.getInt("pref_score_view_interval", 1)));
}
public void setScoreCardSpinnerPosition(int position)
{
storage.putInt("pref_score_view_interval", position);
}
public int getBarCardBoolSpinnerPosition()
{
return Math.min(3, Math.max(0, storage.getInt("pref_bar_card_bool_spinner", 0)));
}
public void setBarCardBoolSpinnerPosition(int position)
{
storage.putInt("pref_bar_card_bool_spinner", position);
}
public int getBarCardNumericalSpinnerPosition()
{
return Math.min(4, Math.max(0, storage.getInt("pref_bar_card_numerical_spinner", 0)));
}
public void setBarCardNumericalSpinnerPosition(int position)
{
storage.putInt("pref_bar_card_numerical_spinner", position);
}
public int getLastHintNumber()
{
return storage.getInt("last_hint_number", -1);
}
public Timestamp getLastHintTimestamp()
{
long unixTime = storage.getLong("last_hint_timestamp", -1);
if (unixTime < 0) return null;
else return new Timestamp(unixTime);
}
public boolean getShowArchived()
{
return storage.getBoolean("pref_show_archived", false);
}
public void setShowArchived(boolean showArchived)
{
storage.putBoolean("pref_show_archived", showArchived);
}
public boolean getShowCompleted()
{
return storage.getBoolean("pref_show_completed", true);
}
public void setShowCompleted(boolean showCompleted)
{
storage.putBoolean("pref_show_completed", showCompleted);
}
public long getSnoozeInterval()
{
return Long.parseLong(storage.getString("pref_snooze_interval", "15"));
}
public void setSnoozeInterval(int interval)
{
storage.putString("pref_snooze_interval", String.valueOf(interval));
}
public int getTheme()
{
return storage.getInt("pref_theme", ThemeSwitcher.THEME_AUTOMATIC);
}
public void setTheme(int theme)
{
storage.putInt("pref_theme", theme);
}
public void incrementLaunchCount()
{
storage.putInt("launch_count", getLaunchCount() + 1);
}
public int getLaunchCount()
{
return storage.getInt("launch_count", 0);
}
public boolean isDeveloper()
{
return storage.getBoolean("pref_developer", false);
}
public void setDeveloper(boolean isDeveloper)
{
storage.putBoolean("pref_developer", isDeveloper);
}
public boolean isFirstRun()
{
return storage.getBoolean("pref_first_run", true);
}
public void setFirstRun(boolean isFirstRun)
{
storage.putBoolean("pref_first_run", isFirstRun);
}
public boolean isPureBlackEnabled()
{
return storage.getBoolean("pref_pure_black", false);
}
public void setPureBlackEnabled(boolean enabled)
{
storage.putBoolean("pref_pure_black", enabled);
}
public boolean isShortToggleEnabled()
{
return storage.getBoolean("pref_short_toggle", false);
}
public void setShortToggleEnabled(boolean enabled)
{
storage.putBoolean("pref_short_toggle", enabled);
}
public void removeListener(Listener listener)
{
listeners.remove(listener);
}
public void clear()
{
storage.clear();
}
public void setDefaultHabitColor(int color)
{
storage.putInt("pref_default_habit_palette_color", color);
}
public void setNotificationsSticky(boolean sticky)
{
storage.putBoolean("pref_sticky_notifications", sticky);
for (Listener l : listeners) l.onNotificationsChanged();
}
public void setNotificationsLed(boolean enabled)
{
storage.putBoolean("pref_led_notifications", enabled);
for (Listener l : listeners) l.onNotificationsChanged();
}
public boolean shouldMakeNotificationsSticky()
{
return storage.getBoolean("pref_sticky_notifications", false);
}
public boolean shouldMakeNotificationsLed()
{
return storage.getBoolean("pref_led_notifications", false);
}
public boolean isCheckmarkSequenceReversed()
{
if (shouldReverseCheckmarks == null) shouldReverseCheckmarks =
storage.getBoolean("pref_checkmark_reverse_order", false);
return shouldReverseCheckmarks;
}
public void setCheckmarkSequenceReversed(boolean reverse)
{
shouldReverseCheckmarks = reverse;
storage.putBoolean("pref_checkmark_reverse_order", reverse);
for (Listener l : listeners) l.onCheckmarkSequenceChanged();
}
public void updateLastHint(int number, Timestamp timestamp)
{
storage.putInt("last_hint_number", number);
storage.putLong("last_hint_timestamp", timestamp.getUnixTime());
}
public int getLastAppVersion()
{
return storage.getInt("last_version", 0);
}
public void setLastAppVersion(int version)
{
storage.putInt("last_version", version);
}
public int getWidgetOpacity()
{
return Integer.parseInt(storage.getString("pref_widget_opacity", "255"));
}
public void setWidgetOpacity(int value)
{
storage.putString("pref_widget_opacity", Integer.toString(value));
}
public boolean isSkipEnabled()
{
return storage.getBoolean("pref_skip_enabled", false);
}
public void setSkipEnabled(boolean value)
{
storage.putBoolean("pref_skip_enabled", value);
}
public String getSyncBaseURL()
{
return storage.getString("pref_sync_base_url", "");
}
public String getSyncKey()
{
return storage.getString("pref_sync_key", "");
}
public String getEncryptionKey()
{
return storage.getString("pref_encryption_key", "");
}
public boolean isSyncEnabled()
{
return storage.getBoolean("pref_sync_enabled", false);
}
public void enableSync(String syncKey, String encKey)
{
storage.putBoolean("pref_sync_enabled", true);
storage.putString("pref_sync_key", syncKey);
storage.putString("pref_encryption_key", encKey);
for (Listener l : listeners) l.onSyncEnabled();
}
public void disableSync()
{
storage.putBoolean("pref_sync_enabled", false);
storage.putString("pref_sync_key", "");
storage.putString("pref_encryption_key", "");
}
public boolean areQuestionMarksEnabled()
{
return storage.getBoolean("pref_unknown_enabled", false);
}
/**
* @return An integer representing the first day of the week. Sunday
* corresponds to 1, Monday to 2, and so on, until Saturday, which is
* represented by 7. By default, this is based on the current system locale,
* unless the user changed this in the settings.
*/
@Deprecated()
public int getFirstWeekdayInt()
{
String weekday = storage.getString("pref_first_weekday", "");
if (weekday.isEmpty()) return DateUtils.getFirstWeekdayNumberAccordingToLocale();
return Integer.parseInt(weekday);
}
public DayOfWeek getFirstWeekday()
{
int weekday = Integer.parseInt(storage.getString("pref_first_weekday", "-1"));
if (weekday < 0) weekday = DateUtils.getFirstWeekdayNumberAccordingToLocale();
switch (weekday) {
case 1: return DayOfWeek.SUNDAY;
case 2: return DayOfWeek.MONDAY;
case 3: return DayOfWeek.TUESDAY;
case 4: return DayOfWeek.WEDNESDAY;
case 5: return DayOfWeek.THURSDAY;
case 6: return DayOfWeek.FRIDAY;
case 7: return DayOfWeek.SATURDAY;
default: throw new IllegalArgumentException();
}
}
public interface Listener
{
default void onCheckmarkSequenceChanged()
{
}
default void onNotificationsChanged()
{
}
default void onSyncEnabled()
{
}
}
public interface Storage
{
void clear();
boolean getBoolean(String key, boolean defValue);
int getInt(String key, int defValue);
long getLong(String key, long defValue);
String getString(String key, String defValue);
void onAttached(Preferences preferences);
void putBoolean(String key, boolean value);
void putInt(String key, int value);
void putLong(String key, long value);
void putString(String key, String value);
void remove(String key);
default void putLongArray(String key, long[] values)
{
putString(key, StringUtils.joinLongs(values));
}
default long[] getLongArray(String key, long[] defValue)
{
String string = getString(key, "");
if (string.isEmpty()) return defValue;
else return StringUtils.splitLongs(string);
}
}
}

View File

@@ -0,0 +1,134 @@
/*
* Copyright (C) 2015-2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.preferences;
import androidx.annotation.*;
import java.io.*;
import java.util.*;
public class PropertiesStorage implements Preferences.Storage
{
@NonNull
private final Properties props;
@NonNull
private File file;
public PropertiesStorage(@NonNull File file)
{
try
{
this.file = file;
props = new Properties();
props.load(new FileInputStream(file));
}
catch (IOException e)
{
throw new RuntimeException(e);
}
}
@Override
public void clear()
{
for(String key : props.stringPropertyNames()) props.remove(key);
flush();
}
@Override
public boolean getBoolean(String key, boolean defValue)
{
String value = props.getProperty(key, Boolean.toString(defValue));
return Boolean.parseBoolean(value);
}
@Override
public int getInt(String key, int defValue)
{
String value = props.getProperty(key, Integer.toString(defValue));
return Integer.parseInt(value);
}
@Override
public long getLong(String key, long defValue)
{
String value = props.getProperty(key, Long.toString(defValue));
return Long.parseLong(value);
}
@Override
public String getString(String key, String defValue)
{
return props.getProperty(key, defValue);
}
@Override
public void onAttached(Preferences preferences)
{
// nop
}
@Override
public void putBoolean(String key, boolean value)
{
props.setProperty(key, Boolean.toString(value));
}
@Override
public void putInt(String key, int value)
{
props.setProperty(key, Integer.toString(value));
flush();
}
private void flush()
{
try
{
props.store(new FileOutputStream(file), "");
}
catch (IOException e)
{
throw new RuntimeException(e);
}
}
@Override
public void putLong(String key, long value)
{
props.setProperty(key, Long.toString(value));
flush();
}
@Override
public void putString(String key, String value)
{
props.setProperty(key, value);
flush();
}
@Override
public void remove(String key)
{
props.remove(key);
flush();
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.preferences;
import org.isoron.uhabits.core.AppScope;
import org.isoron.uhabits.core.models.HabitNotFoundException;
import javax.inject.Inject;
@AppScope
public class WidgetPreferences {
private Preferences.Storage storage;
@Inject
public WidgetPreferences(Preferences.Storage storage) {
this.storage = storage;
}
public void addWidget(int widgetId, long habitIds[]) {
storage.putLongArray(getHabitIdKey(widgetId), habitIds);
}
public long[] getHabitIdsFromWidgetId(int widgetId) {
long[] habitIds;
String habitIdKey = getHabitIdKey(widgetId);
try {
habitIds = storage.getLongArray(habitIdKey, new long[]{-1});
} catch (ClassCastException e) {
// Up to Loop 1.7.11, this preference was not an array, but a single
// long. Trying to read the old preference causes a cast exception.
habitIds = new long[1];
habitIds[0] = storage.getLong(habitIdKey, -1);
storage.putLongArray(habitIdKey, habitIds);
}
return habitIds;
}
public void removeWidget(int id) {
String habitIdKey = getHabitIdKey(id);
storage.remove(habitIdKey);
}
public long getSnoozeTime(long id)
{
return storage.getLong(getSnoozeKey(id), 0);
}
private String getHabitIdKey(int id) {
return String.format("widget-%06d-habit", id);
}
private String getSnoozeKey(long id)
{
return String.format("snooze-%06d", id);
}
public void removeSnoozeTime(long id)
{
storage.putLong(getSnoozeKey(id), 0);
}
public void setSnoozeTime(Long id, long time)
{
storage.putLong(getSnoozeKey(id), time);
}
}

View File

@@ -0,0 +1,176 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.reminders;
import androidx.annotation.*;
import org.isoron.uhabits.core.*;
import org.isoron.uhabits.core.commands.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.preferences.*;
import java.util.*;
import javax.inject.*;
import static org.isoron.uhabits.core.utils.DateUtils.*;
@AppScope
public class ReminderScheduler implements CommandRunner.Listener
{
private final WidgetPreferences widgetPreferences;
private CommandRunner commandRunner;
private HabitList habitList;
private SystemScheduler sys;
@Inject
public ReminderScheduler(@NonNull CommandRunner commandRunner,
@NonNull HabitList habitList,
@NonNull SystemScheduler sys,
@NonNull WidgetPreferences widgetPreferences)
{
this.commandRunner = commandRunner;
this.habitList = habitList;
this.sys = sys;
this.widgetPreferences = widgetPreferences;
}
@Override
public synchronized void onCommandFinished(@Nullable Command command)
{
if (command instanceof CreateRepetitionCommand) return;
if (command instanceof ChangeHabitColorCommand) return;
scheduleAll();
}
public synchronized void schedule(@NonNull Habit habit)
{
if (habit.getId() == null)
{
sys.log("ReminderScheduler", "Habit has null id. Returning.");
return;
}
if (!habit.hasReminder())
{
sys.log("ReminderScheduler", "habit=" + habit.getId() + " has no reminder. Skipping.");
return;
}
long reminderTime = habit.getReminder().getTimeInMillis();
long snoozeReminderTime = widgetPreferences.getSnoozeTime(habit.getId());
if (snoozeReminderTime != 0)
{
long now = applyTimezone(getLocalTime());
sys.log("ReminderScheduler", String.format(
Locale.US,
"Habit %d has been snoozed until %d",
habit.getId(),
snoozeReminderTime));
if (snoozeReminderTime > now)
{
sys.log("ReminderScheduler", "Snooze time is in the future. Accepting.");
reminderTime = snoozeReminderTime;
}
else
{
sys.log("ReminderScheduler", "Snooze time is in the past. Discarding.");
widgetPreferences.removeSnoozeTime(habit.getId());
}
}
scheduleAtTime(habit, reminderTime);
}
public synchronized void scheduleAtTime(@NonNull Habit habit, long reminderTime)
{
sys.log("ReminderScheduler", "Scheduling alarm for habit=" + habit.getId());
if (!habit.hasReminder())
{
sys.log("ReminderScheduler", "habit=" + habit.getId() + " has no reminder. Skipping.");
return;
}
if (habit.isArchived())
{
sys.log("ReminderScheduler", "habit=" + habit.getId() + " is archived. Skipping.");
return;
}
long timestamp = getStartOfDayWithOffset(removeTimezone(reminderTime));
sys.log("ReminderScheduler",
String.format(
Locale.US,
"reminderTime=%d removeTimezone=%d timestamp=%d",
reminderTime,
removeTimezone(reminderTime),
timestamp));
sys.scheduleShowReminder(reminderTime, habit, timestamp);
}
public synchronized void scheduleAll()
{
sys.log("ReminderScheduler", "Scheduling all alarms");
HabitList reminderHabits =
habitList.getFiltered(HabitMatcher.WITH_ALARM);
for (Habit habit : reminderHabits)
schedule(habit);
}
public synchronized void startListening()
{
commandRunner.addListener(this);
}
public synchronized void stopListening()
{
commandRunner.removeListener(this);
}
public synchronized void snoozeReminder(Habit habit, long minutes)
{
long now = applyTimezone(getLocalTime());
long snoozedUntil = now + minutes * 60 * 1000;
widgetPreferences.setSnoozeTime(habit.getId(), snoozedUntil);
schedule(habit);
}
public interface SystemScheduler
{
SchedulerResult scheduleShowReminder(long reminderTime, Habit habit, long timestamp);
SchedulerResult scheduleWidgetUpdate(long updateTime);
void log(String componentName, String msg);
}
public enum SchedulerResult
{
IGNORED,
OK
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.sync
interface AbstractSyncServer {
/**
* Generates and returns a new sync key, which can be used to store and retrive
* data.
*
* @throws ServiceUnavailable If key cannot be generated at this time, for example,
* due to insufficient server resources, temporary server maintenance or network problems.
*/
suspend fun register(): String
/**
* Replaces data for a given sync key.
*
* @throws KeyNotFoundException If key is not found
* @throws EditConflictException If the version of the data provided is not
* exactly the current data version plus one.
* @throws ServiceUnavailable If data cannot be put at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun put(key: String, newData: SyncData)
/**
* Returns data for a given sync key.
*
* @throws KeyNotFoundException If key is not found
* @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun getData(key: String): SyncData
/**
* Returns the current data version for the given key
*
* @throws KeyNotFoundException If key is not found
* @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun getDataVersion(key: String): Long
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@file:Suppress("UnstableApiUsage")
package org.isoron.uhabits.core.sync
import com.google.common.io.ByteStreams
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.invoke
import org.apache.commons.codec.binary.Base64
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Encryption key which can be used with [File.encryptToString], [String.decryptToFile],
* [ByteArray.encrypt] and [ByteArray.decrypt].
*
* To randomly generate a new key, use [EncryptionKey.generate]. To load a key from a
* Base64-encoded string, use [EncryptionKey.fromBase64].
*/
class EncryptionKey private constructor(
val base64: String,
val secretKey: SecretKey
) {
companion object {
fun fromBase64(base64: String): EncryptionKey {
val keySpec = SecretKeySpec(base64.decodeBase64(), "AES")
return EncryptionKey(base64, keySpec)
}
private fun fromSecretKey(spec: SecretKey): EncryptionKey {
val base64 = spec.encoded.encodeBase64().trim()
return EncryptionKey(base64, spec)
}
suspend fun generate(): EncryptionKey = Dispatchers.IO {
try {
val generator = KeyGenerator.getInstance("AES").apply { init(256) }
return@IO fromSecretKey(generator.generateKey())
} catch (e: Exception) {
throw RuntimeException(e)
}
}
}
}
/**
* Encrypts the byte stream using the provided symmetric encryption key.
*
* The initialization vector (16 bytes) is prepended to the cipher text. To decrypt the result, use
* [ByteArray.decrypt], providing the same key.
*/
fun ByteArray.encrypt(key: EncryptionKey): ByteArray {
val cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING")
cipher.init(Cipher.ENCRYPT_MODE, key.secretKey)
val encrypted = cipher.doFinal(this)
return ByteBuffer
.allocate(16 + encrypted.size)
.put(cipher.iv)
.put(encrypted)
.array()
}
/**
* Decrypts a byte stream generated by [ByteArray.encrypt].
*/
fun ByteArray.decrypt(key: EncryptionKey): ByteArray {
val buffer = ByteBuffer.wrap(this)
val iv = ByteArray(16)
buffer.get(iv)
val encrypted = ByteArray(buffer.remaining())
buffer.get(encrypted)
val cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING")
cipher.init(Cipher.DECRYPT_MODE, key.secretKey, IvParameterSpec(iv))
return cipher.doFinal(encrypted)
}
/**
* Takes a string produced by [File.encryptToString], decodes it with Base64, decompresses it with
* gzip, decrypts it with the provided key, then writes the output to the specified file.
*/
fun String.decryptToFile(key: EncryptionKey, output: File) {
val bytes = this.decodeBase64().decrypt(key)
ByteArrayInputStream(bytes).use { bytesInputStream ->
GZIPInputStream(bytesInputStream).use { gzipInputStream ->
FileOutputStream(output).use { fileOutputStream ->
ByteStreams.copy(gzipInputStream, fileOutputStream)
}
}
}
}
/**
* Compresses the file with gzip, encrypts it using the the provided key, then returns a string
* containing the Base64-encoded cipher bytes.
*
* To decrypt and decompress the cipher text back into a file, use [String.decryptToFile].
*/
fun File.encryptToString(key: EncryptionKey): String {
ByteArrayOutputStream().use { bytesOutputStream ->
FileInputStream(this).use { inputStream ->
GZIPOutputStream(bytesOutputStream).use { gzipOutputStream ->
ByteStreams.copy(inputStream, gzipOutputStream)
gzipOutputStream.close()
val bytes = bytesOutputStream.toByteArray()
return bytes.encrypt(key).encodeBase64()
}
}
}
}
fun ByteArray.encodeBase64(): String = Base64.encodeBase64(this).decodeToString()
fun String.decodeBase64(): ByteArray = Base64.decodeBase64(this.toByteArray())

View File

@@ -0,0 +1,29 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.sync
interface NetworkManager {
fun addListener(listener: Listener)
fun remoteListener(listener: Listener)
interface Listener {
fun onNetworkAvailable()
fun onNetworkLost()
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.sync
data class SyncData(
val version: Long,
val content: String
)
data class RegisterReponse(val key: String)
data class GetDataVersionResponse(val version: Long)

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.sync
open class SyncException : RuntimeException()
class KeyNotFoundException : SyncException()
class ServiceUnavailable : SyncException()
class EditConflictException : SyncException()

View File

@@ -0,0 +1,183 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.sync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.invoke
import kotlinx.coroutines.launch
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.commands.Command
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.io.Logging
import org.isoron.uhabits.core.io.LoopDBImporter
import org.isoron.uhabits.core.preferences.Preferences
import java.io.File
import javax.inject.Inject
@AppScope
class SyncManager @Inject constructor(
val preferences: Preferences,
val commandRunner: CommandRunner,
val logging: Logging,
val networkManager: NetworkManager,
val server: AbstractSyncServer,
val db: Database,
val dbImporter: LoopDBImporter,
) : Preferences.Listener, CommandRunner.Listener, NetworkManager.Listener {
private var logger = logging.getLogger("SyncManager")
private var connected = false
private val tmpFile = File.createTempFile("import", "")
private var currVersion = 1L
private var dirty = true
private lateinit var encryptionKey: EncryptionKey
private lateinit var syncKey: String
init {
preferences.addListener(this)
commandRunner.addListener(this)
networkManager.addListener(this)
}
fun sync() = CoroutineScope(Dispatchers.Main).launch {
if (!preferences.isSyncEnabled) {
logger.info("Device sync is disabled. Skipping sync.")
return@launch
}
encryptionKey = EncryptionKey.fromBase64(preferences.encryptionKey)
syncKey = preferences.syncKey
logger.info("Starting sync (key: $syncKey)")
try {
pull()
push()
logger.info("Sync finished successfully.")
} catch (e: ConnectionLostException) {
logger.info("Network unavailable. Aborting sync.")
} catch (e: ServiceUnavailable) {
logger.info("Sync service unavailable. Aborting sync.")
} catch (e: Exception) {
logger.error("Unexpected sync exception. Disabling sync.")
logger.error(e)
preferences.disableSync()
}
}
private suspend fun push(depth: Int = 0) {
if (depth >= 5) {
throw RuntimeException()
}
if (!dirty) {
logger.info("Local database not modified. Skipping push.")
return
}
logger.info("Encrypting local database...")
val encryptedDB = db.file!!.encryptToString(encryptionKey)
val size = encryptedDB.length / 1024
try {
logger.info("Pushing local database (version $currVersion, $size KB)")
assertConnected()
server.put(preferences.syncKey, SyncData(currVersion, encryptedDB))
dirty = false
} catch (e: EditConflictException) {
logger.info("Sync conflict detected while pushing.")
setCurrentVersion(0)
pull()
push(depth = depth + 1)
}
}
private suspend fun pull() = Dispatchers.IO {
logger.info("Querying remote database version...")
assertConnected()
val remoteVersion = server.getDataVersion(syncKey)
logger.info("Remote database version: $remoteVersion")
if (remoteVersion <= currVersion) {
logger.info("Local database is up-to-date. Skipping merge.")
} else {
logger.info("Pulling remote database...")
assertConnected()
val data = server.getData(syncKey)
val size = data.content.length / 1024
logger.info("Pulled remote database (version ${data.version}, $size KB)")
logger.info("Decrypting remote database and merging with local changes...")
data.content.decryptToFile(encryptionKey, tmpFile)
try {
db.beginTransaction()
dbImporter.importHabitsFromFile(tmpFile)
db.setTransactionSuccessful()
} catch (e: Exception) {
logger.error("Failed to import database")
logger.error(e)
} finally {
db.endTransaction()
}
dirty = true
setCurrentVersion(data.version + 1)
}
}
fun onResume() = sync()
fun onPause() = sync()
override fun onSyncEnabled() {
logger.info("Sync enabled.")
setCurrentVersion(1)
dirty = true
sync()
}
override fun onNetworkAvailable() {
logger.info("Network available.")
connected = true
sync()
}
override fun onNetworkLost() {
logger.info("Network unavailable.")
connected = false
}
override fun onCommandFinished(command: Command) {
if (!dirty) setCurrentVersion(currVersion + 1)
dirty = true
}
private fun assertConnected() {
if (!connected) throw ConnectionLostException()
}
private fun setCurrentVersion(v: Long) {
currVersion = v
logger.info("Setting local database version: $currVersion")
}
}
class ConnectionLostException : RuntimeException()

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.tasks;
import androidx.annotation.*;
import org.isoron.uhabits.core.io.*;
import org.isoron.uhabits.core.models.*;
import java.io.*;
import java.util.*;
public class ExportCSVTask implements Task
{
private String archiveFilename;
@NonNull
private final List<Habit> selectedHabits;
private File outputDir;
@NonNull
private final ExportCSVTask.Listener listener;
@NonNull
private final HabitList habitList;
public ExportCSVTask(@NonNull HabitList habitList,
@NonNull List<Habit> selectedHabits,
@NonNull File outputDir,
@NonNull Listener listener)
{
this.listener = listener;
this.habitList = habitList;
this.selectedHabits = selectedHabits;
this.outputDir = outputDir;
}
@Override
public void doInBackground()
{
try
{
HabitsCSVExporter exporter;
exporter = new HabitsCSVExporter(habitList, selectedHabits, outputDir);
archiveFilename = exporter.writeArchive();
}
catch (Exception e)
{
e.printStackTrace();
}
}
@Override
public void onPostExecute()
{
listener.onExportCSVFinished(archiveFilename);
}
public interface Listener
{
void onExportCSVFinished(@Nullable String archiveFilename);
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.tasks
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import java.io.File
import javax.inject.Inject
class ExportCSVTaskFactory
@Inject constructor(
val habitList: HabitList
) {
fun create(
selectedHabits: List<Habit>,
outputDir: File,
listener: ExportCSVTask.Listener,
) = ExportCSVTask(habitList, selectedHabits, outputDir, listener)
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.tasks;
import java.util.*;
public class SingleThreadTaskRunner implements TaskRunner
{
private List<Listener> listeners = new LinkedList<>();
@Override
public void addListener(Listener listener)
{
listeners.add(listener);
}
@Override
public void execute(Task task)
{
for(Listener l : listeners) l.onTaskStarted(task);
if(!task.isCanceled())
{
task.onAttached(this);
task.onPreExecute();
task.doInBackground();
task.onPostExecute();
}
for(Listener l : listeners) l.onTaskFinished(task);
}
@Override
public int getActiveTaskCount()
{
return 0;
}
@Override
public void publishProgress(Task task, int progress)
{
task.onProgressUpdate(progress);
}
@Override
public void removeListener(Listener listener)
{
listeners.remove(listener);
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.tasks;
import androidx.annotation.*;
public interface Task
{
default void cancel() {}
default boolean isCanceled()
{
return false;
}
void doInBackground();
default void onAttached(@NonNull TaskRunner runner) {}
default void onPostExecute() {}
default void onPreExecute() {}
default void onProgressUpdate(int value) {}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.tasks;
public interface TaskRunner
{
void addListener(Listener listener);
void removeListener(Listener listener);
void execute(Task task);
void publishProgress(Task task, int progress);
int getActiveTaskCount();
interface Listener
{
void onTaskStarted(Task task);
void onTaskFinished(Task task);
}
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.test;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.models.sqlite.*;
import org.isoron.uhabits.core.utils.*;
import static org.isoron.uhabits.core.models.Entry.*;
public class HabitFixtures
{
public boolean NON_DAILY_HABIT_CHECKS[] = {
true, false, false, true, true, true, false, false, true, true
};
private final ModelFactory modelFactory;
private HabitList habitList;
public HabitFixtures(ModelFactory modelFactory, HabitList habitList)
{
this.modelFactory = modelFactory;
this.habitList = habitList;
}
public Habit createEmptyHabit()
{
Habit habit = modelFactory.buildHabit();
habit.setName("Meditate");
habit.setQuestion("Did you meditate this morning?");
habit.setColor(new PaletteColor(3));
habit.setFrequency(Frequency.DAILY);
saveIfSQLite(habit);
return habit;
}
public Habit createLongHabit()
{
Habit habit = createEmptyHabit();
habit.setFrequency(new Frequency(3, 7));
habit.setColor(new PaletteColor(4));
Timestamp today = DateUtils.getToday();
int marks[] = {0, 1, 3, 5, 7, 8, 9, 10, 12, 14, 15, 17, 19, 20, 26, 27,
28, 50, 51, 52, 53, 54, 58, 60, 63, 65, 70, 71, 72, 73, 74, 75, 80,
81, 83, 89, 90, 91, 95, 102, 103, 108, 109, 120};
for (int mark : marks)
habit.getOriginalEntries().add(new Entry(today.minus(mark), YES_MANUAL));
habit.recompute();
return habit;
}
public Habit createNumericalHabit()
{
Habit habit = modelFactory.buildHabit();
habit.setType(Habit.NUMBER_HABIT);
habit.setName("Run");
habit.setQuestion("How many miles did you run today?");
habit.setUnit("miles");
habit.setTargetType(Habit.AT_LEAST);
habit.setTargetValue(2.0);
habit.setColor(new PaletteColor(1));
saveIfSQLite(habit);
Timestamp today = DateUtils.getToday();
int times[] = {0, 1, 3, 5, 7, 8, 9, 10};
int values[] = {100, 200, 300, 400, 500, 600, 700, 800};
for (int i = 0; i < times.length; i++)
{
Timestamp timestamp = today.minus(times[i]);
habit.getOriginalEntries().add(new Entry(timestamp, values[i]));
}
habit.recompute();
return habit;
}
public Habit createLongNumericalHabit(Timestamp reference)
{
Habit habit = modelFactory.buildHabit();
habit.setType(Habit.NUMBER_HABIT);
habit.setName("Walk");
habit.setQuestion("How many steps did you walk today?");
habit.setUnit("steps");
habit.setTargetType(Habit.AT_LEAST);
habit.setTargetValue(100);
habit.setColor(new PaletteColor(1));
saveIfSQLite(habit);
int times[] = {0, 5, 9, 15, 17, 21, 23, 27, 28, 35, 41, 45, 47, 53, 56, 62, 70, 73, 78,
83, 86, 94, 101, 106, 113, 114, 120, 126, 130, 133, 141, 143, 148, 151, 157, 164,
166, 171, 173, 176, 179, 183, 191, 259, 264, 268, 270, 275, 282, 284, 289, 295,
302, 306, 310, 315, 323, 325, 328, 335, 343, 349, 351, 353, 357, 359, 360, 367,
372, 376, 380, 385, 393, 400, 404, 412, 415, 418, 422, 425, 433, 437, 444, 449,
455, 460, 462, 465, 470, 471, 479, 481, 485, 489, 494, 495, 500, 501, 503, 507};
int values[] = {230, 306, 148, 281, 134, 285, 104, 158, 325, 236, 303, 210, 118, 124,
301, 201, 156, 376, 347, 367, 396, 134, 160, 381, 155, 354, 231, 134, 164, 354,
236, 398, 199, 221, 208, 397, 253, 276, 214, 341, 299, 221, 353, 250, 341, 168,
374, 205, 182, 217, 297, 321, 104, 237, 294, 110, 136, 229, 102, 271, 250, 294,
158, 319, 379, 126, 282, 155, 288, 159, 215, 247, 207, 226, 244, 158, 371, 219,
272, 228, 350, 153, 356, 279, 394, 202, 213, 214, 112, 248, 139, 245, 165, 256,
370, 187, 208, 231, 341, 312};
for (int i = 0; i < times.length; i++)
{
Timestamp timestamp = reference.minus(times[i]);
habit.getOriginalEntries().add(new Entry(timestamp, values[i]));
}
habit.recompute();
return habit;
}
public Habit createShortHabit()
{
Habit habit = modelFactory.buildHabit();
habit.setName("Wake up early");
habit.setQuestion("Did you wake up before 6am?");
habit.setFrequency(new Frequency(2, 3));
saveIfSQLite(habit);
Timestamp timestamp = DateUtils.getToday();
for (boolean c : NON_DAILY_HABIT_CHECKS)
{
int value = NO;
if (c) value = YES_MANUAL;
habit.getOriginalEntries().add(new Entry(timestamp, value));
timestamp = timestamp.minus(1);
}
habit.recompute();
return habit;
}
private void saveIfSQLite(Habit habit)
{
if (!(habit.getOriginalEntries() instanceof SQLiteEntryList)) return;
habitList.add(habit);
}
}

View File

@@ -0,0 +1,238 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui;
import androidx.annotation.*;
import org.isoron.uhabits.core.*;
import org.isoron.uhabits.core.commands.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.tasks.*;
import org.isoron.uhabits.core.utils.*;
import java.util.*;
import javax.inject.*;
@AppScope
public class NotificationTray
implements CommandRunner.Listener, Preferences.Listener
{
public static final String REMINDERS_CHANNEL_ID = "REMINDERS";
@NonNull
private final TaskRunner taskRunner;
@NonNull
private final CommandRunner commandRunner;
@NonNull
private final Preferences preferences;
private SystemTray systemTray;
@NonNull
private final HashMap<Habit, NotificationData> active;
@Inject
public NotificationTray(@NonNull TaskRunner taskRunner,
@NonNull CommandRunner commandRunner,
@NonNull Preferences preferences,
@NonNull SystemTray systemTray)
{
this.taskRunner = taskRunner;
this.commandRunner = commandRunner;
this.preferences = preferences;
this.systemTray = systemTray;
this.active = new HashMap<>();
}
public void cancel(@NonNull Habit habit)
{
int notificationId = getNotificationId(habit);
systemTray.removeNotification(notificationId);
active.remove(habit);
}
@Override
public void onCommandFinished(@Nullable Command command)
{
if (command instanceof CreateRepetitionCommand)
{
CreateRepetitionCommand createCmd = (CreateRepetitionCommand) command;
Habit habit = createCmd.getHabit();
cancel(habit);
}
if (command instanceof DeleteHabitsCommand)
{
DeleteHabitsCommand deleteCommand = (DeleteHabitsCommand) command;
List<Habit> deleted = deleteCommand.getSelected();
for (Habit habit : deleted)
cancel(habit);
}
}
@Override
public void onNotificationsChanged()
{
reshowAll();
}
public void show(@NonNull Habit habit, Timestamp timestamp, long reminderTime)
{
NotificationData data = new NotificationData(timestamp, reminderTime);
active.put(habit, data);
taskRunner.execute(new ShowNotificationTask(habit, data));
}
public void startListening()
{
commandRunner.addListener(this);
preferences.addListener(this);
}
public void stopListening()
{
commandRunner.removeListener(this);
preferences.removeListener(this);
}
private int getNotificationId(Habit habit)
{
Long id = habit.getId();
if (id == null) return 0;
return (int) (id % Integer.MAX_VALUE);
}
private void reshowAll()
{
for (Habit habit : active.keySet())
{
NotificationData data = active.get(habit);
taskRunner.execute(new ShowNotificationTask(habit, data));
}
}
public interface SystemTray
{
void removeNotification(int notificationId);
void showNotification(Habit habit,
int notificationId,
Timestamp timestamp,
long reminderTime);
void log(String msg);
}
class NotificationData
{
public final Timestamp timestamp;
public final long reminderTime;
public NotificationData(Timestamp timestamp, long reminderTime)
{
this.timestamp = timestamp;
this.reminderTime = reminderTime;
}
}
private class ShowNotificationTask implements Task
{
int todayValue;
private final Habit habit;
private final Timestamp timestamp;
private final long reminderTime;
public ShowNotificationTask(Habit habit, NotificationData data)
{
this.habit = habit;
this.timestamp = data.timestamp;
this.reminderTime = data.reminderTime;
}
@Override
public void doInBackground()
{
Timestamp today = DateUtils.getTodayWithOffset();
todayValue = habit.getComputedEntries().get(today).getValue();
}
@Override
public void onPostExecute()
{
systemTray.log("Showing notification for habit=" + habit.getId());
if (todayValue != Entry.UNKNOWN) {
systemTray.log(String.format(
Locale.US,
"Habit %d already checked. Skipping.",
habit.getId()));
return;
}
if (!habit.hasReminder()) {
systemTray.log(String.format(
Locale.US,
"Habit %d does not have a reminder. Skipping.",
habit.getId()));
return;
}
if (habit.isArchived())
{
systemTray.log(String.format(
Locale.US,
"Habit %d is archived. Skipping.",
habit.getId()));
return;
}
if (!shouldShowReminderToday()) {
systemTray.log(String.format(
Locale.US,
"Habit %d not supposed to run today. Skipping.",
habit.getId()));
return;
}
systemTray.showNotification(habit, getNotificationId(habit), timestamp,
reminderTime);
}
private boolean shouldShowReminderToday()
{
if (!habit.hasReminder()) return false;
Reminder reminder = habit.getReminder();
boolean reminderDays[] = reminder.getDays().toArray();
int weekday = timestamp.getWeekday();
return reminderDays[weekday];
}
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui;
import androidx.annotation.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.ui.views.*;
public abstract class ThemeSwitcher
{
public static final int THEME_DARK = 1;
public static final int THEME_LIGHT = 2;
public static final int THEME_AUTOMATIC = 0;
private final Preferences preferences;
public ThemeSwitcher(@NonNull Preferences preferences)
{
this.preferences = preferences;
}
public void apply()
{
if (isNightMode())
{
if (preferences.isPureBlackEnabled()) applyPureBlackTheme();
else applyDarkTheme();
}
else
{
applyLightTheme();
}
}
public abstract void applyDarkTheme();
public abstract void applyLightTheme();
public abstract void applyPureBlackTheme();
public abstract int getSystemTheme();
public abstract Theme getCurrentTheme();
public boolean isNightMode()
{
int systemTheme = getSystemTheme();
int userTheme = preferences.getTheme();
return (userTheme == THEME_DARK ||
(systemTheme == THEME_DARK && userTheme == THEME_AUTOMATIC));
}
public void toggleNightMode()
{
int systemTheme = getSystemTheme();
int userTheme = preferences.getTheme();
if(userTheme == THEME_AUTOMATIC)
{
if(systemTheme == THEME_LIGHT) preferences.setTheme(THEME_DARK);
if(systemTheme == THEME_DARK) preferences.setTheme(THEME_LIGHT);
}
else if(userTheme == THEME_LIGHT)
{
if (systemTheme == THEME_LIGHT) preferences.setTheme(THEME_DARK);
if (systemTheme == THEME_DARK) preferences.setTheme(THEME_AUTOMATIC);
}
else if(userTheme == THEME_DARK)
{
if (systemTheme == THEME_LIGHT) preferences.setTheme(THEME_AUTOMATIC);
if (systemTheme == THEME_DARK) preferences.setTheme(THEME_LIGHT);
}
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.callbacks;
import org.isoron.uhabits.core.models.*;
public interface OnColorPickedCallback
{
void onColorPicked(PaletteColor color);
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.callbacks;
public interface OnConfirmedCallback
{
void onConfirmed();
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.callbacks;
public interface OnFinishedCallback
{
void onFinish();
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.callbacks
import org.isoron.uhabits.core.models.Timestamp
interface OnToggleCheckmarkListener {
fun onToggleEntry(timestamp: Timestamp, value: Int) {}
}

View File

@@ -0,0 +1,468 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.screens.habits.list;
import androidx.annotation.*;
import org.apache.commons.lang3.*;
import org.isoron.uhabits.core.*;
import org.isoron.uhabits.core.commands.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.tasks.*;
import org.isoron.uhabits.core.utils.*;
import java.util.*;
import javax.inject.*;
/**
* A HabitCardListCache fetches and keeps a cache of all the data necessary to
* render a HabitCardListView.
* <p>
* This is needed since performing database lookups during scrolling can make
* the ListView very slow. It also registers itself as an observer of the
* models, in order to update itself automatically.
* <p>
* Note that this class is singleton-scoped, therefore it is shared among all
* activities.
*/
@AppScope
public class HabitCardListCache implements CommandRunner.Listener
{
private int checkmarkCount;
@Nullable
private Task currentFetchTask;
@NonNull
private Listener listener;
@NonNull
private CacheData data;
@NonNull
private final HabitList allHabits;
@NonNull
private HabitList filteredHabits;
@NonNull
private final TaskRunner taskRunner;
@NonNull
private final CommandRunner commandRunner;
@Inject
public HabitCardListCache(@NonNull HabitList allHabits,
@NonNull CommandRunner commandRunner,
@NonNull TaskRunner taskRunner)
{
if (allHabits == null) throw new NullPointerException();
if (commandRunner == null) throw new NullPointerException();
if (taskRunner == null) throw new NullPointerException();
this.allHabits = allHabits;
this.commandRunner = commandRunner;
this.filteredHabits = allHabits;
this.taskRunner = taskRunner;
this.listener = new Listener()
{
};
data = new CacheData();
}
public synchronized void cancelTasks()
{
if (currentFetchTask != null) currentFetchTask.cancel();
}
public synchronized int[] getCheckmarks(long habitId)
{
return data.checkmarks.get(habitId);
}
/**
* Returns the habits that occupies a certain position on the list.
*
* @param position the position of the habit
* @return the habit at given position or null if position is invalid
*/
@Nullable
public synchronized Habit getHabitByPosition(int position)
{
if (position < 0 || position >= data.habits.size()) return null;
return data.habits.get(position);
}
public synchronized int getHabitCount()
{
return data.habits.size();
}
public synchronized HabitList.Order getPrimaryOrder()
{
return filteredHabits.getPrimaryOrder();
}
public synchronized HabitList.Order getSecondaryOrder()
{
return filteredHabits.getSecondaryOrder();
}
public synchronized double getScore(long habitId)
{
return data.scores.get(habitId);
}
public synchronized void onAttached()
{
refreshAllHabits();
commandRunner.addListener(this);
}
@Override
public synchronized void onCommandFinished(@Nullable Command command)
{
if (command instanceof CreateRepetitionCommand) {
Habit h = ((CreateRepetitionCommand) command).getHabit();
Long id = h.getId();
if (id != null) refreshHabit(id);
} else {
refreshAllHabits();
}
}
public synchronized void onDetached()
{
commandRunner.removeListener(this);
}
public synchronized void refreshAllHabits()
{
if (currentFetchTask != null) currentFetchTask.cancel();
currentFetchTask = new RefreshTask();
taskRunner.execute(currentFetchTask);
}
public synchronized void refreshHabit(long id)
{
taskRunner.execute(new RefreshTask(id));
}
public synchronized void remove(long id)
{
Habit h = data.id_to_habit.get(id);
if (h == null) return;
int position = data.habits.indexOf(h);
data.habits.remove(position);
data.id_to_habit.remove(id);
data.checkmarks.remove(id);
data.scores.remove(id);
listener.onItemRemoved(position);
}
public synchronized void reorder(int from, int to)
{
Habit fromHabit = data.habits.get(from);
data.habits.remove(from);
data.habits.add(to, fromHabit);
listener.onItemMoved(from, to);
}
public synchronized void setCheckmarkCount(int checkmarkCount)
{
this.checkmarkCount = checkmarkCount;
}
public synchronized void setFilter(@NonNull HabitMatcher matcher)
{
if (matcher == null) throw new NullPointerException();
filteredHabits = allHabits.getFiltered(matcher);
}
public synchronized void setListener(@NonNull Listener listener)
{
if (listener == null) throw new NullPointerException();
this.listener = listener;
}
public synchronized void setPrimaryOrder(@NonNull HabitList.Order order)
{
if (order == null) throw new NullPointerException();
allHabits.setPrimaryOrder(order);
filteredHabits.setPrimaryOrder(order);
refreshAllHabits();
}
public synchronized void setSecondaryOrder(@NonNull HabitList.Order order)
{
allHabits.setSecondaryOrder(order);
filteredHabits.setSecondaryOrder(order);
refreshAllHabits();
}
/**
* Interface definition for a callback to be invoked when the data on the
* cache has been modified.
*/
public interface Listener
{
default void onItemChanged(int position)
{
}
default void onItemInserted(int position)
{
}
default void onItemMoved(int oldPosition, int newPosition)
{
}
default void onItemRemoved(int position)
{
}
default void onRefreshFinished()
{
}
}
private class CacheData
{
@NonNull
public final HashMap<Long, Habit> id_to_habit;
@NonNull
public final List<Habit> habits;
@NonNull
public final HashMap<Long, int[]> checkmarks;
@NonNull
public final HashMap<Long, Double> scores;
/**
* Creates a new CacheData without any content.
*/
public CacheData()
{
id_to_habit = new HashMap<>();
habits = new LinkedList<>();
checkmarks = new HashMap<>();
scores = new HashMap<>();
}
public synchronized void copyCheckmarksFrom(@NonNull CacheData oldData)
{
if (oldData == null) throw new NullPointerException();
int[] empty = new int[checkmarkCount];
for (Long id : id_to_habit.keySet())
{
if (oldData.checkmarks.containsKey(id))
checkmarks.put(id, oldData.checkmarks.get(id));
else checkmarks.put(id, empty);
}
}
public synchronized void copyScoresFrom(@NonNull CacheData oldData)
{
if (oldData == null) throw new NullPointerException();
for (Long id : id_to_habit.keySet())
{
if (oldData.scores.containsKey(id))
scores.put(id, oldData.scores.get(id));
else scores.put(id, 0.0);
}
}
public synchronized void fetchHabits()
{
for (Habit h : filteredHabits)
{
if (h.getId() == null) continue;
habits.add(h);
id_to_habit.put(h.getId(), h);
}
}
}
private class RefreshTask implements Task
{
@NonNull
private final CacheData newData;
@Nullable
private final Long targetId;
private boolean isCancelled;
@Nullable
private TaskRunner runner;
public RefreshTask()
{
newData = new CacheData();
targetId = null;
isCancelled = false;
}
public RefreshTask(long targetId)
{
newData = new CacheData();
this.targetId = targetId;
}
@Override
public synchronized void cancel()
{
isCancelled = true;
}
@Override
public synchronized void doInBackground()
{
newData.fetchHabits();
newData.copyScoresFrom(data);
newData.copyCheckmarksFrom(data);
Timestamp today = DateUtils.getTodayWithOffset();
Timestamp dateFrom = today.minus(checkmarkCount - 1);
if (runner != null) runner.publishProgress(this, -1);
for (int position = 0; position < newData.habits.size(); position++)
{
if (isCancelled) return;
Habit habit = newData.habits.get(position);
Long id = habit.getId();
if (targetId != null && !targetId.equals(id)) continue;
newData.scores.put(id, habit.getScores().get(today).getValue());
Integer[] entries = habit.getComputedEntries()
.getByInterval(dateFrom, today)
.stream()
.map(Entry::getValue)
.toArray(Integer[]::new);
newData.checkmarks.put(id, ArrayUtils.toPrimitive(entries));
runner.publishProgress(this, position);
}
}
@Override
public synchronized void onAttached(@NonNull TaskRunner runner)
{
if (runner == null) throw new NullPointerException();
this.runner = runner;
}
@Override
public synchronized void onPostExecute()
{
currentFetchTask = null;
listener.onRefreshFinished();
}
@Override
public synchronized void onProgressUpdate(int currentPosition)
{
if (currentPosition < 0) processRemovedHabits();
else processPosition(currentPosition);
}
private synchronized void performInsert(Habit habit, int position)
{
Long id = habit.getId();
data.habits.add(position, habit);
data.id_to_habit.put(id, habit);
data.scores.put(id, newData.scores.get(id));
data.checkmarks.put(id, newData.checkmarks.get(id));
listener.onItemInserted(position);
}
private synchronized void performMove(@NonNull Habit habit,
int fromPosition,
int toPosition)
{
if(habit == null) throw new NullPointerException();
data.habits.remove(fromPosition);
data.habits.add(toPosition, habit);
listener.onItemMoved(fromPosition, toPosition);
}
private synchronized void performUpdate(long id, int position)
{
double oldScore = data.scores.get(id);
int[] oldCheckmarks = data.checkmarks.get(id);
double newScore = newData.scores.get(id);
int[] newCheckmarks = newData.checkmarks.get(id);
boolean unchanged = true;
if (oldScore != newScore) unchanged = false;
if (!Arrays.equals(oldCheckmarks, newCheckmarks)) unchanged = false;
if (unchanged) return;
data.scores.put(id, newScore);
data.checkmarks.put(id, newCheckmarks);
listener.onItemChanged(position);
}
private synchronized void processPosition(int currentPosition)
{
Habit habit = newData.habits.get(currentPosition);
Long id = habit.getId();
int prevPosition = data.habits.indexOf(habit);
if (prevPosition < 0)
{
performInsert(habit, currentPosition);
}
else
{
if (prevPosition != currentPosition)
performMove(habit, prevPosition, currentPosition);
performUpdate(id, currentPosition);
}
}
private synchronized void processRemovedHabits()
{
Set<Long> before = data.id_to_habit.keySet();
Set<Long> after = newData.id_to_habit.keySet();
Set<Long> removed = new TreeSet<>(before);
removed.removeAll(after);
for (Long id : removed) remove(id);
}
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.screens.habits.list;
import androidx.annotation.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.utils.*;
/**
* Provides a list of hints to be shown at the application startup, and takes
* care of deciding when a new hint should be shown.
*/
public class HintList
{
private final Preferences prefs;
@NonNull
private final String[] hints;
/**
* Constructs a new list containing the provided hints.
*
* @param hints initial list of hints
*/
public HintList(@NonNull Preferences prefs,
@NonNull String hints[])
{
this.prefs = prefs;
this.hints = hints;
}
/**
* Returns a new hint to be shown to the user.
* <p>
* The hint returned is marked as read on the list, and will not be returned
* again. In case all hints have already been read, and there is nothing
* left, returns null.
*
* @return the next hint to be shown, or null if none
*/
public String pop()
{
int next = prefs.getLastHintNumber() + 1;
if (next >= hints.length) return null;
prefs.updateLastHint(next, DateUtils.getToday());
return hints[next];
}
/**
* Returns whether it is time to show a new hint or not.
*
* @return true if hint should be shown, false otherwise
*/
public boolean shouldShow()
{
Timestamp today = DateUtils.getToday();
Timestamp lastHintTimestamp = prefs.getLastHintTimestamp();
return (lastHintTimestamp.isOlderThan(today));
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.screens.habits.list
import org.isoron.uhabits.core.preferences.Preferences
import javax.inject.Inject
class HintListFactory
@Inject constructor(
val preferences: Preferences,
) {
fun create(hints: Array<String>) = HintList(preferences, hints)
}

View File

@@ -0,0 +1,216 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.screens.habits.list;
import androidx.annotation.*;
import org.isoron.uhabits.core.commands.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.tasks.*;
import org.isoron.uhabits.core.ui.callbacks.*;
import org.isoron.uhabits.core.utils.*;
import org.jetbrains.annotations.*;
import java.io.*;
import java.util.*;
import javax.inject.*;
public class ListHabitsBehavior
{
@NonNull
private final HabitList habitList;
@NonNull
private final DirFinder dirFinder;
@NonNull
private final TaskRunner taskRunner;
@NonNull
private final Screen screen;
@NonNull
private final CommandRunner commandRunner;
@NonNull
private final Preferences prefs;
@NonNull
private final BugReporter bugReporter;
@Inject
public ListHabitsBehavior(@NonNull HabitList habitList,
@NonNull DirFinder dirFinder,
@NonNull TaskRunner taskRunner,
@NonNull Screen screen,
@NonNull CommandRunner commandRunner,
@NonNull Preferences prefs,
@NonNull BugReporter bugReporter)
{
this.habitList = habitList;
this.dirFinder = dirFinder;
this.taskRunner = taskRunner;
this.screen = screen;
this.commandRunner = commandRunner;
this.prefs = prefs;
this.bugReporter = bugReporter;
}
public void onClickHabit(@NonNull Habit h)
{
screen.showHabitScreen(h);
}
public void onEdit(@NonNull Habit habit, Timestamp timestamp)
{
EntryList entries = habit.getComputedEntries();
double oldValue = entries.get(timestamp).getValue();
screen.showNumberPicker(oldValue / 1000, habit.getUnit(), newValue ->
{
newValue = Math.round(newValue * 1000);
commandRunner.run(
new CreateRepetitionCommand(habitList, habit, timestamp, (int) newValue)
);
});
}
public void onExportCSV()
{
List<Habit> selected = new LinkedList<>();
for (Habit h : habitList) selected.add(h);
File outputDir = dirFinder.getCSVOutputDir();
taskRunner.execute(
new ExportCSVTask(habitList, selected, outputDir, filename ->
{
if (filename != null) screen.showSendFileScreen(filename);
else screen.showMessage(Message.COULD_NOT_EXPORT);
}));
}
public void onFirstRun()
{
prefs.setFirstRun(false);
prefs.updateLastHint(-1, DateUtils.getToday());
screen.showIntroScreen();
}
public void onReorderHabit(@NonNull Habit from, @NonNull Habit to)
{
taskRunner.execute(() -> habitList.reorder(from, to));
}
public void onRepairDB()
{
taskRunner.execute(() ->
{
habitList.repair();
screen.showMessage(Message.DATABASE_REPAIRED);
});
}
public void onSendBugReport()
{
bugReporter.dumpBugReportToFile();
try
{
String log = bugReporter.getBugReport();
screen.showSendBugReportToDeveloperScreen(log);
}
catch (IOException e)
{
e.printStackTrace();
screen.showMessage(Message.COULD_NOT_GENERATE_BUG_REPORT);
}
}
public void onStartup()
{
prefs.incrementLaunchCount();
if (prefs.isFirstRun()) onFirstRun();
}
public void onToggle(@NonNull Habit habit, Timestamp timestamp, int value)
{
commandRunner.run(
new CreateRepetitionCommand(habitList, habit, timestamp, value)
);
}
public void onSyncKeyOffer(@NotNull String syncKey, @NotNull String encryptionKey)
{
if(prefs.getSyncKey().equals(syncKey)) {
screen.showMessage(Message.SYNC_KEY_ALREADY_INSTALLED);
return;
}
screen.showConfirmInstallSyncKey(() -> {
prefs.enableSync(syncKey, encryptionKey);
screen.showMessage(Message.SYNC_ENABLED);
});
}
public enum Message
{
COULD_NOT_EXPORT, IMPORT_SUCCESSFUL, IMPORT_FAILED, DATABASE_REPAIRED,
COULD_NOT_GENERATE_BUG_REPORT, FILE_NOT_RECOGNIZED, SYNC_ENABLED, SYNC_KEY_ALREADY_INSTALLED
}
public interface BugReporter
{
void dumpBugReportToFile();
String getBugReport() throws IOException;
}
public interface DirFinder
{
File getCSVOutputDir();
}
public interface NumberPickerCallback
{
void onNumberPicked(double newValue);
default void onNumberPickerDismissed() {}
}
public interface Screen
{
void showHabitScreen(@NonNull Habit h);
void showIntroScreen();
void showMessage(@NonNull Message m);
void showNumberPicker(double value,
@NonNull String unit,
@NonNull NumberPickerCallback callback);
void showSendBugReportToDeveloperScreen(String log);
void showSendFileScreen(@NonNull String filename);
void showConfirmInstallSyncKey(@NonNull OnConfirmedCallback callback);
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.screens.habits.list;
import androidx.annotation.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.ui.*;
import javax.inject.*;
public class ListHabitsMenuBehavior
{
@NonNull
private final Screen screen;
@NonNull
private final Adapter adapter;
@NonNull
private final Preferences preferences;
@NonNull
private final ThemeSwitcher themeSwitcher;
private boolean showCompleted;
private boolean showArchived;
@Inject
public ListHabitsMenuBehavior(@NonNull Screen screen,
@NonNull Adapter adapter,
@NonNull Preferences preferences,
@NonNull ThemeSwitcher themeSwitcher)
{
this.screen = screen;
this.adapter = adapter;
this.preferences = preferences;
this.themeSwitcher = themeSwitcher;
showCompleted = preferences.getShowCompleted();
showArchived = preferences.getShowArchived();
updateAdapterFilter();
}
public void onCreateHabit()
{
screen.showSelectHabitTypeDialog();
}
public void onViewFAQ()
{
screen.showFAQScreen();
}
public void onViewAbout()
{
screen.showAboutScreen();
}
public void onViewSettings()
{
screen.showSettingsScreen();
}
public void onToggleShowArchived()
{
showArchived = !showArchived;
preferences.setShowArchived(showArchived);
updateAdapterFilter();
}
public void onToggleShowCompleted()
{
showCompleted = !showCompleted;
preferences.setShowCompleted(showCompleted);
updateAdapterFilter();
}
public void onSortByManually()
{
adapter.setPrimaryOrder(HabitList.Order.BY_POSITION);
}
public void onSortByColor()
{
onSortToggleBy(HabitList.Order.BY_COLOR_ASC, HabitList.Order.BY_COLOR_DESC);
}
public void onSortByScore()
{
onSortToggleBy(HabitList.Order.BY_SCORE_DESC, HabitList.Order.BY_SCORE_ASC);
}
public void onSortByName()
{
onSortToggleBy(HabitList.Order.BY_NAME_ASC, HabitList.Order.BY_NAME_DESC);
}
public void onSortByStatus()
{
onSortToggleBy(HabitList.Order.BY_STATUS_ASC, HabitList.Order.BY_STATUS_DESC);
}
private void onSortToggleBy(HabitList.Order defaultOrder, HabitList.Order reversedOrder)
{
if (adapter.getPrimaryOrder() != defaultOrder) {
if (adapter.getPrimaryOrder() != reversedOrder) {
adapter.setSecondaryOrder(adapter.getPrimaryOrder());
}
adapter.setPrimaryOrder(defaultOrder);
} else {
adapter.setPrimaryOrder(reversedOrder);
}
}
public void onToggleNightMode()
{
themeSwitcher.toggleNightMode();
screen.applyTheme();
}
private void updateAdapterFilter()
{
adapter.setFilter(new HabitMatcherBuilder()
.setArchivedAllowed(showArchived)
.setCompletedAllowed(showCompleted)
.build());
adapter.refresh();
}
public interface Adapter
{
void refresh();
void setFilter(HabitMatcher build);
void setPrimaryOrder(HabitList.Order order);
void setSecondaryOrder(HabitList.Order order);
HabitList.Order getPrimaryOrder();
}
public interface Screen
{
void applyTheme();
void showAboutScreen();
void showFAQScreen();
void showSettingsScreen();
void showSelectHabitTypeDialog();
}
}

View File

@@ -0,0 +1,145 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.screens.habits.list;
import androidx.annotation.*;
import org.isoron.uhabits.core.commands.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.ui.callbacks.*;
import java.util.*;
import javax.inject.*;
public class ListHabitsSelectionMenuBehavior
{
@NonNull
private final Screen screen;
@NonNull
CommandRunner commandRunner;
@NonNull
private final Adapter adapter;
@NonNull
private final HabitList habitList;
@Inject
public ListHabitsSelectionMenuBehavior(@NonNull HabitList habitList,
@NonNull Screen screen,
@NonNull Adapter adapter,
@NonNull CommandRunner commandRunner)
{
this.habitList = habitList;
this.screen = screen;
this.adapter = adapter;
this.commandRunner = commandRunner;
}
public boolean canArchive()
{
for (Habit h : adapter.getSelected())
if (h.isArchived()) return false;
return true;
}
public boolean canEdit()
{
return (adapter.getSelected().size() == 1);
}
public boolean canUnarchive()
{
for (Habit h : adapter.getSelected())
if (!h.isArchived()) return false;
return true;
}
public void onArchiveHabits()
{
commandRunner.run(
new ArchiveHabitsCommand(habitList, adapter.getSelected()));
adapter.clearSelection();
}
public void onChangeColor()
{
List<Habit> selected = adapter.getSelected();
Habit first = selected.get(0);
screen.showColorPicker(first.getColor(), selectedColor ->
{
commandRunner.run(
new ChangeHabitColorCommand(habitList, selected, selectedColor)
);
adapter.clearSelection();
});
}
public void onDeleteHabits()
{
List<Habit> selected = adapter.getSelected();
screen.showDeleteConfirmationScreen(() ->
{
adapter.performRemove(selected);
commandRunner.run(new DeleteHabitsCommand(habitList, selected)
);
adapter.clearSelection();
}, selected.size());
}
public void onEditHabits()
{
screen.showEditHabitsScreen(adapter.getSelected());
adapter.clearSelection();
}
public void onUnarchiveHabits()
{
commandRunner.run(
new UnarchiveHabitsCommand(habitList, adapter.getSelected()));
adapter.clearSelection();
}
public interface Adapter
{
void clearSelection();
List<Habit> getSelected();
void performRemove(List<Habit> selected);
}
public interface Screen
{
void showColorPicker(PaletteColor defaultColor,
@NonNull OnColorPickedCallback callback);
void showDeleteConfirmationScreen(
@NonNull OnConfirmedCallback callback,
int quantity);
void showEditHabitsScreen(@NonNull List<Habit> selected);
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.screens.habits.show
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.ui.screens.habits.show.views.BarCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.BarCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.FrequencyCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.FrequencyCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.HistoryCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.HistoryCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.NotesCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.NotesCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.OverviewCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.OverviewCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.ScoreCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.ScoreCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.StreakCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.StreakCartPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.SubtitleCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.SubtitleCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.TargetCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.TargetCardState
import org.isoron.uhabits.core.ui.views.Theme
data class ShowHabitState(
val title: String = "",
val isNumerical: Boolean = false,
val color: PaletteColor = PaletteColor(1),
val subtitle: SubtitleCardState,
val overview: OverviewCardState,
val notes: NotesCardState,
val target: TargetCardState,
val streaks: StreakCardState,
val scores: ScoreCardState,
val frequency: FrequencyCardState,
val history: HistoryCardState,
val bar: BarCardState,
)
class ShowHabitPresenter(
val habit: Habit,
val habitList: HabitList,
val preferences: Preferences,
val screen: Screen,
val commandRunner: CommandRunner,
) {
val historyCardPresenter = HistoryCardPresenter(
commandRunner = commandRunner,
habit = habit,
habitList = habitList,
preferences = preferences,
screen = screen,
)
val barCardPresenter = BarCardPresenter(
preferences = preferences,
screen = screen,
)
val scoreCardPresenter = ScoreCardPresenter(
preferences = preferences,
screen = screen,
)
companion object {
fun buildState(
habit: Habit,
preferences: Preferences,
theme: Theme,
): ShowHabitState {
return ShowHabitState(
title = habit.name,
color = habit.color,
isNumerical = habit.isNumerical,
subtitle = SubtitleCardPresenter.buildState(
habit = habit,
),
overview = OverviewCardPresenter.buildState(
habit = habit,
),
notes = NotesCardPresenter.buildState(
habit = habit,
),
target = TargetCardPresenter.buildState(
habit = habit,
firstWeekday = preferences.firstWeekdayInt,
),
streaks = StreakCartPresenter.buildState(
habit = habit,
),
scores = ScoreCardPresenter.buildState(
spinnerPosition = preferences.scoreCardSpinnerPosition,
habit = habit,
firstWeekday = preferences.firstWeekdayInt,
),
frequency = FrequencyCardPresenter.buildState(
habit = habit,
firstWeekday = preferences.firstWeekdayInt,
),
history = HistoryCardPresenter.buildState(
habit = habit,
firstWeekday = preferences.firstWeekday,
theme = theme,
),
bar = BarCardPresenter.buildState(
habit = habit,
firstWeekday = preferences.firstWeekdayInt,
boolSpinnerPosition = preferences.barCardBoolSpinnerPosition,
numericalSpinnerPosition = preferences.barCardNumericalSpinnerPosition,
theme = theme,
),
)
}
}
interface Screen :
BarCardPresenter.Screen,
ScoreCardPresenter.Screen,
HistoryCardPresenter.Screen
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (C) 2017 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.screens.habits.show
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.DeleteHabitsCommand
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.tasks.ExportCSVTask
import org.isoron.uhabits.core.tasks.TaskRunner
import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback
import org.isoron.uhabits.core.utils.DateUtils
import java.io.File
import java.util.Random
class ShowHabitMenuPresenter(
private val commandRunner: CommandRunner,
private val habit: Habit,
private val habitList: HabitList,
private val screen: Screen,
private val system: System,
private val taskRunner: TaskRunner,
) {
fun onEditHabit() {
screen.showEditHabitScreen(habit)
}
fun onExportCSV() {
val outputDir = system.getCSVOutputDir()
taskRunner.execute(
ExportCSVTask(habitList, listOf(habit), outputDir) { filename: String? ->
if (filename != null) {
screen.showSendFileScreen(filename)
} else {
screen.showMessage(Message.COULD_NOT_EXPORT)
}
}
)
}
fun onDeleteHabit() {
screen.showDeleteConfirmationScreen {
commandRunner.run(DeleteHabitsCommand(habitList, listOf(habit)))
screen.close()
}
}
fun onRandomize() {
val random = Random()
habit.originalEntries.clear()
var strength = 50.0
for (i in 0 until 365 * 5) {
if (i % 7 == 0) strength = Math.max(0.0, Math.min(100.0, strength + 10 * random.nextGaussian()))
if (random.nextInt(100) > strength) continue
var value = Entry.YES_MANUAL
if (habit.isNumerical) value = (1000 + 250 * random.nextGaussian() * strength / 100).toInt() * 1000
habit.originalEntries.add(Entry(DateUtils.getToday().minus(i), value))
}
habit.recompute()
screen.refresh()
}
enum class Message {
COULD_NOT_EXPORT
}
interface Screen {
fun showEditHabitScreen(habit: Habit)
fun showMessage(m: Message?)
fun showSendFileScreen(filename: String)
fun showDeleteConfirmationScreen(callback: OnConfirmedCallback)
fun close()
fun refresh()
}
interface System {
fun getCSVOutputDir(): File
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.screens.habits.show.views
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.models.groupedSum
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.ui.views.Theme
import org.isoron.uhabits.core.utils.DateUtils
data class BarCardState(
val theme: Theme,
val boolSpinnerPosition: Int,
val bucketSize: Int,
val color: PaletteColor,
val entries: List<Entry>,
val isNumerical: Boolean,
val numericalSpinnerPosition: Int,
)
class BarCardPresenter(
val preferences: Preferences,
val screen: Screen,
) {
companion object {
val numericalBucketSizes = intArrayOf(1, 7, 31, 92, 365)
val boolBucketSizes = intArrayOf(7, 31, 92, 365)
fun buildState(
habit: Habit,
firstWeekday: Int,
numericalSpinnerPosition: Int,
boolSpinnerPosition: Int,
theme: Theme,
): BarCardState {
val bucketSize = if (habit.isNumerical) {
numericalBucketSizes[numericalSpinnerPosition]
} else {
boolBucketSizes[boolSpinnerPosition]
}
val today = DateUtils.getToday()
val oldest = habit.computedEntries.getKnown().lastOrNull()?.timestamp ?: today
val entries = habit.computedEntries.getByInterval(oldest, today).groupedSum(
truncateField = ScoreCardPresenter.getTruncateField(bucketSize),
firstWeekday = firstWeekday,
isNumerical = habit.isNumerical,
)
return BarCardState(
theme = theme,
entries = entries,
bucketSize = bucketSize,
color = habit.color,
isNumerical = habit.isNumerical,
numericalSpinnerPosition = numericalSpinnerPosition,
boolSpinnerPosition = boolSpinnerPosition,
)
}
}
fun onNumericalSpinnerPosition(position: Int) {
preferences.barCardNumericalSpinnerPosition = position
screen.updateWidgets()
screen.refresh()
}
fun onBoolSpinnerPosition(position: Int) {
preferences.barCardBoolSpinnerPosition = position
screen.updateWidgets()
screen.refresh()
}
interface Screen {
fun updateWidgets()
fun refresh()
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.screens.habits.show.views
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.models.Timestamp
import java.util.HashMap
data class FrequencyCardState(
val color: PaletteColor,
val firstWeekday: Int,
val frequency: HashMap<Timestamp, Array<Int>>,
)
class FrequencyCardPresenter {
companion object {
fun buildState(
habit: Habit,
firstWeekday: Int,
) = FrequencyCardState(
color = habit.color,
frequency = habit.originalEntries.computeWeekdayFrequency(
isNumerical = habit.isNumerical
),
firstWeekday = firstWeekday,
)
}
}

Some files were not shown because too many files have changed in this diff Show More