├── .gitignore ├── LICENSE ├── PRIVACY.md ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── debug.keystore ├── proguard-rules.pro ├── schemas │ └── org.kde.bettercounter.boilerplate.AppDatabase │ │ └── 3.json └── src │ ├── debug │ └── res │ │ └── values │ │ └── strings.xml │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── org │ │ │ └── kde │ │ │ └── bettercounter │ │ │ ├── BetterApplication.kt │ │ │ ├── boilerplate │ │ │ ├── AppDatabase.kt │ │ │ ├── Converters.kt │ │ │ ├── CreateFileResultContract.kt │ │ │ ├── DragAndSwipeTouchHelper.kt │ │ │ ├── ImeHelper.kt │ │ │ └── OpenFileResultContract.kt │ │ │ ├── extensions │ │ │ ├── CalendarExtension.kt │ │ │ ├── ChronoUnitExtension.kt │ │ │ ├── DateExtension.kt │ │ │ ├── Dimensions.kt │ │ │ ├── LogExtension.kt │ │ │ ├── LongExtensions.kt │ │ │ └── ZonedDateTime.kt │ │ │ ├── persistence │ │ │ ├── AverageMode.kt │ │ │ ├── CounterColors.kt │ │ │ ├── CounterMetadata.kt │ │ │ ├── CounterSummary.kt │ │ │ ├── Entry.kt │ │ │ ├── EntryDao.kt │ │ │ ├── Exporter.kt │ │ │ ├── Interval.kt │ │ │ ├── Repository.kt │ │ │ └── Tutorial.kt │ │ │ └── ui │ │ │ ├── chart │ │ │ ├── BetterChart.kt │ │ │ ├── ChartHolder.kt │ │ │ └── ChartsAdapter.kt │ │ │ ├── editdialog │ │ │ ├── ColorAdapter.kt │ │ │ ├── CounterSettingsDialogBuilder.kt │ │ │ └── IntervalAdapter.kt │ │ │ ├── main │ │ │ ├── BetterRelativeTimeTextView.kt │ │ │ ├── DateTimePicker.kt │ │ │ ├── EntryListViewAdapter.kt │ │ │ ├── EntryViewHolder.kt │ │ │ ├── MainActivity.kt │ │ │ └── MainActivityViewModel.kt │ │ │ ├── settings │ │ │ ├── SettingsActivity.kt │ │ │ └── SettingsViewModel.kt │ │ │ └── widget │ │ │ ├── WidgetConfigureActivity.kt │ │ │ ├── WidgetProvider.kt │ │ │ └── WidgetViewModel.kt │ └── res │ │ ├── color │ │ └── outlinebox.xml │ │ ├── drawable-nodpi │ │ └── widget_preview.png │ │ ├── drawable │ │ ├── bg_color_circle.xml │ │ ├── button_state_color.xml │ │ ├── ic_add.xml │ │ ├── ic_check.xml │ │ ├── ic_edit.xml │ │ ├── ic_minusone.xml │ │ ├── ic_plusone.xml │ │ ├── ic_time.xml │ │ ├── ripple_color_1.xml │ │ ├── ripple_color_2.xml │ │ ├── ripple_color_3.xml │ │ ├── ripple_color_4.xml │ │ ├── ripple_color_5.xml │ │ ├── ripple_color_6.xml │ │ ├── ripple_color_7.xml │ │ ├── ripple_color_8.xml │ │ └── ripple_color_default.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── activity_settings.xml │ │ ├── color_circle.xml │ │ ├── counter_settings.xml │ │ ├── fragment_chart.xml │ │ ├── fragment_entry.xml │ │ ├── progress_dialog.xml │ │ ├── widget.xml │ │ ├── widget_configure.xml │ │ └── widget_preview.xml │ │ ├── menu │ │ ├── main.xml │ │ └── popup_menu.xml │ │ ├── mipmap-anydpi-v26 │ │ └── ic_launcher.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_adaptive_back.png │ │ ├── ic_launcher_adaptive_fore.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_adaptive_back.png │ │ ├── ic_launcher_adaptive_fore.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_adaptive_back.png │ │ ├── ic_launcher_adaptive_fore.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_adaptive_back.png │ │ ├── ic_launcher_adaptive_fore.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_adaptive_back.png │ │ ├── ic_launcher_adaptive_fore.png │ │ └── ic_launcher_monochrome.png │ │ ├── resources.properties │ │ ├── values-ca │ │ └── strings.xml │ │ ├── values-da │ │ └── strings.xml │ │ ├── values-de │ │ └── strings.xml │ │ ├── values-es │ │ └── strings.xml │ │ ├── values-fr │ │ └── strings.xml │ │ ├── values-ru │ │ └── strings.xml │ │ ├── values-tr │ │ └── strings.xml │ │ ├── values-zh-rCN │ │ └── strings.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── size_constants.xml │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ └── widget_info.xml │ └── test │ └── java │ └── org │ └── kde │ └── bettercounter │ └── extensions │ ├── CalendarExtensionTest.kt │ ├── ChronoUnitExtensionTest.kt │ └── ImportTest.kt ├── build.gradle.kts ├── fastlane ├── Appfile └── metadata │ └── android │ ├── ca │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt │ ├── da-DK │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt │ ├── en-US │ ├── changelogs │ │ ├── 30000.txt │ │ ├── 30001.txt │ │ ├── 30100.txt │ │ ├── 30200.txt │ │ ├── 40000.txt │ │ ├── 40001.txt │ │ ├── 40100.txt │ │ ├── 40200.txt │ │ ├── 40300.txt │ │ ├── 40301.txt │ │ ├── 40400.txt │ │ ├── 40401.txt │ │ ├── 40500.txt │ │ ├── 40600.txt │ │ ├── 40601.txt │ │ ├── 40602.txt │ │ ├── 40603.txt │ │ ├── 40700.txt │ │ ├── 40800.txt │ │ ├── 40900.txt │ │ ├── 40901.txt │ │ ├── 40902.txt │ │ ├── 40903.txt │ │ ├── 40904.txt │ │ ├── 41000.txt │ │ ├── 41001.txt │ │ ├── 41002.txt │ │ ├── 50001.txt │ │ ├── 50003.txt │ │ └── 50100.txt │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ └── phoneScreenshots │ │ │ ├── 1.png │ │ │ ├── 2.png │ │ │ ├── 3.png │ │ │ └── 4.png │ ├── short_description.txt │ └── title.txt │ ├── es-ES │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt │ └── fr-FR │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── screenshot.png └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | .kotlin 11 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | BetterCounter works offline, so no personal information is sent to any server or ever leaves your device. 2 | 3 | Note, however, that if your Android phone has [Backup by Google One](https://support.google.com/android/answer/2819582) enabled, then Google will make a periodic backup of the app's data to their servers. 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BetterCounter 2 | 3 | BetterCounter icon 4 | 5 | ### A multi-purpose counter app 6 | 7 | - Track good and bad habits (eg: exercising, smoking, drinking...) 8 | - Track when's the last time you did something (eg: water your plants, change your bedsheets, poop...) 9 | - Count your lives in MtG 10 | 11 | ### Get it now! 12 | 13 | - From [F-Droid](https://f-droid.org/vi/packages/org.kde.bettercounter/) 14 | - From the [Play Store](https://play.google.com/store/apps/details?id=org.kde.bettercounter) 15 | 16 | ### Features 17 | 18 | - Records the date and time of each individual counter increase. 19 | - Graphs your data over time and calculates statistics. 20 | - Lets you export your data (eg: to analyze it with your choice of tools). 21 | - Your data is never sent to any server (except for Google's app backup, if enabled). 22 | - Simple as hell and will stay this way. 23 | 24 | ### Screens 25 | 26 | BetterCounter screenshot 27 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | buildscript { 2 | dependencies { 3 | classpath(libs.android.gradlePlugin) 4 | classpath(libs.kotlin.gradlePlugin) 5 | } 6 | } 7 | 8 | plugins { 9 | alias(libs.plugins.android.application) 10 | alias(libs.plugins.kotlin.android) 11 | alias(libs.plugins.google.devtools.ksp) 12 | } 13 | 14 | android { 15 | namespace = "org.kde.bettercounter" 16 | compileSdk = 35 17 | defaultConfig { 18 | applicationId = "org.kde.bettercounter" 19 | minSdk = 21 20 | targetSdk = 35 21 | versionCode = 50100 22 | versionName = "5.1.0" 23 | 24 | javaCompileOptions { 25 | annotationProcessorOptions { 26 | argument("room.schemaLocation", "$projectDir/schemas") 27 | } 28 | } 29 | } 30 | buildFeatures { 31 | viewBinding = true 32 | buildConfig = true 33 | } 34 | compileOptions { 35 | isCoreLibraryDesugaringEnabled = true 36 | sourceCompatibility = JavaVersion.VERSION_17 37 | targetCompatibility = JavaVersion.VERSION_17 38 | } 39 | kotlinOptions { 40 | jvmTarget = "17" 41 | } 42 | androidResources { 43 | generateLocaleConfig = true 44 | } 45 | signingConfigs { 46 | getByName("debug") { 47 | storeFile = file("debug.keystore") 48 | storePassword = "android" 49 | keyAlias = "androiddebugkey" 50 | keyPassword = "android" 51 | } 52 | } 53 | buildTypes { 54 | getByName("release") { 55 | isMinifyEnabled = true 56 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 57 | } 58 | getByName("debug") { 59 | signingConfig = signingConfigs.getByName("debug") 60 | applicationIdSuffix = ".debug" 61 | versionNameSuffix = " Dev" 62 | } 63 | } 64 | } 65 | 66 | dependencies { 67 | implementation(libs.douglasjunior.android.simple.tooltip) 68 | implementation(libs.appdevnext.mpAndroidChart) 69 | implementation(libs.androidx.core.ktx) 70 | implementation(libs.androidx.appcompat) 71 | implementation(libs.androidx.lifecycle.viewmodel.ktx) 72 | implementation(libs.androidx.room.runtime) 73 | ksp(libs.androidx.room.compiler) 74 | implementation(libs.androidx.room.ktx) 75 | implementation(libs.androidx.recyclerview) 76 | implementation(libs.material) 77 | 78 | coreLibraryDesugaring(libs.android.desugarJdkLibs) // Chrono.UNITS for Android < 26 79 | testImplementation(libs.junit) 80 | } 81 | -------------------------------------------------------------------------------- /app/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertvaka/bettercounter/146940c0bcee1ea1b0d0ad8a4ce853a5161cc09d/app/debug.keystore -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | -dontobfuscate -------------------------------------------------------------------------------- /app/schemas/org.kde.bettercounter.boilerplate.AppDatabase/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 3, 5 | "identityHash": "82d3ea18843988649a6790a509244339", 6 | "entities": [ 7 | { 8 | "tableName": "Entry", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `date` INTEGER NOT NULL, `name` TEXT NOT NULL)", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": false 16 | }, 17 | { 18 | "fieldPath": "date", 19 | "columnName": "date", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "name", 25 | "columnName": "name", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | } 29 | ], 30 | "primaryKey": { 31 | "columnNames": [ 32 | "id" 33 | ], 34 | "autoGenerate": true 35 | }, 36 | "indices": [ 37 | { 38 | "name": "index_Entry_name", 39 | "unique": false, 40 | "columnNames": [ 41 | "name" 42 | ], 43 | "orders": [], 44 | "createSql": "CREATE INDEX IF NOT EXISTS `index_Entry_name` ON `${TABLE_NAME}` (`name`)" 45 | } 46 | ], 47 | "foreignKeys": [] 48 | } 49 | ], 50 | "views": [], 51 | "setupQueries": [ 52 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 53 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '82d3ea18843988649a6790a509244339')" 54 | ] 55 | } 56 | } -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | AAA Dev BetterCounter 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | 40 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/BetterApplication.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter 2 | 3 | import android.app.Application 4 | import android.util.Log 5 | 6 | 7 | class BetterApplication : Application() { 8 | 9 | override fun onCreate() { 10 | super.onCreate() 11 | Log.d("BetterApplication", "onCreate") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/boilerplate/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.boilerplate 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | import androidx.room.TypeConverters 8 | import org.kde.bettercounter.persistence.Entry 9 | import org.kde.bettercounter.persistence.EntryDao 10 | 11 | @Database(entities = [Entry::class], version = 3) 12 | @TypeConverters(Converters::class) 13 | abstract class AppDatabase : RoomDatabase() { 14 | abstract fun entryDao(): EntryDao 15 | 16 | companion object { 17 | @Volatile 18 | private var INSTANCE: AppDatabase? = null 19 | 20 | fun getInstance(context: Context): AppDatabase = 21 | INSTANCE ?: synchronized(this) { 22 | INSTANCE ?: Room.databaseBuilder( 23 | context.applicationContext, 24 | AppDatabase::class.java, "appdb" 25 | ).build().also { INSTANCE = it } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/boilerplate/Converters.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.boilerplate 2 | 3 | import androidx.room.TypeConverter 4 | import org.json.JSONArray 5 | import org.json.JSONException 6 | import java.util.Date 7 | 8 | class Converters { 9 | companion object { 10 | @TypeConverter 11 | @JvmStatic 12 | fun dateFromTimestamp(value: Long?): Date? { 13 | return value?.let { Date(it) } 14 | } 15 | 16 | @TypeConverter 17 | @JvmStatic 18 | fun dateToTimestamp(date: Date?): Long? { 19 | return date?.time 20 | } 21 | 22 | @JvmStatic 23 | fun stringListToString(list: List?): String { 24 | return JSONArray(list).toString() 25 | } 26 | 27 | @JvmStatic 28 | fun stringToStringList(jsonStr: String?): List { 29 | return try { 30 | val json = JSONArray(jsonStr) 31 | val ret: MutableList = ArrayList() 32 | for (i in 0 until json.length()) { 33 | ret.add(json.getString(i)) 34 | } 35 | ret 36 | } catch (e: JSONException) { 37 | e.printStackTrace() 38 | emptyList() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/boilerplate/CreateFileResultContract.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.boilerplate 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import androidx.activity.result.contract.ActivityResultContract 8 | 9 | data class CreateFileParams( 10 | val fileMimeType: String, 11 | val suggestedFileName: String, 12 | ) 13 | 14 | class CreateFileResultContract : ActivityResultContract() { 15 | 16 | override fun createIntent(context: Context, input: CreateFileParams): Intent = 17 | Intent(Intent.ACTION_CREATE_DOCUMENT).apply { 18 | addCategory(Intent.CATEGORY_OPENABLE) 19 | setTypeAndNormalize(input.fileMimeType) 20 | putExtra(Intent.EXTRA_TITLE, input.suggestedFileName) 21 | addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) 22 | addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) 23 | } 24 | 25 | override fun parseResult(resultCode: Int, intent: Intent?): Uri? = when (resultCode) { 26 | Activity.RESULT_OK -> intent?.data 27 | else -> null 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/boilerplate/DragAndSwipeTouchHelper.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.boilerplate 2 | 3 | import androidx.recyclerview.widget.ItemTouchHelper 4 | import androidx.recyclerview.widget.RecyclerView 5 | 6 | class DragAndSwipeTouchHelper(private val mAdapter: ListGesturesCallback) : 7 | ItemTouchHelper.Callback() { 8 | 9 | private var isDragging: Boolean = false 10 | 11 | override fun isLongPressDragEnabled(): Boolean = false // we start drag manually 12 | override fun isItemViewSwipeEnabled(): Boolean = true 13 | 14 | override fun getMovementFlags( 15 | recyclerView: RecyclerView, 16 | viewHolder: RecyclerView.ViewHolder, 17 | ): Int { 18 | val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN 19 | val swipeFlags = 0 20 | return makeMovementFlags(dragFlags, swipeFlags) 21 | } 22 | 23 | override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { 24 | } 25 | 26 | override fun onMove( 27 | recyclerView: RecyclerView, 28 | viewHolder: RecyclerView.ViewHolder, 29 | target: RecyclerView.ViewHolder 30 | ): Boolean { 31 | mAdapter.onMove(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) 32 | return true 33 | } 34 | 35 | override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { 36 | if (isDragging) { 37 | isDragging = false 38 | mAdapter.onDragEnd(viewHolder) 39 | } 40 | super.clearView(recyclerView, viewHolder) 41 | } 42 | 43 | override fun onSelectedChanged( 44 | viewHolder: RecyclerView.ViewHolder?, 45 | actionState: Int, 46 | ) { 47 | if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { 48 | isDragging = true 49 | mAdapter.onDragStart(viewHolder) 50 | } 51 | super.onSelectedChanged(viewHolder, actionState) 52 | } 53 | 54 | interface ListGesturesCallback { 55 | fun onMove(fromPosition: Int, toPosition: Int) 56 | fun onDragStart(viewHolder: RecyclerView.ViewHolder?) 57 | fun onDragEnd(viewHolder: RecyclerView.ViewHolder?) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/boilerplate/ImeHelper.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.boilerplate 2 | 3 | import android.content.Context.INPUT_METHOD_SERVICE 4 | import android.view.View 5 | import android.view.inputmethod.InputMethodManager 6 | import androidx.core.view.ViewCompat 7 | import androidx.core.view.WindowInsetsCompat 8 | 9 | fun isKeyboardVisible(rootView: View): Boolean { 10 | val imeInsets = ViewCompat.getRootWindowInsets(rootView)?.getInsets(WindowInsetsCompat.Type.ime()) ?: return false 11 | return imeInsets.bottom > 0 12 | } 13 | 14 | fun hideKeyboard(rootView: View) { 15 | val imm = rootView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager 16 | imm.hideSoftInputFromWindow(rootView.windowToken, 0) 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/boilerplate/OpenFileResultContract.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.boilerplate 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import androidx.activity.result.contract.ActivityResultContract 8 | 9 | data class OpenFileParams( 10 | val fileMimeType: String, 11 | ) 12 | 13 | class OpenFileResultContract : ActivityResultContract() { 14 | 15 | override fun createIntent(context: Context, input: OpenFileParams): Intent = 16 | Intent(Intent.ACTION_OPEN_DOCUMENT).apply { 17 | addCategory(Intent.CATEGORY_OPENABLE) 18 | setTypeAndNormalize(input.fileMimeType) 19 | } 20 | 21 | override fun parseResult(resultCode: Int, intent: Intent?): Uri? = when (resultCode) { 22 | Activity.RESULT_OK -> intent?.data 23 | else -> null 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/extensions/CalendarExtension.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.extensions 2 | 3 | import org.kde.bettercounter.persistence.Interval 4 | import java.text.SimpleDateFormat 5 | import java.util.Calendar 6 | import java.util.Locale 7 | 8 | fun Calendar.truncated(field: Int): Calendar { 9 | val cal = copy() 10 | cal.set(Calendar.MILLISECOND, 0) 11 | cal.set(Calendar.SECOND, 0) 12 | if (field == Calendar.MINUTE) return cal 13 | cal.set(Calendar.MINUTE, 0) 14 | if (field == Calendar.HOUR_OF_DAY || field == Calendar.HOUR) return cal 15 | cal.set(Calendar.HOUR_OF_DAY, 0) 16 | if (field in listOf(Calendar.DATE, Calendar.DAY_OF_WEEK, Calendar.DAY_OF_MONTH, Calendar.DAY_OF_YEAR)) return cal 17 | if (field in listOf(Calendar.WEEK_OF_YEAR, Calendar.WEEK_OF_MONTH)) { 18 | val dow = cal.get(Calendar.DAY_OF_WEEK) 19 | val offset = if (dow < firstDayOfWeek) 7 - (firstDayOfWeek - dow) else dow - firstDayOfWeek 20 | cal.add(Calendar.DAY_OF_MONTH, -offset) 21 | return cal 22 | } 23 | cal.set(Calendar.DATE, 1) 24 | if (field == Calendar.MONTH) return cal 25 | cal.set(Calendar.MONTH, Calendar.JANUARY) 26 | if (field == Calendar.YEAR) return cal 27 | throw UnsupportedOperationException("truncate by $field not implemented") 28 | } 29 | 30 | fun Calendar.truncated(field: Interval): Calendar = truncated(field.toChronoUnit().toCalendarField()) 31 | 32 | fun Calendar.debugToSimpleDateString(): String { 33 | val dateFormat = SimpleDateFormat("dd-MM-yyyy HH:mm", Locale.US) 34 | return dateFormat.format(time) 35 | } 36 | 37 | fun Calendar.copy(): Calendar = clone() as Calendar 38 | 39 | fun Calendar.plusInterval(interval: Interval, times: Int): Calendar { 40 | val cal = copy() 41 | when (interval) { 42 | Interval.HOUR -> cal.add(Calendar.HOUR_OF_DAY, 1 * times) 43 | Interval.DAY -> cal.add(Calendar.DAY_OF_YEAR, 1 * times) 44 | Interval.WEEK -> cal.add(Calendar.DAY_OF_YEAR, 7 * times) 45 | Interval.MONTH -> cal.add(Calendar.MONTH, 1 * times) 46 | Interval.YEAR -> cal.add(Calendar.YEAR, 1 * times) 47 | Interval.LIFETIME -> cal.add(Calendar.YEAR, 1000) 48 | } 49 | return cal 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/extensions/ChronoUnitExtension.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.extensions 2 | 3 | import java.time.LocalDateTime 4 | import java.time.ZoneId 5 | import java.time.temporal.ChronoUnit 6 | import java.util.Calendar 7 | import java.util.Date 8 | 9 | // Rounds up to the nearest integer. Both dates included. Eg: returns 2 weeks from Monday at 00:00 to next Monday at 00:00 10 | fun ChronoUnit.count(fromDate: Date, toDate: Date): Int { 11 | val calendarField = toCalendarField() 12 | val truncatedFrom = fromDate.toCalendar().truncated(calendarField) 13 | val truncatedTo = toDate.toCalendar().truncated(calendarField) 14 | return between(truncatedFrom, truncatedTo).toInt() + 1 15 | } 16 | 17 | fun ChronoUnit.between(from: Calendar, to: Calendar): Long { 18 | val systemTz = ZoneId.systemDefault() 19 | // ChronoUnit.between can use both ZonedDateTime and Instant, however, 20 | // ChronoUnit.WEEK.between doesn't work for Instant for some reason. 21 | val fromZonedDateTime = from.toInstant().atZone(systemTz) 22 | val toZonedDateTime = to.toInstant().atZone(systemTz) 23 | return between(fromZonedDateTime, toZonedDateTime) 24 | } 25 | 26 | fun ChronoUnit.toCalendarField(): Int = 27 | when (this) { 28 | ChronoUnit.HOURS -> Calendar.HOUR 29 | ChronoUnit.DAYS -> Calendar.DAY_OF_WEEK 30 | ChronoUnit.WEEKS -> Calendar.WEEK_OF_YEAR 31 | ChronoUnit.MONTHS -> Calendar.MONTH 32 | ChronoUnit.YEARS -> Calendar.YEAR 33 | else -> throw UnsupportedOperationException("$this can't be converted to Calendar field") 34 | } 35 | 36 | fun millisecondsUntilNextHour(): Long { 37 | val current = LocalDateTime.now() 38 | val nextHour = current.truncatedTo(ChronoUnit.HOURS).plusHours(1) 39 | return ChronoUnit.MILLIS.between(current, nextHour) 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/extensions/DateExtension.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.extensions 2 | 3 | import java.util.Calendar 4 | import java.util.Date 5 | 6 | fun Date.toCalendar(): Calendar { 7 | val cal = Calendar.getInstance() 8 | cal.time = this 9 | return cal 10 | } 11 | 12 | fun max(a: Date, b: Date): Date { 13 | return if (a.time > b.time) a else b 14 | } 15 | 16 | fun min(a: Date, b: Date): Date { 17 | return if (a.time < b.time) a else b 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/extensions/Dimensions.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.extensions 2 | 3 | import android.content.Context 4 | 5 | fun Int.dpToPx(context: Context): Int { 6 | val density = context.resources.displayMetrics.density 7 | return (this * density).toInt() 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/extensions/LogExtension.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.extensions 2 | 3 | import android.util.Log 4 | 5 | fun T.andLog(tag: String = "LOGGERINO"): T { 6 | Log.e(tag, this.toString()) 7 | return this 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/extensions/LongExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.extensions 2 | 3 | import java.time.Instant 4 | import java.time.LocalDateTime 5 | import java.time.ZoneId 6 | import java.time.ZoneOffset 7 | 8 | fun Long.toLocalDateTime(): LocalDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault()) 9 | 10 | fun Long.toUTCLocalDateTime(): LocalDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneId.ofOffset("UTC", ZoneOffset.UTC)) 11 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/extensions/ZonedDateTime.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.extensions 2 | 3 | import java.time.ZonedDateTime 4 | 5 | fun ZonedDateTime.toEpochMilli(): Long = toInstant().toEpochMilli() 6 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/persistence/AverageMode.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.persistence 2 | 3 | enum class AverageMode { 4 | FIRST_TO_NOW, // Average between first entry and now 5 | FIRST_TO_LAST // Average between first entry and last entry 6 | } 7 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/persistence/CounterColors.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.persistence 2 | 3 | import android.content.Context 4 | import androidx.core.content.ContextCompat 5 | import org.kde.bettercounter.R 6 | 7 | @JvmInline 8 | value class CounterColor(val colorInt: Int) 9 | 10 | class CounterColors { 11 | val defaultColor : CounterColor 12 | val allColors : List 13 | val rippleDrawables : Map 14 | val defaultColorIntForChart : Int 15 | 16 | private constructor(context : Context) { 17 | defaultColor = CounterColor(ContextCompat.getColor(context, R.color.colorLightBackground)) 18 | defaultColorIntForChart = ContextCompat.getColor(context, R.color.colorAccent) 19 | rippleDrawables = mapOf( 20 | defaultColor.colorInt to R.drawable.ripple_color_default, 21 | ContextCompat.getColor(context, R.color.color1) to R.drawable.ripple_color_1, 22 | ContextCompat.getColor(context, R.color.color2) to R.drawable.ripple_color_2, 23 | ContextCompat.getColor(context, R.color.color3) to R.drawable.ripple_color_3, 24 | ContextCompat.getColor(context, R.color.color4) to R.drawable.ripple_color_4, 25 | ContextCompat.getColor(context, R.color.color5) to R.drawable.ripple_color_5, 26 | ContextCompat.getColor(context, R.color.color6) to R.drawable.ripple_color_6, 27 | ContextCompat.getColor(context, R.color.color7) to R.drawable.ripple_color_7, 28 | ContextCompat.getColor(context, R.color.color8) to R.drawable.ripple_color_8, 29 | ) 30 | allColors = listOf( 31 | defaultColor, 32 | CounterColor(ContextCompat.getColor(context, R.color.color1)), 33 | CounterColor(ContextCompat.getColor(context, R.color.color2)), 34 | CounterColor(ContextCompat.getColor(context, R.color.color3)), 35 | CounterColor(ContextCompat.getColor(context, R.color.color4)), 36 | CounterColor(ContextCompat.getColor(context, R.color.color5)), 37 | CounterColor(ContextCompat.getColor(context, R.color.color6)), 38 | CounterColor(ContextCompat.getColor(context, R.color.color7)), 39 | CounterColor(ContextCompat.getColor(context, R.color.color8)), 40 | ) 41 | } 42 | 43 | fun getRippleDrawableRes(counterColor: CounterColor): Int? { 44 | return rippleDrawables[counterColor.colorInt] 45 | } 46 | 47 | fun getColorIntForChart(counterColor: CounterColor): Int { 48 | return if (counterColor == defaultColor) { 49 | defaultColorIntForChart 50 | } else { 51 | counterColor.colorInt 52 | } 53 | } 54 | 55 | companion object { 56 | @Volatile 57 | private var INSTANCE: CounterColors? = null 58 | 59 | fun getInstance(context: Context): CounterColors = 60 | INSTANCE ?: synchronized(this) { 61 | INSTANCE ?: CounterColors(context.applicationContext) 62 | .also { INSTANCE = it } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/persistence/CounterMetadata.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.persistence 2 | 3 | data class CounterMetadata( 4 | var name: String, 5 | var interval: Interval, 6 | var goal: Int, 7 | var color: CounterColor, 8 | ) 9 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/persistence/CounterSummary.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.persistence 2 | 3 | import java.util.Calendar 4 | import java.util.Date 5 | 6 | class CounterSummary( 7 | var name: String, 8 | var interval: Interval, 9 | var goal: Int, 10 | var color: CounterColor, 11 | var lastIntervalCount: Int, 12 | var totalCount: Int, 13 | var leastRecent: Date?, 14 | var mostRecent: Date?, 15 | ) { 16 | fun latestBetweenNowAndMostRecentEntry(): Date { 17 | val now = Calendar.getInstance().time 18 | val lastEntry = mostRecent 19 | return if (lastEntry != null && lastEntry > now) lastEntry else now 20 | } 21 | 22 | fun isGoalMet(): Boolean { 23 | return goal in 1..lastIntervalCount 24 | } 25 | 26 | fun getFormattedCount(): CharSequence = buildString { 27 | append(lastIntervalCount) 28 | if (goal > 0) { 29 | append('/') 30 | append(goal) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/persistence/Entry.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.persistence 2 | 3 | import androidx.room.Entity 4 | import androidx.room.Index 5 | import androidx.room.PrimaryKey 6 | import androidx.room.TypeConverters 7 | import org.kde.bettercounter.boilerplate.Converters 8 | import java.util.Date 9 | 10 | @Entity(indices = [Index("name")]) 11 | @TypeConverters(Converters::class) 12 | data class Entry( 13 | @PrimaryKey(autoGenerate = true) val id: Int? = null, 14 | val date: Date, 15 | val name: String, 16 | ) 17 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/persistence/EntryDao.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.persistence 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import java.util.Date 9 | 10 | @Dao 11 | interface EntryDao { 12 | 13 | @Query("SELECT * FROM entry WHERE name = (:name) ORDER BY id DESC LIMIT 1") 14 | suspend fun getLastAdded(name: String): Entry? 15 | 16 | @Query("SELECT * FROM entry WHERE name = (:name) ORDER BY date DESC LIMIT 1") 17 | suspend fun getMostRecent(name: String): Entry? 18 | 19 | @Query("SELECT * FROM entry WHERE name = (:name) ORDER BY date ASC LIMIT 1") 20 | suspend fun getLeastRecent(name: String): Entry? 21 | 22 | @Query("SELECT COUNT(*) FROM entry WHERE name = (:name)") 23 | suspend fun getCount(name: String): Int 24 | 25 | @Query("SELECT date FROM entry WHERE name = (:name) ORDER BY date ASC LIMIT 1") 26 | suspend fun getFirstDate(name: String): Date? 27 | 28 | @Query("SELECT date FROM entry WHERE name = (:name) ORDER BY date DESC LIMIT 1") 29 | suspend fun getLastDate(name: String): Date? 30 | 31 | @Query("SELECT COUNT(*) FROM entry WHERE name = (:name) AND date >= (:since) AND date <= (:until)") 32 | suspend fun getCountInRange(name: String, since: Date, until: Date): Int 33 | 34 | @Query("UPDATE entry set name = (:newName) WHERE name = (:oldName)") 35 | suspend fun renameAllEntries(oldName: String, newName: String): Int 36 | 37 | @Query("SELECT * FROM entry WHERE name = (:name) AND date >= (:since) AND date <= (:until) ORDER BY date ASC") 38 | suspend fun getAllEntriesInRangeSortedByDate(name: String, since: Date, until: Date): List 39 | 40 | @Query("SELECT * FROM entry WHERE name = (:name) ORDER BY date ASC") 41 | suspend fun getAllEntriesSortedByDate(name: String): List 42 | 43 | @Insert(onConflict = OnConflictStrategy.REPLACE) 44 | suspend fun insert(entry: Entry) 45 | 46 | @Insert(onConflict = OnConflictStrategy.REPLACE) 47 | suspend fun bulkInsert(entry: List) 48 | 49 | @Delete 50 | suspend fun delete(entry: Entry) 51 | 52 | @Query("DELETE FROM entry WHERE name = (:name)") 53 | suspend fun deleteAll(name: String) 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/persistence/Exporter.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.persistence 2 | 3 | import android.app.Application 4 | import android.widget.Toast 5 | import androidx.core.net.toUri 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.Job 9 | import kotlinx.coroutines.launch 10 | import org.kde.bettercounter.R 11 | import java.io.OutputStream 12 | 13 | class Exporter(val application: Application, val repo: Repository) { 14 | 15 | companion object { 16 | private var autoExportJob : Job? = null 17 | } 18 | 19 | fun autoExportIfEnabled() { 20 | if (!repo.isAutoExportOnSaveEnabled()) { 21 | return 22 | } 23 | autoExportJob?.cancel() // Ensure we don't have two exports running in parallel 24 | autoExportJob = CoroutineScope(Dispatchers.Main).launch { 25 | try { 26 | val uri = repo.getAutoExportFileUri()!!.toUri() 27 | application.contentResolver.openOutputStream(uri, "wt")?.let { stream -> 28 | exportAll(stream) { } 29 | } 30 | } catch (_: Exception) { 31 | repo.setAutoExportOnSave(false) 32 | Toast.makeText(application, R.string.export_error, Toast.LENGTH_SHORT).show() 33 | } 34 | } 35 | } 36 | 37 | suspend fun exportAll(stream: OutputStream, progressCallback: (progress: Int) -> Unit) { 38 | stream.use { 39 | it.bufferedWriter().use { writer -> 40 | for ((i, name) in repo.getCounterList().withIndex()) { 41 | progressCallback(i) 42 | val entries = repo.getAllEntriesSortedByDate(name) 43 | writer.write(name) 44 | for (entry in entries) { 45 | writer.write(",") 46 | writer.write(entry.date.time.toString()) 47 | } 48 | writer.write("\n") 49 | } 50 | progressCallback(repo.getCounterList().size) 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/persistence/Interval.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.persistence 2 | 3 | import android.content.Context 4 | import org.kde.bettercounter.R 5 | import java.time.temporal.ChronoUnit 6 | import java.util.Calendar 7 | 8 | enum class Interval(val humanReadableResource: Int) { 9 | HOUR(R.string.interval_hour), 10 | DAY(R.string.interval_day), 11 | WEEK(R.string.interval_week), 12 | MONTH(R.string.interval_month), 13 | YEAR(R.string.interval_year), 14 | LIFETIME(R.string.interval_lifetime), 15 | ; 16 | 17 | companion object { 18 | val DEFAULT = LIFETIME 19 | 20 | fun humanReadableValues(context: Context): List { 21 | return entries.map { context.getString(it.humanReadableResource) } 22 | } 23 | } 24 | 25 | fun asCalendarField(): Int = when (this) { 26 | HOUR -> Calendar.MINUTE 27 | DAY -> Calendar.HOUR_OF_DAY 28 | WEEK -> Calendar.DAY_OF_WEEK 29 | MONTH -> Calendar.DAY_OF_MONTH 30 | YEAR -> Calendar.MONTH 31 | LIFETIME -> Calendar.MONTH // Not really, but :shrug: 32 | } 33 | 34 | fun toChronoUnit(): ChronoUnit = 35 | when (this) { 36 | HOUR -> ChronoUnit.HOURS 37 | DAY -> ChronoUnit.DAYS 38 | WEEK -> ChronoUnit.WEEKS 39 | MONTH -> ChronoUnit.MONTHS 40 | YEAR -> ChronoUnit.YEARS 41 | LIFETIME -> throw UnsupportedOperationException("$this can't be converted to ChronoUnit") 42 | } 43 | 44 | fun toHumanReadableResourceId(): Int = humanReadableResource 45 | 46 | fun toChartDisplayableInterval(): Interval { 47 | // When displaying in a chart, LIFETIME counters will still display year by year 48 | return if (this == LIFETIME) YEAR else this 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/persistence/Repository.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.persistence 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.content.SharedPreferences 6 | import androidx.core.content.edit 7 | import org.kde.bettercounter.boilerplate.AppDatabase 8 | import org.kde.bettercounter.boilerplate.Converters 9 | import org.kde.bettercounter.extensions.plusInterval 10 | import org.kde.bettercounter.extensions.truncated 11 | import java.util.Calendar 12 | import java.util.Date 13 | 14 | const val COUNTERS_PREFS_KEY = "counters" 15 | const val COUNTERS_INTERVAL_PREFS_KEY = "interval.%s" 16 | const val COUNTERS_COLOR_PREFS_KEY = "color.%s" 17 | const val COUNTERS_GOAL_PREFS_KEY = "goal.%s" 18 | const val TUTORIALS_PREFS_KEY = "tutorials" 19 | const val AUTO_EXPORT_ENABLED_KEY = "auto_export_enabled" 20 | const val AVERAGE_CALCULATION_MODE_KEY = "average_calculation_mode" 21 | const val AUTO_EXPORT_FILE_URI_KEY = "auto_export_file_uri" 22 | 23 | class Repository( 24 | private val application: Application, 25 | private val entryDao: EntryDao, 26 | private val sharedPref: SharedPreferences 27 | ) { 28 | companion object { 29 | fun create(application: Application): Repository { 30 | val db = AppDatabase.getInstance(application) 31 | val prefs : SharedPreferences = application.getSharedPreferences("prefs", Context.MODE_PRIVATE) 32 | return Repository(application, db.entryDao(), prefs) 33 | } 34 | } 35 | 36 | fun getCounterList(): List { 37 | val countersStr = sharedPref.getString(COUNTERS_PREFS_KEY, "[]") 38 | return Converters.stringToStringList(countersStr) 39 | } 40 | 41 | fun setCounterList(list: List) { 42 | val jsonStr = Converters.stringListToString(list) 43 | sharedPref.edit { putString(COUNTERS_PREFS_KEY, jsonStr) } 44 | } 45 | 46 | private fun getCounterColor(name: String): CounterColor { 47 | val key = COUNTERS_COLOR_PREFS_KEY.format(name) 48 | return CounterColor(sharedPref.getInt(key, CounterColors.getInstance(application).defaultColor.colorInt)) 49 | } 50 | 51 | private fun getCounterInterval(name: String): Interval { 52 | val key = COUNTERS_INTERVAL_PREFS_KEY.format(name) 53 | val str = sharedPref.getString(key, null) 54 | return when (str) { 55 | "YTD" -> Interval.YEAR 56 | null -> Interval.DEFAULT 57 | else -> Interval.valueOf(str) 58 | } 59 | } 60 | 61 | private fun getCounterGoal(name: String): Int { 62 | val key = COUNTERS_GOAL_PREFS_KEY.format(name) 63 | return sharedPref.getInt(key, 0) 64 | } 65 | 66 | fun deleteCounterMetadata(name: String) { 67 | val colorKey = COUNTERS_COLOR_PREFS_KEY.format(name) 68 | val intervalKey = COUNTERS_INTERVAL_PREFS_KEY.format(name) 69 | val goalKey = COUNTERS_GOAL_PREFS_KEY.format(name) 70 | sharedPref.edit { 71 | remove(colorKey) 72 | remove(intervalKey) 73 | remove(goalKey) 74 | } 75 | } 76 | 77 | fun setCounterMetadata(counter: CounterMetadata) { 78 | val colorKey = COUNTERS_COLOR_PREFS_KEY.format(counter.name) 79 | val intervalKey = COUNTERS_INTERVAL_PREFS_KEY.format(counter.name) 80 | val goalKey = COUNTERS_GOAL_PREFS_KEY.format(counter.name) 81 | sharedPref.edit { 82 | putInt(colorKey, counter.color.colorInt) 83 | putString(intervalKey, counter.interval.toString()) 84 | putInt(goalKey, counter.goal) 85 | } 86 | } 87 | 88 | suspend fun getLeastRecentEntry(name:String): Date? = entryDao.getFirstDate(name) 89 | 90 | suspend fun getCounterSummary(name: String): CounterSummary { 91 | val interval = getCounterInterval(name) 92 | val color = getCounterColor(name) 93 | val goal = getCounterGoal(name) 94 | val intervalStartDate = when (interval) { 95 | Interval.LIFETIME -> Calendar.getInstance().apply { set(Calendar.YEAR, 1990) } 96 | else -> Calendar.getInstance().truncated(interval) 97 | } 98 | val intervalEndDate = intervalStartDate.plusInterval(interval, 1) 99 | return CounterSummary( 100 | name = name, 101 | color = color, 102 | interval = interval, 103 | goal = goal, 104 | lastIntervalCount = entryDao.getCountInRange(name, intervalStartDate.time, intervalEndDate.time), 105 | totalCount = entryDao.getCount(name), 106 | leastRecent = entryDao.getFirstDate(name), 107 | mostRecent = entryDao.getLastDate(name), 108 | ) 109 | } 110 | 111 | suspend fun renameCounter(oldName: String, newName: String) { 112 | entryDao.renameAllEntries(oldName, newName) 113 | } 114 | 115 | suspend fun addEntry(name: String, date: Date = Calendar.getInstance().time) { 116 | entryDao.insert(Entry(name = name, date = date)) 117 | } 118 | 119 | suspend fun removeEntry(name: String): Date? { 120 | val entry = entryDao.getLastAdded(name) 121 | if (entry != null) { 122 | entryDao.delete(entry) 123 | } 124 | return entry?.date 125 | } 126 | 127 | suspend fun removeAllEntries(name: String) { 128 | entryDao.deleteAll(name) 129 | } 130 | 131 | suspend fun getEntriesForRangeSortedByDate(name: String, since: Date, until: Date): List { 132 | return entryDao.getAllEntriesInRangeSortedByDate(name, since, until) 133 | } 134 | 135 | suspend fun getAllEntriesSortedByDate(name: String): List { 136 | return entryDao.getAllEntriesSortedByDate(name) 137 | } 138 | 139 | suspend fun bulkAddEntries(entries: List) { 140 | entryDao.bulkInsert(entries) 141 | } 142 | 143 | fun getTutorialsShown() : Set { 144 | return sharedPref.getStringSet(TUTORIALS_PREFS_KEY, setOf())!! 145 | } 146 | 147 | fun setTutorialsShown(tutorials: Set) { 148 | sharedPref.edit { putStringSet(TUTORIALS_PREFS_KEY, tutorials) } 149 | } 150 | 151 | fun isAutoExportOnSaveEnabled(): Boolean { 152 | return sharedPref.getBoolean(AUTO_EXPORT_ENABLED_KEY, false) 153 | } 154 | 155 | fun setAutoExportOnSave(enabled: Boolean) { 156 | sharedPref.edit { putBoolean(AUTO_EXPORT_ENABLED_KEY, enabled) } 157 | } 158 | 159 | fun getAutoExportFileUri(): String? { 160 | return sharedPref.getString(AUTO_EXPORT_FILE_URI_KEY, null) 161 | } 162 | 163 | fun setAutoExportFileUri(uriString: String) { 164 | sharedPref.edit { putString(AUTO_EXPORT_FILE_URI_KEY, uriString) } 165 | } 166 | 167 | fun getAverageCalculationMode(): AverageMode { 168 | val ordinal = sharedPref.getInt(AVERAGE_CALCULATION_MODE_KEY, AverageMode.FIRST_TO_LAST.ordinal) 169 | return AverageMode.entries[ordinal] 170 | } 171 | 172 | fun setAverageCalculationMode(mode: AverageMode) { 173 | sharedPref.edit { putInt(AVERAGE_CALCULATION_MODE_KEY, mode.ordinal) } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/persistence/Tutorial.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.persistence 2 | 3 | import android.content.Context 4 | import android.view.Gravity 5 | import android.view.View 6 | import io.github.douglasjunior.androidSimpleTooltip.SimpleTooltip 7 | import org.kde.bettercounter.R 8 | 9 | enum class Tutorial { 10 | DRAG, 11 | PICK_DATE, 12 | CHANGE_GRAPH_INTERVAL, 13 | ; 14 | 15 | private fun getText() = when(this) { 16 | DRAG -> R.string.tutorial_drag 17 | PICK_DATE -> R.string.tutorial_pickdate 18 | CHANGE_GRAPH_INTERVAL -> R.string.tutorial_change_graph_interval 19 | } 20 | 21 | fun show(context: Context, anchorView : View, onDismissListener: SimpleTooltip.OnDismissListener? = null) { 22 | SimpleTooltip.Builder(context) 23 | .anchorView(anchorView) 24 | .text(getText()) 25 | .gravity(Gravity.BOTTOM) 26 | .animated(true) 27 | .focusable(true) // modal requires focusable 28 | .modal(true) 29 | .onDismissListener(onDismissListener) 30 | .build() 31 | .show() 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/ui/chart/ChartsAdapter.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.ui.chart 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.recyclerview.widget.RecyclerView 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.cancel 10 | import kotlinx.coroutines.flow.combine 11 | import kotlinx.coroutines.launch 12 | import org.kde.bettercounter.databinding.FragmentChartBinding 13 | import org.kde.bettercounter.extensions.between 14 | import org.kde.bettercounter.extensions.count 15 | import org.kde.bettercounter.extensions.plusInterval 16 | import org.kde.bettercounter.extensions.toCalendar 17 | import org.kde.bettercounter.extensions.truncated 18 | import org.kde.bettercounter.persistence.CounterSummary 19 | import org.kde.bettercounter.persistence.Interval 20 | import org.kde.bettercounter.ui.main.MainActivityViewModel 21 | import java.util.Calendar 22 | 23 | class ChartsAdapter( 24 | private val activity: AppCompatActivity, 25 | private val viewModel: MainActivityViewModel, 26 | private val counter: CounterSummary, 27 | private val interval: Interval, 28 | private val onIntervalChange: (Interval) -> Unit, 29 | private val onDateChange: ChartsAdapter.(Calendar) -> Unit, 30 | private val onDataDisplayed: () -> Unit, 31 | ) : RecyclerView.Adapter() { 32 | 33 | val coroutineScope = CoroutineScope(Dispatchers.Main) 34 | 35 | private val boundViewHolders = mutableListOf() 36 | 37 | private val inflater: LayoutInflater = LayoutInflater.from(activity) 38 | 39 | private var numCharts: Int = countNumCharts(counter) 40 | override fun getItemCount(): Int = numCharts 41 | 42 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChartHolder { 43 | val binding = FragmentChartBinding.inflate(inflater, parent, false) 44 | return ChartHolder(activity, viewModel, binding) 45 | } 46 | 47 | override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { 48 | super.onDetachedFromRecyclerView(recyclerView) 49 | coroutineScope.cancel() 50 | } 51 | 52 | override fun onBindViewHolder(holder: ChartHolder, position: Int) { 53 | val rangeStart = findRangeStartForPosition(position) 54 | val rangeEnd = rangeStart.plusInterval(interval, 1) 55 | boundViewHolders.add(holder) 56 | 57 | val maxCountFlow = viewModel.getMaxCountForInterval(counter.name, interval) 58 | val entriesFlow = viewModel.getEntriesForRangeSortedByDate( 59 | counter.name, 60 | rangeStart.time, 61 | rangeEnd.time 62 | ) 63 | 64 | coroutineScope.launch { 65 | combine(maxCountFlow, entriesFlow, ::Pair).collect { (maxCount, entries) -> 66 | holder.display( 67 | counter, 68 | entries, 69 | interval, 70 | rangeStart, 71 | rangeEnd, 72 | maxCount, 73 | onIntervalChange, 74 | ) { onDateChange(it) } 75 | onDataDisplayed() 76 | } 77 | } 78 | } 79 | 80 | private fun findRangeStartForPosition(position: Int): Calendar { 81 | val counterBegin = counter.leastRecent?.toCalendar() ?: Calendar.getInstance() 82 | val firstInterval = counterBegin.truncated(interval) 83 | return firstInterval.plusInterval(interval, position) 84 | } 85 | 86 | fun findPositionForRangeStart(cal: Calendar): Int { 87 | val endRange = counter.leastRecent 88 | if (endRange != null) { 89 | val endCal = endRange.toCalendar().truncated(interval) 90 | val count = interval.toChronoUnit().between(endCal, cal).toInt() 91 | return count.coerceIn(0, numCharts - 1) 92 | } 93 | return 0 94 | } 95 | 96 | override fun onViewRecycled(holder: ChartHolder) { 97 | boundViewHolders.remove(holder) 98 | } 99 | 100 | private fun countNumCharts(counter: CounterSummary): Int { 101 | val firstDate = counter.leastRecent ?: return 1 102 | val lastDate = counter.latestBetweenNowAndMostRecentEntry() 103 | return interval.toChronoUnit().count(firstDate, lastDate) 104 | } 105 | 106 | /* 107 | fun animate() { 108 | for (holder in boundViewHolders) { 109 | holder.binding.chart.animateY(200) 110 | } 111 | } 112 | */ 113 | } 114 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/ui/editdialog/ColorAdapter.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.ui.editdialog 2 | 3 | import android.content.Context 4 | import android.graphics.Color 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.annotation.ColorInt 9 | import androidx.appcompat.widget.AppCompatButton 10 | import androidx.core.graphics.drawable.DrawableCompat 11 | import androidx.recyclerview.widget.RecyclerView 12 | import org.kde.bettercounter.R 13 | import org.kde.bettercounter.persistence.CounterColor 14 | import org.kde.bettercounter.persistence.CounterColors 15 | 16 | // Based on https://github.com/kristiyanP/colorpicker 17 | class ColorAdapter(val context: Context) : RecyclerView.Adapter() { 18 | 19 | var selectedColor: CounterColor 20 | get() { 21 | return colors[selectedPosition] 22 | } 23 | set(color) { 24 | for (i in colors.indices) { 25 | val colorPal = colors[i] 26 | if (colorPal == color) { 27 | selectedPosition = i 28 | } 29 | } 30 | } 31 | 32 | private var selectedPosition = 0 33 | set(pos) { 34 | val prevSelected = selectedPosition 35 | field = pos 36 | notifyItemChanged(selectedPosition) 37 | notifyItemChanged(prevSelected) 38 | } 39 | 40 | private var colors: List = CounterColors.getInstance(context).allColors 41 | 42 | inner class ViewHolder(val colorButton: AppCompatButton) : 43 | RecyclerView.ViewHolder(colorButton), 44 | View.OnClickListener { 45 | init { 46 | colorButton.setOnClickListener(this) 47 | } 48 | 49 | override fun onClick(v: View) { 50 | selectedPosition = layoutPosition 51 | } 52 | } 53 | 54 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 55 | val view = LayoutInflater.from(parent.context).inflate(R.layout.color_circle, parent, false) 56 | return ViewHolder(view as AppCompatButton) 57 | } 58 | 59 | private fun isDarkColor(@ColorInt color: Int): Boolean { 60 | val red = Color.red(color) 61 | val green = Color.green(color) 62 | val blue: Int = Color.blue(color) 63 | 64 | // https://en.wikipedia.org/wiki/YIQ 65 | // https://24ways.org/2010/calculating-color-contrast/ 66 | val yiq: Int = ((red * 299) + (green * 587) + (blue * 114)) / 1000 67 | return yiq < 192 68 | } 69 | 70 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 71 | val colorInt = colors[position].colorInt 72 | val textColor = if (isDarkColor(colorInt)) Color.WHITE else Color.BLACK 73 | val tickText = if (position == selectedPosition) "✔" else "" 74 | 75 | holder.colorButton.text = tickText 76 | holder.colorButton.setTextColor(textColor) 77 | holder.colorButton.contentDescription = context.getString(R.string.color_hint, position + 1) 78 | val background = DrawableCompat.wrap(holder.colorButton.background) 79 | DrawableCompat.setTint(background, colorInt) 80 | holder.colorButton.background = background 81 | } 82 | 83 | override fun getItemCount(): Int = colors.size 84 | } 85 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/ui/editdialog/IntervalAdapter.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.ui.editdialog 2 | 3 | import android.content.Context 4 | import android.widget.ArrayAdapter 5 | import org.kde.bettercounter.persistence.Interval 6 | 7 | class IntervalAdapter( 8 | context: Context 9 | ) : ArrayAdapter( 10 | context, 11 | android.R.layout.simple_spinner_dropdown_item, 12 | Interval.humanReadableValues(context) 13 | ) { 14 | fun positionOf(interval: Interval): Int = Interval.entries.indexOf(interval) 15 | 16 | fun itemAt(position: Int): Interval = Interval.entries[position] 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/ui/main/BetterRelativeTimeTextView.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.ui.main 2 | 3 | import android.content.Context 4 | import android.os.Handler 5 | import android.os.Looper 6 | import android.os.Parcel 7 | import android.os.Parcelable 8 | import android.text.format.DateUtils 9 | import android.util.AttributeSet 10 | import android.view.View 11 | import androidx.appcompat.widget.AppCompatTextView 12 | import org.kde.bettercounter.R 13 | import java.lang.ref.WeakReference 14 | import kotlin.math.abs 15 | 16 | /** 17 | * A `TextView` that, given a reference time, renders that time as a time period relative to the current time. 18 | * @author Based on RelativeTimeTextView by Kiran Rao 19 | */ 20 | open class BetterRelativeTimeTextView : AppCompatTextView { 21 | var referenceTime: Long = -1L 22 | set(value) { 23 | field = value 24 | 25 | // Note that this method could be called when a row in a ListView is recycled. 26 | // Hence, we need to first stop any currently running schedules (for example from the recycled view. 27 | stopTaskForPeriodicallyUpdatingRelativeTime() 28 | 29 | // Instantiate a new runnable with the new reference time 30 | initUpdateTimeTask() 31 | 32 | // Start a new schedule. 33 | startTaskForPeriodicallyUpdatingRelativeTime() 34 | 35 | // Finally, update the text display. 36 | updateTextDisplay() 37 | } 38 | 39 | private val mHandler = Handler(Looper.getMainLooper()) 40 | private var mUpdateTimeTask: UpdateTimeRunnable? = null 41 | private var isUpdateTaskRunning = false 42 | 43 | constructor(ctx: Context, attrs: AttributeSet?) : super(ctx, attrs) 44 | constructor(ctx: Context, attrs: AttributeSet?, defStyle: Int) : super(ctx, attrs, defStyle) 45 | 46 | private fun updateTextDisplay() { 47 | if (referenceTime < 0) { 48 | setText(R.string.never) 49 | } else { 50 | text = getRelativeTimeDisplayString(referenceTime, System.currentTimeMillis()) 51 | } 52 | } 53 | 54 | /** 55 | * Get the text to display for relative time. 56 | * 57 | * You can override this method to customize the string returned. For example you could add prefixes or suffixes, or use Spans to style the string etc 58 | * @param referenceTime The reference time passed in through [.setReferenceTime] or through `reference_time` attribute 59 | * @param now The current time 60 | * @return The display text for the relative time 61 | */ 62 | open fun getRelativeTimeDisplayString(referenceTime: Long, now: Long): CharSequence { 63 | var difference: Long = abs(now - referenceTime) 64 | val days = difference / DateUtils.DAY_IN_MILLIS 65 | difference -= days * DateUtils.DAY_IN_MILLIS 66 | val hours = difference / DateUtils.HOUR_IN_MILLIS 67 | difference -= hours * DateUtils.HOUR_IN_MILLIS 68 | val minutes = difference / DateUtils.MINUTE_IN_MILLIS 69 | return if (referenceTime > now) { 70 | when { 71 | days > 0 -> context.getString(R.string.time_in_dhm, days, hours, minutes) 72 | hours > 0 -> context.getString(R.string.time_in_hm, hours, minutes) 73 | minutes > 0 -> context.getString(R.string.time_in_m, minutes) 74 | else -> context.getString(R.string.just_now) 75 | } 76 | } else { 77 | when { 78 | days > 0 -> context.getString(R.string.time_ago_dhm, days, hours, minutes) 79 | hours > 0 -> context.getString(R.string.time_ago_hm, hours, minutes) 80 | minutes > 0 -> context.getString(R.string.time_ago_m, minutes) 81 | else -> context.getString(R.string.just_now) 82 | } 83 | } 84 | } 85 | 86 | override fun onAttachedToWindow() { 87 | super.onAttachedToWindow() 88 | startTaskForPeriodicallyUpdatingRelativeTime() 89 | } 90 | 91 | override fun onDetachedFromWindow() { 92 | super.onDetachedFromWindow() 93 | stopTaskForPeriodicallyUpdatingRelativeTime() 94 | } 95 | 96 | override fun onVisibilityChanged(changedView: View, visibility: Int) { 97 | super.onVisibilityChanged(changedView, visibility) 98 | if (visibility == GONE || visibility == INVISIBLE) { 99 | stopTaskForPeriodicallyUpdatingRelativeTime() 100 | } else { 101 | startTaskForPeriodicallyUpdatingRelativeTime() 102 | } 103 | } 104 | 105 | private fun startTaskForPeriodicallyUpdatingRelativeTime() { 106 | if (mUpdateTimeTask == null || mUpdateTimeTask!!.isDetached) initUpdateTimeTask() 107 | mHandler.post(mUpdateTimeTask!!) 108 | isUpdateTaskRunning = true 109 | } 110 | 111 | private fun initUpdateTimeTask() { 112 | mUpdateTimeTask = UpdateTimeRunnable(this, referenceTime) 113 | } 114 | 115 | private fun stopTaskForPeriodicallyUpdatingRelativeTime() { 116 | if (isUpdateTaskRunning) { 117 | mUpdateTimeTask!!.detach() 118 | mHandler.removeCallbacks(mUpdateTimeTask!!) 119 | isUpdateTaskRunning = false 120 | } 121 | } 122 | 123 | override fun onSaveInstanceState(): Parcelable? { 124 | val superState = super.onSaveInstanceState() 125 | val ss = SavedState(superState) 126 | ss.referenceTime = referenceTime 127 | return ss 128 | } 129 | 130 | override fun onRestoreInstanceState(state: Parcelable) { 131 | if (state !is SavedState) { 132 | super.onRestoreInstanceState(state) 133 | return 134 | } 135 | referenceTime = state.referenceTime 136 | super.onRestoreInstanceState(state.superState) 137 | } 138 | 139 | private class SavedState : BaseSavedState { 140 | var referenceTime: Long = 0 141 | 142 | constructor(superState: Parcelable?) : super(superState) 143 | constructor(`in`: Parcel) : super(`in`) { 144 | referenceTime = `in`.readLong() 145 | } 146 | 147 | override fun writeToParcel(dest: Parcel, flags: Int) { 148 | super.writeToParcel(dest, flags) 149 | dest.writeLong(referenceTime) 150 | } 151 | 152 | companion object { 153 | @JvmField 154 | val CREATOR = object : Parcelable.Creator { 155 | override fun createFromParcel(source: Parcel): SavedState { 156 | return SavedState(source) 157 | } 158 | 159 | override fun newArray(size: Int): Array { 160 | return arrayOfNulls(size) 161 | } 162 | } 163 | } 164 | } 165 | 166 | private class UpdateTimeRunnable(view: BetterRelativeTimeTextView?, private val mRefTime: Long) : Runnable { 167 | private val viewWeakRef: WeakReference = WeakReference(view) 168 | 169 | val isDetached: Boolean 170 | get() = viewWeakRef.get() == null 171 | 172 | fun detach() { 173 | viewWeakRef.clear() 174 | } 175 | 176 | override fun run() { 177 | val view = viewWeakRef.get() ?: return 178 | val difference = abs(System.currentTimeMillis() - mRefTime) 179 | val differenceRoundedToMinute = (difference / DateUtils.MINUTE_IN_MILLIS) * DateUtils.MINUTE_IN_MILLIS 180 | view.updateTextDisplay() 181 | view.mHandler.postDelayed(this, difference - differenceRoundedToMinute) 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/ui/main/DateTimePicker.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.ui.main 2 | 3 | import android.text.format.DateFormat 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.google.android.material.datepicker.MaterialDatePicker 6 | import com.google.android.material.timepicker.MaterialTimePicker 7 | import com.google.android.material.timepicker.TimeFormat 8 | import org.kde.bettercounter.extensions.toEpochMilli 9 | import org.kde.bettercounter.extensions.toLocalDateTime 10 | import org.kde.bettercounter.extensions.toUTCLocalDateTime 11 | import java.time.ZoneId 12 | import java.time.ZoneOffset 13 | import java.util.Calendar 14 | 15 | fun showDateTimePicker(activity: AppCompatActivity, initialDateTime: Calendar, callback: (Calendar) -> Unit) { 16 | val initialHour = initialDateTime.get(Calendar.HOUR_OF_DAY) 17 | val initialMinute = initialDateTime.get(Calendar.MINUTE) 18 | val timeFormat = if (DateFormat.is24HourFormat(activity)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H 19 | showDatePicker(activity, initialDateTime) { cal -> 20 | MaterialTimePicker.Builder() 21 | .setTimeFormat(timeFormat) 22 | .setHour(initialHour) 23 | .setMinute(initialMinute) 24 | .setInputMode(MaterialTimePicker.INPUT_MODE_CLOCK) 25 | .build().apply { 26 | addOnPositiveButtonClickListener { 27 | cal.set(Calendar.MINUTE, minute) 28 | cal.set(Calendar.HOUR_OF_DAY, hour) 29 | callback(cal) 30 | } 31 | }.show(activity.supportFragmentManager, "timePicker") 32 | } 33 | } 34 | 35 | fun showDatePicker(activity: AppCompatActivity, initialDateTime: Calendar, callback: (Calendar) -> Unit) { 36 | // MaterialDatePicker needs UTC, see https://stackoverflow.com/questions/63929730/materialdatepicker-returning-wrong-value/71541489#71541489 37 | val initialTime = initialDateTime.timeInMillis.toLocalDateTime().atZone(ZoneId.ofOffset("UTC", ZoneOffset.UTC)).toEpochMilli() 38 | MaterialDatePicker.Builder.datePicker() 39 | .setSelection(initialTime) 40 | .build().apply { 41 | addOnPositiveButtonClickListener { 42 | val cal = Calendar.getInstance() 43 | // MaterialDatePicker returns UTC 44 | cal.timeInMillis = it.toUTCLocalDateTime().atZone(ZoneId.systemDefault()).toEpochMilli() 45 | callback(cal) 46 | } 47 | } 48 | .show(activity.supportFragmentManager, "datePicker") 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/ui/main/EntryViewHolder.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.ui.main 2 | 3 | import android.view.HapticFeedbackConstants 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.recyclerview.widget.ItemTouchHelper 6 | import androidx.recyclerview.widget.RecyclerView 7 | import io.github.douglasjunior.androidSimpleTooltip.SimpleTooltip.OnDismissListener 8 | import org.kde.bettercounter.R 9 | import org.kde.bettercounter.databinding.FragmentEntryBinding 10 | import org.kde.bettercounter.persistence.CounterColors 11 | import org.kde.bettercounter.persistence.CounterSummary 12 | import org.kde.bettercounter.persistence.Tutorial 13 | import java.util.Calendar 14 | 15 | class EntryViewHolder( 16 | private val activity: AppCompatActivity, 17 | val binding: FragmentEntryBinding, 18 | private var viewModel: MainActivityViewModel, 19 | private val touchHelper: ItemTouchHelper, 20 | private val onClickListener: (counter: CounterSummary) -> Unit?, 21 | private val canDrag: () -> Boolean 22 | ) : RecyclerView.ViewHolder(binding.root) { 23 | 24 | fun onBind(counter: CounterSummary) { 25 | binding.root.setBackgroundColor(counter.color.colorInt) 26 | val rippleRes = CounterColors.getInstance(activity).getRippleDrawableRes(counter.color) 27 | if (rippleRes != null) { 28 | binding.increaseButton.setBackgroundResource(rippleRes) 29 | binding.decreaseButton.setBackgroundResource(rippleRes) 30 | } else { 31 | binding.increaseButton.background = null 32 | binding.decreaseButton.background = null 33 | } 34 | binding.increaseButton.setOnClickListener { 35 | viewModel.incrementCounter(counter.name) 36 | if (!viewModel.isTutorialShown(Tutorial.PICK_DATE)) { 37 | viewModel.setTutorialShown(Tutorial.PICK_DATE) 38 | showPickDateTutorial() 39 | } 40 | } 41 | binding.increaseButton.setOnLongClickListener { 42 | showDateTimePicker(activity, Calendar.getInstance()) { pickedDateTime -> 43 | viewModel.incrementCounter(counter.name, pickedDateTime.time) 44 | } 45 | true 46 | } 47 | binding.decreaseButton.setOnClickListener { viewModel.decrementCounter(counter.name) } 48 | binding.draggableArea.setOnClickListener { onClickListener(counter) } 49 | binding.draggableArea.setOnLongClickListener { 50 | if (!canDrag()) return@setOnLongClickListener false 51 | touchHelper.startDrag(this@EntryViewHolder) 52 | @Suppress("DEPRECATION") 53 | binding.draggableArea.performHapticFeedback( 54 | HapticFeedbackConstants.LONG_PRESS, 55 | HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING, 56 | ) 57 | true 58 | } 59 | binding.nameText.text = counter.name 60 | binding.countText.text = counter.getFormattedCount() 61 | 62 | val checkDrawable = if (counter.isGoalMet()) R.drawable.ic_check else 0 63 | binding.countText.setCompoundDrawablesRelativeWithIntrinsicBounds(checkDrawable, 0, 0, 0) 64 | 65 | val mostRecentDate = counter.mostRecent 66 | if (mostRecentDate != null) { 67 | binding.timestampText.referenceTime = mostRecentDate.time 68 | binding.decreaseButton.isEnabled = true 69 | } else { 70 | binding.timestampText.referenceTime = -1L 71 | binding.decreaseButton.isEnabled = false 72 | } 73 | } 74 | 75 | fun showPickDateTutorial(onDismissListener: OnDismissListener? = null) { 76 | Tutorial.PICK_DATE.show(activity, binding.increaseButton, onDismissListener) 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/ui/settings/SettingsActivity.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.ui.settings 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import android.provider.MediaStore 7 | import android.view.MenuItem 8 | import android.view.View 9 | import androidx.activity.result.ActivityResultLauncher 10 | import androidx.appcompat.app.AppCompatActivity 11 | import androidx.core.net.toUri 12 | import com.google.android.material.snackbar.Snackbar 13 | import org.kde.bettercounter.R 14 | import org.kde.bettercounter.boilerplate.CreateFileParams 15 | import org.kde.bettercounter.boilerplate.CreateFileResultContract 16 | import org.kde.bettercounter.databinding.ActivitySettingsBinding 17 | import org.kde.bettercounter.persistence.AverageMode 18 | 19 | class SettingsActivity : AppCompatActivity() { 20 | 21 | private val viewModel : SettingsViewModel by lazy { SettingsViewModel(application) } 22 | private val binding: ActivitySettingsBinding by lazy { ActivitySettingsBinding.inflate(layoutInflater) } 23 | 24 | override fun onCreate(savedInstanceState: Bundle?) { 25 | super.onCreate(savedInstanceState) 26 | setContentView(binding.root) 27 | 28 | setSupportActionBar(binding.settingsToolbar) 29 | supportActionBar?.setDisplayHomeAsUpEnabled(true) 30 | supportActionBar?.title = getString(R.string.settings) 31 | 32 | // Auto-export 33 | binding.switchAutoExport.isChecked = viewModel.isAutoExportOnSaveEnabled() 34 | binding.switchAutoExport.setOnCheckedChangeListener { _, isChecked -> 35 | viewModel.setAutoExportOnSave(isChecked) 36 | if (isChecked && viewModel.getAutoExportFileUri() == null) { 37 | selectAutoExportFile() 38 | } else { 39 | updateAutoExportFileButtonVisibility(isChecked) 40 | } 41 | } 42 | binding.buttonChangeAutoExportFile.setOnClickListener { 43 | selectAutoExportFile() 44 | } 45 | updateAutoExportFileButtonVisibility(binding.switchAutoExport.isChecked) 46 | 47 | // Average calculation mode 48 | when (viewModel.getAverageCalculationMode()) { 49 | AverageMode.FIRST_TO_NOW -> binding.radioFirstToNow.isChecked = true 50 | AverageMode.FIRST_TO_LAST -> binding.radioFirstToLast.isChecked = true 51 | } 52 | 53 | binding.radioGroupAverageMode.setOnCheckedChangeListener { _, checkedId -> 54 | val mode = when (checkedId) { 55 | R.id.radioFirstToNow -> AverageMode.FIRST_TO_NOW 56 | R.id.radioFirstToLast -> AverageMode.FIRST_TO_LAST 57 | else -> AverageMode.FIRST_TO_LAST 58 | } 59 | viewModel.setAverageCalculationMode(mode) 60 | } 61 | } 62 | 63 | private fun updateAutoExportFileButtonVisibility(autoExportEnabled: Boolean) { 64 | val existingUri = viewModel.getAutoExportFileUri()?.toUri() 65 | if (autoExportEnabled && existingUri != null) { 66 | displayCurrentExportFileName(existingUri) 67 | binding.buttonChangeAutoExportFile.visibility = View.VISIBLE 68 | } else { 69 | binding.textCurrentExportFile.text = getString(R.string.export_disabled) 70 | binding.buttonChangeAutoExportFile.visibility = View.GONE 71 | } 72 | } 73 | 74 | private fun displayCurrentExportFileName(uri: Uri) { 75 | val fileName = getFileNameFromUri(uri) ?: uri.toString() 76 | binding.textCurrentExportFile.text = getString(R.string.current_export_file, fileName) 77 | } 78 | 79 | private fun getFileNameFromUri(uri: Uri): String? { 80 | try { 81 | return when (uri.scheme) { 82 | "content" -> { 83 | contentResolver.query(uri, null, null, null, null)?.use { cursor -> 84 | if (cursor.moveToFirst()) { 85 | val displayNameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) 86 | if (displayNameIndex != -1) { 87 | cursor.getString(displayNameIndex) 88 | } else null 89 | } else null 90 | } 91 | } 92 | "file" -> { 93 | uri.lastPathSegment 94 | } 95 | else -> null 96 | } 97 | } catch (e: Exception) { 98 | e.printStackTrace() 99 | return uri.toString() 100 | } 101 | } 102 | 103 | private fun selectAutoExportFile() { 104 | val fileName = "bettercounter-auto-export.csv" 105 | autoExportFilePicker.launch(CreateFileParams("text/csv", fileName)) 106 | } 107 | 108 | private val autoExportFilePicker: ActivityResultLauncher = registerForActivityResult( 109 | CreateFileResultContract() 110 | ) { uri: Uri? -> 111 | if (uri != null) { 112 | try { 113 | contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) 114 | 115 | viewModel.setAutoExportFileUri(uri.toString()) 116 | 117 | displayCurrentExportFileName(uri) 118 | } catch (e: Exception) { 119 | e.printStackTrace() 120 | Snackbar.make(binding.root, getString(R.string.export_error), Snackbar.LENGTH_LONG).show() 121 | 122 | viewModel.setAutoExportOnSave(false) 123 | binding.switchAutoExport.isChecked = false 124 | } 125 | } else { 126 | if (viewModel.getAutoExportFileUri() == null) { 127 | viewModel.setAutoExportOnSave(false) 128 | binding.switchAutoExport.isChecked = false 129 | } 130 | } 131 | } 132 | 133 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 134 | if (item.itemId == android.R.id.home) { 135 | finish() 136 | return true 137 | } 138 | return super.onOptionsItemSelected(item) 139 | } 140 | 141 | } -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/ui/settings/SettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.ui.settings 2 | 3 | import android.app.Application 4 | import org.kde.bettercounter.persistence.AverageMode 5 | import org.kde.bettercounter.persistence.Repository 6 | 7 | class SettingsViewModel(application: Application) { 8 | 9 | private val repo: Repository = Repository.create(application) 10 | 11 | fun isAutoExportOnSaveEnabled(): Boolean { 12 | return repo.isAutoExportOnSaveEnabled() 13 | } 14 | 15 | fun setAutoExportOnSave(enabled: Boolean) { 16 | repo.setAutoExportOnSave(enabled) 17 | } 18 | 19 | fun getAutoExportFileUri(): String? { 20 | return repo.getAutoExportFileUri() 21 | } 22 | 23 | fun setAutoExportFileUri(uriString: String) { 24 | repo.setAutoExportFileUri(uriString) 25 | } 26 | 27 | fun getAverageCalculationMode(): AverageMode { 28 | return repo.getAverageCalculationMode() 29 | } 30 | 31 | fun setAverageCalculationMode(mode: AverageMode) { 32 | repo.setAverageCalculationMode(mode) 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/ui/widget/WidgetConfigureActivity.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.ui.widget 2 | 3 | import android.appwidget.AppWidgetManager 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.widget.ArrayAdapter 7 | import android.widget.Toast 8 | import androidx.appcompat.app.AppCompatActivity 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.launch 12 | import org.kde.bettercounter.R 13 | import org.kde.bettercounter.databinding.WidgetConfigureBinding 14 | 15 | class WidgetConfigureActivity : AppCompatActivity() { 16 | 17 | private val viewModel : WidgetViewModel by lazy { WidgetViewModel(application) } 18 | private val binding: WidgetConfigureBinding by lazy { WidgetConfigureBinding.inflate(layoutInflater) } 19 | 20 | private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID 21 | 22 | public override fun onCreate(savedInstanceState: Bundle?) { 23 | super.onCreate(savedInstanceState) 24 | setContentView(binding.root) 25 | setSupportActionBar(binding.toolbar) 26 | 27 | // Cancel widget placement if the user presses the back button. 28 | setResult(RESULT_CANCELED) 29 | 30 | val extras = intent.extras 31 | if (extras != null) { 32 | appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) 33 | } 34 | 35 | if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { 36 | finish() 37 | return 38 | } 39 | 40 | val counterNames = viewModel.getCounterList() 41 | if (counterNames.isEmpty()) { 42 | Toast.makeText(this, R.string.no_counters, Toast.LENGTH_SHORT).show() 43 | finish() 44 | } 45 | 46 | binding.counterNamesList.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, counterNames) 47 | binding.counterNamesList.setOnItemClickListener { _, _, position, _ -> 48 | 49 | val counterName = counterNames[position] 50 | WidgetViewModel.saveWidgetCounterNamePref(this, appWidgetId, counterName) 51 | 52 | val appWidgetManager = AppWidgetManager.getInstance(this) 53 | 54 | CoroutineScope(Dispatchers.Main).launch { 55 | WidgetProvider.updateAppWidget(application, viewModel, appWidgetManager, appWidgetId) 56 | } 57 | 58 | val resultValue = Intent() 59 | resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) 60 | setResult(RESULT_OK, resultValue) 61 | finish() 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/java/org/kde/bettercounter/ui/widget/WidgetViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.kde.bettercounter.ui.widget 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.content.Intent 6 | import androidx.core.content.edit 7 | import androidx.localbroadcastmanager.content.LocalBroadcastManager 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.flow 12 | import kotlinx.coroutines.flow.flowOn 13 | import kotlinx.coroutines.launch 14 | import org.kde.bettercounter.persistence.CounterSummary 15 | import org.kde.bettercounter.persistence.Exporter 16 | import org.kde.bettercounter.persistence.Repository 17 | import org.kde.bettercounter.ui.main.MainActivity 18 | import java.util.Calendar 19 | import java.util.Date 20 | 21 | class WidgetViewModel(val application: Application) { 22 | 23 | private val repo: Repository = Repository.create(application) 24 | private val exporter: Exporter = Exporter(application, repo) 25 | 26 | fun incrementCounter(name: String, date: Date = Calendar.getInstance().time) { 27 | CoroutineScope(Dispatchers.IO).launch { 28 | repo.addEntry(name, date) 29 | WidgetProvider.refreshWidget(application, name) 30 | exporter.autoExportIfEnabled() 31 | LocalBroadcastManager.getInstance(application) 32 | .sendBroadcast(Intent(MainActivity.ACTION_REFRESH_COUNTER).apply { 33 | putExtra(MainActivity.EXTRA_COUNTER_NAME, name) 34 | }) 35 | } 36 | } 37 | 38 | fun getCounterSummary(name: String): Flow = flow { 39 | emit(repo.getCounterSummary(name)) 40 | }.flowOn(Dispatchers.IO) 41 | 42 | fun counterExists(name: String): Boolean = repo.getCounterList().contains(name) 43 | 44 | fun getCounterList() = repo.getCounterList() 45 | 46 | companion object { 47 | private const val PREFS_NAME = "org.kde.bettercounter.ui.widget.WidgetProvider" 48 | private const val PREF_PREFIX_KEY = "appwidget_" 49 | 50 | fun saveWidgetCounterNamePref( 51 | context: Context, 52 | appWidgetId: Int, 53 | counterName: String 54 | ) { 55 | context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit { 56 | putString(PREF_PREFIX_KEY + appWidgetId, counterName) 57 | } 58 | } 59 | 60 | fun loadWidgetCounterNamePref(context: Context, appWidgetId: Int): String { 61 | val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) 62 | return prefs.getString(PREF_PREFIX_KEY + appWidgetId, null) 63 | ?: throw NoSuchElementException("Counter preference not found for widget id: $appWidgetId") 64 | } 65 | 66 | fun deleteWidgetCounterNamePref(context: Context, appWidgetId: Int) { 67 | context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit { 68 | remove(PREF_PREFIX_KEY + appWidgetId) 69 | } 70 | } 71 | 72 | fun existsWidgetCounterNamePref(context: Context, appWidgetId: Int): Boolean { 73 | val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) 74 | return prefs.contains(PREF_PREFIX_KEY + appWidgetId) 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/res/color/outlinebox.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-nodpi/widget_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertvaka/bettercounter/146940c0bcee1ea1b0d0ad8a4ce853a5161cc09d/app/src/main/res/drawable-nodpi/widget_preview.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/bg_color_circle.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/button_state_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_add.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_check.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_edit.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_minusone.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_plusone.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_time.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple_color_1.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 4 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple_color_2.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 4 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple_color_3.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 4 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple_color_4.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 4 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple_color_5.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 4 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple_color_6.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 4 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple_color_7.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 4 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple_color_8.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 4 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple_color_default.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 4 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 15 | 16 | 21 | 22 | 23 | 24 | 33 | 34 | 46 | 47 | 57 | 58 | 64 | 65 | 66 | 67 | 76 | 77 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 13 | 19 | 20 | 21 | 22 | 25 | 26 | 31 | 32 | 38 | 39 | 44 | 45 | 51 | 52 | 60 | 61 |