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 \"\"")
}
}
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,
ConfigurationItem::class,
TemporarilyAllowedApp::class
], version = 2)
], version = 3)
abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database {
companion object {
private val lock = Object()
@ -67,7 +67,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database
.setJournalMode(JournalMode.TRUNCATE)
.fallbackToDestructiveMigration()
.addMigrations(
DatabaseMigrations.MIGRATE_TO_V2
DatabaseMigrations.MIGRATE_TO_V2,
DatabaseMigrations.MIGRATE_TO_V3
)
.build()
}

View file

@ -65,6 +65,9 @@ abstract class CategoryDao {
@Query("SELECT id, child_id, temporarily_blocked FROM category")
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(

View file

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

View file

@ -136,7 +136,8 @@ class AppSetupLogic(private val appLogic: AppLogic) {
title = defaultCategories.allowedAppsTitle,
blockedMinutesInWeek = ImmutableBitmask((BitSet())),
extraTimeInMillis = 0,
temporarilyBlocked = false
temporarilyBlocked = false,
parentCategoryId = ""
))
appLogic.database.category().addCategory(Category(
@ -145,7 +146,8 @@ class AppSetupLogic(private val appLogic: AppLogic) {
title = defaultCategories.allowedGamesTitle,
blockedMinutesInWeek = defaultCategories.allowedGamesBlockedTimes,
extraTimeInMillis = 0,
temporarilyBlocked = false
temporarilyBlocked = false,
parentCategoryId = ""
))
// 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 category = categories.find { it.id == appCategory?.categoryId }
?: categories.find { it.id == deviceUserEntry.categoryForNotAssignedApps }
val parentCategory = categories.find { it.id == category?.parentCategoryId }
if (category == null) {
usedTimeUpdateHelper?.commit(appLogic)
@ -196,7 +197,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
))
appLogic.platformIntegration.setSuspendedApps(listOf(foregroundAppPackageName), true)
appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName)
} else if (category.temporarilyBlocked) {
} else if (category.temporarilyBlocked or (parentCategory?.temporarilyBlocked == true)) {
usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
@ -218,7 +219,8 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
))
} else if (
// check blocked time areas
(category.blockedMinutesInWeek.read(minuteOfWeek))
(category.blockedMinutesInWeek.read(minuteOfWeek)) or
(parentCategory?.blockedMinutesInWeek?.read(minuteOfWeek) == true)
) {
usedTimeUpdateHelper?.commit(appLogic)
@ -230,8 +232,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
} else {
// check time limits
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
usedTimeUpdateHelper?.commit(appLogic)
@ -241,33 +246,61 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
))
} else {
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(
date = nowDate,
categoryId = category.id,
childCategoryId = category.id,
parentCategoryId = parentCategory?.id,
oldInstance = usedTimeUpdateHelper,
usedTimeItemForDay = usedTimes.get(nowDate.dayOfWeek),
usedTimeItemForDayChild = usedTimes.get(nowDate.dayOfWeek),
usedTimeItemForDayParent = parentUsedTimes.get(nowDate.dayOfWeek),
logic = appLogic
)
usedTimeUpdateHelper = newUsedTimeItemBatchUpdateHelper
val usedTimesSparseArray = SparseLongArray()
fun buildUsedTimesSparseArray(items: SparseArray<UsedTimeItem>, isParentCategory: Boolean): SparseLongArray {
val result = SparseLongArray()
for (i in 0..6) {
val usedTimesItem = usedTimes[i]?.usedMillis
for (i in 0..6) {
val usedTimesItem = items[i]?.usedMillis
if (newUsedTimeItemBatchUpdateHelper.date.dayOfWeek == i) {
usedTimesSparseArray.put(i, newUsedTimeItemBatchUpdateHelper.getTotalUsedTime())
} else {
usedTimesSparseArray.put(i, (if (usedTimesItem != null) usedTimesItem else 0))
if (newUsedTimeItemBatchUpdateHelper.date.dayOfWeek == i) {
result.put(
i,
if (isParentCategory)
newUsedTimeItemBatchUpdateHelper.getTotalUsedTimeParent()
else
newUsedTimeItemBatchUpdateHelper.getTotalUsedTimeChild()
)
} else {
result.put(i, usedTimesItem ?: 0)
}
}
return result
}
val remaining = RemainingTime.getRemainingTime(
nowDate.dayOfWeek, usedTimesSparseArray, rules,
val remainingChild = RemainingTime.getRemainingTime(
nowDate.dayOfWeek,
buildUsedTimesSparseArray(usedTimes, isParentCategory = false),
rules,
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) {
// unlimited

View file

@ -129,22 +129,24 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
if (categoryEntry2 == null) {
liveDataFromValue(BlockingReason.NotPartOfAnCategory)
} else {
getBlockingReasonStep4Point5(categoryEntry2, child, timeZone)
getBlockingReasonStep4Point5(categoryEntry2, child, timeZone, false)
}
}
} else if (categoryEntry.temporarilyBlocked) {
liveDataFromValue(BlockingReason.TemporarilyBlocked)
} 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) {
Log.d(LOG_TAG, "step 4.5")
}
if (category.temporarilyBlocked) {
return liveDataFromValue(BlockingReason.TemporarilyBlocked)
}
val areLimitsDisabled: LiveData<Boolean>
if (child.disableLimitsUntil == 0L) {
@ -163,6 +165,18 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
} else {
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 {
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> {
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.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 {
suspend fun eventuallyUpdateInstance(
date: DateInTimezone,
categoryId: String,
childCategoryId: String,
parentCategoryId: String?,
oldInstance: UsedTimeItemBatchUpdateHelper?,
usedTimeItemForDay: UsedTimeItem?,
usedTimeItemForDayChild: UsedTimeItem?,
usedTimeItemForDayParent: UsedTimeItem?,
logic: AppLogic
): UsedTimeItemBatchUpdateHelper {
if (oldInstance != null && oldInstance.date == date && oldInstance.categoryId == categoryId) {
if (oldInstance.cachedItem != usedTimeItemForDay) {
oldInstance.cachedItem = usedTimeItemForDay
if (
oldInstance != null &&
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
@ -44,8 +61,10 @@ class UsedTimeItemBatchUpdateHelper(val date: DateInTimezone, val categoryId: St
return UsedTimeItemBatchUpdateHelper(
date = date,
categoryId = categoryId,
cachedItem = usedTimeItemForDay
childCategoryId = childCategoryId,
parentCategoryId = parentCategoryId,
cachedItemChild = usedTimeItemForDayChild,
cachedItemParent = usedTimeItemForDayParent
)
}
}
@ -66,18 +85,18 @@ class UsedTimeItemBatchUpdateHelper(val date: DateInTimezone, val categoryId: St
}
}
fun getTotalUsedTime(): Long {
val cachedItem = cachedItem
return (if (cachedItem == null) 0 else cachedItem.usedMillis) + timeToAdd
}
fun getTotalUsedTimeChild(): Long = (cachedItemChild?.usedMillis ?: 0) + timeToAdd
fun getTotalUsedTimeParent(): Long = (cachedItemParent?.usedMillis ?: 0) + timeToAdd
fun getCachedExtraTimeToSubtract(): Int {
return extraTimeToSubtract
}
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) {
@ -86,7 +105,7 @@ class UsedTimeItemBatchUpdateHelper(val date: DateInTimezone, val categoryId: St
} else {
ApplyActionUtil.applyAppLogicAction(
AddUsedTimeAction(
categoryId = categoryId,
categoryId = childCategoryId,
timeToAdd = timeToAdd,
dayOfEpoch = date.dayOfEpoch,
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

View file

@ -33,34 +33,46 @@ object LocalDatabaseAppLogicActionDispatcher {
try {
when(action) {
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
// try to update
val updatedRows = database.usedTimes().addUsedTime(
categoryId = action.categoryId,
timeToAdd = action.timeToAdd,
dayOfEpoch = action.dayOfEpoch
)
if (updatedRows == 0) {
// create new entry
database.usedTimes().insertUsedTime(UsedTimeItem(
categoryId = action.categoryId,
dayOfEpoch = action.dayOfEpoch,
usedMillis = action.timeToAdd.toLong()
))
} // required to make this compile
if (action.extraTimeToSubtract != 0) {
database.category().subtractCategoryExtraTime(
categoryId = action.categoryId,
removedExtraTime = action.extraTimeToSubtract
fun handleAddUsedTime(categoryId: String) {
// try to update
val updatedRows = database.usedTimes().addUsedTime(
categoryId = categoryId,
timeToAdd = action.timeToAdd,
dayOfEpoch = action.dayOfEpoch
)
} else {
// required to make this compile
if (updatedRows == 0) {
// create new entry
database.usedTimes().insertUsedTime(UsedTimeItem(
categoryId = categoryId,
dayOfEpoch = action.dayOfEpoch,
usedMillis = action.timeToAdd.toLong()
))
}
if (action.extraTimeToSubtract != 0) {
database.category().subtractCategoryExtraTime(
categoryId = categoryId,
removedExtraTime = action.extraTimeToSubtract
)
}
}
handleAddUsedTime(categoryEntry.id)
if (parentCategoryEntry?.childId == categoryEntry.childId) {
handleAddUsedTime(parentCategoryEntry.id)
}
null
}
is AddInstalledAppsAction -> {
database.app().addAppsSync(

View file

@ -73,7 +73,8 @@ object LocalDatabaseParentActionDispatcher {
// nothing blocked by default
blockedMinutesInWeek = ImmutableBitmask(BitSet()),
extraTimeInMillis = 0,
temporarilyBlocked = false
temporarilyBlocked = false,
parentCategoryId = ""
))
}
is DeleteCategoryAction -> {
@ -104,13 +105,24 @@ object LocalDatabaseParentActionDispatcher {
database.category().updateCategoryExtraTime(action.categoryId, action.newExtraTime)
}
is IncrementCategoryExtraTimeAction -> {
DatabaseValidation.assertCategoryExists(database, action.categoryId)
if (action.addedExtraTime < 0) {
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)
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 -> {
DatabaseValidation.assertCategoryExists(database, action.categoryId)
@ -281,6 +293,29 @@ object LocalDatabaseParentActionDispatcher {
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 { }
database.setTransactionSuccessful()

View file

@ -58,6 +58,16 @@ class CategorySettingsFragment : Fragment() {
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.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.model.Category
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.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic
@ -73,10 +73,10 @@ class AssignAllAppsCategoryDialogFragment: BottomSheetDialogFragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = AssignAppDialogBinding.inflate(inflater, container, false)
val list = binding.categoryList
val binding = BottomSheetSelectionListBinding.inflate(inflater, container, false)
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 ->
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.CategoryApp
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.livedata.map
import io.timelimit.android.livedata.switchMap
@ -96,9 +96,9 @@ class AssignAppCategoryDialogFragment: BottomSheetDialogFragment() {
}
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 ->
categoryAppEntry.map { appCategory ->
@ -157,7 +157,7 @@ class AssignAppCategoryDialogFragment: BottomSheetDialogFragment() {
})
matchingAppEntries.observe(this, Observer {
binding.appTitle = it.firstOrNull()?.title
binding.title = it.firstOrNull()?.title
})
return binding.root

View file

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

View file

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

View file

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

View file

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

View file

@ -39,6 +39,10 @@
name="usedForAppsWithoutCategory"
type="boolean" />
<variable
name="parentCategoryTitle"
type="String" />
<import type="android.text.TextUtils" />
<import type="android.view.View" />
</data>
@ -71,6 +75,14 @@
android:layout_width="match_parent"
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
android:visibility="@{TextUtils.isEmpty(usedTimeToday) ? View.GONE : View.VISIBLE}"
android:text="@{usedTimeToday}"

View file

@ -68,6 +68,9 @@
<include android:id="@+id/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
app:cardUseCompatPadding="true"
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_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>

View file

@ -39,4 +39,5 @@
</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>

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_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>

View file

@ -40,4 +40,5 @@
</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>