Add parent category support

This commit is contained in:
Jonas L 2019-01-28 16:35:56 +01:00
parent b7bf364aa6
commit 917f134d77
29 changed files with 1009 additions and 82 deletions

View file

@ -0,0 +1,443 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "80fe6fe576c0c935a61ae412e7d14437",
"entities": [
{
"tableName": "user",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `password` TEXT NOT NULL, `type` TEXT NOT NULL, `timezone` TEXT NOT NULL, `disable_limits_until` INTEGER NOT NULL, `category_for_not_assigned_apps` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timeZone",
"columnName": "timezone",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "disableLimitsUntil",
"columnName": "disable_limits_until",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryForNotAssignedApps",
"columnName": "category_for_not_assigned_apps",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "device",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `model` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `current_user_id` TEXT NOT NULL, `current_protection_level` TEXT NOT NULL, `highest_permission_level` TEXT NOT NULL, `current_usage_stats_permission` TEXT NOT NULL, `highest_usage_stats_permission` TEXT NOT NULL, `current_notification_access_permission` TEXT NOT NULL, `highest_notification_access_permission` TEXT NOT NULL, `current_app_version` INTEGER NOT NULL, `highest_app_version` INTEGER NOT NULL, `tried_disabling_device_admin` INTEGER NOT NULL, `had_manipulation` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "addedAt",
"columnName": "added_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currentUserId",
"columnName": "current_user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "currentProtectionLevel",
"columnName": "current_protection_level",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "highestProtectionLevel",
"columnName": "highest_permission_level",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "currentUsageStatsPermission",
"columnName": "current_usage_stats_permission",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "highestUsageStatsPermission",
"columnName": "highest_usage_stats_permission",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "currentNotificationAccessPermission",
"columnName": "current_notification_access_permission",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "highestNotificationAccessPermission",
"columnName": "highest_notification_access_permission",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "currentAppVersion",
"columnName": "current_app_version",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "highestAppVersion",
"columnName": "highest_app_version",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "manipulationTriedDisablingDeviceAdmin",
"columnName": "tried_disabling_device_admin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hadManipulation",
"columnName": "had_manipulation",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "app",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `title` TEXT NOT NULL, `launchable` INTEGER NOT NULL, `recommendation` TEXT NOT NULL, PRIMARY KEY(`package_name`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isLaunchable",
"columnName": "launchable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recommendation",
"columnName": "recommendation",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"package_name"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_app_package_name",
"unique": false,
"columnNames": [
"package_name"
],
"createSql": "CREATE INDEX `index_app_package_name` ON `${TABLE_NAME}` (`package_name`)"
}
],
"foreignKeys": []
},
{
"tableName": "category_app",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`category_id`, `package_name`))",
"fields": [
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"category_id",
"package_name"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_category_app_category_id",
"unique": false,
"columnNames": [
"category_id"
],
"createSql": "CREATE INDEX `index_category_app_category_id` ON `${TABLE_NAME}` (`category_id`)"
},
{
"name": "index_category_app_package_name",
"unique": false,
"columnNames": [
"package_name"
],
"createSql": "CREATE INDEX `index_category_app_package_name` ON `${TABLE_NAME}` (`package_name`)"
}
],
"foreignKeys": []
},
{
"tableName": "category",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `child_id` TEXT NOT NULL, `title` TEXT NOT NULL, `blocked_times` TEXT NOT NULL, `extra_time` INTEGER NOT NULL, `temporarily_blocked` INTEGER NOT NULL, `parent_category_id` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "childId",
"columnName": "child_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockedMinutesInWeek",
"columnName": "blocked_times",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extraTimeInMillis",
"columnName": "extra_time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "temporarilyBlocked",
"columnName": "temporarily_blocked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "parentCategoryId",
"columnName": "parent_category_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "used_time",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_of_epoch` INTEGER NOT NULL, `used_time` INTEGER NOT NULL, `category_id` TEXT NOT NULL, PRIMARY KEY(`category_id`, `day_of_epoch`))",
"fields": [
{
"fieldPath": "dayOfEpoch",
"columnName": "day_of_epoch",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "usedMillis",
"columnName": "used_time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"category_id",
"day_of_epoch"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "time_limit_rule",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `apply_to_extra_time_usage` INTEGER NOT NULL, `day_mask` INTEGER NOT NULL, `max_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "applyToExtraTimeUsage",
"columnName": "apply_to_extra_time_usage",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dayMask",
"columnName": "day_mask",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maximumTimeInMillis",
"columnName": "max_time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "config",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "key",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "temporarily_allowed_app",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, PRIMARY KEY(`package_name`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"package_name"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"80fe6fe576c0c935a61ae412e7d14437\")"
]
}
}

View file

@ -9,4 +9,10 @@ object DatabaseMigrations {
database.execSQL("ALTER TABLE `user` ADD COLUMN `category_for_not_assigned_apps` TEXT NOT NULL DEFAULT \"\"") database.execSQL("ALTER TABLE `user` ADD COLUMN `category_for_not_assigned_apps` TEXT NOT NULL DEFAULT \"\"")
} }
} }
val MIGRATE_TO_V3 = object: Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `category` ADD COLUMN `parent_category_id` TEXT NOT NULL DEFAULT \"\"")
}
}
} }

View file

@ -31,7 +31,7 @@ import io.timelimit.android.data.model.*
TimeLimitRule::class, TimeLimitRule::class,
ConfigurationItem::class, ConfigurationItem::class,
TemporarilyAllowedApp::class TemporarilyAllowedApp::class
], version = 2) ], version = 3)
abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database {
companion object { companion object {
private val lock = Object() private val lock = Object()
@ -67,7 +67,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database
.setJournalMode(JournalMode.TRUNCATE) .setJournalMode(JournalMode.TRUNCATE)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.addMigrations( .addMigrations(
DatabaseMigrations.MIGRATE_TO_V2 DatabaseMigrations.MIGRATE_TO_V2,
DatabaseMigrations.MIGRATE_TO_V3
) )
.build() .build()
} }

View file

@ -65,6 +65,9 @@ abstract class CategoryDao {
@Query("SELECT id, child_id, temporarily_blocked FROM category") @Query("SELECT id, child_id, temporarily_blocked FROM category")
abstract fun getAllCategoriesShortInfo(): LiveData<List<CategoryShortInfo>> abstract fun getAllCategoriesShortInfo(): LiveData<List<CategoryShortInfo>>
@Query("UPDATE category SET parent_category_id = :parentCategoryId WHERE id = :categoryId")
abstract fun updateParentCategory(categoryId: String, parentCategoryId: String)
} }
data class CategoryShortInfo( data class CategoryShortInfo(

View file

@ -42,7 +42,9 @@ data class Category(
@ColumnInfo(name = "extra_time") @ColumnInfo(name = "extra_time")
val extraTimeInMillis: Long, val extraTimeInMillis: Long,
@ColumnInfo(name = "temporarily_blocked") @ColumnInfo(name = "temporarily_blocked")
val temporarilyBlocked: Boolean val temporarilyBlocked: Boolean,
@ColumnInfo(name = "parent_category_id")
val parentCategoryId: String
): JsonSerializable { ): JsonSerializable {
companion object { companion object {
const val MINUTES_PER_DAY = 60 * 24 const val MINUTES_PER_DAY = 60 * 24
@ -54,6 +56,7 @@ data class Category(
private const val BLOCKED_MINUTES_IN_WEEK = "blockedMinutesInWeek" private const val BLOCKED_MINUTES_IN_WEEK = "blockedMinutesInWeek"
private const val EXTRA_TIME_IN_MILLIS = "extraTimeInMillis" private const val EXTRA_TIME_IN_MILLIS = "extraTimeInMillis"
private const val TEMPORARILY_BLOCKED = "temporarilyBlocked" private const val TEMPORARILY_BLOCKED = "temporarilyBlocked"
private const val PARENT_CATEGORY_ID = "parentCategoryId"
fun parse(reader: JsonReader): Category { fun parse(reader: JsonReader): Category {
var id: String? = null var id: String? = null
@ -62,6 +65,8 @@ data class Category(
var blockedMinutesInWeek: ImmutableBitmask? = null var blockedMinutesInWeek: ImmutableBitmask? = null
var extraTimeInMillis: Long? = null var extraTimeInMillis: Long? = null
var temporarilyBlocked: Boolean? = null var temporarilyBlocked: Boolean? = null
// this field was added later so it has got a default value
var parentCategoryId = ""
reader.beginObject() reader.beginObject()
@ -73,6 +78,7 @@ data class Category(
BLOCKED_MINUTES_IN_WEEK -> blockedMinutesInWeek = ImmutableBitmaskJson.parse(reader.nextString(), BLOCKED_MINUTES_IN_WEEK_LENGTH) BLOCKED_MINUTES_IN_WEEK -> blockedMinutesInWeek = ImmutableBitmaskJson.parse(reader.nextString(), BLOCKED_MINUTES_IN_WEEK_LENGTH)
EXTRA_TIME_IN_MILLIS -> extraTimeInMillis = reader.nextLong() EXTRA_TIME_IN_MILLIS -> extraTimeInMillis = reader.nextLong()
TEMPORARILY_BLOCKED -> temporarilyBlocked = reader.nextBoolean() TEMPORARILY_BLOCKED -> temporarilyBlocked = reader.nextBoolean()
PARENT_CATEGORY_ID -> parentCategoryId = reader.nextString()
else -> reader.skipValue() else -> reader.skipValue()
} }
} }
@ -85,7 +91,8 @@ data class Category(
title = title!!, title = title!!,
blockedMinutesInWeek = blockedMinutesInWeek!!, blockedMinutesInWeek = blockedMinutesInWeek!!,
extraTimeInMillis = extraTimeInMillis!!, extraTimeInMillis = extraTimeInMillis!!,
temporarilyBlocked = temporarilyBlocked!! temporarilyBlocked = temporarilyBlocked!!,
parentCategoryId = parentCategoryId
) )
} }
} }
@ -112,6 +119,7 @@ data class Category(
writer.name(BLOCKED_MINUTES_IN_WEEK).value(ImmutableBitmaskJson.serialize(blockedMinutesInWeek)) writer.name(BLOCKED_MINUTES_IN_WEEK).value(ImmutableBitmaskJson.serialize(blockedMinutesInWeek))
writer.name(EXTRA_TIME_IN_MILLIS).value(extraTimeInMillis) writer.name(EXTRA_TIME_IN_MILLIS).value(extraTimeInMillis)
writer.name(TEMPORARILY_BLOCKED).value(temporarilyBlocked) writer.name(TEMPORARILY_BLOCKED).value(temporarilyBlocked)
writer.name(PARENT_CATEGORY_ID).value(parentCategoryId)
writer.endObject() writer.endObject()
} }

View file

@ -136,7 +136,8 @@ class AppSetupLogic(private val appLogic: AppLogic) {
title = defaultCategories.allowedAppsTitle, title = defaultCategories.allowedAppsTitle,
blockedMinutesInWeek = ImmutableBitmask((BitSet())), blockedMinutesInWeek = ImmutableBitmask((BitSet())),
extraTimeInMillis = 0, extraTimeInMillis = 0,
temporarilyBlocked = false temporarilyBlocked = false,
parentCategoryId = ""
)) ))
appLogic.database.category().addCategory(Category( appLogic.database.category().addCategory(Category(
@ -145,7 +146,8 @@ class AppSetupLogic(private val appLogic: AppLogic) {
title = defaultCategories.allowedGamesTitle, title = defaultCategories.allowedGamesTitle,
blockedMinutesInWeek = defaultCategories.allowedGamesBlockedTimes, blockedMinutesInWeek = defaultCategories.allowedGamesBlockedTimes,
extraTimeInMillis = 0, extraTimeInMillis = 0,
temporarilyBlocked = false temporarilyBlocked = false,
parentCategoryId = ""
)) ))
// add default allowed apps // add default allowed apps

View file

@ -186,6 +186,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
val appCategory = appCategories.get(Pair(foregroundAppPackageName, categories.map { it.id })).waitForNullableValue() val appCategory = appCategories.get(Pair(foregroundAppPackageName, categories.map { it.id })).waitForNullableValue()
val category = categories.find { it.id == appCategory?.categoryId } val category = categories.find { it.id == appCategory?.categoryId }
?: categories.find { it.id == deviceUserEntry.categoryForNotAssignedApps } ?: categories.find { it.id == deviceUserEntry.categoryForNotAssignedApps }
val parentCategory = categories.find { it.id == category?.parentCategoryId }
if (category == null) { if (category == null) {
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
@ -196,7 +197,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
)) ))
appLogic.platformIntegration.setSuspendedApps(listOf(foregroundAppPackageName), true) appLogic.platformIntegration.setSuspendedApps(listOf(foregroundAppPackageName), true)
appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName) appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName)
} else if (category.temporarilyBlocked) { } else if (category.temporarilyBlocked or (parentCategory?.temporarilyBlocked == true)) {
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
@ -218,7 +219,8 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
)) ))
} else if ( } else if (
// check blocked time areas // check blocked time areas
(category.blockedMinutesInWeek.read(minuteOfWeek)) (category.blockedMinutesInWeek.read(minuteOfWeek)) or
(parentCategory?.blockedMinutesInWeek?.read(minuteOfWeek) == true)
) { ) {
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
@ -230,8 +232,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
} else { } else {
// check time limits // check time limits
val rules = timeLimitRules.get(category.id).waitForNonNullValue() val rules = timeLimitRules.get(category.id).waitForNonNullValue()
val parentRules = parentCategory?.let {
timeLimitRules.get(it.id).waitForNonNullValue()
} ?: emptyList()
if (rules.isEmpty()) { if (rules.isEmpty() and parentRules.isEmpty()) {
// unlimited // unlimited
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
@ -241,33 +246,61 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
)) ))
} else { } else {
val usedTimes = usedTimesOfCategoryAndWeekByFirstDayOfWeek.get(Pair(category.id, nowDate.dayOfEpoch - nowDate.dayOfWeek)).waitForNonNullValue() val usedTimes = usedTimesOfCategoryAndWeekByFirstDayOfWeek.get(Pair(category.id, nowDate.dayOfEpoch - nowDate.dayOfWeek)).waitForNonNullValue()
val parentUsedTimes = parentCategory?.let {
usedTimesOfCategoryAndWeekByFirstDayOfWeek.get(Pair(it.id, nowDate.dayOfEpoch - nowDate.dayOfWeek)).waitForNonNullValue()
} ?: SparseArray()
val newUsedTimeItemBatchUpdateHelper = UsedTimeItemBatchUpdateHelper.eventuallyUpdateInstance( val newUsedTimeItemBatchUpdateHelper = UsedTimeItemBatchUpdateHelper.eventuallyUpdateInstance(
date = nowDate, date = nowDate,
categoryId = category.id, childCategoryId = category.id,
parentCategoryId = parentCategory?.id,
oldInstance = usedTimeUpdateHelper, oldInstance = usedTimeUpdateHelper,
usedTimeItemForDay = usedTimes.get(nowDate.dayOfWeek), usedTimeItemForDayChild = usedTimes.get(nowDate.dayOfWeek),
usedTimeItemForDayParent = parentUsedTimes.get(nowDate.dayOfWeek),
logic = appLogic logic = appLogic
) )
usedTimeUpdateHelper = newUsedTimeItemBatchUpdateHelper usedTimeUpdateHelper = newUsedTimeItemBatchUpdateHelper
val usedTimesSparseArray = SparseLongArray() fun buildUsedTimesSparseArray(items: SparseArray<UsedTimeItem>, isParentCategory: Boolean): SparseLongArray {
val result = SparseLongArray()
for (i in 0..6) { for (i in 0..6) {
val usedTimesItem = usedTimes[i]?.usedMillis val usedTimesItem = items[i]?.usedMillis
if (newUsedTimeItemBatchUpdateHelper.date.dayOfWeek == i) { if (newUsedTimeItemBatchUpdateHelper.date.dayOfWeek == i) {
usedTimesSparseArray.put(i, newUsedTimeItemBatchUpdateHelper.getTotalUsedTime()) result.put(
i,
if (isParentCategory)
newUsedTimeItemBatchUpdateHelper.getTotalUsedTimeParent()
else
newUsedTimeItemBatchUpdateHelper.getTotalUsedTimeChild()
)
} else { } else {
usedTimesSparseArray.put(i, (if (usedTimesItem != null) usedTimesItem else 0)) result.put(i, usedTimesItem ?: 0)
} }
} }
val remaining = RemainingTime.getRemainingTime( return result
nowDate.dayOfWeek, usedTimesSparseArray, rules, }
val remainingChild = RemainingTime.getRemainingTime(
nowDate.dayOfWeek,
buildUsedTimesSparseArray(usedTimes, isParentCategory = false),
rules,
Math.max(0, category.extraTimeInMillis - newUsedTimeItemBatchUpdateHelper.getCachedExtraTimeToSubtract()) Math.max(0, category.extraTimeInMillis - newUsedTimeItemBatchUpdateHelper.getCachedExtraTimeToSubtract())
) )
val remainingParent = parentCategory?.let {
RemainingTime.getRemainingTime(
nowDate.dayOfWeek,
buildUsedTimesSparseArray(parentUsedTimes, isParentCategory = true),
parentRules,
Math.max(0, parentCategory.extraTimeInMillis - newUsedTimeItemBatchUpdateHelper.getCachedExtraTimeToSubtract())
)
}
val remaining = RemainingTime.min(remainingChild, remainingParent)
if (remaining == null) { if (remaining == null) {
// unlimited // unlimited

View file

@ -129,22 +129,24 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
if (categoryEntry2 == null) { if (categoryEntry2 == null) {
liveDataFromValue(BlockingReason.NotPartOfAnCategory) liveDataFromValue(BlockingReason.NotPartOfAnCategory)
} else { } else {
getBlockingReasonStep4Point5(categoryEntry2, child, timeZone) getBlockingReasonStep4Point5(categoryEntry2, child, timeZone, false)
} }
} }
} else if (categoryEntry.temporarilyBlocked) {
liveDataFromValue(BlockingReason.TemporarilyBlocked)
} else { } else {
getBlockingReasonStep4Point5(categoryEntry, child, timeZone) getBlockingReasonStep4Point5(categoryEntry, child, timeZone, false)
} }
} }
} }
private fun getBlockingReasonStep4Point5(category: Category, child: User, timeZone: TimeZone): LiveData<BlockingReason> { private fun getBlockingReasonStep4Point5(category: Category, child: User, timeZone: TimeZone, isParentCategory: Boolean): LiveData<BlockingReason> {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 4.5") Log.d(LOG_TAG, "step 4.5")
} }
if (category.temporarilyBlocked) {
return liveDataFromValue(BlockingReason.TemporarilyBlocked)
}
val areLimitsDisabled: LiveData<Boolean> val areLimitsDisabled: LiveData<Boolean>
if (child.disableLimitsUntil == 0L) { if (child.disableLimitsUntil == 0L) {
@ -163,6 +165,18 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
} else { } else {
getBlockingReasonStep5(category, timeZone) getBlockingReasonStep5(category, timeZone)
} }
}.switchMap { result ->
if (result == BlockingReason.None && (!isParentCategory) && category.parentCategoryId.isNotEmpty()) {
appLogic.database.category().getCategoryByChildIdAndId(child.id, category.parentCategoryId).switchMap { parentCategory ->
if (parentCategory == null) {
liveDataFromValue(BlockingReason.None)
} else {
getBlockingReasonStep4Point5(parentCategory, child, timeZone, true)
}
}
} else {
liveDataFromValue(result)
}
} }
} }

View file

@ -30,6 +30,17 @@ data class RemainingTime(val includingExtraTime: Long, val default: Long) {
} }
companion object { companion object {
fun min(a: RemainingTime?, b: RemainingTime?): RemainingTime? = if (a == null) {
b
} else if (b == null) {
a
} else {
RemainingTime(
includingExtraTime = Math.min(a.includingExtraTime, b.includingExtraTime),
default = Math.min(a.default, b.default)
)
}
private fun getRulesRelatedToDay(dayOfWeek: Int, rules: List<TimeLimitRule>): List<TimeLimitRule> { private fun getRulesRelatedToDay(dayOfWeek: Int, rules: List<TimeLimitRule>): List<TimeLimitRule> {
return rules.filter { (it.dayMask.toInt() and (1 shl dayOfWeek)) != 0 } return rules.filter { (it.dayMask.toInt() and (1 shl dayOfWeek)) != 0 }
} }

View file

@ -22,18 +22,35 @@ import io.timelimit.android.livedata.waitForNullableValue
import io.timelimit.android.sync.actions.AddUsedTimeAction import io.timelimit.android.sync.actions.AddUsedTimeAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil import io.timelimit.android.sync.actions.apply.ApplyActionUtil
class UsedTimeItemBatchUpdateHelper(val date: DateInTimezone, val categoryId: String, var cachedItem: UsedTimeItem?) { class UsedTimeItemBatchUpdateHelper(
val date: DateInTimezone,
val childCategoryId: String,
val parentCategoryId: String?,
var cachedItemChild: UsedTimeItem?,
var cachedItemParent: UsedTimeItem?
) {
companion object { companion object {
suspend fun eventuallyUpdateInstance( suspend fun eventuallyUpdateInstance(
date: DateInTimezone, date: DateInTimezone,
categoryId: String, childCategoryId: String,
parentCategoryId: String?,
oldInstance: UsedTimeItemBatchUpdateHelper?, oldInstance: UsedTimeItemBatchUpdateHelper?,
usedTimeItemForDay: UsedTimeItem?, usedTimeItemForDayChild: UsedTimeItem?,
usedTimeItemForDayParent: UsedTimeItem?,
logic: AppLogic logic: AppLogic
): UsedTimeItemBatchUpdateHelper { ): UsedTimeItemBatchUpdateHelper {
if (oldInstance != null && oldInstance.date == date && oldInstance.categoryId == categoryId) { if (
if (oldInstance.cachedItem != usedTimeItemForDay) { oldInstance != null &&
oldInstance.cachedItem = usedTimeItemForDay oldInstance.date == date &&
oldInstance.childCategoryId == childCategoryId &&
oldInstance.parentCategoryId == parentCategoryId
) {
if (oldInstance.cachedItemChild != usedTimeItemForDayChild) {
oldInstance.cachedItemChild = usedTimeItemForDayChild
}
if (oldInstance.cachedItemParent != usedTimeItemForDayParent) {
oldInstance.cachedItemParent = usedTimeItemForDayParent
} }
return oldInstance return oldInstance
@ -44,8 +61,10 @@ class UsedTimeItemBatchUpdateHelper(val date: DateInTimezone, val categoryId: St
return UsedTimeItemBatchUpdateHelper( return UsedTimeItemBatchUpdateHelper(
date = date, date = date,
categoryId = categoryId, childCategoryId = childCategoryId,
cachedItem = usedTimeItemForDay parentCategoryId = parentCategoryId,
cachedItemChild = usedTimeItemForDayChild,
cachedItemParent = usedTimeItemForDayParent
) )
} }
} }
@ -66,18 +85,18 @@ class UsedTimeItemBatchUpdateHelper(val date: DateInTimezone, val categoryId: St
} }
} }
fun getTotalUsedTime(): Long { fun getTotalUsedTimeChild(): Long = (cachedItemChild?.usedMillis ?: 0) + timeToAdd
val cachedItem = cachedItem fun getTotalUsedTimeParent(): Long = (cachedItemParent?.usedMillis ?: 0) + timeToAdd
return (if (cachedItem == null) 0 else cachedItem.usedMillis) + timeToAdd
}
fun getCachedExtraTimeToSubtract(): Int { fun getCachedExtraTimeToSubtract(): Int {
return extraTimeToSubtract return extraTimeToSubtract
} }
suspend fun queryCurrentStatusFromDatabase(database: Database) { suspend fun queryCurrentStatusFromDatabase(database: Database) {
cachedItem = database.usedTimes().getUsedTimeItem(categoryId, date.dayOfEpoch).waitForNullableValue() cachedItemChild = database.usedTimes().getUsedTimeItem(childCategoryId, date.dayOfEpoch).waitForNullableValue()
cachedItemParent = parentCategoryId?.let {
database.usedTimes().getUsedTimeItem(parentCategoryId, date.dayOfEpoch).waitForNullableValue()
}
} }
suspend fun commit(logic: AppLogic) { suspend fun commit(logic: AppLogic) {
@ -86,7 +105,7 @@ class UsedTimeItemBatchUpdateHelper(val date: DateInTimezone, val categoryId: St
} else { } else {
ApplyActionUtil.applyAppLogicAction( ApplyActionUtil.applyAppLogicAction(
AddUsedTimeAction( AddUsedTimeAction(
categoryId = categoryId, categoryId = childCategoryId,
timeToAdd = timeToAdd, timeToAdd = timeToAdd,
dayOfEpoch = date.dayOfEpoch, dayOfEpoch = date.dayOfEpoch,
extraTimeToSubtract = extraTimeToSubtract extraTimeToSubtract = extraTimeToSubtract

View file

@ -137,6 +137,17 @@ data class SetCategoryForUnassignedApps(val childId: String, val categoryId: Str
} }
} }
} }
data class SetParentCategory(val categoryId: String, val parentCategory: String): ParentAction() {
// parent category id can be empty
init {
IdGenerator.assertIdValid(categoryId)
if (parentCategory.isNotEmpty()) {
IdGenerator.assertIdValid(parentCategory)
}
}
}
// DeviceDao // DeviceDao

View file

@ -33,11 +33,16 @@ object LocalDatabaseAppLogicActionDispatcher {
try { try {
when(action) { when(action) {
is AddUsedTimeAction -> { is AddUsedTimeAction -> {
DatabaseValidation.assertCategoryExists(database, action.categoryId) val categoryEntry = database.category().getCategoryByIdSync(action.categoryId)!!
val parentCategoryEntry = if (categoryEntry.parentCategoryId.isNotEmpty())
database.category().getCategoryByIdSync(categoryEntry.parentCategoryId)
else
null
fun handleAddUsedTime(categoryId: String) {
// try to update // try to update
val updatedRows = database.usedTimes().addUsedTime( val updatedRows = database.usedTimes().addUsedTime(
categoryId = action.categoryId, categoryId = categoryId,
timeToAdd = action.timeToAdd, timeToAdd = action.timeToAdd,
dayOfEpoch = action.dayOfEpoch dayOfEpoch = action.dayOfEpoch
) )
@ -46,22 +51,29 @@ object LocalDatabaseAppLogicActionDispatcher {
// create new entry // create new entry
database.usedTimes().insertUsedTime(UsedTimeItem( database.usedTimes().insertUsedTime(UsedTimeItem(
categoryId = action.categoryId, categoryId = categoryId,
dayOfEpoch = action.dayOfEpoch, dayOfEpoch = action.dayOfEpoch,
usedMillis = action.timeToAdd.toLong() usedMillis = action.timeToAdd.toLong()
)) ))
} // required to make this compile }
if (action.extraTimeToSubtract != 0) { if (action.extraTimeToSubtract != 0) {
database.category().subtractCategoryExtraTime( database.category().subtractCategoryExtraTime(
categoryId = action.categoryId, categoryId = categoryId,
removedExtraTime = action.extraTimeToSubtract removedExtraTime = action.extraTimeToSubtract
) )
} else {
// required to make this compile
} }
} }
handleAddUsedTime(categoryEntry.id)
if (parentCategoryEntry?.childId == categoryEntry.childId) {
handleAddUsedTime(parentCategoryEntry.id)
}
null
}
is AddInstalledAppsAction -> { is AddInstalledAppsAction -> {
database.app().addAppsSync( database.app().addAppsSync(
action.apps.map { action.apps.map {

View file

@ -73,7 +73,8 @@ object LocalDatabaseParentActionDispatcher {
// nothing blocked by default // nothing blocked by default
blockedMinutesInWeek = ImmutableBitmask(BitSet()), blockedMinutesInWeek = ImmutableBitmask(BitSet()),
extraTimeInMillis = 0, extraTimeInMillis = 0,
temporarilyBlocked = false temporarilyBlocked = false,
parentCategoryId = ""
)) ))
} }
is DeleteCategoryAction -> { is DeleteCategoryAction -> {
@ -104,13 +105,24 @@ object LocalDatabaseParentActionDispatcher {
database.category().updateCategoryExtraTime(action.categoryId, action.newExtraTime) database.category().updateCategoryExtraTime(action.categoryId, action.newExtraTime)
} }
is IncrementCategoryExtraTimeAction -> { is IncrementCategoryExtraTimeAction -> {
DatabaseValidation.assertCategoryExists(database, action.categoryId)
if (action.addedExtraTime < 0) { if (action.addedExtraTime < 0) {
throw IllegalArgumentException("invalid added extra time") throw IllegalArgumentException("invalid added extra time")
} }
val category = database.category().getCategoryByIdSync(action.categoryId)
?: throw IllegalArgumentException("category ${action.categoryId} does not exist")
database.category().incrementCategoryExtraTime(action.categoryId, action.addedExtraTime) database.category().incrementCategoryExtraTime(action.categoryId, action.addedExtraTime)
if (category.parentCategoryId.isNotEmpty()) {
val parentCategory = database.category().getCategoryByIdSync(category.parentCategoryId)
if (parentCategory?.childId == category.childId) {
database.category().incrementCategoryExtraTime(parentCategory.id, action.addedExtraTime)
}
}
null
} }
is UpdateCategoryTemporarilyBlockedAction -> { is UpdateCategoryTemporarilyBlockedAction -> {
DatabaseValidation.assertCategoryExists(database, action.categoryId) DatabaseValidation.assertCategoryExists(database, action.categoryId)
@ -281,6 +293,29 @@ object LocalDatabaseParentActionDispatcher {
childId = action.childId childId = action.childId
) )
} }
is SetParentCategory -> {
val category = database.category().getCategoryByIdSync(action.categoryId)!!
if (action.parentCategory.isNotEmpty()) {
val categories = database.category().getCategoriesByChildIdSync(category.childId)
val parentCategoryItem = categories.find { it.id == action.parentCategory }
?: throw IllegalArgumentException("selected parent category does not exist")
if (parentCategoryItem.parentCategoryId.isNotEmpty()) {
throw IllegalArgumentException("can not set a category as parent which itself has got a parent")
}
if (categories.find { it.parentCategoryId == action.categoryId } != null) {
throw IllegalArgumentException("can not make category a child category if it is already a parent category")
}
}
database.category().updateParentCategory(
categoryId = action.categoryId,
parentCategoryId = action.parentCategory
)
}
}.let { } }.let { }
database.setTransactionSuccessful() database.setTransactionSuccessful()

View file

@ -58,6 +58,16 @@ class CategorySettingsFragment : Fragment() {
auth = auth auth = auth
) )
ParentCategoryView.bind(
binding = binding.parentCategory,
lifecycleOwner = this,
categoryId = params.categoryId,
childId = params.childId,
database = appLogic.database,
fragmentManager = fragmentManager!!,
auth = auth
)
binding.btnDeleteCategory.setOnClickListener { deleteCategory() } binding.btnDeleteCategory.setOnClickListener { deleteCategory() }
binding.editCategoryTitleGo.setOnClickListener { renameCategory() } binding.editCategoryTitleGo.setOnClickListener { renameCategory() }

View file

@ -0,0 +1,53 @@
/*
* Open TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program 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 version 3 of the License.
*
* This program 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.ui.manage.category.settings
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import io.timelimit.android.data.Database
import io.timelimit.android.databinding.ManageParentCategoryBinding
import io.timelimit.android.ui.main.ActivityViewModel
object ParentCategoryView {
fun bind(
binding: ManageParentCategoryBinding,
auth: ActivityViewModel,
lifecycleOwner: LifecycleOwner,
categoryId: String,
childId: String,
database: Database,
fragmentManager: FragmentManager
) {
database.category().getCategoriesByChildId(childId).observe(lifecycleOwner, Observer { categories ->
val ownCategory = categories.find { it.id == categoryId }
val parentCategory = categories.find { it.id == ownCategory?.parentCategoryId }
val hasSubCategories = categories.find { it.parentCategoryId == categoryId } != null
binding.parentCategoryTitle = parentCategory?.title
binding.isParentCategory = hasSubCategories
})
binding.selectParentButton.setOnClickListener {
if (auth.requestAuthenticationOrReturnTrue()) {
SelectParentCategoryDialogFragment.newInstance(
childId = childId,
categoryId = categoryId
).show(fragmentManager)
}
}
}
}

View file

@ -0,0 +1,151 @@
/*
* Open TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program 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 version 3 of the License.
*
* This program 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.ui.manage.category.settings
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckedTextView
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import io.timelimit.android.R
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.Category
import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.BottomSheetSelectionListBinding
import io.timelimit.android.extensions.showSafe
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.SetParentCategory
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.ActivityViewModelHolder
class SelectParentCategoryDialogFragment: BottomSheetDialogFragment() {
companion object {
private const val DIALOG_TAG = "SelectParentCategoryDialogFragment"
private const val CATEGORY_ID = "categoryId"
private const val CHILD_ID = "childId"
fun newInstance(childId: String, categoryId: String) = SelectParentCategoryDialogFragment().apply {
arguments = Bundle().apply {
putString(CHILD_ID, childId)
putString(CATEGORY_ID, categoryId)
}
}
}
val childId: String by lazy { arguments!!.getString(CHILD_ID) }
val categoryId: String by lazy { arguments!!.getString(CATEGORY_ID) }
val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) }
val database: Database by lazy { logic.database }
val auth: ActivityViewModel by lazy { (activity as ActivityViewModelHolder).getActivityViewModel() }
val childCategoryEntries: LiveData<List<Category>> by lazy {
database.category().getCategoriesByChildId(childId)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
childCategoryEntries.observe(this, Observer { categories ->
val ownCategory = categories.find { it.id == categoryId }
val hasSubCategories = categories.find { it.parentCategoryId == categoryId } != null
if (ownCategory == null || hasSubCategories) {
dismissAllowingStateLoss()
}
})
auth.authenticatedUser.observe(this, Observer {
if (it?.second?.type != UserType.Parent) {
dismissAllowingStateLoss()
}
})
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = BottomSheetSelectionListBinding.inflate(inflater, container, false)
binding.title = getString(R.string.category_settings_parent_category_title)
val list = binding.list
childCategoryEntries.observe(this, Observer { categories ->
list.removeAllViews()
val ownCategory = categories.find { it.id == categoryId }
val ownParentCategory = categories.find { it.id == ownCategory?.parentCategoryId }
fun buildRow(): CheckedTextView = LayoutInflater.from(context!!).inflate(
android.R.layout.simple_list_item_single_choice,
list,
false
) as CheckedTextView
categories.forEach { category ->
if (category.id != categoryId) {
val row = buildRow()
row.text = category.title
row.isChecked = category.id == ownCategory?.parentCategoryId
row.isEnabled = categories.find { it.id == category.parentCategoryId } == null
row.setOnClickListener {
if (!row.isChecked) {
auth.tryDispatchParentAction(
SetParentCategory(
categoryId = categoryId,
parentCategory = category.id
)
)
}
dismiss()
}
list.addView(row)
}
}
buildRow().let { row ->
row.setText(R.string.category_settings_parent_category_none)
row.isChecked = ownParentCategory == null
row.setOnClickListener {
if (!row.isChecked) {
auth.tryDispatchParentAction(
SetParentCategory(
categoryId = categoryId,
parentCategory = ""
)
)
}
dismiss()
}
list.addView(row)
}
})
return binding.root
}
fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG)
}

View file

@ -28,7 +28,7 @@ import io.timelimit.android.R
import io.timelimit.android.data.Database import io.timelimit.android.data.Database
import io.timelimit.android.data.model.Category import io.timelimit.android.data.model.Category
import io.timelimit.android.data.model.UserType import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.AssignAppDialogBinding import io.timelimit.android.databinding.BottomSheetSelectionListBinding
import io.timelimit.android.extensions.showSafe import io.timelimit.android.extensions.showSafe
import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DefaultAppLogic
@ -73,10 +73,10 @@ class AssignAllAppsCategoryDialogFragment: BottomSheetDialogFragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = AssignAppDialogBinding.inflate(inflater, container, false) val binding = BottomSheetSelectionListBinding.inflate(inflater, container, false)
val list = binding.categoryList val list = binding.list
binding.appTitle = resources.getQuantityString(R.plurals.generic_plural_app, appPackageNames.size, appPackageNames.size) binding.title = resources.getQuantityString(R.plurals.generic_plural_app, appPackageNames.size, appPackageNames.size)
childCategoryEntries.observe(this, Observer { categories -> childCategoryEntries.observe(this, Observer { categories ->
fun buildRow(): CheckedTextView = LayoutInflater.from(context!!).inflate( fun buildRow(): CheckedTextView = LayoutInflater.from(context!!).inflate(

View file

@ -30,7 +30,7 @@ import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.Category import io.timelimit.android.data.model.Category
import io.timelimit.android.data.model.CategoryApp import io.timelimit.android.data.model.CategoryApp
import io.timelimit.android.data.model.UserType import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.AssignAppDialogBinding import io.timelimit.android.databinding.BottomSheetSelectionListBinding
import io.timelimit.android.extensions.showSafe import io.timelimit.android.extensions.showSafe
import io.timelimit.android.livedata.map import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.switchMap import io.timelimit.android.livedata.switchMap
@ -96,9 +96,9 @@ class AssignAppCategoryDialogFragment: BottomSheetDialogFragment() {
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = AssignAppDialogBinding.inflate(inflater, container, false) val binding = BottomSheetSelectionListBinding.inflate(inflater, container, false)
val list = binding.categoryList val list = binding.list
childCategoryEntries.switchMap { categories -> childCategoryEntries.switchMap { categories ->
categoryAppEntry.map { appCategory -> categoryAppEntry.map { appCategory ->
@ -157,7 +157,7 @@ class AssignAppCategoryDialogFragment: BottomSheetDialogFragment() {
}) })
matchingAppEntries.observe(this, Observer { matchingAppEntries.observe(this, Observer {
binding.appTitle = it.firstOrNull()?.title binding.title = it.firstOrNull()?.title
}) })
return binding.root return binding.root

View file

@ -125,6 +125,7 @@ class Adapter: RecyclerView.Adapter<ViewHolder>() {
null null
} }
binding.usedForAppsWithoutCategory = item.usedForNotAssignedApps binding.usedForAppsWithoutCategory = item.usedForNotAssignedApps
binding.parentCategoryTitle = item.parentCategoryTitle
binding.card.setOnClickListener { handlers?.onCategoryClicked(item.category) } binding.card.setOnClickListener { handlers?.onCategoryClicked(item.category) }

View file

@ -26,5 +26,6 @@ data class CategoryItem(
val isBlockedTimeNow: Boolean, val isBlockedTimeNow: Boolean,
val remainingTimeToday: Long?, val remainingTimeToday: Long?,
val usedTimeToday: Long, val usedTimeToday: Long,
val usedForNotAssignedApps: Boolean val usedForNotAssignedApps: Boolean,
val parentCategoryTitle: String?
): ManageChildCategoriesListItem() ): ManageChildCategoriesListItem()

View file

@ -87,10 +87,10 @@ class ManageChildCategoriesModel(application: Application): AndroidViewModel(app
val firstDayOfWeek = childDate.dayOfEpoch - childDate.dayOfWeek val firstDayOfWeek = childDate.dayOfEpoch - childDate.dayOfWeek
categories.map { category -> categories.map { category ->
val rules = rulesByCategoryId[category.id] ?: emptyList() val rules = rulesByCategoryId[category.id] ?: emptyList()
val usedTimeItemsForCategory = usedTimesByCategory[category.id] val usedTimeItemsForCategory = usedTimesByCategory[category.id]
?: emptyList() ?: emptyList()
val parentCategory = categories.find { it.id == category.parentCategoryId }
CategoryItem( CategoryItem(
category = category, category = category,
@ -110,7 +110,8 @@ class ManageChildCategoriesModel(application: Application): AndroidViewModel(app
)?.includingExtraTime, )?.includingExtraTime,
usedTimeToday = usedTimeItemsForCategory.find { item -> item.dayOfEpoch == childDate.dayOfEpoch }?.usedMillis usedTimeToday = usedTimeItemsForCategory.find { item -> item.dayOfEpoch == childDate.dayOfEpoch }?.usedMillis
?: 0, ?: 0,
usedForNotAssignedApps = categoryForUnassignedApps == category.id usedForNotAssignedApps = categoryForUnassignedApps == category.id,
parentCategoryTitle = parentCategory?.title
) )
} }
} }

View file

@ -19,7 +19,7 @@
<data> <data>
<variable <variable
name="appTitle" name="title"
type="String" /> type="String" />
</data> </data>
@ -31,13 +31,13 @@
<TextView <TextView
android:textAppearance="?android:textAppearanceLarge" android:textAppearance="?android:textAppearanceLarge"
android:padding="8dp" android:padding="8dp"
android:text="@{appTitle}" android:text="@{title}"
tools:text="Systemeinstellungen" tools:text="Systemeinstellungen"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
<LinearLayout <LinearLayout
android:id="@+id/category_list" android:id="@+id/list"
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">

View file

@ -39,6 +39,10 @@
name="usedForAppsWithoutCategory" name="usedForAppsWithoutCategory"
type="boolean" /> type="boolean" />
<variable
name="parentCategoryTitle"
type="String" />
<import type="android.text.TextUtils" /> <import type="android.text.TextUtils" />
<import type="android.view.View" /> <import type="android.view.View" />
</data> </data>
@ -71,6 +75,14 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:visibility="@{TextUtils.isEmpty(parentCategoryTitle) ? View.GONE : View.VISIBLE}"
tools:text="@string/manage_child_category_is_child"
android:text="@{@string/manage_child_category_is_child(parentCategoryTitle)}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView <TextView
android:visibility="@{TextUtils.isEmpty(usedTimeToday) ? View.GONE : View.VISIBLE}" android:visibility="@{TextUtils.isEmpty(usedTimeToday) ? View.GONE : View.VISIBLE}"
android:text="@{usedTimeToday}" android:text="@{usedTimeToday}"

View file

@ -68,6 +68,9 @@
<include android:id="@+id/category_for_unassigned_apps" <include android:id="@+id/category_for_unassigned_apps"
layout="@layout/manage_category_for_unassigned_apps" /> layout="@layout/manage_category_for_unassigned_apps" />
<include android:id="@+id/parent_category"
layout="@layout/manage_parent_category" />
<androidx.cardview.widget.CardView <androidx.cardview.widget.CardView
app:cardUseCompatPadding="true" app:cardUseCompatPadding="true"
android:layout_width="match_parent" android:layout_width="match_parent"

View file

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Open TimeLimit Copyright <C> 2019 Jonas Lochmann
This program 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 version 3 of the License.
This program 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 <https://www.gnu.org/licenses/>.
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="isParentCategory"
type="boolean" />
<variable
name="parentCategoryTitle"
type="String" />
<import type="android.view.View" />
<import type="android.text.TextUtils" />
</data>
<androidx.cardview.widget.CardView
app:cardUseCompatPadding="true"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:padding="8dp"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:text="@string/category_settings_parent_category_title"
android:textAppearance="?android:textAppearanceLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:text="@string/category_settings_parent_category_intro"
android:textAppearance="?android:textAppearanceMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{isParentCategory ? View.VISIBLE : View.GONE}"
android:text="@string/category_settings_parent_category_already_used_as_parent"
android:textAppearance="?android:textAppearanceMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{TextUtils.isEmpty(parentCategoryTitle) ? View.GONE : View.VISIBLE}"
tools:text="@string/category_settings_parent_category_assigned_to"
android:text="@{@string/category_settings_parent_category_assigned_to(parentCategoryTitle)}"
android:textAppearance="?android:textAppearanceMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Button
android:id="@+id/select_parent_button"
android:enabled="@{!isParentCategory}"
android:text="@string/category_settings_parent_category_button"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</layout>

View file

@ -27,4 +27,11 @@
<string name="category_settings_extra_time_change_toast">Die neue Extrazeit wurde gespeichert</string> <string name="category_settings_extra_time_change_toast">Die neue Extrazeit wurde gespeichert</string>
<string name="category_settings_delete_dialog">Möchten Sie die Kategorie %s löschen?</string> <string name="category_settings_delete_dialog">Möchten Sie die Kategorie %s löschen?</string>
<string name="category_settings_parent_category_title">Ober-Kategorie</string>
<string name="category_settings_parent_category_intro">Wenn die Oberkategorie einer Kategorie blockiert ist (z.B. weil die Zeit verbraucht wurde), dann wird auch die Unterkategorie blockiert. Wenn die Unterkategorie verwendet wird, dann wird die Nutzungsdauer auch zur Ober-Kategorie hinzugefügt.</string>
<string name="category_settings_parent_category_already_used_as_parent">Sie können keine Oberkategorie wählen, weil diese Kategorie bereits die Oberkategorie für eine andere Kategorie ist.</string>
<string name="category_settings_parent_category_assigned_to">Diese Kategorie ist eine Unterkategorie von %s.</string>
<string name="category_settings_parent_category_button">Oberkategorie wählen</string>
<string name="category_settings_parent_category_none">keine Oberkategorie</string>
</resources> </resources>

View file

@ -39,4 +39,5 @@
</string> </string>
<string name="manage_child_category_for_unassigned_apps">wird für Apps ohne Kategorie verwendet</string> <string name="manage_child_category_for_unassigned_apps">wird für Apps ohne Kategorie verwendet</string>
<string name="manage_child_category_is_child">Unterkategorie von %s</string>
</resources> </resources>

View file

@ -28,4 +28,11 @@
<string name="category_settings_extra_time_change_toast">The new extra time was saved</string> <string name="category_settings_extra_time_change_toast">The new extra time was saved</string>
<string name="category_settings_delete_dialog">Do you want to delete the category %s?</string> <string name="category_settings_delete_dialog">Do you want to delete the category %s?</string>
<string name="category_settings_parent_category_title">Parent category</string>
<string name="category_settings_parent_category_intro">When the parent category of a category is blocked (e.g. because the time is over), then the child category is blocked too. When using the child category, the used time is also added to the parent category.</string>
<string name="category_settings_parent_category_already_used_as_parent">You can not select a parent category because this category is the parent of an other one.</string>
<string name="category_settings_parent_category_assigned_to">This category is a child category of %s now.</string>
<string name="category_settings_parent_category_button">Select parent category</string>
<string name="category_settings_parent_category_none">no parent category</string>
</resources> </resources>

View file

@ -40,4 +40,5 @@
</string> </string>
<string name="manage_child_category_for_unassigned_apps">used for Apps without category</string> <string name="manage_child_category_for_unassigned_apps">used for Apps without category</string>
<string name="manage_child_category_is_child">Child category of %s</string>
</resources> </resources>