Add task system

This commit is contained in:
Jonas Lochmann 2020-11-16 01:00:00 +01:00
parent 0862f57c59
commit e8fab30a4a
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
60 changed files with 3706 additions and 427 deletions

File diff suppressed because it is too large Load diff

View file

@ -39,6 +39,7 @@ interface Database {
fun derivedDataDao(): DerivedDataDao
fun userLimitLoginCategoryDao(): UserLimitLoginCategoryDao
fun categoryNetworkId(): CategoryNetworkIdDao
fun childTasks(): ChildTaskDao
fun <T> runInTransaction(block: () -> T): T
fun <T> runInUnobservedTransaction(block: () -> T): T

View file

@ -243,4 +243,11 @@ object DatabaseMigrations {
database.execSQL("ALTER TABLE `category` ADD COLUMN `disable_limits_until` INTEGER NOT NULL DEFAULT 0")
}
}
val MIGRATE_TO_V34 = object: Migration(33, 34) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `child_task` (`task_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `task_title` TEXT NOT NULL, `extra_time_duration` INTEGER NOT NULL, `pending_request` INTEGER NOT NULL, `last_grant_timestamp` INTEGER NOT NULL, PRIMARY KEY(`task_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
database.execSQL("ALTER TABLE `category` ADD COLUMN `tasks_version` TEXT NOT NULL DEFAULT ''")
}
}
}

View file

@ -48,8 +48,9 @@ import java.util.concurrent.TimeUnit
UserKey::class,
SessionDuration::class,
UserLimitLoginCategory::class,
CategoryNetworkId::class
], version = 33)
CategoryNetworkId::class,
ChildTask::class
], version = 34)
abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database {
companion object {
private val lock = Object()
@ -116,7 +117,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database
DatabaseMigrations.MIGRATE_TO_V30,
DatabaseMigrations.MIGRATE_TO_V31,
DatabaseMigrations.MIGRATE_TO_V32,
DatabaseMigrations.MIGRATE_TO_V33
DatabaseMigrations.MIGRATE_TO_V33,
DatabaseMigrations.MIGRATE_TO_V34
)
.setQueryExecutor(Threads.database)
.build()

View file

@ -44,6 +44,7 @@ object DatabaseBackupLowlevel {
private const val SESSION_DURATION = "sessionDuration"
private const val USER_LIMIT_LOGIN_CATEGORY = "userLimitLoginCategory"
private const val CATEGORY_NETWORK_ID = "categoryNetworkId"
private const val CHILD_TASK = "childTask"
fun outputAsBackupJson(database: Database, outputStream: OutputStream) {
val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8))
@ -92,6 +93,7 @@ object DatabaseBackupLowlevel {
handleCollection(SESSION_DURATION) { offset, pageSize -> database.sessionDuration().getSessionDurationPageSync(offset, pageSize) }
handleCollection(USER_LIMIT_LOGIN_CATEGORY) { offset, pageSize -> database.userLimitLoginCategoryDao().getAllowedContactPageSync(offset, pageSize) }
handleCollection(CATEGORY_NETWORK_ID) { offset, pageSize -> database.categoryNetworkId().getPageSync(offset, pageSize) }
handleCollection(CHILD_TASK) { offset, pageSize -> database.childTasks().getPageSync(offset, pageSize) }
writer.endObject().flush()
}
@ -101,6 +103,7 @@ object DatabaseBackupLowlevel {
var userLoginLimitCategories = emptyList<UserLimitLoginCategory>()
var categoryNetworkId = emptyList<CategoryNetworkId>()
var childTasks = emptyList<ChildTask>()
database.runInTransaction {
database.deleteAllData()
@ -267,6 +270,19 @@ object DatabaseBackupLowlevel {
reader.endArray()
}
CHILD_TASK -> {
reader.beginArray()
mutableListOf<ChildTask>().let { list ->
while (reader.hasNext()) {
list.add(ChildTask.parse(reader))
}
childTasks = list
}
reader.endArray()
}
else -> reader.skipValue()
}
}
@ -274,6 +290,7 @@ object DatabaseBackupLowlevel {
if (userLoginLimitCategories.isNotEmpty()) { database.userLimitLoginCategoryDao().addItemsSync(userLoginLimitCategories) }
if (categoryNetworkId.isNotEmpty()) { database.categoryNetworkId().insertItemsSync(categoryNetworkId) }
if (childTasks.isNotEmpty()) { database.childTasks().insertItemsSync(childTasks) }
}
}
}

View file

