Add custom time warnings

This commit is contained in:
Jonas Lochmann 2022-03-28 02:00:00 +02:00
parent 2d8156add2
commit 14c7804ba8
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
26 changed files with 2030 additions and 170 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 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
@ -40,6 +40,7 @@ interface Database {
fun userLimitLoginCategoryDao(): UserLimitLoginCategoryDao
fun categoryNetworkId(): CategoryNetworkIdDao
fun childTasks(): ChildTaskDao
fun timeWarning(): CategoryTimeWarningDao
fun <T> runInTransaction(block: () -> T): T
fun <T> runInUnobservedTransaction(block: () -> T): T

View file

@ -280,4 +280,10 @@ object DatabaseMigrations {
// nothing to do, there was just a new config item type added
}
}
val MIGRATE_TO_V40 = object: Migration(39, 40) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `category_time_warning` (`category_id` TEXT NOT NULL, `minutes` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `minutes`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
}
}
}

View file

@ -50,8 +50,9 @@ import java.util.concurrent.TimeUnit
SessionDuration::class,
UserLimitLoginCategory::class,
CategoryNetworkId::class,
ChildTask::class
], version = 39)
ChildTask::class,
CategoryTimeWarning::class
], version = 40)
abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database {
companion object {
private val lock = Object()
@ -124,7 +125,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database
DatabaseMigrations.MIGRATE_TO_V36,
DatabaseMigrations.MIGRATE_TO_V37,
DatabaseMigrations.MIGRATE_TO_V38,
DatabaseMigrations.MIGRATE_TO_V39
DatabaseMigrations.MIGRATE_TO_V39,
DatabaseMigrations.MIGRATE_TO_V40
)
.setQueryExecutor(Threads.database)
.addCallback(object: Callback() {

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 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
@ -45,6 +45,7 @@ object DatabaseBackupLowlevel {
private const val USER_LIMIT_LOGIN_CATEGORY = "userLimitLoginCategory"
private const val CATEGORY_NETWORK_ID = "categoryNetworkId"
private const val CHILD_TASK = "childTask"
private const val CATEGORY_TIME_WARNINGS = "timeWarnings"
fun outputAsBackupJson(database: Database, outputStream: OutputStream) {
val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8))
@ -73,6 +74,14 @@ object DatabaseBackupLowlevel {
writer.endArray()
}
fun <T: JsonSerializable> handleCollection(name: String, items: List<T>) {
writer.name(name).beginArray()
items.forEach { it.serialize(writer) }
writer.endArray()
}
handleCollection(APP) {offset, pageSize -> database.app().getAppPageSync(offset, pageSize) }
handleCollection(CATEGORY) {offset: Int, pageSize: Int -> database.category().getCategoryPageSync(offset, pageSize) }
handleCollection(CATEGORY_APP) { offset, pageSize -> database.categoryApp().getCategoryAppPageSync(offset, pageSize) }
@ -94,6 +103,7 @@ object DatabaseBackupLowlevel {
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) }
handleCollection(CATEGORY_TIME_WARNINGS, database.timeWarning().getAllItemsSync())
writer.endObject().flush()
}
@ -104,6 +114,7 @@ object DatabaseBackupLowlevel {
var userLoginLimitCategories = emptyList<UserLimitLoginCategory>()
var categoryNetworkId = emptyList<CategoryNetworkId>()
var childTasks = emptyList<ChildTask>()
var timeWarnings = emptyList<CategoryTimeWarning>()
database.runInTransaction {
database.deleteAllData()
@ -283,6 +294,19 @@ object DatabaseBackupLowlevel {
reader.endArray()
}
CATEGORY_TIME_WARNINGS -> {
reader.beginArray()
mutableListOf<CategoryTimeWarning>().let { list ->
while (reader.hasNext()) {
list.add(CategoryTimeWarning.parse(reader))
}
timeWarnings = list
}
reader.endArray()
}
else -> reader.skipValue()
}
}
@ -291,6 +315,7 @@ object DatabaseBackupLowlevel {
if (userLoginLimitCategories.isNotEmpty()) { database.userLimitLoginCategoryDao().addItemsSync(userLoginLimitCategories) }
if (categoryNetworkId.isNotEmpty()) { database.categoryNetworkId().insertItemsSync(categoryNetworkId) }
if (childTasks.isNotEmpty()) { database.childTasks().insertItemsSync(childTasks) }
if (timeWarnings.isNotEmpty()) { database.timeWarning().insertItemsSync(timeWarnings) }
}
}
}

View file

@ -0,0 +1,44 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 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.CategoryTimeWarning
@Dao
interface CategoryTimeWarningDao {
@Query("SELECT * FROM category_time_warning")
fun getAllItemsSync(): List<CategoryTimeWarning>
@Query("SELECT * FROM category_time_warning WHERE category_id = :categoryId")
fun getItemsByCategoryIdSync(categoryId: String): List<CategoryTimeWarning>
@Query("SELECT * FROM category_time_warning WHERE category_id = :categoryId")
fun getItemsByCategoryIdLive(categoryId: String): LiveData<List<CategoryTimeWarning>>
@Insert
fun insertItemsSync(items: List<CategoryTimeWarning>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertItemIgnoreConflictSync(item: CategoryTimeWarning)
@Query("DELETE FROM category_time_warning WHERE category_id = :categoryId AND minutes = :minutes")
fun deleteItem(categoryId: String, minutes: Int)
@Query("DELETE FROM category_time_warning WHERE category_id = :categoryId")
fun deleteByCategoryIdSync(categoryId: String)
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 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
@ -33,7 +33,8 @@ enum class Table {
User,
UserKey,
UserLimitLoginCategory,
CategoryNetworkId
CategoryNetworkId,
CategoryTimeWarning
}
object TableNames {
@ -54,6 +55,7 @@ object TableNames {
const val USER_KEY = "user_key"
const val USER_LIMIT_LOGIN_CATEGORY = "user_limit_login_category"
const val CATEGORY_NETWORK_ID = "category_network_id"
const val CATEGORY_TIME_WARNING = "category_time_warning"
}
object TableUtil {
@ -75,6 +77,7 @@ object TableUtil {
Table.UserKey -> TableNames.USER_KEY
Table.UserLimitLoginCategory -> TableNames.USER_LIMIT_LOGIN_CATEGORY
Table.CategoryNetworkId -> TableNames.CATEGORY_NETWORK_ID
Table.CategoryTimeWarning -> TableNames.CATEGORY_TIME_WARNING
}
fun toEnum(value: String): Table = when (value) {
@ -95,6 +98,7 @@ object TableUtil {
TableNames.USER_KEY -> Table.UserKey
TableNames.USER_LIMIT_LOGIN_CATEGORY -> Table.UserLimitLoginCategory
TableNames.CATEGORY_NETWORK_ID -> Table.CategoryNetworkId
TableNames.CATEGORY_TIME_WARNING -> Table.CategoryTimeWarning
else -> throw IllegalArgumentException()
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 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
@ -263,15 +263,13 @@ data class Category(
}
object CategoryTimeWarnings {
val durationToBitIndex = mapOf(
1000L * 60 to 0, // 1 minute
1000L * 60 * 3 to 1, // 3 minutes
1000L * 60 * 5 to 2, // 5 minutes
1000L * 60 * 10 to 3, // 10 minutes
1000L * 60 * 15 to 4 // 15 minutes
val durationInMinutesToBitIndex = mapOf(
1 to 0,
3 to 1,
5 to 2,
10 to 3,
15 to 4
)
val durations = durationToBitIndex.keys
}
object CategoryFlags {

View file

@ -0,0 +1,85 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 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.*
import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.JsonSerializable
@Entity(
tableName = "category_time_warning",
primaryKeys = ["category_id", "minutes"],
foreignKeys = [
ForeignKey(
entity = Category::class,
parentColumns = ["id"],
childColumns = ["category_id"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)
]
)
data class CategoryTimeWarning (
@ColumnInfo(name = "category_id")
val categoryId: String,
val minutes: Int
): JsonSerializable {
companion object {
private const val CATEGORY_ID = "categoryId"
private const val MINUTES = "minutes"
const val MIN = 1
const val MAX = 60 * 24 * 7 - 2
fun parse(reader: JsonReader): CategoryTimeWarning {
var categoryId: String? = null
var minutes: Int? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
CATEGORY_ID -> categoryId = reader.nextString()
MINUTES -> minutes = reader.nextInt()
else -> reader.skipValue()
}
}
reader.endObject()
return CategoryTimeWarning(
categoryId = categoryId!!,
minutes = minutes!!
)
}
}
init {
IdGenerator.assertIdValid(categoryId)
if (minutes < MIN || minutes > MAX) {
throw IllegalArgumentException()
}
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(CATEGORY_ID).value(categoryId)
writer.name(MINUTES).value(minutes)
writer.endObject()
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 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
@ -25,7 +25,8 @@ data class CategoryRelatedData(
val usedTimes: List<UsedTimeItem>,
val durations: List<SessionDuration>,
val networks: List<CategoryNetworkId>,
val limitLoginCategories: List<UserLimitLoginCategory>
val limitLoginCategories: List<UserLimitLoginCategory>,
val additionalTimeWarnings: List<CategoryTimeWarning>
) {
companion object {
fun load(category: Category, database: Database): CategoryRelatedData = database.runInUnobservedTransaction {
@ -34,6 +35,7 @@ data class CategoryRelatedData(
val durations = database.sessionDuration().getSessionDurationItemsByCategoryIdSync(category.id)
val networks = database.categoryNetworkId().getByCategoryIdSync(category.id)
val limitLoginCategories = database.userLimitLoginCategoryDao().getByCategoryIdSync(category.id)
val additionalTimeWarnings = database.timeWarning().getItemsByCategoryIdSync(category.id)
CategoryRelatedData(
category = category,
@ -41,11 +43,24 @@ data class CategoryRelatedData(
usedTimes = usedTimes,
durations = durations,
networks = networks,
limitLoginCategories = limitLoginCategories
limitLoginCategories = limitLoginCategories,
additionalTimeWarnings = additionalTimeWarnings
)
}
}
val allTimeWarningMinutes: Set<Int> by lazy {
mutableSetOf<Int>().also { result ->
CategoryTimeWarnings.durationInMinutesToBitIndex.entries.forEach { (durationInMinutes, bitIndex) ->
if (category.timeWarnings and (1 shl bitIndex) != 0) {
result.add(durationInMinutes)
}
}
additionalTimeWarnings.forEach { result.add(it.minutes) }
}
}
fun update(
category: Category,
updateRules: Boolean,
@ -53,6 +68,7 @@ data class CategoryRelatedData(
updateDurations: Boolean,
updateNetworks: Boolean,
updateLimitLoginCategories: Boolean,
updateTimeWarnings: Boolean,
database: Database
): CategoryRelatedData = database.runInUnobservedTransaction {
if (category.id != this.category.id) {
@ -64,10 +80,12 @@ data class CategoryRelatedData(
val durations = if (updateDurations) database.sessionDuration().getSessionDurationItemsByCategoryIdSync(category.id) else durations
val networks = if (updateNetworks) database.categoryNetworkId().getByCategoryIdSync(category.id) else networks
val limitLoginCategories = if (updateLimitLoginCategories) database.userLimitLoginCategoryDao().getByCategoryIdSync(category.id) else limitLoginCategories
val additionalTimeWarnings = if (updateTimeWarnings) database.timeWarning().getItemsByCategoryIdSync(category.id) else additionalTimeWarnings
if (
category == this.category && rules == this.rules && usedTimes == this.usedTimes &&
durations == this.durations && networks == this.networks && limitLoginCategories == this.limitLoginCategories
durations == this.durations && networks == this.networks &&
limitLoginCategories == this.limitLoginCategories && additionalTimeWarnings == this.additionalTimeWarnings
) {
this
} else {
@ -77,7 +95,8 @@ data class CategoryRelatedData(
usedTimes = usedTimes,
durations = durations,
networks = networks,
limitLoginCategories = limitLoginCategories
limitLoginCategories = limitLoginCategories,
additionalTimeWarnings = additionalTimeWarnings
)
}
}

View file

@ -39,7 +39,7 @@ data class UserRelatedData(
private val relatedTables = arrayOf(
Table.User, Table.Category, Table.TimeLimitRule,
Table.UsedTimeItem, Table.SessionDuration, Table.CategoryApp,
Table.CategoryNetworkId
Table.CategoryNetworkId, Table.UserLimitLoginCategory, Table.CategoryTimeWarning
)
fun load(user: User, database: Database): UserRelatedData = database.runInUnobservedTransaction {
@ -103,11 +103,12 @@ data class UserRelatedData(
private var categoryAppsInvalidated = false
private var categoryNetworksInvalidated = false
private var limitLoginCategoriesInvalidated = false
private var timeWarningsInvalidated = false
private val invalidated
get() = userInvalidated || categoriesInvalidated || rulesInvalidated || usedTimesInvalidated ||
sessionDurationsInvalidated || categoryAppsInvalidated || categoryNetworksInvalidated ||
limitLoginCategoriesInvalidated
limitLoginCategoriesInvalidated || timeWarningsInvalidated
override fun onInvalidated(tables: Set<Table>) {
tables.forEach {
@ -120,6 +121,7 @@ data class UserRelatedData(
Table.CategoryApp -> categoryAppsInvalidated = true
Table.CategoryNetworkId -> categoryNetworksInvalidated = true
Table.UserLimitLoginCategory -> limitLoginCategoriesInvalidated = true
Table.CategoryTimeWarning -> timeWarningsInvalidated = true
else -> {/* do nothing */}
}
}
@ -144,13 +146,17 @@ data class UserRelatedData(
updateRules = rulesInvalidated,
updateTimes = usedTimesInvalidated,
updateNetworks = categoryNetworksInvalidated,
updateLimitLoginCategories = limitLoginCategoriesInvalidated
updateLimitLoginCategories = limitLoginCategoriesInvalidated,
updateTimeWarnings = timeWarningsInvalidated
) ?: CategoryRelatedData.load(
category = category,
database = database
)
}
} else if (sessionDurationsInvalidated || rulesInvalidated || usedTimesInvalidated || categoryNetworksInvalidated || limitLoginCategoriesInvalidated) {
} else if (
sessionDurationsInvalidated || rulesInvalidated || usedTimesInvalidated ||
categoryNetworksInvalidated || limitLoginCategoriesInvalidated || timeWarningsInvalidated
) {
categories.map {
it.update(
category = it.category,
@ -159,7 +165,8 @@ data class UserRelatedData(
updateRules = rulesInvalidated,
updateTimes = usedTimesInvalidated,
updateNetworks = categoryNetworksInvalidated,
updateLimitLoginCategories = limitLoginCategoriesInvalidated
updateLimitLoginCategories = limitLoginCategoriesInvalidated,
updateTimeWarnings = timeWarningsInvalidated
)
}
} else {

View file

@ -25,7 +25,6 @@ import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.coroutines.runAsyncExpectForever
import io.timelimit.android.data.backup.DatabaseBackup
import io.timelimit.android.data.model.CategoryTimeWarnings
import io.timelimit.android.data.model.ExperimentalFlags
import io.timelimit.android.data.model.UserType
import io.timelimit.android.data.model.derived.UserRelatedData
@ -46,7 +45,6 @@ import io.timelimit.android.logic.blockingreason.needsNetworkId
import io.timelimit.android.sync.actions.ForceSyncAction
import io.timelimit.android.sync.actions.UpdateDeviceStatusAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.ui.IsAppInForeground
import io.timelimit.android.ui.lock.LockActivity
import io.timelimit.android.util.AndroidVersion
import io.timelimit.android.util.TimeTextUtil
@ -393,33 +391,50 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
val oldSessionDuration = handling.remainingSessionDuration?.let { it - timeToSubtractForCategory }
// trigger time warnings
fun showTimeWarningNotification(title: Int, roundedNewTime: Long) {
appLogic.platformIntegration.showTimeWarningNotification(
title = appLogic.context.getString(title, category.title),
text = TimeTextUtil.remaining(roundedNewTime.toInt(), appLogic.context)
)
fun handleTimeWarnings(
notificationTitleStringResource: Int,
roundedNewTimeInMilliseconds: Long
) {
val roundedNewTimeInMinutes = roundedNewTimeInMilliseconds / (1000 * 60)
if (
// CategoryTimeWarning.MAX is still small enough for an integer
roundedNewTimeInMilliseconds >= 0 &&
roundedNewTimeInMilliseconds < Int.MAX_VALUE &&
roundedNewTimeInMinutes >= 0 &&
roundedNewTimeInMinutes < Int.MAX_VALUE &&
handling.createdWithCategoryRelatedData.allTimeWarningMinutes.contains(
roundedNewTimeInMinutes.toInt()
)
) {
appLogic.platformIntegration.showTimeWarningNotification(
title = appLogic.context.getString(
notificationTitleStringResource,
category.title
),
text = TimeTextUtil.remaining(
roundedNewTimeInMilliseconds.toInt(),
appLogic.context
)
)
}
}
if (oldRemainingTime / (1000 * 60) != newRemainingTime / (1000 * 60)) {
// eventually show remaining time warning
val roundedNewTime = ((newRemainingTime / (1000 * 60)) + 1) * (1000 * 60)
val flagIndex = CategoryTimeWarnings.durationToBitIndex[roundedNewTime]
if (flagIndex != null && category.timeWarnings and (1 shl flagIndex) != 0) {
showTimeWarningNotification(title = R.string.time_warning_not_title, roundedNewTime = roundedNewTime)
}
handleTimeWarnings(
notificationTitleStringResource = R.string.time_warning_not_title,
roundedNewTimeInMilliseconds = ((newRemainingTime / (1000 * 60)) + 1) * 1000 * 60
)
}
if (oldSessionDuration != null) {
val newSessionDuration = oldSessionDuration - timeToSubtract
// eventually show session duration warning
if (oldSessionDuration / (1000 * 60) != newSessionDuration / (1000 * 60)) {
val roundedNewTime = ((newSessionDuration / (1000 * 60)) + 1) * (1000 * 60)
val flagIndex = CategoryTimeWarnings.durationToBitIndex[roundedNewTime]
if (flagIndex != null && category.timeWarnings and (1 shl flagIndex) != 0) {
showTimeWarningNotification(title = R.string.time_warning_not_title_session, roundedNewTime = roundedNewTime)
}
if (oldSessionDuration / (1000 * 60) != newSessionDuration / (1000 * 60)) {
handleTimeWarnings(
notificationTitleStringResource = R.string.time_warning_not_title_session,
roundedNewTimeInMilliseconds = ((newSessionDuration / (1000 * 60)) + 1) * (1000 * 60)
)
}
}
@ -434,12 +449,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (nextBlockedMinute != null) {
val minutesUntilNextBlockedMinute = nextBlockedMinute - nowMinuteOfWeek
val msUntilNextBlocking = minutesUntilNextBlockedMinute.toLong() * 1000 * 60
val flagIndex = CategoryTimeWarnings.durationToBitIndex[msUntilNextBlocking]
if (flagIndex != null && category.timeWarnings and (1 shl flagIndex) != 0) {
showTimeWarningNotification(title = R.string.time_warning_not_title_blocked_time_area, roundedNewTime = msUntilNextBlocking)
}
handleTimeWarnings(
notificationTitleStringResource = R.string.time_warning_not_title_blocked_time_area,
roundedNewTimeInMilliseconds = minutesUntilNextBlockedMinute.toLong() * 1000 * 60
)
}
}

View file

@ -387,6 +387,20 @@ object ApplyServerDataStatus {
}
)
}
// apply time warnings
database.timeWarning().deleteByCategoryIdSync(newCategory.categoryId)
if (newCategory.additionalTimeWarnings.isNotEmpty()) {
database.timeWarning().insertItemsSync(
newCategory.additionalTimeWarnings.map { minutes ->
CategoryTimeWarning(
categoryId = newCategory.categoryId,
minutes = minutes
)
}
)
}
}
}
}

View file

@ -726,16 +726,28 @@ data class UpdateCategoryTemporarilyBlockedAction(val categoryId: String, val bl
writer.endObject()
}
}
data class UpdateCategoryTimeWarningsAction(val categoryId: String, val enable: Boolean, val flags: Int): ParentAction() {
data class UpdateCategoryTimeWarningsAction(
val categoryId: String,
val enable: Boolean,
val flags: Int,
val minutes: Int?
): ParentAction() {
companion object {
const val TYPE_VALUE = "UPDATE_CATEGORY_TIME_WARNINGS"
private const val CATEGORY_ID = "categoryId"
private const val ENABLE = "enable"
private const val FLAGS = "flags"
private const val MINUTES = "minutes"
}
init {
IdGenerator.assertIdValid(categoryId)
if (minutes != null) {
if (minutes < CategoryTimeWarning.MIN || minutes > CategoryTimeWarning.MAX) {
throw IllegalArgumentException()
}
}
}
override fun serialize(writer: JsonWriter) {
@ -746,6 +758,10 @@ data class UpdateCategoryTimeWarningsAction(val categoryId: String, val enable:
writer.name(ENABLE).value(enable)
writer.name(FLAGS).value(flags)
if (minutes != null) {
writer.name(MINUTES).value(minutes)
}
writer.endObject()
}
}

View file

@ -686,6 +686,19 @@ object LocalDatabaseParentActionDispatcher {
database.category().updateCategorySync(modified)
}
if (action.minutes != null) {
if (action.enable) database.timeWarning().insertItemIgnoreConflictSync(
CategoryTimeWarning(
categoryId = action.categoryId,
minutes = action.minutes
)
)
else database.timeWarning().deleteItem(
categoryId = action.categoryId,
minutes = action.minutes
)
}
null
}
is UpdateCategoryBatteryLimit -> {

View file

@ -459,7 +459,8 @@ data class ServerUpdatedCategoryBaseData(
val networks: List<ServerCategoryNetworkId>,
val disableLimitsUntil: Long,
val flags: Long,
val blockNotificationDelay: Long
val blockNotificationDelay: Long,
val additionalTimeWarnings: Set<Int>
) {
companion object {
private const val CATEGORY_ID = "categoryId"
@ -481,6 +482,7 @@ data class ServerUpdatedCategoryBaseData(
private const val DISABLE_LIMITS_UNTIL = "dlu"
private const val FLAGS = "flags"
private const val BLOCK_NOTIFICATION_DELAY = "blockNotificationDelay"
private const val ADDITIONAL_TIME_WARNINGS = "atw"
fun parse(reader: JsonReader): ServerUpdatedCategoryBaseData {
var categoryId: String? = null
@ -503,6 +505,7 @@ data class ServerUpdatedCategoryBaseData(
var disableLimitsUntil = 0L
var flags = 0L
var blockNotificationDelay = 0L
var additionalTimeWarnings = emptySet<Int>()
reader.beginObject()
while (reader.hasNext()) {
@ -526,31 +529,41 @@ data class ServerUpdatedCategoryBaseData(
DISABLE_LIMITS_UNTIL -> disableLimitsUntil = reader.nextLong()
FLAGS -> flags = reader.nextLong()
BLOCK_NOTIFICATION_DELAY -> blockNotificationDelay = reader.nextLong()
ADDITIONAL_TIME_WARNINGS -> additionalTimeWarnings = mutableSetOf<Int>().also { result ->
reader.beginArray()
while (reader.hasNext()) {
result.add(reader.nextInt())
}
reader.endArray()
}
else -> reader.skipValue()
}
}
reader.endObject()
return ServerUpdatedCategoryBaseData(
categoryId = categoryId!!,
childId = childId!!,
title = title!!,
blockedMinutesInWeek = blockedMinutesInWeek!!,
extraTimeInMillis = extraTimeInMillis!!,
extraTimeDay = extraTimeDay,
temporarilyBlocked = temporarilyBlocked!!,
temporarilyBlockedEndTime = temporarilyBlockedEndTime,
baseDataVersion = baseDataVersion!!,
parentCategoryId = parentCategoryId!!,
blockAllNotifications = blockAllNotifications,
timeWarnings = timeWarnings,
minBatteryLevelCharging = minBatteryLevelCharging,
minBatteryLevelMobile = minBatteryLevelMobile,
sort = sort,
networks = networks,
disableLimitsUntil = disableLimitsUntil,
flags = flags,
blockNotificationDelay = blockNotificationDelay
categoryId = categoryId!!,
childId = childId!!,
title = title!!,
blockedMinutesInWeek = blockedMinutesInWeek!!,
extraTimeInMillis = extraTimeInMillis!!,
extraTimeDay = extraTimeDay,
temporarilyBlocked = temporarilyBlocked!!,
temporarilyBlockedEndTime = temporarilyBlockedEndTime,
baseDataVersion = baseDataVersion!!,
parentCategoryId = parentCategoryId!!,
blockAllNotifications = blockAllNotifications,
timeWarnings = timeWarnings,
minBatteryLevelCharging = minBatteryLevelCharging,
minBatteryLevelMobile = minBatteryLevelMobile,
sort = sort,
networks = networks,
disableLimitsUntil = disableLimitsUntil,
flags = flags,
blockNotificationDelay = blockNotificationDelay,
additionalTimeWarnings = additionalTimeWarnings
)
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 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
@ -23,6 +23,7 @@ import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar
import io.timelimit.android.R
@ -38,6 +39,8 @@ import io.timelimit.android.ui.main.ActivityViewModel
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.manage.category.settings.timewarning.CategoryTimeWarningStatus
import io.timelimit.android.ui.manage.category.settings.timewarning.CategoryTimeWarningView
import io.timelimit.android.ui.payment.RequiresPurchaseDialogFragment
import io.timelimit.android.ui.util.bind
@ -46,6 +49,7 @@ class CategorySettingsFragment : Fragment() {
private const val PERMISSION_REQUEST_CODE = 1
private const val CHILD_ID = "childId"
private const val CATEGORY_ID = "categoryId"
private const val TIME_WARNING_STATUS = "timeWarningStatus"
fun newInstance(childId: String, categoryId: String) = CategorySettingsFragment().apply {
arguments = Bundle().apply {
@ -61,10 +65,35 @@ class CategorySettingsFragment : Fragment() {
private val categoryId: String get() = requireArguments().getString(CATEGORY_ID)!!
private val notificationFilterModel: ManageNotificationFilterModel by viewModels()
private val timeWarningStatus = MutableLiveData<CategoryTimeWarningStatus>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
timeWarningStatus.value = savedInstanceState?.getParcelable(TIME_WARNING_STATUS) ?: CategoryTimeWarningStatus.default
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = FragmentCategorySettingsBinding.inflate(inflater, container, false)
val categoryEntry = appLogic.database.category().getCategoryByChildIdAndId(childId, categoryId)
val timeWarnings = appLogic.database.timeWarning().getItemsByCategoryIdLive(categoryId)
categoryEntry.observe(viewLifecycleOwner) {
if (it != null) {
timeWarningStatus.value?.let { old ->
timeWarningStatus.value = old.update(it)
}
}
}
timeWarnings.observe(viewLifecycleOwner) {
if (it != null) {
timeWarningStatus.value?.let { old ->
timeWarningStatus.value = old.update(it)
}
}
}
val childDate = appLogic.database.user().getChildUserByIdLive(childId).mapToTimezone().switchMap { timezone ->
liveDataFromFunction (1000 * 10L) { DateInTimezone.newInstance(appLogic.timeApi.getCurrentTimeInMillis(), timezone) }
@ -122,11 +151,13 @@ class CategorySettingsFragment : Fragment() {
)
CategoryTimeWarningView.bind(
view = binding.timeWarnings,
auth = auth,
categoryLive = categoryEntry,
lifecycleOwner = this,
fragmentManager = parentFragmentManager
view = binding.timeWarnings,
auth = auth,
statusLive = timeWarningStatus,
lifecycleOwner = this,
fragmentManager = parentFragmentManager,
categoryId = categoryId,
serverApiLevelInfo = appLogic.serverApiLevelLogic.infoLive
)
ManageCategoryNetworksView.bind(
@ -254,4 +285,10 @@ class CategorySettingsFragment : Fragment() {
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
timeWarningStatus.value?.let { outState.putParcelable(TIME_WARNING_STATUS, it) }
}
}

View file

@ -1,86 +0,0 @@
/*
* 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.category.settings
import android.widget.CheckBox
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import io.timelimit.android.R
import io.timelimit.android.data.model.Category
import io.timelimit.android.data.model.CategoryTimeWarnings
import io.timelimit.android.databinding.CategoryTimeWarningsViewBinding
import io.timelimit.android.sync.actions.UpdateCategoryTimeWarningsAction
import io.timelimit.android.ui.help.HelpDialogFragment
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.util.TimeTextUtil
object CategoryTimeWarningView {
fun bind(
view: CategoryTimeWarningsViewBinding,
lifecycleOwner: LifecycleOwner,
categoryLive: LiveData<Category?>,
auth: ActivityViewModel,
fragmentManager: FragmentManager
) {
view.titleView.setOnClickListener {
HelpDialogFragment.newInstance(
title = R.string.time_warning_title,
text = R.string.time_warning_desc
).show(fragmentManager)
}
view.linearLayout.removeAllViews()
val durationToCheckbox = mutableMapOf<Long, CheckBox>()
CategoryTimeWarnings.durations.sorted().forEach { duration ->
CheckBox(view.root.context).let { checkbox ->
checkbox.text = TimeTextUtil.time(duration.toInt(), view.root.context)
view.linearLayout.addView(checkbox)
durationToCheckbox[duration] = checkbox
}
}
categoryLive.observe(lifecycleOwner, Observer { category ->
durationToCheckbox.entries.forEach { (duration, checkbox) ->
checkbox.setOnCheckedChangeListener { _, _ -> }
val flag = (1 shl CategoryTimeWarnings.durationToBitIndex[duration]!!)
val enable = (category?.timeWarnings ?: 0) and flag != 0
checkbox.isChecked = enable
checkbox.setOnCheckedChangeListener { _, isChecked ->
if (isChecked != enable && category != null) {
if (auth.tryDispatchParentAction(
UpdateCategoryTimeWarningsAction(
categoryId = category.id,
enable = isChecked,
flags = flag
)
)) {
// it worked
} else {
checkbox.isChecked = enable
}
}
}
}
})
}
}

View file

@ -0,0 +1,81 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.ui.manage.category.settings.timewarning
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import io.timelimit.android.data.model.CategoryTimeWarning
import io.timelimit.android.data.model.CategoryTimeWarnings
import io.timelimit.android.databinding.AddTimeWarningDialogBinding
import io.timelimit.android.extensions.showSafe
import io.timelimit.android.sync.actions.UpdateCategoryTimeWarningsAction
import io.timelimit.android.ui.main.ActivityViewModelHolder
class AddTimeWarningDialogFragment: BottomSheetDialogFragment() {
companion object {
private const val DIALOG_TAG = "AddTimeWarningDialogFragment"
private const val CATEGORY_ID = "categoryId"
fun newInstance(categoryId: String) = AddTimeWarningDialogFragment().apply {
arguments = Bundle().apply {
putString(CATEGORY_ID, categoryId)
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val binding = AddTimeWarningDialogBinding.inflate(inflater, container, false)
val categoryId = requireArguments().getString(CATEGORY_ID)!!
val auth = (requireActivity() as ActivityViewModelHolder).getActivityViewModel()
auth.authenticatedUser.observe(viewLifecycleOwner) { if (it == null) dismissAllowingStateLoss() }
binding.numberPicker.minValue = CategoryTimeWarning.MIN
binding.numberPicker.maxValue = CategoryTimeWarning.MAX
binding.confirmButton.setOnClickListener {
val minutes = binding.numberPicker.value
val flagIndex = CategoryTimeWarnings.durationInMinutesToBitIndex[minutes]
val action = if (flagIndex != null) {
UpdateCategoryTimeWarningsAction(
categoryId = categoryId,
enable = true,
flags = 1 shl flagIndex,
minutes = null
)
} else {
UpdateCategoryTimeWarningsAction(
categoryId = categoryId,
enable = true,
flags = 0,
minutes = minutes
)
}
if (auth.tryDispatchParentAction(action)) {
dismissAllowingStateLoss()
}
}
return binding.root
}
fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG)
}

View file

@ -0,0 +1,118 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.ui.manage.category.settings.timewarning
import android.os.Parcelable
import io.timelimit.android.data.model.Category
import io.timelimit.android.data.model.CategoryTimeWarning
import io.timelimit.android.data.model.CategoryTimeWarnings
import io.timelimit.android.sync.actions.UpdateCategoryTimeWarningsAction
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.util.*
@Parcelize
data class CategoryTimeWarningStatus(
private val categoryFlags: Int?,
private val timeWarnings: Set<Int>?,
private val additionalTimeWarningSlots: Set<Int>
): Parcelable {
companion object {
val default = CategoryTimeWarningStatus(
categoryFlags = null,
timeWarnings = null,
additionalTimeWarningSlots = emptySet()
)
}
fun update(category: Category): CategoryTimeWarningStatus {
if (this.categoryFlags == category.timeWarnings) return this
return this.copy(categoryFlags = category.timeWarnings)
}
fun update(warnings: List<CategoryTimeWarning>): CategoryTimeWarningStatus {
val timeWarnings = warnings.map { it.minutes }.toSet()
if (this.timeWarnings == timeWarnings) return this
return this.copy(
timeWarnings = timeWarnings,
additionalTimeWarningSlots = additionalTimeWarningSlots + timeWarnings
)
}
fun buildAction(categoryId: String, minutes: Int, enable: Boolean): UpdateCategoryTimeWarningsAction {
val flagIndex = CategoryTimeWarnings.durationInMinutesToBitIndex[minutes]
return if (enable) {
if (flagIndex != null) UpdateCategoryTimeWarningsAction(
categoryId = categoryId,
enable = true,
flags = 1 shl flagIndex,
minutes = null
) else UpdateCategoryTimeWarningsAction(
categoryId = categoryId,
enable = true,
flags = 0,
minutes = minutes
)
} else {
UpdateCategoryTimeWarningsAction(
categoryId = categoryId,
enable = false,
flags = if (flagIndex != null) 1 shl flagIndex else 0,
minutes = if (timeWarnings != null && timeWarnings.contains(minutes)) minutes else null
)
}
}
@IgnoredOnParcel
val display = TreeMap<Int, CategoryTimeWarningOptionStatus>().also { result ->
val complete = categoryFlags != null && timeWarnings != null
additionalTimeWarningSlots.forEach { minute ->
result[minute] = if (complete) CategoryTimeWarningOptionStatus.Unchecked
else CategoryTimeWarningOptionStatus.Undefined
}
timeWarnings?.forEach { minute -> result[minute] = CategoryTimeWarningOptionStatus.Checked }
CategoryTimeWarnings.durationInMinutesToBitIndex.forEach { (minute, bitIndex) ->
result[minute] = if (complete && categoryFlags != null) {
if (categoryFlags and (1 shl bitIndex) != 0) CategoryTimeWarningOptionStatus.Checked
else CategoryTimeWarningOptionStatus.Unchecked
} else CategoryTimeWarningOptionStatus.Undefined
}
}.let { output ->
mutableListOf<CategoryTimeWarningOption>().also { result ->
output.entries.forEach { (minute, status) ->
result.add(CategoryTimeWarningOption(minute, status))
}
}
}.toList()
data class CategoryTimeWarningOption(
val minutes: Int,
val status: CategoryTimeWarningOptionStatus
)
enum class CategoryTimeWarningOptionStatus {
Checked,
Unchecked,
Undefined
}
}

View file

@ -0,0 +1,105 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.ui.manage.category.settings.timewarning
import android.view.View
import android.widget.CheckBox
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import io.timelimit.android.R
import io.timelimit.android.databinding.CategoryTimeWarningsViewBinding
import io.timelimit.android.logic.ServerApiLevelInfo
import io.timelimit.android.ui.help.HelpDialogFragment
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.util.TimeTextUtil
object CategoryTimeWarningView {
fun bind(
view: CategoryTimeWarningsViewBinding,
lifecycleOwner: LifecycleOwner,
statusLive: LiveData<CategoryTimeWarningStatus>,
auth: ActivityViewModel,
fragmentManager: FragmentManager,
categoryId: String,
serverApiLevelInfo: LiveData<ServerApiLevelInfo>
) {
view.titleView.setOnClickListener {
HelpDialogFragment.newInstance(
title = R.string.time_warning_title,
text = R.string.time_warning_desc
).show(fragmentManager)
}
view.addTimeWarningButton.setOnClickListener {
if (auth.requestAuthenticationOrReturnTrue()) {
AddTimeWarningDialogFragment.newInstance(categoryId).show(fragmentManager)
}
}
serverApiLevelInfo.observe(lifecycleOwner) { info ->
view.addTimeWarningButton.visibility = if (info.hasLevelOrIsOffline(3)) View.VISIBLE else View.GONE
}
view.linearLayout.removeAllViews()
val views = mutableListOf<CheckBox>()
statusLive.observe(lifecycleOwner) { status ->
if (views.size != status.display.size) {
views.clear()
view.linearLayout.removeAllViews()
for (index in 1..status.display.size) {
CheckBox(view.root.context).also { checkbox ->
views.add(checkbox)
view.linearLayout.addView(checkbox)
}
}
}
status.display.forEachIndexed { index, item ->
val checkbox = views[index]
val enabled = item.status != CategoryTimeWarningStatus.CategoryTimeWarningOptionStatus.Undefined
val checked = item.status == CategoryTimeWarningStatus.CategoryTimeWarningOptionStatus.Checked
checkbox.text = TimeTextUtil.time(item.minutes * 1000 * 60, view.root.context)
checkbox.setOnCheckedChangeListener(null)
checkbox.isEnabled = enabled
checkbox.isChecked = checked
if (item.status != CategoryTimeWarningStatus.CategoryTimeWarningOptionStatus.Undefined) {
checkbox.setOnCheckedChangeListener { _, isChecked ->
if (isChecked != checked) {
if (auth.tryDispatchParentAction(status.buildAction(
categoryId = categoryId,
minutes = item.minutes,
enable = isChecked
))) {
// it worked
} else {
checkbox.isChecked = checked
}
}
}
}
}
}
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 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
@ -89,13 +89,23 @@ object DuplicateChildActions {
oldCategory.category.timeWarnings.let { timeWarnings ->
if (timeWarnings != 0) {
result.add(UpdateCategoryTimeWarningsAction(
categoryId = newCategoryId,
enable = true,
flags = timeWarnings
categoryId = newCategoryId,
enable = true,
flags = timeWarnings,
minutes = null
))
}
}
oldCategory.additionalTimeWarnings.forEach { timeWarning ->
result.add(UpdateCategoryTimeWarningsAction(
categoryId = newCategoryId,
enable = true,
flags = 0,
minutes = timeWarning.minutes
))
}
if (oldCategory.category.minBatteryLevelWhileCharging != 0 || oldCategory.category.minBatteryLevelMobile != 0) {
result.add(UpdateCategoryBatteryLimit(
categoryId = newCategoryId,

View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 - 2022 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">
<LinearLayout
android:orientation="vertical"
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:textAppearance="?android:textAppearanceLarge"
android:text="@string/time_warning_custom_add"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:orientation="horizontal"
android:layout_gravity="center_horizontal"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<NumberPicker
android:id="@+id/number_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:paddingStart="8dp"
android:paddingEnd="0dp"
android:textAppearance="?android:textAppearanceMedium"
android:layout_gravity="center_vertical"
android:text="@string/select_time_span_view_minutes"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<Button
android:id="@+id/confirm_button"
android:text="@string/generic_ok"
android:layout_gravity="end"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</layout>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
TimeLimit Copyright <C> 2019 - 2022 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.
@ -45,6 +45,14 @@
<!-- checkboxes will be added hear -->
</LinearLayout>
<Button
style="?materialButtonOutlinedStyle"
android:layout_gravity="end"
android:text="@string/time_warning_custom_add"
android:id="@+id/add_time_warning_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</layout>

View file

@ -1123,6 +1123,7 @@
<string name="notification_channel_time_warning_title">Zeitwarnungen</string>
<string name="notification_channel_time_warning_text">Benachrichtigungen für Kinder, wenn nur noch wenig Zeit übrig ist</string>
<string name="time_warning_custom_add">Zeitwarnung hinzufügen</string>
<string name="notification_channel_premium_expires_title">Vollversionablauf</string>
<string name="notification_channel_premium_expires_text">Benachrichtigungen, wenn die Vollversion bald abläuft</string>

View file

@ -1168,6 +1168,7 @@
<string name="notification_channel_time_warning_title">Time warning</string>
<string name="notification_channel_time_warning_text">Notification for childs if there is not much time remaining</string>
<string name="time_warning_custom_add">Add time warning</string>
<string name="notification_channel_premium_expires_title">Premium version expires</string>
<string name="notification_channel_premium_expires_text">Notification if the premium version expires shortly</string>