@ -65,7 +65,7 @@ abstract class CategoryDao {
@Query("UPDATE category SET temporarily_blocked = :blocked, temporarily_blocked_end_time = :endTime WHERE id = :categoryId")
abstract fun updateCategoryTemporarilyBlocked(categoryId: String, blocked: Boolean, endTime: Long)
@Query("SELECT id, base_version, apps_version, rules_version, usedtimes_version FROM category")
@Query("SELECT id, base_version, apps_version, rules_version, usedtimes_version, tasks_version FROM category")
abstract fun getCategoriesWithVersionNumbers(): LiveData<List<CategoryWithVersionNumbers>>
@Query("UPDATE category SET apps_version = :assignedAppsVersion WHERE id = :categoryId")
@ -77,10 +77,13 @@ abstract class CategoryDao {
@Query("UPDATE category SET usedtimes_version = :usedTimesVersion WHERE id = :categoryId")
abstract fun updateCategoryUsedTimesVersion(categoryId: String, usedTimesVersion: String)
@Query("UPDATE category SET tasks_version = :tasksVersion WHERE id = :categoryId")
abstract fun updateCategoryTasksVersion(categoryId: String, tasksVersion: String)
@Update
abstract fun updateCategorySync(category: Category)
@Query("UPDATE category SET apps_version = \"\", rules_version = \"\", usedtimes_version = \"\", base_version = \"\"")
@Query("UPDATE category SET apps_version = '', rules_version = '', usedtimes_version = '', base_version = '', tasks_version = ''")
abstract fun deleteAllCategoriesVersionNumbers()
@Query("SELECT * FROM category LIMIT :pageSize OFFSET :offset")
@ -114,7 +117,9 @@ data class CategoryWithVersionNumbers(
@ColumnInfo(name = "rules_version")
val timeLimitRulesVersion: String,
@ColumnInfo(name = "usedtimes_version")
val usedTimeItemsVersion: String
val usedTimeItemsVersion: String,
@ColumnInfo(name = "tasks_version")
val taskListVersion: String
)
data class CategoryShortInfo(

View file

@ -0,0 +1,65 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.data.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import io.timelimit.android.data.model.ChildTask
import io.timelimit.android.data.model.derived.ChildTaskWithCategoryTitle
import io.timelimit.android.data.model.derived.FullChildTask
@Dao
interface ChildTaskDao {
@Query("SELECT * FROM child_task LIMIT :pageSize OFFSET :offset")
fun getPageSync(offset: Int, pageSize: Int): List<ChildTask>
@Insert
fun insertItemsSync(items: List<ChildTask>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun forceInsertItemsSync(items: List<ChildTask>)
@Insert
fun insertItemSync(item: ChildTask)
@Update
fun updateItemSync(item: ChildTask)
@Query("SELECT child_task.*, category.title as category_title FROM child_task JOIN category ON (child_task.category_id = category.id) WHERE category.child_id = :userId")
fun getTasksByUserIdWithCategoryTitlesLive(userId: String): LiveData<List<ChildTaskWithCategoryTitle>>
@Query("SELECT child_task.*, category.title as category_title, user.name as child_name FROM child_task JOIN category ON (child_task.category_id = category.id) JOIN user ON (category.child_id = user.id) WHERE child_task.pending_request = 1")
fun getPendingTasks(): LiveData<List<FullChildTask>>
@Query("SELECT * FROM child_task WHERE category_id = :categoryId")
fun getTasksByCategoryId(categoryId: String): LiveData<List<ChildTask>>
@Query("SELECT * FROM child_task WHERE task_id = :taskId")
fun getTaskByTaskId(taskId: String): ChildTask?
@Query("SELECT * FROM child_task WHERE task_id = :taskId")
fun getTaskByTaskIdLive(taskId: String): LiveData<ChildTask?>
@Query("SELECT * FROM child_task WHERE task_id = :taskId")
suspend fun getTaskByTaskIdCoroutine(taskId: String): ChildTask?
@Query("DELETE FROM child_task WHERE task_id = :taskId")
fun removeTaskById(taskId: String)
@Query("DELETE FROM child_task WHERE category_id = :categoryId")
fun removeTasksByCategoryId(categoryId: String)
}

View file

@ -56,6 +56,8 @@ data class Category(
val timeLimitRulesVersion: String,
@ColumnInfo(name = "usedtimes_version")
val usedTimesVersion: String,
@ColumnInfo(name = "tasks_version", defaultValue = "")
val tasksVersion: String,
@ColumnInfo(name = "parent_category_id")
val parentCategoryId: String,
@ColumnInfo(name = "block_all_notifications")
@ -95,6 +97,7 @@ data class Category(
private const val SORT = "sort"
private const val EXTRA_TIME_DAY = "extraTimeDay"
private const val DISABLE_LIMIITS_UNTIL = "dlu"
private const val TASKS_VERSION = "tv"
fun parse(reader: JsonReader): Category {
var id: String? = null
@ -117,6 +120,7 @@ data class Category(
var sort = 0
var extraTimeDay = -1
var disableLimitsUntil = 0L
var tasksVersion = ""
reader.beginObject()
@ -141,6 +145,7 @@ data class Category(
SORT -> sort = reader.nextInt()
EXTRA_TIME_DAY -> extraTimeDay = reader.nextInt()
DISABLE_LIMIITS_UNTIL -> disableLimitsUntil = reader.nextLong()
TASKS_VERSION -> tasksVersion = reader.nextString()
else -> reader.skipValue()
}
}
@ -159,6 +164,7 @@ data class Category(
assignedAppsVersion = assignedAppsVersion!!,
timeLimitRulesVersion = timeLimitRulesVersion!!,
usedTimesVersion = usedTimesVersion!!,
tasksVersion = tasksVersion,
parentCategoryId = parentCategoryId,
blockAllNotifications = blockAllNotifications,
timeWarnings = timeWarnings,
@ -214,6 +220,7 @@ data class Category(
writer.name(ASSIGNED_APPS_VERSION).value(assignedAppsVersion)
writer.name(RULES_VERSION).value(timeLimitRulesVersion)
writer.name(USED_TIMES_VERSION).value(usedTimesVersion)
writer.name(TASKS_VERSION).value(tasksVersion)
writer.name(PARENT_CATEGORY_ID).value(parentCategoryId)
writer.name(BlOCK_ALL_NOTIFICATIONS).value(blockAllNotifications)
writer.name(TIME_WARNINGS).value(timeWarnings)

View file

@ -0,0 +1,121 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.data.model
import android.util.JsonReader
import android.util.JsonWriter
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.JsonSerializable
@Entity(
tableName = "child_task",
foreignKeys = [
ForeignKey(
entity = Category::class,
childColumns = ["category_id"],
parentColumns = ["id"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)
]
)
data class ChildTask(
@PrimaryKey
@ColumnInfo(name = "task_id")
val taskId: String,
@ColumnInfo(name = "category_id")
val categoryId: String,
@ColumnInfo(name = "task_title")
val taskTitle: String,
@ColumnInfo(name = "extra_time_duration")
val extraTimeDuration: Int,
@ColumnInfo(name = "pending_request")
val pendingRequest: Boolean,
// 0 = not yet granted
@ColumnInfo(name = "last_grant_timestamp")
val lastGrantTimestamp: Long
): JsonSerializable {
companion object {
private const val TASK_ID = "taskId"
private const val CATEGORY_ID = "categoryId"
private const val TASK_TITLE = "taskTitle"
private const val EXTRA_TIME_DURATION = "extraTimeDuration"
private const val PENDING_REQUEST = "pendingRequest"
private const val LAST_GRANT_TIMESTAMP = "lastGrantTimestamp"
const val MAX_EXTRA_TIME = 1000 * 60 * 60 * 24
const val MAX_TASK_TITLE_LENGTH = 50
fun parse(reader: JsonReader): ChildTask {
var taskId: String? = null
var categoryId: String? = null
var taskTitle: String? = null
var extraTimeDuration: Int? = null
var pendingRequest: Boolean? = null
var lastGrantTimestamp: Long? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
TASK_ID -> taskId = reader.nextString()
CATEGORY_ID -> categoryId = reader.nextString()
TASK_TITLE -> taskTitle = reader.nextString()
EXTRA_TIME_DURATION -> extraTimeDuration = reader.nextInt()
PENDING_REQUEST -> pendingRequest = reader.nextBoolean()
LAST_GRANT_TIMESTAMP -> lastGrantTimestamp = reader.nextLong()
else -> reader.skipValue()
}
}
reader.endObject()
return ChildTask(
taskId = taskId!!,
categoryId = categoryId!!,
taskTitle = taskTitle!!,
extraTimeDuration = extraTimeDuration!!,
pendingRequest = pendingRequest!!,
lastGrantTimestamp = lastGrantTimestamp!!
)
}
}
init {
IdGenerator.assertIdValid(taskId)
IdGenerator.assertIdValid(categoryId)
if (taskTitle.isEmpty() || taskTitle.length > MAX_TASK_TITLE_LENGTH) throw IllegalArgumentException()
if (extraTimeDuration <= 0 || extraTimeDuration > MAX_EXTRA_TIME) throw IllegalArgumentException()
if (lastGrantTimestamp < 0) throw IllegalArgumentException()
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TASK_ID).value(taskId)
writer.name(CATEGORY_ID).value(categoryId)
writer.name(TASK_TITLE).value(taskTitle)
writer.name(EXTRA_TIME_DURATION).value(extraTimeDuration)
writer.name(PENDING_REQUEST).value(pendingRequest)
writer.name(LAST_GRANT_TIMESTAMP).value(lastGrantTimestamp)
writer.endObject()
}
}

View file

@ -213,6 +213,7 @@ object HintsToShow {
const val CONTACTS_INTRO = 16L
const val TIMELIMIT_RULE_MUSTREAD = 32L
const val BLOCKED_TIME_AREAS_OBSOLETE = 64L
const val TASKS_INTRODUCTION = 128L
}
object ExperimentalFlags {

View file

@ -0,0 +1,28 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.data.model.derived
import androidx.room.ColumnInfo
import androidx.room.Embedded
import io.timelimit.android.data.model.ChildTask
data class ChildTaskWithCategoryTitle(
@Embedded
val childTask: ChildTask,
@ColumnInfo(name = "category_title")
val categoryTitle: String
)

View file

@ -0,0 +1,30 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.data.model.derived
import androidx.room.ColumnInfo
import androidx.room.Embedded
import io.timelimit.android.data.model.ChildTask
data class FullChildTask(
@Embedded
val childTask: ChildTask,
@ColumnInfo(name = "category_title")
val categoryTitle: String,
@ColumnInfo(name = "child_name")
val childName: String
)

View file

@ -179,6 +179,7 @@ class AppSetupLogic(private val appLogic: AppLogic) {
assignedAppsVersion = "",
timeLimitRulesVersion = "",
usedTimesVersion = "",
tasksVersion = "",
parentCategoryId = "",
blockAllNotifications = false,
timeWarnings = 0,
@ -201,6 +202,7 @@ class AppSetupLogic(private val appLogic: AppLogic) {
assignedAppsVersion = "",
timeLimitRulesVersion = "",
usedTimesVersion = "",
tasksVersion = "",
parentCategoryId = "",
blockAllNotifications = false,
timeWarnings = 0,

View file

@ -105,8 +105,21 @@ data class CategoryItselfHandling (
else
categoryRelatedData.networks.find { CategoryNetworkId.anonymizeNetworkId(itemId = it.networkItemId, networkId = currentNetworkId) == it.hashedNetworkId } != null
val allRelatedRules = if (areLimitsTemporarilyDisabled)
emptyList()
else
RemainingTime.getRulesRelatedToDay(
dayOfWeek = dayOfWeek,
minuteOfDay = minuteInWeek % MinuteOfDay.LENGTH,
rules = categoryRelatedData.rules
)
val regularRelatedRules = allRelatedRules.filter { it.maximumTimeInMillis != 0 }
val hasBlockedTimeAreaRelatedRule = allRelatedRules.find { it.maximumTimeInMillis == 0 } != null
val missingNetworkTimeForBlockedTimeAreas = !categoryRelatedData.category.blockedMinutesInWeek.dataNotToModify.isEmpty
val okByBlockedTimeAreas = areLimitsTemporarilyDisabled || !categoryRelatedData.category.blockedMinutesInWeek.read(minuteInWeek)
val okByBlockedTimeAreas = areLimitsTemporarilyDisabled || (
(!categoryRelatedData.category.blockedMinutesInWeek.read(minuteInWeek)) && (!hasBlockedTimeAreaRelatedRule))
val dependsOnMaxMinuteOfWeekByBlockedTimeAreas = categoryRelatedData.category.blockedMinutesInWeek.let { blockedTimeAreas ->
if (blockedTimeAreas.dataNotToModify[minuteInWeek]) {
blockedTimeAreas.dataNotToModify.nextClearBit(minuteInWeek)
@ -121,27 +134,18 @@ data class CategoryItselfHandling (
else
dependsOnMaxMinuteOfWeekByBlockedTimeAreas % MinuteOfDay.LENGTH
val relatedRules = if (areLimitsTemporarilyDisabled)
emptyList()
else
RemainingTime.getRulesRelatedToDay(
dayOfWeek = dayOfWeek,
minuteOfDay = minuteInWeek % MinuteOfDay.LENGTH,
rules = categoryRelatedData.rules
)
val remainingTime = RemainingTime.getRemainingTime(
usedTimes = categoryRelatedData.usedTimes,
// dependsOnMaxTimeByRules always depends on the day so that this is invalidated correctly
extraTime = categoryRelatedData.category.getExtraTime(dayOfEpoch = dayOfEpoch),
rules = relatedRules,
rules = regularRelatedRules,
dayOfWeek = dayOfWeek,
minuteOfDay = minuteInWeek % MinuteOfDay.LENGTH,
firstDayOfWeekAsEpochDay = firstDayOfWeekAsEpochDay
)
val remainingSessionDuration = RemainingSessionDuration.getRemainingSessionDuration(
rules = relatedRules,
rules = regularRelatedRules,
minuteOfDay = minuteInWeek % MinuteOfDay.LENGTH,
dayOfWeek = dayOfWeek,
timestamp = timeInMillis,
@ -149,8 +153,8 @@ data class CategoryItselfHandling (
)
val missingNetworkTimeForRules = categoryRelatedData.rules.isNotEmpty()
val okByTimeLimitRules = relatedRules.isEmpty() || (remainingTime != null && remainingTime.hasRemainingTime)
val dependsOnMaxTimeByMinuteOfDay = (relatedRules.minBy { it.endMinuteOfDay }?.endMinuteOfDay ?: Int.MAX_VALUE).coerceAtMost(
val okByTimeLimitRules = regularRelatedRules.isEmpty() || (remainingTime != null && remainingTime.hasRemainingTime)
val dependsOnMaxTimeByMinuteOfDay = (allRelatedRules.minBy { it.endMinuteOfDay }?.endMinuteOfDay ?: Int.MAX_VALUE).coerceAtMost(
categoryRelatedData.rules
.filter {
// related to today
@ -195,12 +199,12 @@ data class CategoryItselfHandling (
val missingNetworkTime = !shouldTrustTimeTemporarily &&
(missingNetworkTimeForDisableTempBlocking || missingNetworkTimeForBlockedTimeAreas || missingNetworkTimeForRules)
val shouldCountTime = relatedRules.isNotEmpty()
val shouldCountTime = regularRelatedRules.isNotEmpty()
val shouldCountExtraTime = remainingTime?.usingExtraTime == true
val sessionDurationSlotsToCount = if (remainingSessionDuration != null && remainingSessionDuration <= 0)
emptySet()
else
relatedRules.filter { it.sessionDurationLimitEnabled }.map {
regularRelatedRules.filter { it.sessionDurationLimitEnabled }.map {
AddUsedTimeActionItemSessionDurationLimitSlot(
startMinuteOfDay = it.startMinuteOfDay,
endMinuteOfDay = it.endMinuteOfDay,
@ -219,7 +223,7 @@ data class CategoryItselfHandling (
val maxTimeToAdd = maxTimeToAddByRegularTime.coerceAtMost(maxTimeToAddBySessionDuration)
val additionalTimeCountingSlots = if (shouldCountTime)
relatedRules
regularRelatedRules
.filterNot { it.appliesToWholeDay }
.map { AddUsedTimeActionItemAdditionalCountingSlot(it.startMinuteOfDay, it.endMinuteOfDay) }
.toSet()

View file

@ -325,6 +325,7 @@ object ApplyServerDataStatus {
assignedAppsVersion = "",
timeLimitRulesVersion = "",
usedTimesVersion = "",
tasksVersion = "",
parentCategoryId = newCategory.parentCategoryId,
timeWarnings = newCategory.timeWarnings,
minBatteryLevelMobile = newCategory.minBatteryLevelMobile,
@ -495,6 +496,25 @@ object ApplyServerDataStatus {
}
}
status.newCategoryTasks.forEach { tasks ->
database.childTasks().removeTasksByCategoryId(tasks.categoryId)
database.childTasks().forceInsertItemsSync(
tasks.tasks.map { task ->
ChildTask(
taskId = task.taskId,
categoryId = tasks.categoryId,
taskTitle = task.taskTitle,
extraTimeDuration = task.extraTimeDuration,
pendingRequest = task.pendingRequest,
lastGrantTimestamp = task.lastGrantTimestamp
)
}
)
database.category().updateCategoryTasksVersion(categoryId = tasks.categoryId, tasksVersion = tasks.version)
}
status.newUserList?.data?.forEach { user ->
if (user.limitLoginCategory == null) {
database.userLimitLoginCategoryDao().removeItemSync(user.id)

View file

@ -492,6 +492,24 @@ object ForceSyncAction: AppLogicAction() {
}
data class MarkTaskPendingAction(val taskId: String): AppLogicAction() {
companion object {
const val TYPE_VALUE = "MARK_TASK_PENDING"
private const val TASK_ID = "taskId"
}
init { IdGenerator.assertIdValid(taskId) }
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(TASK_ID).value(taskId)
writer.endObject()
}
}
data class AddCategoryAppsAction(val categoryId: String, val packageNames: List<String>): ParentAction() {
companion object {
const val TYPE_VALUE = "ADD_CATEGORY_APPS"
@ -928,6 +946,81 @@ data class UpdateCategoryDisableLimitsAction(val categoryId: String, val endTime
}
}
data class UpdateChildTaskAction(val isNew: Boolean, val taskId: String, val categoryId: String, val taskTitle: String, val extraTimeDuration: Int): ParentAction() {
companion object {
private const val TYPE_VALUE = "UPDATE_CHILD_TASK"
private const val IS_NEW = "isNew"
private const val TASK_ID = "taskId"
private const val CATEGORY_ID = "categoryId"
private const val TASK_TITLE = "taskTitle"
private const val EXTRA_TIME_DURATION = "extraTimeDuration"
}
init {
IdGenerator.assertIdValid(taskId)
IdGenerator.assertIdValid(categoryId)
if (taskTitle.isEmpty() || taskTitle.length > ChildTask.MAX_TASK_TITLE_LENGTH) throw IllegalArgumentException()
if (extraTimeDuration <= 0 || extraTimeDuration > ChildTask.MAX_EXTRA_TIME) throw IllegalArgumentException()
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(IS_NEW).value(isNew)
writer.name(TASK_ID).value(taskId)
writer.name(CATEGORY_ID).value(categoryId)
writer.name(TASK_TITLE).value(taskTitle)
writer.name(EXTRA_TIME_DURATION).value(extraTimeDuration)
writer.endObject()
}
}
data class DeleteChildTaskAction(val taskId: String): ParentAction() {
companion object {
private const val TYPE_VALUE = "DELETE_CHILD_TASK"
private const val TASK_ID = "taskId"
}
init { IdGenerator.assertIdValid(taskId) }
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(TASK_ID).value(taskId)
writer.endObject()
}
}
data class ReviewChildTaskAction(val taskId: String, val ok: Boolean, val time: Long): ParentAction() {
companion object {
private const val TYPE_VALUE = "REVIEW_CHILD_TASK"
private const val TASK_ID = "taskId"
private const val OK = "ok"
private const val TIME = "time"
}
init {
if (time <= 0) throw IllegalArgumentException()
IdGenerator.assertIdValid(taskId)
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(TASK_ID).value(taskId)
writer.name(OK).value(ok)
writer.name(TIME).value(time)
writer.endObject()
}
}
// DeviceDao
data class UpdateDeviceStatusAction(

View file

@ -381,6 +381,15 @@ object LocalDatabaseAppLogicActionDispatcher {
null
}
is MarkTaskPendingAction -> {
val task = database.childTasks().getTaskByTaskId(action.taskId) ?: throw RuntimeException()
val category = database.category().getCategoryByIdSync(task.categoryId)!!
val device = database.device().getDeviceByIdSync(deviceId)!!
if (category.childId != device.currentUserId) throw IllegalStateException()
database.childTasks().updateItemSync(task.copy(pendingRequest = true))
}
}.let { }
}
}

View file

@ -139,6 +139,7 @@ object LocalDatabaseParentActionDispatcher {
assignedAppsVersion = "",
timeLimitRulesVersion = "",
usedTimesVersion = "",
tasksVersion = "",
parentCategoryId = "",
blockAllNotifications = false,
timeWarnings = 0,
@ -767,6 +768,64 @@ object LocalDatabaseParentActionDispatcher {
database.category().updateCategorySync(category.copy(disableLimitsUntil = action.endTime))
}
is UpdateChildTaskAction -> {
val task = database.childTasks().getTaskByTaskId(taskId = action.taskId)
val notFound = task == null
if (notFound != action.isNew) {
if (action.isNew) {
throw IllegalArgumentException("task exists already")
} else {
throw IllegalArgumentException("task not found")
}
}
if (task == null) {
database.childTasks().insertItemSync(
ChildTask(
taskId = action.taskId,
taskTitle = action.taskTitle,
categoryId = action.categoryId,
extraTimeDuration = action.extraTimeDuration,
lastGrantTimestamp = 0,
pendingRequest = false
)
)
} else {
database.childTasks().updateItemSync(
task.copy(
taskTitle = action.taskTitle,
categoryId = action.categoryId,
extraTimeDuration = action.extraTimeDuration,
)
)
}
}
is DeleteChildTaskAction -> {
val task = database.childTasks().getTaskByTaskId(taskId = action.taskId) ?: throw IllegalArgumentException("task not found")
database.childTasks().removeTaskById(taskId = task.taskId)
}
is ReviewChildTaskAction -> {
val task = database.childTasks().getTaskByTaskId(taskId = action.taskId) ?: throw IllegalArgumentException("task not found")
if (!task.pendingRequest) throw IllegalArgumentException("did review of a task which is not pending")
if (action.ok) {
val category = database.category().getCategoryByIdSync(task.categoryId)!!
if (category.extraTimeDay != 0 && category.extraTimeInMillis > 0) {
// if the current time is daily, then extend the daily time only
database.category().updateCategoryExtraTime(categoryId = category.id, extraTimeDay = category.extraTimeDay, newExtraTime = category.extraTimeInMillis + task.extraTimeDuration)
} else {
database.category().updateCategoryExtraTime(categoryId = category.id, extraTimeDay = -1, newExtraTime = category.extraTimeInMillis + task.extraTimeDuration)
}
database.childTasks().updateItemSync(task.copy(pendingRequest = false, lastGrantTimestamp = action.time))
} else {
database.childTasks().updateItemSync(task.copy(pendingRequest = false))
}
}
}.let { }
}
}

View file

@ -34,7 +34,7 @@ data class ClientDataStatus(
private const val CATEGORIES = "categories"
private const val USERS = "users"
private const val CLIENT_LEVEL = "clientLevel"
private const val CLIENT_LEVEL_VALUE = 2
private const val CLIENT_LEVEL_VALUE = 3
val empty = ClientDataStatus(
deviceListVersion = "",
@ -63,7 +63,8 @@ data class ClientDataStatus(
baseVersion = it.baseVersion,
assignedAppsVersion = it.assignedAppsVersion,
timeLimitRulesVersion = it.timeLimitRulesVersion,
usedTimeItemsVersion = it.usedTimeItemsVersion
usedTimeItemsVersion = it.usedTimeItemsVersion,
taskListVersion = it.taskListVersion
)
}
@ -104,13 +105,15 @@ data class CategoryDataStatus(
val baseVersion: String,
val assignedAppsVersion: String,
val timeLimitRulesVersion: String,
val usedTimeItemsVersion: String
val usedTimeItemsVersion: String,
val taskListVersion: String
) {
companion object {
private const val BASE_VERSION = "base"
private const val ASSIGNED_APPS_VERSION = "apps"
private const val TIME_LIMIT_RULES_VERSION = "rules"
private const val USED_TIME_ITEMS_VERSION = "usedTime"
private const val TASK_LIST_VERSION = "tasks"
}
fun serialize(writer: JsonWriter) {
@ -121,6 +124,8 @@ data class CategoryDataStatus(
writer.name(TIME_LIMIT_RULES_VERSION).value(timeLimitRulesVersion)
writer.name(USED_TIME_ITEMS_VERSION).value(usedTimeItemsVersion)
if (taskListVersion.isNotEmpty()) writer.name(TASK_LIST_VERSION).value(taskListVersion)
writer.endObject()
}
}

View file

@ -40,6 +40,7 @@ data class ServerDataStatus(
val newCategoryAssignedApps: List<ServerUpdatedCategoryAssignedApps>,
val newCategoryUsedTimes: List<ServerUpdatedCategoryUsedTimes>,
val newCategoryTimeLimitRules: List<ServerUpdatedTimeLimitRules>,
val newCategoryTasks: List<ServerUpdatedCategoryTasks>,
val newUserList: ServerUserList?,
val fullVersionUntil: Long,
val message: String?
@ -52,6 +53,7 @@ data class ServerDataStatus(
private const val NEW_CATEGORY_ASSIGNED_APPS = "categoryApp"
private const val NEW_CATEGORY_USED_TIMES = "usedTimes"
private const val NEW_CATEGORY_TIME_LIMIT_RULES = "rules"
private const val NEW_CATEGORY_TASKS = "tasks"
private const val NEW_USER_LIST = "users"
private const val FULL_VERSION_UNTIL = "fullVersion"
private const val MESSAGE = "message"
@ -64,6 +66,7 @@ data class ServerDataStatus(
var newCategoryAssignedApps: List<ServerUpdatedCategoryAssignedApps> = Collections.emptyList()
var newCategoryUsedTimes: List<ServerUpdatedCategoryUsedTimes> = Collections.emptyList()
var newCategoryTimeLimitRules: List<ServerUpdatedTimeLimitRules> = Collections.emptyList()
var newCategoryTasks: List<ServerUpdatedCategoryTasks> = emptyList()
var newUserList: ServerUserList? = null
var fullVersionUntil: Long? = null
var message: String? = null
@ -78,6 +81,7 @@ data class ServerDataStatus(
NEW_CATEGORY_ASSIGNED_APPS -> newCategoryAssignedApps = ServerUpdatedCategoryAssignedApps.parseList(reader)
NEW_CATEGORY_USED_TIMES -> newCategoryUsedTimes = ServerUpdatedCategoryUsedTimes.parseList(reader)
NEW_CATEGORY_TIME_LIMIT_RULES -> newCategoryTimeLimitRules = ServerUpdatedTimeLimitRules.parseList(reader)
NEW_CATEGORY_TASKS -> newCategoryTasks = ServerUpdatedCategoryTasks.parseList(reader)
NEW_USER_LIST -> newUserList = ServerUserList.parse(reader)
FULL_VERSION_UNTIL -> fullVersionUntil = reader.nextLong()
MESSAGE -> message = reader.nextString()
@ -94,6 +98,7 @@ data class ServerDataStatus(
newCategoryAssignedApps = newCategoryAssignedApps,
newCategoryUsedTimes = newCategoryUsedTimes,
newCategoryTimeLimitRules = newCategoryTimeLimitRules,
newCategoryTasks = newCategoryTasks,
newUserList = newUserList,
fullVersionUntil = fullVersionUntil!!,
message = message
@ -892,6 +897,90 @@ data class ServerTimeLimitRule(
)
}
data class ServerUpdatedCategoryTasks (
val categoryId: String,
val version: String,
val tasks: List<ServerUpdatedCategoryTask>
) {
companion object {
private const val CATEGORY_ID = "categoryId"
private const val VERSION = "version"
private const val TASKS = "tasks"
fun parse(reader: JsonReader): ServerUpdatedCategoryTasks {
var categoryId: String? = null
var version: String? = null
var tasks: List<ServerUpdatedCategoryTask>? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
CATEGORY_ID -> categoryId = reader.nextString()
VERSION -> version = reader.nextString()
TASKS -> tasks = ServerUpdatedCategoryTask.parseList(reader)
else -> reader.skipValue()
}
}
reader.endObject()
return ServerUpdatedCategoryTasks(
categoryId = categoryId!!,
version = version!!,
tasks = tasks!!
)
}
fun parseList(reader: JsonReader): List<ServerUpdatedCategoryTasks> = parseJsonArray(reader) { parse(reader) }
}
}
data class ServerUpdatedCategoryTask (
val taskId: String,
val taskTitle: String,
val extraTimeDuration: Int,
val pendingRequest: Boolean,
val lastGrantTimestamp: Long
) {
companion object {
private const val TASK_ID = "i"
private const val TASK_TITLE = "t"
private const val EXTRA_TIME_DURATION = "d"
private const val PENDING_REQUEST = "p"
private const val LAST_GRANT_TIMESTAMP = "l"
fun parse(reader: JsonReader): ServerUpdatedCategoryTask {
var taskId: String? = null
var taskTitle: String? = null
var extraTimeDuration: Int? = null
var pendingRequest: Boolean? = null
var lastGrantTimestamp: Long? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
TASK_ID -> taskId = reader.nextString()
TASK_TITLE -> taskTitle = reader.nextString()
EXTRA_TIME_DURATION -> extraTimeDuration = reader.nextInt()
PENDING_REQUEST -> pendingRequest = reader.nextBoolean()
LAST_GRANT_TIMESTAMP -> lastGrantTimestamp = reader.nextLong()
else -> reader.skipValue()
}
}
reader.endObject()
return ServerUpdatedCategoryTask(
taskId = taskId!!,
taskTitle = taskTitle!!,
extraTimeDuration = extraTimeDuration!!,
pendingRequest = pendingRequest!!,
lastGrantTimestamp = lastGrantTimestamp!!
)
}
fun parseList(reader: JsonReader): List<ServerUpdatedCategoryTask> = parseJsonArray(reader) { parse(reader) }
}
}
data class ServerInstalledAppsData(
val deviceId: String,
val version: String,

View file

@ -25,6 +25,7 @@ import io.timelimit.android.ui.main.FragmentWithCustomTitle
import io.timelimit.android.ui.manage.category.usagehistory.UsageHistoryFragment
import io.timelimit.android.ui.manage.child.advanced.ManageChildAdvancedFragment
import io.timelimit.android.ui.manage.child.apps.ChildAppsFragment
import io.timelimit.android.ui.manage.child.tasks.ManageChildTasksFragment
abstract class ChildFragmentWrapper: SingleFragmentWrapper() {
abstract val childId: String
@ -65,3 +66,12 @@ class ChildUsageHistoryFragmentWrapper: ChildFragmentWrapper(), FragmentWithCust
override fun createChildFragment(): Fragment = UsageHistoryFragment.newInstance(userId = childId, categoryId = null)
override fun getCustomTitle() = child.map { it?.let { "${it.name} - ${getString(R.string.usage_history_title)}" } }
}
class ChildTasksFragmentWrapper: ChildFragmentWrapper(), FragmentWithCustomTitle {
private val params by lazy { ChildTasksFragmentWrapperArgs.fromBundle(requireArguments()) }
override val childId: String get() = params.childId
override val showAuthButton: Boolean = true
override fun createChildFragment(): Fragment = ManageChildTasksFragment.newInstance(childId = childId)
override fun getCustomTitle() = child.map { it?.let { "${it.name} - ${getString(R.string.manage_child_tasks)}" } }
}

View file

@ -85,6 +85,8 @@ class LockActivity : AppCompatActivity(), ActivityViewModelHolder {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val adapter = LockActivityAdapter(supportFragmentManager, this)
setContentView(R.layout.lock_activity)
if (savedInstanceState == null) {
@ -97,7 +99,7 @@ class LockActivity : AppCompatActivity(), ActivityViewModelHolder {
model.init(blockedPackageName, blockedActivityName)
pager.adapter = LockActivityAdapter(supportFragmentManager, this)
pager.adapter = adapter
model.content.observe(this) {
if (isResumed && it is LockscreenContent.Blocked.BlockedCategory && it.reason == BlockingReason.RequiresCurrentDevice && !model.didOpenSetCurrentDeviceScreen) {
@ -123,11 +125,17 @@ class LockActivity : AppCompatActivity(), ActivityViewModelHolder {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
showAuth.value = position > 0
showAuth.value = position == 1
}
})
tabs.setupWithViewPager(pager)
model.content.observe(this) {
val isTimeOver = it is LockscreenContent.Blocked.BlockedCategory && it.blockingHandling.activityBlockingReason == BlockingReason.TimeOver
adapter.showTasksFragment = isTimeOver
}
}
override fun onDestroy() {

View file

@ -21,19 +21,24 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import io.timelimit.android.R
import kotlin.properties.Delegates
class LockActivityAdapter(fragmentManager: FragmentManager, private val context: Context): FragmentPagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
override fun getCount(): Int = 2
var showTasksFragment: Boolean by Delegates.observable(false) { _, _, _ -> notifyDataSetChanged() }
override fun getCount(): Int = if (showTasksFragment) 3 else 2
override fun getItem(position: Int): Fragment = when (position) {
0 -> LockReasonFragment()
1 -> LockActionFragment()
2 -> LockTaskFragment()
else -> throw IllegalArgumentException()
}
override fun getPageTitle(position: Int): CharSequence? = context.getString(when (position) {
0 -> R.string.lock_tab_reason
1 -> R.string.lock_tab_action
2 -> R.string.lock_tab_task
else -> throw IllegalArgumentException()
})
}

View file

@ -171,6 +171,18 @@ class LockModel(application: Application): AndroidViewModel(application) {
val osClockInMillis = liveDataFromFunction { logic.timeApi.getCurrentTimeInMillis() }
private val categoryIdForTasks = content.map {
if (it is LockscreenContent.Blocked.BlockedCategory && it.blockingHandling.activityBlockingReason == BlockingReason.TimeOver)
it.blockedCategoryId
else null
}.ignoreUnchanged()
val blockedCategoryTasks = categoryIdForTasks.switchMap { categoryId ->
if (categoryId != null)
logic.database.childTasks().getTasksByCategoryId(categoryId)
else liveDataFromValue(emptyList())
}
fun confirmLocalTime() {
logic.realTimeLogic.confirmLocalTime()
}

View file

@ -0,0 +1,96 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.lock
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.timelimit.android.R
import io.timelimit.android.data.model.ChildTask
import io.timelimit.android.databinding.ChildTaskItemBinding
import io.timelimit.android.databinding.IntroCardBinding
import io.timelimit.android.util.TimeTextUtil
import kotlin.properties.Delegates
class LockTaskAdapter: RecyclerView.Adapter<LockTaskAdapter.Holder>() {
companion object {
private const val TYPE_INTRODUCTION = 1
private const val TYPE_ITEM = 2
}
var content: List<LockTaskItem> by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() }
var listener: Listener? = null
init { setHasStableIds(true) }
override fun getItemCount(): Int = content.size
override fun getItemId(position: Int): Long = content[position].let { item ->
when (item) {
is LockTaskItem.Task -> item.task.taskId.hashCode()
else -> item.hashCode()
}
}.toLong()
override fun getItemViewType(position: Int): Int = when (content[position]) {
is LockTaskItem.Task -> TYPE_ITEM
LockTaskItem.Introduction -> TYPE_INTRODUCTION
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = LayoutInflater.from(parent.context).let { inflater ->
LockTaskAdapter.Holder(
when (viewType) {
TYPE_INTRODUCTION -> IntroCardBinding.inflate(inflater, parent, false).also {
it.title = parent.context.getString(R.string.lock_tab_task)
it.text = parent.context.getString(R.string.lock_task_introduction)
it.noSwipe = true
}.root
TYPE_ITEM -> ChildTaskItemBinding.inflate(inflater, parent, false).also {
it.root.tag = it
}.root
else -> throw IllegalArgumentException()
}
)
}
override fun onBindViewHolder(holder: LockTaskAdapter.Holder, position: Int) {
val context = holder.itemView.context
val item = content[position]
when (item) {
LockTaskItem.Introduction -> {/* nothing to do */}
is LockTaskItem.Task -> {
val binding = holder.itemView.tag as ChildTaskItemBinding
binding.title = item.task.taskTitle
binding.duration = TimeTextUtil.time(item.task.extraTimeDuration, context)
binding.pendingReview = item.task.pendingRequest
binding.executePendingBindings()
binding.root.setOnClickListener { listener?.onTaskClicked(item.task) }
}
}.let {/* require handling all paths */}
}
class Holder(view: View): RecyclerView.ViewHolder(view)
interface Listener {
fun onTaskClicked(task: ChildTask)
}
}

View file

@ -0,0 +1,59 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.lock
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import io.timelimit.android.R
import io.timelimit.android.data.model.ChildTask
import io.timelimit.android.ui.manage.child.tasks.ConfirmTaskDialogFragment
import kotlinx.android.synthetic.main.recycler_fragment.*
class LockTaskFragment: Fragment() {
private val model: LockModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.recycler_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = LockTaskAdapter()
recycler.layoutManager = LinearLayoutManager(requireContext())
recycler.adapter = adapter
model.blockedCategoryTasks.observe(viewLifecycleOwner) { tasks ->
adapter.content = listOf(LockTaskItem.Introduction) + tasks.map { LockTaskItem.Task(it) }
}
adapter.listener = object: LockTaskAdapter.Listener {
override fun onTaskClicked(task: ChildTask) {
if (task.pendingRequest)
TaskReviewPendingDialogFragment.newInstance().show(parentFragmentManager)
else
ConfirmTaskDialogFragment.newInstance(taskId = task.taskId, taskTitle = task.taskTitle).show(parentFragmentManager)
}
}
}
}

View file

@ -0,0 +1,24 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.lock
import io.timelimit.android.data.model.ChildTask
sealed class LockTaskItem {
object Introduction: LockTaskItem()
data class Task(val task: ChildTask): LockTaskItem()
}

View file

@ -0,0 +1,40 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.lock
import android.app.Dialog
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import io.timelimit.android.R
import io.timelimit.android.extensions.showSafe
class TaskReviewPendingDialogFragment: DialogFragment() {
companion object {
private const val DIALOG_TAG = "TaskReviewPendingDialogFragment"
fun newInstance() = TaskReviewPendingDialogFragment()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = AlertDialog.Builder(requireContext(), theme)
.setMessage(R.string.lock_task_review_pending_dialog)
.setPositiveButton(R.string.generic_ok, null)
.create()
fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG)
}

View file

@ -25,7 +25,6 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.data.extensions.mapToTimezone
import io.timelimit.android.databinding.FragmentCategorySettingsBinding
import io.timelimit.android.date.DateInTimezone
@ -39,7 +38,7 @@ import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.category.settings.addusedtime.AddUsedTimeDialogFragment
import io.timelimit.android.ui.manage.category.settings.networks.ManageCategoryNetworksView
import io.timelimit.android.ui.payment.RequiresPurchaseDialogFragment
import io.timelimit.android.ui.view.SelectTimeSpanViewListener
import io.timelimit.android.ui.util.bind
class CategorySettingsFragment : Fragment() {
companion object {
@ -210,16 +209,8 @@ class CategorySettingsFragment : Fragment() {
binding.extraTimeSelection.enablePickerMode(it)
})
binding.extraTimeSelection.listener = object: SelectTimeSpanViewListener {
override fun onTimeSpanChanged(newTimeInMillis: Long) {
updateEditExtraTimeConfirmButtonVisibility()
}
override fun setEnablePickerMode(enable: Boolean) {
Threads.database.execute {
appLogic.database.config().setEnableAlternativeDurationSelectionSync(enable)
}
}
binding.extraTimeSelection.bind(appLogic.database, viewLifecycleOwner) {
updateEditExtraTimeConfirmButtonVisibility()
}
binding.switchLimitExtraTimeToToday.setOnCheckedChangeListener { _, _ ->

View file

@ -123,10 +123,10 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment(), DurationPic
}
)
view.applyToExtraTime = newRule.applyToExtraTimeUsage
view.timeSpan.timeInMillis = newRule.maximumTimeInMillis.toLong()
val affectedDays = Math.max(0, (0..6).map { (newRule.dayMask.toInt() shr it) and 1 }.sum())
view.timeSpan.maxDays = Math.max(0, affectedDays - 1) // max prevents crash
view.timeSpan.timeInMillis = newRule.maximumTimeInMillis.toLong()
view.affectsMultipleDays = affectedDays >= 2
view.applyToWholeDay = newRule.appliesToWholeDay

View file

@ -89,6 +89,14 @@ class ManageChildFragment : ChildFragmentWrapper(), FragmentWithCustomTitle {
true
}
R.id.menu_manage_child_tasks -> {
navigation.safeNavigate(
ManageChildFragmentDirections.actionManageChildFragmentToManageChildTasksFragment(childId = childId),
R.id.manageChildFragment
)
true
}
else -> super.onOptionsItemSelected(item)
}

View file

@ -24,6 +24,7 @@ import io.timelimit.android.R
import io.timelimit.android.data.model.Category
import io.timelimit.android.databinding.AddItemViewBinding
import io.timelimit.android.databinding.CategoryRichCardBinding
import io.timelimit.android.databinding.IntroCardBinding
import io.timelimit.android.ui.util.DateUtil
import io.timelimit.android.util.TimeTextUtil
import kotlin.properties.Delegates
@ -91,8 +92,10 @@ class Adapter: RecyclerView.Adapter<ViewHolder>() {
TYPE_INTRO ->
IntroViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.category_list_intro, parent, false)
IntroCardBinding.inflate(LayoutInflater.from(parent.context), parent, false).also {
it.title = parent.context.getString(R.string.manage_child_categories_intro_title)
it.text = parent.context.getString(R.string.manage_child_categories_intro_text)
}.root
)
TYPE_MANIPULATION_WARNING ->

View file

@ -42,7 +42,7 @@ import io.timelimit.android.ui.manage.child.ManageChildFragmentArgs
import io.timelimit.android.ui.manage.child.ManageChildFragmentDirections
import io.timelimit.android.ui.manage.child.category.create.CreateCategoryDialogFragment
import io.timelimit.android.ui.manage.child.category.specialmode.SetCategorySpecialModeFragment
import kotlinx.android.synthetic.main.fragment_manage_child_categories.*
import kotlinx.android.synthetic.main.recycler_fragment.*
class ManageChildCategoriesFragment : Fragment() {
companion object {
@ -57,7 +57,7 @@ class ManageChildCategoriesFragment : Fragment() {
private val model: ManageChildCategoriesModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_manage_child_categories, container, false)
return inflater.inflate(R.layout.recycler_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View file

@ -0,0 +1,99 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.child.tasks
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.timelimit.android.R
import io.timelimit.android.data.model.ChildTask
import io.timelimit.android.databinding.AddItemViewBinding
import io.timelimit.android.databinding.ChildTaskItemBinding
import io.timelimit.android.databinding.IntroCardBinding
import io.timelimit.android.ui.util.DateUtil
import io.timelimit.android.util.TimeTextUtil
import kotlin.properties.Delegates
class ChildTaskAdapter: RecyclerView.Adapter<ChildTaskAdapter.Holder>() {
companion object {
private const val TYPE_ADD = 1
private const val TYPE_INTRO = 2
private const val TYPE_TASK = 3
}
var data: List<ChildTaskItem> by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() }
var listener: Listener? = null
init { setHasStableIds(true) }
override fun getItemCount(): Int = data.size
override fun getItemId(position: Int): Long = data[position].let { item ->
if (item is ChildTaskItem.Task) item.taskItem.taskId.hashCode() else item.hashCode()
}.toLong()
override fun getItemViewType(position: Int): Int = when (data[position]) {
ChildTaskItem.Add -> TYPE_ADD
ChildTaskItem.Intro -> TYPE_INTRO
is ChildTaskItem.Task -> TYPE_TASK
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder = LayoutInflater.from(parent.context).let { inflater ->
Holder(when (viewType) {
TYPE_ADD -> AddItemViewBinding.inflate(inflater, parent, false).also {
it.label = parent.context.getString(R.string.manage_child_tasks_add)
it.root.setOnClickListener { listener?.onAddClicked() }
}.root
TYPE_INTRO -> IntroCardBinding.inflate(inflater, parent, false).also {
it.title = parent.context.getString(R.string.manage_child_tasks)
it.text = parent.context.getString(R.string.manage_child_tasks_intro)
}.root
TYPE_TASK -> ChildTaskItemBinding.inflate(inflater, parent, false).also {
it.root.tag = it
}.root
else -> throw IllegalArgumentException()
})
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val item = data[position]
if (item is ChildTaskItem.Task) {
val context = holder.itemView.context
val binding = holder.itemView.tag as ChildTaskItemBinding
binding.title = item.taskItem.taskTitle
binding.category = item.categoryTitle
binding.duration = TimeTextUtil.time(item.taskItem.extraTimeDuration, context)
binding.lastGrant = item.taskItem.lastGrantTimestamp.let { time ->
if (time == 0L) null else DateUtil.formatAbsoluteDate(context, time)
}
binding.executePendingBindings()
binding.root.setOnClickListener { listener?.onTaskClicked(item.taskItem) }
}
}
class Holder(view: View): RecyclerView.ViewHolder(view)
interface Listener {
fun onAddClicked()
fun onTaskClicked(task: ChildTask)
}
}

View file

@ -0,0 +1,25 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.child.tasks
import io.timelimit.android.data.model.ChildTask
sealed class ChildTaskItem {
object Add: ChildTaskItem()
object Intro: ChildTaskItem()
class Task(val taskItem: ChildTask, val categoryTitle: String): ChildTaskItem()
}

View file

@ -0,0 +1,57 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.child.tasks
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.timelimit.android.async.Threads
import io.timelimit.android.data.model.HintsToShow
import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.switchMap
import io.timelimit.android.logic.DefaultAppLogic
class ChildTaskModel (application: Application): AndroidViewModel(application) {
private val logic = DefaultAppLogic.with(application)
private val childIdLive = MutableLiveData<String>()
private val data = childIdLive.switchMap { childId -> logic.database.childTasks().getTasksByUserIdWithCategoryTitlesLive(userId = childId) }
private val dataListItemsLive: LiveData<List<ChildTaskItem>> = data.map { items -> items.map { ChildTaskItem.Task(it.childTask, it.categoryTitle) } }
private val didHideIntroductionLive = logic.database.config().wereHintsShown(HintsToShow.TASKS_INTRODUCTION)
private var didInit = false
val listContent = didHideIntroductionLive.switchMap { didHideIntroduction ->
dataListItemsLive.map { dataListItems ->
if (didHideIntroduction)
dataListItems + listOf(ChildTaskItem.Add)
else
listOf(ChildTaskItem.Intro) + dataListItems + listOf(ChildTaskItem.Add)
}
}
fun init(childId: String) {
if (didInit) return
didInit = true
childIdLive.value = childId
}
fun hideIntro() {
Threads.database.submit { logic.database.config().setHintsShownSync(HintsToShow.TASKS_INTRODUCTION) }
}
}

View file

@ -0,0 +1,66 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.child.tasks
import android.app.Dialog
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import io.timelimit.android.R
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.sync.actions.MarkTaskPendingAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.ui.main.getActivityViewModel
class ConfirmTaskDialogFragment: DialogFragment() {
companion object {
private const val DIALOG_TAG = "ConfirmTaskDialogFragment"
private const val TASK_TITLE = "taskTitle"
private const val TASK_ID = "taskId"
fun newInstance(taskId: String, taskTitle: String) = ConfirmTaskDialogFragment().apply {
arguments = Bundle().apply {
putString(TASK_ID, taskId)
putString(TASK_TITLE, taskTitle)
}
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val taskId = requireArguments().getString(TASK_ID)!!
val taskTitle = requireArguments().getString(TASK_TITLE)!!
val logic = getActivityViewModel(requireActivity()).logic
return AlertDialog.Builder(requireContext(), theme)
.setTitle(taskTitle)
.setMessage(R.string.lock_task_confirm_dialog)
.setNegativeButton(R.string.generic_no, null)
.setPositiveButton(R.string.generic_yes) { _, _ ->
runAsync {
ApplyActionUtil.applyAppLogicAction(
action = MarkTaskPendingAction(taskId = taskId),
appLogic = logic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
.create()
}
fun show(fragmentManager: FragmentManager) = show(fragmentManager, DIALOG_TAG)
}

View file

@ -0,0 +1,85 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.child.tasks
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import io.timelimit.android.extensions.showSafe
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.ui.fragment.BottomSheetSelectionListDialog
import io.timelimit.android.ui.main.getActivityViewModel
class EditTaskCategoryDialogFragment: BottomSheetSelectionListDialog() {
companion object {
private const val DIALOG_TAG = "EditTaskCategoryDialogFragment"
private const val CHILD_ID = "childId"
private const val CATEGORY_ID = "categoryId"
fun newInstance(childId: String, categoryId: String?, target: Fragment) = EditTaskCategoryDialogFragment().apply {
arguments = Bundle().apply {
putString(CHILD_ID, childId)
if (categoryId != null) putString(CATEGORY_ID, categoryId)
}
setTargetFragment(target, 0)
}
}
override val title: String? = null
private val listener: Listener get() = targetFragment as Listener
private val auth get() = getActivityViewModel(requireActivity())
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val database = DefaultAppLogic.with(requireContext()).database
val childId = requireArguments().getString(CHILD_ID)!!
val currentCategoryId = if (requireArguments().containsKey(CATEGORY_ID)) requireArguments().getString(CATEGORY_ID) else null
database.user().getChildUserByIdLive(childId).observe(viewLifecycleOwner) {
if (it == null) dismissAllowingStateLoss()
}
auth.authenticatedUser.observe(viewLifecycleOwner) {
if (it == null) dismissAllowingStateLoss()
}
database.category().getCategoriesByChildId(childId).observe(viewLifecycleOwner) { categories ->
clearList()
categories.forEach { category ->
addListItem(
label = category.title,
checked = category.id == currentCategoryId,
click = {
listener.onCategorySelected(category.id)
dismissAllowingStateLoss()
}
)
}
}
}
fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG)
interface Listener {
fun onCategorySelected(categoryId: String)
}
}

View file

@ -0,0 +1,122 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.child.tasks
import android.os.Bundle
import android.text.InputFilter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import io.timelimit.android.R
import io.timelimit.android.data.model.ChildTask
import io.timelimit.android.databinding.EditTaskFragmentBinding
import io.timelimit.android.extensions.addOnTextChangedListener
import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.util.bind
class EditTaskDialogFragment: BottomSheetDialogFragment(), EditTaskCategoryDialogFragment.Listener {
companion object {
private const val DIALOG_TAG = "EditTaskDialogFragment"
private const val CHILD_ID = "childId"
private const val TASK_ID = "taskId"
fun newInstance(childId: String, taskId: String?, listener: Fragment) = EditTaskDialogFragment().apply {
arguments = Bundle().apply {
putString(CHILD_ID, childId)
if (taskId != null) putString(TASK_ID, taskId)
setTargetFragment(listener, 0)
}
}
}
private val auth get() = getActivityViewModel(requireActivity())
private val model by viewModels<EditTaskModel>()
private val target get() = targetFragment as Listener
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = EditTaskFragmentBinding.inflate(inflater, container, false)
val args = requireArguments()
val childId = args.getString(CHILD_ID)!!
val taskId = if (args.containsKey(TASK_ID)) args.getString(TASK_ID) else null
model.init(childId = childId, taskId = taskId)
binding.isNewTask = taskId == null
binding.taskTitle.filters = arrayOf(InputFilter.LengthFilter(ChildTask.MAX_TASK_TITLE_LENGTH))
binding.taskTitle.addOnTextChangedListener {
val value = binding.taskTitle.text.toString()
if (model.taskTitleLive.value != value) model.taskTitleLive.value = value
}
model.taskTitleLive.observe(viewLifecycleOwner) { value ->
if (value != binding.taskTitle.text.toString()) binding.taskTitle.setText(value)
}
model.selectedCategoryTitle.observe(viewLifecycleOwner) { categoryTitle ->
binding.categoryDropdown.text = categoryTitle ?: getString(R.string.manage_child_tasks_select_category)
}
binding.categoryDropdown.setOnClickListener {
EditTaskCategoryDialogFragment.newInstance(childId = childId, categoryId = model.categoryIdLive.value, target = this).show(parentFragmentManager)
}
binding.timespan.bind(model.logic.database, viewLifecycleOwner) {
if (model.durationLive.value != it) model.durationLive.value = it
}
model.durationLive.observe(viewLifecycleOwner) {
if (it != binding.timespan.timeInMillis) binding.timespan.timeInMillis = it
}
binding.confirmButton.isEnabled = false
model.valid.observe(viewLifecycleOwner) { binding.confirmButton.isEnabled = it }
model.shouldClose.observe(viewLifecycleOwner) { if (it) dismissAllowingStateLoss() }
model.isBusy.observe(viewLifecycleOwner) { binding.flipper.displayedChild = if (it) 1 else 0 }
binding.deleteButton.setOnClickListener { model.deleteRule(auth) { target.onTaskRemoved(it) } }
binding.confirmButton.setOnClickListener { model.saveRule(auth); target.onTaskSaved() }
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
auth.authenticatedUser.observe(this) {
if (it == null) dismissAllowingStateLoss()
}
}
override fun onCategorySelected(categoryId: String) { model.categoryIdLive.value = categoryId }
fun show(fragmentManager: FragmentManager) = show(fragmentManager, DIALOG_TAG)
interface Listener {
fun onTaskRemoved(task: ChildTask)
fun onTaskSaved()
}
}

View file

@ -0,0 +1,150 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.child.tasks
import android.app.Application
import android.widget.Toast
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import io.timelimit.android.R
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.model.ChildTask
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.DeleteChildTaskAction
import io.timelimit.android.sync.actions.UpdateChildTaskAction
import io.timelimit.android.ui.main.ActivityViewModel
import java.lang.IllegalArgumentException
class EditTaskModel(application: Application): AndroidViewModel(application) {
val logic = DefaultAppLogic.with(application)
private var didInit = false
private var originalTask: ChildTask? = null
private val childIdLive = MutableLiveData<String>()
private val taskIdLive = MutableLiveData<String?>()
private val isBusyInternal = MutableLiveData<Boolean>().apply { value = true }
private val shouldCloseInternal = MutableLiveData<Boolean>().apply { value = false }
private val isMissingTask = taskIdLive.switchMap { taskId ->
if (taskId == null) liveDataFromValue(false)
else logic.database.childTasks().getTaskByTaskIdLive(taskId).map { it == null}
}
val categoryIdLive = MutableLiveData<String?>()
val taskTitleLive = MutableLiveData<String>()
val durationLive = MutableLiveData<Long>()
val isBusy = isBusyInternal.or(isMissingTask)
val shouldClose = shouldCloseInternal.castDown()
private val selectedCategory = childIdLive.switchMap { childId ->
categoryIdLive.switchMap { categoryId ->
if (categoryId != null)
logic.database.category().getCategoryByChildIdAndId(childId = childId, categoryId = categoryId)
else
liveDataFromValue(null)
}
}
val selectedCategoryTitle = selectedCategory.map { it?.title }
private val validCategory = selectedCategory.map { it != null }
private val validTitle = taskTitleLive.map { it.isNotBlank() && it.length <= ChildTask.MAX_TASK_TITLE_LENGTH }
private val durationValid = durationLive.map { it > 0 && it <= ChildTask.MAX_EXTRA_TIME }
val valid = validCategory.and(validTitle).and(durationValid)
fun init(childId: String, taskId: String?) {
if (didInit) return; didInit = true
childIdLive.value = childId
taskIdLive.value = taskId
categoryIdLive.value = null
taskTitleLive.value = ""
durationLive.value = 1000L * 60 * 15
runAsync {
if (taskId != null) {
val task = logic.database.childTasks().getTaskByTaskIdCoroutine(taskId)
if (task != null) {
categoryIdLive.value = task.categoryId
taskTitleLive.value = task.taskTitle
durationLive.value = task.extraTimeDuration.toLong()
originalTask = task
} else {
shouldCloseInternal.value = true
}
}
isBusyInternal.value = false
}
}
fun deleteRule(auth: ActivityViewModel, onTaskRemoved: (ChildTask) -> Unit) {
val taskId = taskIdLive.value
val oldTask = originalTask
if (taskId != null && oldTask != null) {
isBusyInternal.value = true
auth.tryDispatchParentAction(
DeleteChildTaskAction(taskId = taskId)
)
onTaskRemoved(oldTask)
shouldCloseInternal.value = true
}
}
fun saveRule(auth: ActivityViewModel) {
isBusyInternal.value = true
val taskId = taskIdLive.value
val categoryId = categoryIdLive.value ?: return
val duration = durationLive.value ?: return
val taskTitle = taskTitleLive.value ?: return
try {
if (taskId == null) {
auth.tryDispatchParentAction(
UpdateChildTaskAction(
taskId = IdGenerator.generateId(),
categoryId = categoryId,
extraTimeDuration = duration.toInt(),
isNew = true,
taskTitle = taskTitle
)
)
} else {
auth.tryDispatchParentAction(
UpdateChildTaskAction(
taskId = taskId,
categoryId = categoryId,
extraTimeDuration = duration.toInt(),
isNew = false,
taskTitle = taskTitle
)
)
}
} catch (ex: IllegalArgumentException) {
Toast.makeText(getApplication(), R.string.error_general, Toast.LENGTH_SHORT).show()
}
shouldCloseInternal.value = true
}
}

View file

@ -0,0 +1,118 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.child.tasks
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import io.timelimit.android.R
import io.timelimit.android.data.model.ChildTask
import io.timelimit.android.sync.actions.UpdateChildTaskAction
import io.timelimit.android.ui.main.getActivityViewModel
import kotlinx.android.synthetic.main.recycler_fragment.*
class ManageChildTasksFragment: Fragment(), EditTaskDialogFragment.Listener {
companion object {
private const val CHILD_ID = "childId"
fun newInstance(childId: String) = ManageChildTasksFragment().apply {
arguments = Bundle().apply {
putString(CHILD_ID, childId)
}
}
}
private val childId get() = requireArguments().getString(CHILD_ID)!!
private val auth get() = getActivityViewModel(requireActivity())
private val model: ChildTaskModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
model.init(childId = childId)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.recycler_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = ChildTaskAdapter()
recycler.layoutManager = LinearLayoutManager(requireContext())
recycler.adapter = adapter
model.listContent.observe(viewLifecycleOwner) { adapter.data = it }
adapter.listener = object: ChildTaskAdapter.Listener {
override fun onAddClicked() {
if (auth.requestAuthenticationOrReturnTrue()) {
EditTaskDialogFragment.newInstance(childId = childId, taskId = null, listener = this@ManageChildTasksFragment).show(parentFragmentManager)
}
}
override fun onTaskClicked(task: ChildTask) {
if (auth.requestAuthenticationOrReturnTrue()) {
EditTaskDialogFragment.newInstance(childId = childId, taskId = task.taskId, listener = this@ManageChildTasksFragment).show(parentFragmentManager)
}
}
}
ItemTouchHelper(object: ItemTouchHelper.SimpleCallback(0, 0) {
override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
val index = viewHolder.adapterPosition
val item = if (index == RecyclerView.NO_POSITION) null else adapter.data[index]
return if (item == ChildTaskItem.Intro) {
ItemTouchHelper.START or ItemTouchHelper.END
} else 0
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { model.hideIntro() }
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean = throw IllegalStateException()
}).attachToRecyclerView(recycler)
}
override fun onTaskRemoved(task: ChildTask) {
Snackbar.make(requireView(), R.string.manage_child_tasks_toast_removed, Snackbar.LENGTH_SHORT)
.setAction(R.string.generic_undo) {
auth.tryDispatchParentAction(
UpdateChildTaskAction(
isNew = true,
taskId = task.taskId,
taskTitle = task.taskTitle,
extraTimeDuration = task.extraTimeDuration,
categoryId = task.categoryId
)
)
}
.show()
}
override fun onTaskSaved() {
Snackbar.make(requireView(), R.string.manage_child_tasks_toast_saved, Snackbar.LENGTH_SHORT).show()
}
}

View file

@ -19,21 +19,18 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.CoroutineFragment
import io.timelimit.android.data.model.Device
import io.timelimit.android.data.model.HintsToShow
import io.timelimit.android.data.model.User
import io.timelimit.android.data.model.UserType
import io.timelimit.android.data.model.*
import io.timelimit.android.livedata.waitForNonNullValue
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.ReviewChildTaskAction
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.getActivityViewModel
import kotlinx.android.synthetic.main.fragment_overview.*
@ -41,11 +38,9 @@ import kotlinx.coroutines.launch
class OverviewFragment : CoroutineFragment() {
private val handlers: OverviewFragmentParentHandlers by lazy { parentFragment as OverviewFragmentParentHandlers }
private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) }
private val auth: ActivityViewModel by lazy { getActivityViewModel(activity!!) }
private val model: OverviewFragmentModel by lazy {
ViewModelProviders.of(this).get(OverviewFragmentModel::class.java)
}
private val logic: AppLogic by lazy { DefaultAppLogic.with(requireContext()) }
private val auth: ActivityViewModel by lazy { getActivityViewModel(requireActivity()) }
private val model: OverviewFragmentModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_overview, container, false)
@ -57,7 +52,7 @@ class OverviewFragment : CoroutineFragment() {
val adapter = OverviewFragmentAdapter()
recycler.adapter = adapter
recycler.layoutManager = LinearLayoutManager(context!!)
recycler.layoutManager = LinearLayoutManager(requireContext())
adapter.handlers = object: OverviewFragmentHandlers {
override fun onAddUserClicked() {
@ -106,9 +101,33 @@ class OverviewFragment : CoroutineFragment() {
override fun onSetDeviceListVisibility(level: DeviceListItemVisibility) {
model.showMoreDevices(level)
}
override fun onTaskConfirmed(task: ChildTask) {
auth.tryDispatchParentAction(
ReviewChildTaskAction(
taskId = task.taskId,
ok = true,
time = logic.timeApi.getCurrentTimeInMillis()
)
)
}
override fun onTaskRejected(task: ChildTask) {
auth.tryDispatchParentAction(
ReviewChildTaskAction(
taskId = task.taskId,
ok = false,
time = logic.timeApi.getCurrentTimeInMillis()
)
)
}
override fun onSkipTaskReviewClicked(task: ChildTask) {
if (auth.requestAuthenticationOrReturnTrue()) model.hideTask(task.taskId)
}
}
model.listEntries.observe(this, Observer { adapter.data = it })
model.listEntries.observe(viewLifecycleOwner) { adapter.data = it }
ItemTouchHelper(
object: ItemTouchHelper.Callback() {

View file

@ -21,10 +21,13 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.data.model.ChildTask
import io.timelimit.android.data.model.Device
import io.timelimit.android.data.model.User
import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.*
import io.timelimit.android.ui.util.DateUtil
import io.timelimit.android.util.TimeTextUtil
import kotlin.properties.Delegates
class OverviewFragmentAdapter : RecyclerView.Adapter<OverviewFragmentViewHolder>() {
@ -45,6 +48,7 @@ class OverviewFragmentAdapter : RecyclerView.Adapter<OverviewFragmentViewHolder>
return when (item) {
is OverviewFragmentItemDevice -> "device ${item.device.id}".hashCode().toLong()
is OverviewFragmentItemUser -> "user ${item.user.id}".hashCode().toLong()
is TaskReviewOverviewItem -> "task ${item.task.taskId}".hashCode().toLong()
else -> item.hashCode().toLong()
}
}
@ -70,6 +74,7 @@ class OverviewFragmentAdapter : RecyclerView.Adapter<OverviewFragmentViewHolder>
is OverviewFragmentHeaderFinishSetup -> OverviewFragmentViewType.FinishSetup
is OverviewFragmentItemMessage -> OverviewFragmentViewType.ServerMessage
is ShowMoreOverviewFragmentItem -> OverviewFragmentViewType.ShowMoreButton
is TaskReviewOverviewItem -> OverviewFragmentViewType.TaskReview
}
override fun getItemViewType(position: Int) = getItemType(getItem(position)).ordinal
@ -155,6 +160,10 @@ class OverviewFragmentAdapter : RecyclerView.Adapter<OverviewFragmentViewHolder>
.inflate(R.layout.show_more_list_item, parent, false)
)
OverviewFragmentViewType.TaskReview.ordinal -> TaskReviewHolder(
FragmentOverviewTaskReviewBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
else -> throw IllegalStateException()
}
@ -241,6 +250,23 @@ class OverviewFragmentAdapter : RecyclerView.Adapter<OverviewFragmentViewHolder>
}
}.let { }
}
is TaskReviewOverviewItem -> {
holder as TaskReviewHolder
holder.binding.let {
it.categoryTitle = item.categoryTitle
it.childName = item.childTitle
it.duration = TimeTextUtil.time(item.task.extraTimeDuration, it.root.context)
it.lastGrant = if (item.task.lastGrantTimestamp == 0L) null else DateUtil.formatAbsoluteDate(it.root.context, item.task.lastGrantTimestamp)
it.taskTitle = item.task.taskTitle
it.yesButton.setOnClickListener { handlers?.onTaskConfirmed(item.task) }
it.noButton.setOnClickListener { handlers?.onTaskRejected(item.task) }
it.skipButton.setOnClickListener { handlers?.onSkipTaskReviewClicked(item.task) }
}
holder.binding.executePendingBindings()
}
}.let { }
}
}
@ -254,7 +280,8 @@ enum class OverviewFragmentViewType {
Introduction,
FinishSetup,
ServerMessage,
ShowMoreButton
ShowMoreButton,
TaskReview
}
sealed class OverviewFragmentViewHolder(view: View): RecyclerView.ViewHolder(view)
@ -267,6 +294,7 @@ class IntroViewHolder(view: View): OverviewFragmentViewHolder(view)
class FinishSetupViewHolder(view: View): OverviewFragmentViewHolder(view)
class ServerMessageViewHolder(val binding: FragmentOverviewServerMessageBinding): OverviewFragmentViewHolder(binding.root)
class ShowMoreViewHolder(view: View): OverviewFragmentViewHolder(view)
class TaskReviewHolder(val binding: FragmentOverviewTaskReviewBinding): OverviewFragmentViewHolder(binding.root)
interface OverviewFragmentHandlers {
fun onAddUserClicked()
@ -276,4 +304,7 @@ interface OverviewFragmentHandlers {
fun onFinishSetupClicked()
fun onShowAllUsersClicked()
fun onSetDeviceListVisibility(level: DeviceListItemVisibility)
fun onSkipTaskReviewClicked(task: ChildTask)
fun onTaskConfirmed(task: ChildTask)
fun onTaskRejected(task: ChildTask)
}

View file

@ -15,6 +15,7 @@
*/
package io.timelimit.android.ui.overview.overview
import io.timelimit.android.data.model.ChildTask
import io.timelimit.android.data.model.Device
import io.timelimit.android.data.model.User
import io.timelimit.android.data.model.UserType
@ -40,3 +41,4 @@ sealed class ShowMoreOverviewFragmentItem: OverviewFragmentItem() {
object ShowAllUsers: ShowMoreOverviewFragmentItem()
data class ShowMoreDevices(val level: DeviceListItemVisibility): ShowMoreOverviewFragmentItem()
}
data class TaskReviewOverviewItem(val task: ChildTask, val childTitle: String, val categoryTitle: String): OverviewFragmentItem()

View file

@ -115,42 +115,63 @@ class OverviewFragmentModel(application: Application): AndroidViewModel(applicat
}
}
private val hiddenTaskIdsLive = MutableLiveData<Set<String>>().apply { value = emptySet() }
private val tasksWithPendingReviewLive = logic.database.childTasks().getPendingTasks()
private val pendingTasksToShowLive = hiddenTaskIdsLive.switchMap { hiddenTaskIds ->
tasksWithPendingReviewLive.map { tasksWithPendingReview ->
tasksWithPendingReview.filterNot { hiddenTaskIds.contains(it.childTask.taskId) }
}
}
private val pendingTaskItemLive = pendingTasksToShowLive.map { tasks ->
tasks.firstOrNull()?.let {
TaskReviewOverviewItem(task = it.childTask, childTitle = it.childName, categoryTitle = it.categoryTitle)
}
}
fun hideTask(taskId: String) {
hiddenTaskIdsLive.value = (hiddenTaskIdsLive.value ?: emptySet()) + setOf(taskId)
}
val listEntries = introEntries.switchMap { introEntries ->
deviceEntries.switchMap { deviceEntries ->
userEntries.switchMap { userEntries ->
itemVisibility.map { itemVisibility ->
mutableListOf<OverviewFragmentItem>().apply {
addAll(introEntries)
pendingTaskItemLive.switchMap { pendingTaskItem ->
itemVisibility.map { itemVisibility ->
mutableListOf<OverviewFragmentItem>().apply {
addAll(introEntries)
add(OverviewFragmentHeaderDevices)
val shownDevices = when (itemVisibility.devices) {
DeviceListItemVisibility.BareMinimum -> deviceEntries.filter { it.isCurrentDevice || it.isImportant }
DeviceListItemVisibility.AllChildDevices -> deviceEntries.filter { it.isCurrentDevice || it.isImportant || it.deviceUser?.type == UserType.Child }
DeviceListItemVisibility.AllDevices -> deviceEntries
}
addAll(shownDevices)
if (shownDevices.size == deviceEntries.size) {
add(OverviewFragmentActionAddDevice)
} else {
add(ShowMoreOverviewFragmentItem.ShowMoreDevices(when (itemVisibility.devices) {
DeviceListItemVisibility.BareMinimum -> if (deviceEntries.find { it.deviceUser?.type == UserType.Child } != null)
DeviceListItemVisibility.AllChildDevices else DeviceListItemVisibility.AllDevices
DeviceListItemVisibility.AllChildDevices -> DeviceListItemVisibility.AllDevices
DeviceListItemVisibility.AllDevices -> DeviceListItemVisibility.AllDevices
}))
}
if (pendingTaskItem != null) add(pendingTaskItem)
add(OverviewFragmentHeaderUsers)
if (itemVisibility.showParentUsers) {
addAll(userEntries)
add(OverviewFragmentActionAddUser)
} else {
userEntries.forEach { if (it.user.type != UserType.Parent) add(it) }
add(ShowMoreOverviewFragmentItem.ShowAllUsers)
}
}.toList()
}
} as LiveData<List<OverviewFragmentItem>>
add(OverviewFragmentHeaderDevices)
val shownDevices = when (itemVisibility.devices) {
DeviceListItemVisibility.BareMinimum -> deviceEntries.filter { it.isCurrentDevice || it.isImportant }
DeviceListItemVisibility.AllChildDevices -> deviceEntries.filter { it.isCurrentDevice || it.isImportant || it.deviceUser?.type == UserType.Child }
DeviceListItemVisibility.AllDevices -> deviceEntries
}
addAll(shownDevices)
if (shownDevices.size == deviceEntries.size) {
add(OverviewFragmentActionAddDevice)
} else {
add(ShowMoreOverviewFragmentItem.ShowMoreDevices(when (itemVisibility.devices) {
DeviceListItemVisibility.BareMinimum -> if (deviceEntries.find { it.deviceUser?.type == UserType.Child } != null)
DeviceListItemVisibility.AllChildDevices else DeviceListItemVisibility.AllDevices
DeviceListItemVisibility.AllChildDevices -> DeviceListItemVisibility.AllDevices
DeviceListItemVisibility.AllDevices -> DeviceListItemVisibility.AllDevices
}))
}
add(OverviewFragmentHeaderUsers)
if (itemVisibility.showParentUsers) {
addAll(userEntries)
add(OverviewFragmentActionAddUser)
} else {
userEntries.forEach { if (it.user.type != UserType.Parent) add(it) }
add(ShowMoreOverviewFragmentItem.ShowAllUsers)
}
}.toList()
}
} as LiveData<List<OverviewFragmentItem>>
}
}
}

View file

@ -0,0 +1,39 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 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.util
import androidx.lifecycle.LifecycleOwner
import io.timelimit.android.async.Threads
import io.timelimit.android.data.Database
import io.timelimit.android.ui.view.SelectTimeSpanView
import io.timelimit.android.ui.view.SelectTimeSpanViewListener
fun SelectTimeSpanView.bind(database: Database, lifecycleOwner: LifecycleOwner, listener: (Long) -> Unit) {
database.config().getEnableAlternativeDurationSelectionAsync().observe(lifecycleOwner) {
enablePickerMode(it)
}
this.listener = object: SelectTimeSpanViewListener {
override fun onTimeSpanChanged(newTimeInMillis: Long) { listener(timeInMillis) }
override fun setEnablePickerMode(enable: Boolean) {
Threads.database.execute {
database.config().setEnableAlternativeDurationSelectionSync(enable)
}
}
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2020 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
@ -19,33 +19,44 @@ import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.SeekBar
import android.widget.*
import io.timelimit.android.R
import io.timelimit.android.databinding.ViewSelectTimeSpanBinding
import io.timelimit.android.util.TimeTextUtil
import kotlin.properties.Delegates
class SelectTimeSpanView(context: Context, attributeSet: AttributeSet? = null): FrameLayout(context, attributeSet) {
private val binding = ViewSelectTimeSpanBinding.inflate(LayoutInflater.from(context), this, false)
init {
addView(binding.root)
LayoutInflater.from(context).inflate(R.layout.view_select_time_span, this, true)
}
private val seekbarContainer = findViewById<View>(R.id.seekbar_container)
private val pickerContainer = findViewById<View>(R.id.picker_container)
private val switchToPickerButton = findViewById<ImageButton>(R.id.switch_to_picker_button)
private val switchToSeekbarButton = findViewById<ImageButton>(R.id.switch_to_seekbar_button)
private val daysText = findViewById<TextView>(R.id.days_text)
private val dayPickerContainer = findViewById<View>(R.id.day_picker_container)
private val dayPicker = findViewById<NumberPicker>(R.id.day_picker)
private val daySeekbar = findViewById<SeekBar>(R.id.days_seek)
private val hoursText = findViewById<TextView>(R.id.hours_text)
private val hourPicker = findViewById<NumberPicker>(R.id.hour_picker)
private val hourSeekbar = findViewById<SeekBar>(R.id.hours_seek)
private val minutesText = findViewById<TextView>(R.id.minutes_text)
private val minutePicker = findViewById<NumberPicker>(R.id.minute_picker)
private val minuteSeekbar = findViewById<SeekBar>(R.id.minutes_seek)
var listener: SelectTimeSpanViewListener? = null
var timeInMillis: Long by Delegates.observable(0L) { _, _, _ ->
bindTime()
var timeInMillis: Long by Delegates.observable(0L) { _, oldValue, newValue ->
if (oldValue != newValue) { bindTime() }
listener?.onTimeSpanChanged(timeInMillis)
}
var maxDays: Int by Delegates.observable(0) { _, _, _ ->
binding.maxDays = maxDays
binding.dayPicker.maxValue = maxDays
binding.dayPickerContainer.visibility = if (maxDays > 0) View.VISIBLE else View.GONE
}
var maxDays: Int by Delegates.observable(0) { _, _, newValue -> bindMaxDays(newValue) }
init {
val attributes = context.obtainStyledAttributes(attributeSet, R.styleable.SelectTimeSpanView)
@ -56,77 +67,76 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet? = null):
attributes.recycle()
bindTime()
enablePickerMode(false)
}
private fun bindMaxDays(newValue: Int) {
val multipleDays = newValue > 0
val vis = if (multipleDays) View.VISIBLE else View.GONE
dayPicker.maxValue = newValue
daySeekbar.max = newValue
dayPickerContainer.visibility = vis
daysText.visibility = vis
daySeekbar.visibility = vis
}
private fun bindTime() {
val totalMinutes = (timeInMillis / (1000 * 60)).toInt()
val totalHours = totalMinutes / 60
val totalDays = totalHours / 24
val minutes = totalMinutes % 60
val hours = totalHours % 24
val duration = Duration.decode(timeInMillis)
binding.days = totalDays
binding.minutes = minutes
binding.hours = hours
daysText.text = TimeTextUtil.days(duration.days, context!!)
minutesText.text = TimeTextUtil.minutes(duration.minutes, context!!)
hoursText.text = TimeTextUtil.hours(duration.hours, context!!)
binding.daysText = TimeTextUtil.days(totalDays, context!!)
binding.minutesText = TimeTextUtil.minutes(minutes, context!!)
binding.hoursText = TimeTextUtil.hours(hours, context!!)
minutePicker.value = duration.minutes
minuteSeekbar.progress = duration.minutes
binding.minutePicker.value = binding.minutes ?: 0
binding.hourPicker.value = binding.hours ?: 0
binding.dayPicker.value = binding.days ?: 0
}
hourPicker.value = duration.hours
hourSeekbar.progress = duration.hours
private fun readStatusFromBinding() {
val days = binding.days!!.toLong()
val hours = binding.hours!!.toLong()
val minutes = binding.minutes!!.toLong()
timeInMillis = (((days * 24) + hours) * 60 + minutes) * 1000 * 60
dayPicker.value = duration.days
daySeekbar.progress = duration.days
}
fun clearNumberPickerFocus() {
binding.minutePicker.clearFocus()
binding.hourPicker.clearFocus()
binding.dayPicker.clearFocus()
minutePicker.clearFocus()
hourPicker.clearFocus()
dayPicker.clearFocus()
}
fun enablePickerMode(enable: Boolean) {
binding.seekbarContainer.visibility = if (enable) View.GONE else View.VISIBLE
binding.pickerContainer.visibility = if (enable) View.VISIBLE else View.GONE
seekbarContainer.visibility = if (enable) View.GONE else View.VISIBLE
pickerContainer.visibility = if (enable) View.VISIBLE else View.GONE
}
init {
binding.minutePicker.minValue = 0
binding.minutePicker.maxValue = 59
minutePicker.minValue = 0
minutePicker.maxValue = 59
binding.hourPicker.minValue = 0
binding.hourPicker.maxValue = 23
hourPicker.minValue = 0
hourPicker.maxValue = 23
binding.dayPicker.minValue = 0
binding.dayPicker.maxValue = 1
binding.dayPickerContainer.visibility = View.GONE
dayPicker.minValue = 0
dayPicker.maxValue = 1
dayPickerContainer.visibility = View.GONE
binding.minutePicker.setOnValueChangedListener { _, _, newValue ->
binding.minutes = newValue
readStatusFromBinding()
minutePicker.setOnValueChangedListener { _, _, newValue ->
timeInMillis = Duration.decode(timeInMillis).copy(minutes = newValue).timeInMillis
}
binding.hourPicker.setOnValueChangedListener { _, _, newValue ->
binding.hours = newValue
readStatusFromBinding()
hourPicker.setOnValueChangedListener { _, _, newValue ->
timeInMillis = Duration.decode(timeInMillis).copy(hours = newValue).timeInMillis
}
binding.dayPicker.setOnValueChangedListener { _, _, newValue ->
binding.days = newValue
readStatusFromBinding()
dayPicker.setOnValueChangedListener { _, _, newValue ->
timeInMillis = Duration.decode(timeInMillis).copy(days = newValue).timeInMillis
}
binding.daysSeek.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener {
daySeekbar.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
binding.days = progress
readStatusFromBinding()
timeInMillis = Duration.decode(timeInMillis).copy(days = progress).timeInMillis
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
@ -138,10 +148,9 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet? = null):
}
})
binding.hoursSeek.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener {
hourSeekbar.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
binding.hours = progress
readStatusFromBinding()
timeInMillis = Duration.decode(timeInMillis).copy(hours = progress).timeInMillis
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
@ -153,10 +162,9 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet? = null):
}
})
binding.minutesSeek.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener {
minuteSeekbar.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
binding.minutes = progress
readStatusFromBinding()
timeInMillis = Duration.decode(timeInMillis).copy(minutes = progress).timeInMillis
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
@ -168,10 +176,26 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet? = null):
}
})
binding.pickerContainer.visibility = GONE
pickerContainer.visibility = GONE
binding.switchToPickerButton.setOnClickListener { listener?.setEnablePickerMode(true) }
binding.switchToSeekbarButton.setOnClickListener { listener?.setEnablePickerMode(false) }
switchToPickerButton.setOnClickListener { listener?.setEnablePickerMode(true) }
switchToSeekbarButton.setOnClickListener { listener?.setEnablePickerMode(false) }
}
internal data class Duration (val days: Int, val hours: Int, val minutes: Int) {
companion object {
fun decode(timeInMillis: Long): Duration {
val totalMinutes = (timeInMillis / (1000 * 60)).toInt()
val totalHours = totalMinutes / 60
val totalDays = totalHours / 24
val minutes = totalMinutes % 60
val hours = totalHours % 24
return Duration(days = totalDays, hours = hours, minutes = minutes)
}
}
val timeInMillis = ((((days * 24L) + hours) * 60 + minutes) * 1000 * 60)
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,5.5c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM5,12c-2.8,0 -5,2.2 -5,5s2.2,5 5,5 5,-2.2 5,-5 -2.2,-5 -5,-5zM5,20.5c-1.9,0 -3.5,-1.6 -3.5,-3.5s1.6,-3.5 3.5,-3.5 3.5,1.6 3.5,3.5 -1.6,3.5 -3.5,3.5zM10.8,10.5l2.4,-2.4 0.8,0.8c1.3,1.3 3,2.1 5.1,2.1L19.1,9c-1.5,0 -2.7,-0.6 -3.6,-1.5l-1.9,-1.9c-0.5,-0.4 -1,-0.6 -1.6,-0.6s-1.1,0.2 -1.4,0.6L7.8,8.4c-0.4,0.4 -0.6,0.9 -0.6,1.4 0,0.6 0.2,1.1 0.6,1.4L11,14v5h2v-6.2l-2.2,-2.3zM19,12c-2.8,0 -5,2.2 -5,5s2.2,5 5,5 5,-2.2 5,-5 -2.2,-5 -5,-5zM19,20.5c-1.9,0 -3.5,-1.6 -3.5,-3.5s1.6,-3.5 3.5,-3.5 3.5,1.6 3.5,3.5 -1.6,3.5 -3.5,3.5z"/>
</vector>

View file

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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/>.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto">
<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:textAppearance="?android:textAppearanceLarge"
android:text="@string/manage_child_categories_intro_title"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:text="@string/manage_child_categories_intro_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceSmall"
android:text="@string/generic_swipe_to_dismiss"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</FrameLayout>

View file

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 - 2020 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="title"
type="String" />
<variable
name="category"
type="String" />
<variable
name="duration"
type="String" />
<variable
name="pendingReview"
type="boolean" />
<variable
name="lastGrant"
type="String" />
<import type="android.text.TextUtils" />
<import type="android.view.View" />
</data>
<androidx.cardview.widget.CardView
android:foreground="?selectableItemBackground"
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardUseCompatPadding="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:textAppearance="?android:textAppearanceLarge"
tools:text="Zimmer aufräumen"
android:text="@{title}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<!-- hidden at the lock screen where only items of one category are shown -->
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:visibility="@{category == null ? View.GONE : View.VISIBLE}"
tools:text="Erlaubte Spiele"
android:text="@{category}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
tools:text="15 Minuten"
android:text="@{duration}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{pendingReview ? View.VISIBLE : View.GONE}"
android:textAppearance="?android:textAppearanceMedium"
android:text="@string/lock_task_review_pending_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{TextUtils.isEmpty(lastGrant) ? View.GONE : View.VISIBLE}"
android:textAppearance="?android:textAppearanceMedium"
tools:text="@string/manage_child_task_last_grant"
android:text="@{@string/manage_child_task_last_grant(lastGrant)}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</layout>

View file

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 - 2020 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:tools="http://schemas.android.com/tools">
<data>
<variable
name="isNewTask"
type="boolean" />
<import type="android.view.View" />
</data>
<ViewFlipper
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/flipper">
<LinearLayout
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
tools:text="@string/manage_child_tasks_edit"
android:text="@{isNewTask ? @string/manage_child_tasks_add : @string/manage_child_tasks_edit}"
android:textAppearance="?android:textAppearanceLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<EditText
android:id="@+id/task_title"
tools:text="Zimmer aufräumen"
android:hint="@string/manage_child_tasks_title_hint"
android:maxLines="1"
android:singleLine="true"
android:imeOptions="actionDone"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Button
android:id="@+id/category_dropdown"
tools:text="@string/manage_child_tasks_select_category"
style="?borderlessButtonStyle"
android:drawableEnd="@drawable/ic_baseline_expand_more_24"
android:drawableTint="?colorOnSurface"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<io.timelimit.android.ui.view.SelectTimeSpanView
android:id="@+id/timespan"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<View
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="1px" />
<Button
android:layout_marginEnd="4dp"
android:layout_marginStart="4dp"
android:visibility="@{isNewTask ? View.GONE : View.VISIBLE}"
android:id="@+id/delete_button"
style="?borderlessButtonStyle"
android:textColor="@color/text_red"
android:text="@string/generic_delete"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
android:layout_marginEnd="4dp"
android:layout_marginStart="4dp"
android:id="@+id/confirm_button"
android:text="@string/generic_save"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ProgressBar
style="?android:progressBarStyleLarge"
android:layout_centerInParent="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</RelativeLayout>
</ViewFlipper>
</layout>

View file

@ -1,26 +0,0 @@
<!--
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/>.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="io.timelimit.android.ui.manage.child.category.ManageChildCategoriesFragment">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View file

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 - 2020 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="childName"
type="String" />
<variable
name="taskTitle"
type="String" />
<variable
name="categoryTitle"
type="String" />
<variable
name="duration"
type="String" />
<variable
name="lastGrant"
type="String" />
<import type="android.text.TextUtils" />
<import type="android.view.View" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.cardview.widget.CardView
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:foreground="?selectableItemBackground"
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/task_review_title"
android:textAppearance="?android:textAppearanceLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
tools:text="@string/task_review_text"
android:text="@{@string/task_review_text(childName, taskTitle)}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceSmall"
tools:text="@string/task_review_category"
android:text="@{@string/task_review_category(duration, categoryTitle)}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{TextUtils.isEmpty(lastGrant) ? View.GONE : View.VISIBLE}"
android:textAppearance="?android:textAppearanceSmall"
tools:text="@string/task_review_last_grant"
android:text="@{@string/task_review_last_grant(lastGrant)}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_marginEnd="4dp"
android:layout_marginTop="16dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/skip_button"
style="?borderlessButtonStyle"
android:text="@string/generic_skip"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<View
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<Button
android:id="@+id/no_button"
android:layout_marginEnd="8dp"
style="?materialButtonOutlinedStyle"
android:text="@string/generic_no"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
android:id="@+id/yes_button"
style="?materialButtonOutlinedStyle"
android:text="@string/generic_yes"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
</FrameLayout>
</layout>

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 - 2020 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:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="title"
type="String" />
<variable
name="text"
type="String" />
<variable
name="noSwipe"
type="boolean" />
<import type="android.view.View" />
</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:textAppearance="?android:textAppearanceLarge"
android:text="@{title}"
tools:text="@string/manage_child_categories_intro_title"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:text="@{text}"
tools:text="@string/manage_child_categories_intro_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{noSwipe ? View.GONE : View.VISIBLE}"
android:textAppearance="?android:textAppearanceSmall"
android:text="@string/generic_swipe_to_dismiss"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</layout>

View file

@ -0,0 +1,20 @@
<!--
TimeLimit Copyright <C> 2019 - 2020 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/>.
-->
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/recycler" />

View file

@ -13,191 +13,150 @@
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:tools="http://schemas.android.com/tools">
<data>
<variable
name="minutes"
type="Integer" />
<variable
name="hours"
type="Integer" />
<variable
name="days"
type="Integer" />
<variable
name="maxDays"
type="Integer" />
<variable
name="minutesText"
type="String" />
<variable
name="hoursText"
type="String" />
<variable
name="daysText"
type="String" />
<import type="android.view.View" />
</data>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:orientation="vertical"
android:id="@+id/seekbar_container"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/seekbar_container"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:layout_width="0dp"
android:layout_height="wrap_content">
<LinearLayout
android:layout_weight="1"
android:orientation="vertical"
<SeekBar
android:id="@+id/minutes_seek"
android:max="59"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content" />
<SeekBar
android:id="@+id/minutes_seek"
android:progress="@{safeUnbox(minutes)}"
android:max="59"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/minutes_text"
tools:text="5 Minuten"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
tools:text="5 Minuten"
android:text="@{minutesText}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<SeekBar
android:id="@+id/hours_seek"
android:max="23"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<SeekBar
android:id="@+id/hours_seek"
android:progress="@{safeUnbox(hours)}"
android:max="23"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
tools:text="1 Stunde"
android:id="@+id/hours_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
tools:text="1 Stunde"
android:text="@{hoursText}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<SeekBar
android:id="@+id/days_seek"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<SeekBar
android:visibility="@{safeUnbox(maxDays) > 0 ? View.VISIBLE : View.GONE}"
android:id="@+id/days_seek"
android:progress="@{safeUnbox(days)}"
android:max="@{safeUnbox(maxDays)}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
tools:text="2 Tage"
android:id="@+id/days_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{safeUnbox(maxDays) > 0 ? View.VISIBLE : View.GONE}"
tools:text="2 Tage"
android:text="@{daysText}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<ImageButton
android:tint="?colorOnSurface"
android:id="@+id/switch_to_picker_button"
android:src="@drawable/ic_unfold_more_black_24dp"
android:background="?selectableItemBackground"
android:layout_gravity="center_vertical"
android:layout_width="32dp"
android:layout_height="32dp" />
<ImageButton
android:tint="?colorOnSurface"
android:id="@+id/switch_to_picker_button"
android:src="@drawable/ic_unfold_more_black_24dp"
android:background="?selectableItemBackground"
android:layout_gravity="center_vertical"
android:layout_width="32dp"
android:layout_height="32dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/picker_container"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/day_picker_container"
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<NumberPicker
android:id="@+id/day_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:layout_gravity="center_horizontal"
android:text="@string/select_time_span_view_days"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:id="@+id/picker_container"
android:orientation="horizontal"
android:layout_width="match_parent"
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/day_picker_container"
android:orientation="vertical"
<NumberPicker
android:id="@+id/hour_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
android:layout_height="wrap_content" />
<NumberPicker
android:id="@+id/day_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:layout_gravity="center_horizontal"
android:text="@string/select_time_span_view_days"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:orientation="vertical"
<TextView
android:layout_gravity="center_horizontal"
android:text="@string/select_time_span_view_hours"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<NumberPicker
android:id="@+id/hour_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:layout_gravity="center_horizontal"
android:text="@string/select_time_span_view_hours"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<NumberPicker
android:id="@+id/minute_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:layout_gravity="center_horizontal"
android:text="@string/select_time_span_view_minutes"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<View
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="0dp" />
<ImageButton
android:tint="?colorOnSurface"
android:id="@+id/switch_to_seekbar_button"
android:rotation="90"
android:layout_margin="16dp"
android:src="@drawable/ic_unfold_more_black_24dp"
android:background="?selectableItemBackground"
android:layout_gravity="center_vertical"
android:layout_width="32dp"
android:layout_height="32dp" />
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<NumberPicker
android:id="@+id/minute_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:layout_gravity="center_horizontal"
android:text="@string/select_time_span_view_minutes"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<View
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="0dp" />
<ImageButton
android:tint="?colorOnSurface"
android:id="@+id/switch_to_seekbar_button"
android:rotation="90"
android:layout_margin="16dp"
android:src="@drawable/ic_unfold_more_black_24dp"
android:background="?selectableItemBackground"
android:layout_gravity="center_vertical"
android:layout_width="32dp"
android:layout_height="32dp" />
</LinearLayout>
</layout>
</LinearLayout>

View file

@ -19,6 +19,13 @@
<item
app:showAsAction="ifRoom"
app:iconTint="?colorOnPrimary"
android:icon="@drawable/ic_baseline_directions_bike_24"
android:title="@string/manage_child_tasks"
android:id="@+id/menu_manage_child_tasks" />
<item
app:showAsAction="never"
app:iconTint="?colorOnPrimary"
android:icon="@drawable/ic_phone_black_24dp"
android:title="@string/contacts_title_long"
android:id="@+id/menu_manage_child_phone" />

View file

@ -107,8 +107,7 @@
<fragment
android:id="@+id/manageChildFragment"
android:name="io.timelimit.android.ui.manage.child.ManageChildFragment"
android:label="fragment_manage_child"
tools:layout="@layout/fragment_manage_child" >
android:label="fragment_manage_child">
<action
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
@ -150,12 +149,18 @@
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_manageChildFragment_to_manageChildTasksFragment"
app:destination="@id/manageChildTasksFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
<fragment
android:id="@+id/manageCategoryFragment"
android:name="io.timelimit.android.ui.manage.category.ManageCategoryFragment"
android:label="fragment_manage_category"
tools:layout="@layout/fragment_manage_category" >
android:label="fragment_manage_category">
<argument
android:name="childId"
app:argType="string" />
@ -571,4 +576,12 @@
android:name="categoryId"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/manageChildTasksFragment"
android:name="io.timelimit.android.ui.fragment.ChildTasksFragmentWrapper"
android:label="ManageChildTasksFragment" >
<argument
android:name="childId"
app:argType="string" />
</fragment>
</navigation>

View file

@ -26,6 +26,9 @@
<string name="generic_manage_card">Verwaltung</string>
<string name="generic_enable">Aktivieren</string>
<string name="generic_show_more">Mehr anzeigen</string>
<string name="generic_no">Nein</string>
<string name="generic_yes">Ja</string>
<string name="generic_skip">Überspringen</string>
<string name="generic_swipe_to_dismiss">Sie können diesen Hinweis entfernen, indem Sie ihn zur Seite wischen</string>
<string name="generic_runtime_permission_rejected">Berechtigung abgelehnt; Sie können die Berechtigungen in den Systemeinstellungen verwalten</string>
@ -526,6 +529,7 @@
<string name="lock_tab_reason">Grund</string>
<string name="lock_tab_action">Aktionen</string>
<string name="lock_tab_task">Aufgaben</string>
<string name="lock_header_blocked">Gesperrt!</string>
<string name="lock_header_what">Was wurde gesperrt?</string>
@ -619,6 +623,18 @@
</string>
<string name="lock_overlay_text">Sperre %s</string>
<string name="lock_task_introduction">Aufgaben ermöglichen eine Zeitverlängerung.
Dafür muss ein Elternteil Aufgaben für die entsprechende Kategorie hinterlegen.
Ein Kind kann angeben, eine Aufgabe erledigt zu haben und nach der Bestätigung eines
Elternteils wird eine entsprechende Extrazeit vergeben.
</string>
<string name="lock_task_confirm_dialog">Hast Du diese Aufgabe erledigt? Die Zeit gibt es
sowieso erst, wenn ein Elternteil das bestätigt hat.</string>
<string name="lock_task_review_pending_dialog">Die Erledigung der Aufgabe wurde markiert.
Jetzt muss nur noch ein Elternteil das auf der Startseite von TimeLimit besätigen.
</string>
<string name="lock_task_review_pending_hint">warte auf Bestätigung</string>
<string name="login_user_selection_text">Wählen Sie einen Benutzer, um sich anzumelden</string>
<string name="login_password_hint">Passwort</string>
<string name="login_snackbar_wrong">Dieses Passwort ist nicht korrekt</string>
@ -1480,4 +1496,22 @@
<string name="admin_description_direct">Dies ermöglicht es TimeLimit zu verhindern, ausgeschaltet zu werden</string>
<string name="admin_disable_warning">Soll der Geräteadministrator wirklich deaktiviert werden?</string>
<string name="manage_child_tasks">Aufgaben</string>
<string name="manage_child_tasks_add">Aufgabe erstellen</string>
<string name="manage_child_tasks_edit">Aufgabe bearbeiten</string>
<string name="manage_child_tasks_intro">Aufgaben sind Tätigkeiten, die ein Kind machen kann,
um Extrazeit zu bekommen. Ein Elternteil muss die Erledigung bestätigen, bevor die Extrazeit
wirksam wird.
</string>
<string name="manage_child_task_last_grant">Letzte Bestätigung: %s</string>
<string name="manage_child_tasks_select_category">Kategorie wählen</string>
<string name="manage_child_tasks_title_hint">Aufgabentitel</string>
<string name="manage_child_tasks_toast_saved">Aufgabe gespeichert</string>
<string name="manage_child_tasks_toast_removed">Aufgabe wurde gelöscht</string>
<string name="task_review_title">Aufgabenbestätigung</string>
<string name="task_review_text">%1$s hat angegeben, %2$s erledigt zu haben. Ist das richtig?</string>
<string name="task_review_category">Dafür wird es %1$s Extrazeit für %2$s geben</string>
<string name="task_review_last_grant">Diese Aufgabe wurde zuletzt bestätigt am %s</string>
</resources>

View file

@ -13,7 +13,10 @@
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<resources xmlns:tools="http://schemas.android.com/tools" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<resources
xmlns:tools="http://schemas.android.com/tools"
xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"
tools:ignore="MissingTranslation">
<string name="generic_ok">OK</string>
<string name="generic_cancel">Cancel</string>
<string name="generic_help">Help</string>
@ -25,7 +28,10 @@
<string name="generic_go">Go</string>
<string name="generic_manage_card">Manage</string>
<string name="generic_enable">Enable</string>
<string name="generic_show_more" tools:ignore="MissingTranslation">Show more</string>
<string name="generic_show_more">Show more</string>
<string name="generic_no">No</string>
<string name="generic_yes">Yes</string>
<string name="generic_skip">Skip</string>
<string name="generic_swipe_to_dismiss">Swipe to the side to remove this message</string>
<string name="generic_runtime_permission_rejected">Permission rejected; You can manage permissions in the system settings</string>
@ -211,42 +217,42 @@
<string name="background_logic_toast_sync_apps">TimeLimit: Failure while trying to update the app list</string>
<string name="blocked_time_areas" tools:ignore="MissingTranslation">
<string name="blocked_time_areas">
Blocked time areas
</string>
<string name="blocked_time_areas_help_about" tools:ignore="MissingTranslation">
<string name="blocked_time_areas_help_about">
You can set blocked time areas here.
During a blocked time area, no Apps of the category can be used.
You can define the time area to the minute.
</string>
<string name="blocked_time_areas_help_colors" tools:ignore="MissingTranslation">
<string name="blocked_time_areas_help_colors">
There are 2 colors in this view: green means allowed, red means blocked.
</string>
<string name="blocked_time_areas_help_structure" tools:ignore="MissingTranslation">
<string name="blocked_time_areas_help_structure">
The days and hour are displayed as headlines, the minutes as tiles.
You can jump to a day using the day dropdown.
</string>
<string name="blocked_time_areas_help_modify" tools:ignore="MissingTranslation">
<string name="blocked_time_areas_help_modify">
To change one area, tap on the start time. It will become blue.
Then select the end time.
Now, the area colors will be inverted.
</string>
<string name="blocked_time_areas_snackbar_modified" tools:ignore="MissingTranslation">
<string name="blocked_time_areas_snackbar_modified">
The blocked time areas were changed
</string>
<string name="blocked_time_areas_snackbar_child_hint" tools:ignore="MissingTranslation">You can only add limits without parent; Undoing is not possible</string>
<string name="blocked_time_areas_snackbar_child_hint">You can only add limits without parent; Undoing is not possible</string>
<string name="blocked_time_areas_checkbox_detailed" tools:ignore="MissingTranslation">show detailed settings</string>
<string name="blocked_time_areas_checkbox_detailed">show detailed settings</string>
<string name="blocked_time_areas_copy_to_other_days" tools:ignore="MissingTranslation">Copy settings from one day to other days</string>
<string name="blocked_time_areas_copy_from" tools:ignore="MissingTranslation">Copy from</string>
<string name="blocked_time_areas_copy_to" tools:ignore="MissingTranslation">Copy from %s to</string>
<string name="blocked_time_areas_copy_to_other_days">Copy settings from one day to other days</string>
<string name="blocked_time_areas_copy_from">Copy from</string>
<string name="blocked_time_areas_copy_to">Copy from %s to</string>
<string name="category_apps_title">assigned Apps</string>
@ -312,7 +318,7 @@
when the Apps itself are blocked AND when the Apps itself are not blocked
</string>
<string name="category_settings" tools:ignore="MissingTranslation">Advanced Settings</string>
<string name="category_settings">Advanced Settings</string>
<string name="category_settings_rename">Rename category</string>
<string name="category_settings_rename_empty">The new name must not be empty</string>
<string name="category_settings_delete">Delete category</string>
@ -528,8 +534,8 @@
<string name="diagnose_exf_mad">Try to detect multiple active Apps (splitscreen support)</string>
<string name="diagnose_exf_rsl">Only allow login of parent users with limit login category after syncing</string>
<string name="diagnose_exf_bss">Automatically disable splitscreen using the accessibility service</string>
<string name="diagnose_exf_hmw" tools:ignore="MissingTranslation">Hide manipulation warning in the category list</string>
<string name="diagnose_exf_esb" tools:ignore="MissingTranslation">Do not use a overlay or the home button for blocking</string>
<string name="diagnose_exf_hmw">Hide manipulation warning in the category list</string>
<string name="diagnose_exf_esb">Do not use a overlay or the home button for blocking</string>
<string name="diagnose_bg_task_loop_ex">Background task loop exception</string>
@ -574,8 +580,9 @@
and it is not one - using an older version is enough to circumvent this.
</string>
<string name="lock_tab_reason" tools:ignore="MissingTranslation">Reason</string>
<string name="lock_tab_action" tools:ignore="MissingTranslation">Actions</string>
<string name="lock_tab_reason">Reason</string>
<string name="lock_tab_action">Actions</string>
<string name="lock_tab_task">Tasks</string>
<string name="lock_header_blocked">Blocked!</string>
<string name="lock_header_what">What was blocked?</string>
@ -673,6 +680,17 @@
</string>
<string name="lock_overlay_text">Blocking %s</string>
<string name="lock_task_introduction">Doing tasks allows getting extra time.
Parent users must add tasks and they must confirm that a task was done
before the extra time is granted.
</string>
<string name="lock_task_confirm_dialog">Have you finished this task? The time
will only be granted if a parent confirms it.</string>
<string name="lock_task_review_pending_dialog">It is noted that this task is finished.
The only missing part is a parent which confirms this at the start screen of TimeLimit.
</string>
<string name="lock_task_review_pending_hint">waiting for confirmation</string>
<string name="login_user_selection_text">Choose an user to authenticate</string>
<string name="login_password_hint">Password</string>
<string name="login_snackbar_wrong">The password is invalid</string>
@ -732,7 +750,7 @@
<string name="category_networks_toast_enable_location_service">Please enable location access</string>
<string name="manage_child_tab_other" tools:ignore="MissingTranslation">Advanced settings</string>
<string name="manage_child_tab_other">Advanced settings</string>
<string name="manage_child_category_no_time_limits">no time limit</string>
@ -767,10 +785,10 @@
<string name="manage_child_redirected_toast">Press back to show the overview screen of TimeLimit</string>
<string name="manage_child_special_mode_wizard_block_title" tools:ignore="MissingTranslation">Block %s temporarily</string>
<string name="manage_child_special_mode_wizard_block_option" tools:ignore="MissingTranslation">block temporarily</string>
<string name="manage_child_special_mode_wizard_disable_limits_title" tools:ignore="MissingTranslation">Disable limits for %s</string>
<string name="manage_child_special_mode_wizard_disable_limits_option" tools:ignore="MissingTranslation">disable limits temporarily</string>
<string name="manage_child_special_mode_wizard_block_title">Block %s temporarily</string>
<string name="manage_child_special_mode_wizard_block_option">block temporarily</string>
<string name="manage_child_special_mode_wizard_disable_limits_title">Disable limits for %s</string>
<string name="manage_child_special_mode_wizard_disable_limits_option">disable limits temporarily</string>
<string name="manage_device_activity_level_blocking_title">Activity level blocking</string>
<string name="manage_device_activity_level_blocking_text">
@ -967,8 +985,8 @@
<string name="manage_disable_time_limits_btn_today">for today</string>
<string name="manage_disable_time_limits_btn_time">until a time</string>
<string name="manage_disable_time_limits_btn_date">until a date</string>
<string name="manage_disable_time_limits_btn_no_end_time" tools:ignore="MissingTranslation">no scheduled end time</string>
<string name="manage_disable_time_limits_btn_until" tools:ignore="MissingTranslation">until %s</string>
<string name="manage_disable_time_limits_btn_no_end_time">no scheduled end time</string>
<string name="manage_disable_time_limits_btn_until">until %s</string>
<string name="manage_disable_time_limits_dialog_until">Disable time limits until ...</string>
<string name="manage_disable_time_limits_toast_time_in_past">The selected time is in the past</string>
@ -1071,7 +1089,7 @@
Apps with category are removed from the previous category when they are added to a new category -
use parent and child categories to \"add\" an App to multiple categories
</string>
<string name="must_read_blocked_time_areas_obsolete" tools:ignore="MissingTranslation">
<string name="must_read_blocked_time_areas_obsolete">
The blocked time areas are old. For most cases, using time limit rules with 0 minutes duration
for a part of a day is a better solution.
</string>
@ -1129,9 +1147,9 @@
<string name="overview_device_item_older_version">uses an older TimeLimit version</string>
<string name="overview_user_item_temporarily_blocked">temporarily blocked</string>
<string name="overview_user_item_temporarily_blocked_until" tools:ignore="MissingTranslation">temporarily blocked until %s</string>
<string name="overview_user_item_temporarily_blocked_until">temporarily blocked until %s</string>
<string name="overview_user_item_temporarily_disabled">Time limits temporarily disabled</string>
<string name="overview_user_item_temporarily_disabled_until" tools:ignore="MissingTranslation">Time limits temporarily disabled until %s</string>
<string name="overview_user_item_temporarily_disabled_until">Time limits temporarily disabled until %s</string>
<string name="overview_user_item_role_child">is restricted</string>
<string name="overview_user_item_role_parent">can change settings</string>
@ -1479,7 +1497,7 @@
<string name="usage_history_time_area">from %s until %s</string>
<string name="usage_history_item_session_duration_limit">Session duration limit of %s with %s break</string>
<string name="usage_history_item_last_usage">Last usage: %s</string>
<string name="usage_history_filter_all_categories" tools:ignore="MissingTranslation">All Categories</string>
<string name="usage_history_filter_all_categories">All Categories</string>
<string name="usage_stats_permission_required_and_missing_title">Usage stats access is required</string>
<string name="usage_stats_permission_required_and_missing_text">TimeLimit needs this permission to work correctly. Select \"permissions\" below to enable it.</string>
@ -1532,4 +1550,21 @@
<string name="admin_description_direct">This allows TimeLimit to prevent it from being disabled.</string>
<string name="admin_disable_warning">Do you want to disable the device admin?</string>
<string name="manage_child_tasks">Tasks</string>
<string name="manage_child_tasks_add">Add Task</string>
<string name="manage_child_tasks_edit">Edit Task</string>
<string name="manage_child_tasks_intro">Tasks are things which a child can do to get extra time.
A parent must confirm the completion before the extra time is granted.
</string>
<string name="manage_child_task_last_grant">Last confirmation: %s</string>
<string name="manage_child_tasks_select_category">Select a category</string>
<string name="manage_child_tasks_title_hint">Task title</string>
<string name="manage_child_tasks_toast_saved">Task saved</string>
<string name="manage_child_tasks_toast_removed">Task removed</string>
<string name="task_review_title">Task confirmation</string>
<string name="task_review_text">%1$s said that %2$s was finished. Is this correct?</string>
<string name="task_review_category">This will grant %1$s extra time for %2$s</string>
<string name="task_review_last_grant">This task was confirmed last time at %s</string>
</resources>