Improve the widget

This commit is contained in:
Jonas Lochmann 2022-08-15 02:00:00 +02:00
parent baff9a110d
commit 46550588f5
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
33 changed files with 2843 additions and 217 deletions

File diff suppressed because it is too large Load diff

View file

@ -112,6 +112,14 @@
android:taskAffinity=":update" android:taskAffinity=":update"
android:launchMode="singleTop" /> android:launchMode="singleTop" />
<activity
android:name=".ui.widget.config.WidgetConfigActivity"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
</intent-filter>
</activity>
<!-- system integration --> <!-- system integration -->
<receiver android:name=".integration.platform.android.receiver.BootReceiver" android:exported="false"> <receiver android:name=".integration.platform.android.receiver.BootReceiver" android:exported="false">

View file

@ -46,6 +46,7 @@ interface Database {
fun cryptContainerKeyResult(): CryptContainerKeyResultDao fun cryptContainerKeyResult(): CryptContainerKeyResultDao
fun deviceKey(): DeviceKeyDao fun deviceKey(): DeviceKeyDao
fun u2f(): U2FDao fun u2f(): U2FDao
fun widgetCategory(): WidgetCategoryDao
fun <T> runInTransaction(block: () -> T): T fun <T> runInTransaction(block: () -> T): T
fun <T> runInUnobservedTransaction(block: () -> T): T fun <T> runInUnobservedTransaction(block: () -> T): T

View file

@ -326,6 +326,13 @@ object DatabaseMigrations {
} }
} }
val MIGRATE_TO_V45 = object: Migration(44, 45) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `widget_category` (`widget_id` INTEGER NOT NULL, `category_id` TEXT NOT NULL, PRIMARY KEY(`widget_id`, `category_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_widget_category_category_id` ON `widget_category` (`category_id`)")
}
}
val ALL = arrayOf( val ALL = arrayOf(
MIGRATE_TO_V2, MIGRATE_TO_V2,
MIGRATE_TO_V3, MIGRATE_TO_V3,
@ -369,6 +376,7 @@ object DatabaseMigrations {
MIGRATE_TO_V41, MIGRATE_TO_V41,
MIGRATE_TO_V42, MIGRATE_TO_V42,
MIGRATE_TP_V43, MIGRATE_TP_V43,
MIGRATE_TO_V44 MIGRATE_TO_V44,
MIGRATE_TO_V45
) )
} }

View file

@ -58,8 +58,9 @@ import java.util.concurrent.TimeUnit
CryptContainerPendingKeyRequest::class, CryptContainerPendingKeyRequest::class,
CryptContainerKeyResult::class, CryptContainerKeyResult::class,
DevicePublicKey::class, DevicePublicKey::class,
UserU2FKey::class UserU2FKey::class,
], version = 44) WidgetCategory::class
], version = 45)
abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database {
companion object { companion object {
private val lock = Object() private val lock = Object()

View file

@ -0,0 +1,47 @@
/*
* 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.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.timelimit.android.data.model.WidgetCategory
@Dao
interface WidgetCategoryDao {
@Query("DELETE FROM widget_category WHERE widget_id = :widgetId")
fun deleteByWidgetId(widgetId: Int)
@Query("DELETE FROM widget_category WHERE widget_id IN (:widgetIds)")
fun deleteByWidgetIds(widgetIds: IntArray)
@Query("DELETE FROM widget_category WHERE widget_id = :widgetId AND category_id IN (:categoryIds)")
fun deleteByWidgetIdAndCategoryIds(widgetId: Int, categoryIds: List<String>)
@Query("DELETE FROM widget_category")
fun deleteAll()
@Query("SELECT * FROM widget_category")
fun queryLive(): LiveData<List<WidgetCategory>>
@Query("SELECT category_id FROM widget_category WHERE widget_id = :widgetId")
fun queryByWidgetIdSync(widgetId: Int): List<String>
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(items: List<WidgetCategory>)
}

View file

@ -0,0 +1,40 @@
/*
* 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 androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(
tableName = "widget_category",
primaryKeys = ["widget_id", "category_id"],
foreignKeys = [
ForeignKey(
entity = Category::class,
parentColumns = ["id"],
childColumns = ["category_id"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)
]
)
data class WidgetCategory (
@ColumnInfo(name = "widget_id")
val widgetId: Int,
@ColumnInfo(name = "category_id", index = true)
val categoryId: String
)

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -44,8 +44,13 @@ class BackgroundActionService: Service() {
fun prepareRevokeTemporarilyAllowed(context: Context) = Intent(context, BackgroundActionService::class.java) fun prepareRevokeTemporarilyAllowed(context: Context) = Intent(context, BackgroundActionService::class.java)
.putExtra(ACTION, ACTION_REVOKE_TEMPORARILY_ALLOWED_APPS) .putExtra(ACTION, ACTION_REVOKE_TEMPORARILY_ALLOWED_APPS)
fun prepareSwitchToDefaultUser(context: Context) = Intent(context, BackgroundActionService::class.java) fun getSwitchToDefaultUserIntent(context: Context) = PendingIntent.getService(
.putExtra(ACTION, ACTION_SWITCH_TO_DEFAULT_USER) context,
PendingIntentIds.SWITCH_TO_DEFAULT_USER,
Intent(context, BackgroundActionService::class.java)
.putExtra(ACTION, ACTION_SWITCH_TO_DEFAULT_USER),
PendingIntentIds.PENDING_INTENT_FLAGS
)
fun prepareDismissNotification(context: Context, type: Int, id: String) = Intent(context, BackgroundActionService::class.java) fun prepareDismissNotification(context: Context, type: Int, id: String) = Intent(context, BackgroundActionService::class.java)
.putExtra(ACTION, ACTION_DISMISS_NOTIFICATION) .putExtra(ACTION, ACTION_DISMISS_NOTIFICATION)

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 * 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 * it under the terms of the GNU General Public License as published by
@ -17,7 +17,6 @@ package io.timelimit.android.integration.platform.android
import android.app.ActivityManager import android.app.ActivityManager
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -89,12 +88,7 @@ class BackgroundService: Service() {
NotificationCompat.Action.Builder( NotificationCompat.Action.Builder(
R.drawable.ic_account_circle_black_24dp, R.drawable.ic_account_circle_black_24dp,
context.getString(R.string.manage_device_default_user_switch_btn), context.getString(R.string.manage_device_default_user_switch_btn),
PendingIntent.getService( BackgroundActionService.getSwitchToDefaultUserIntent(context)
context,
PendingIntentIds.SWITCH_TO_DEFAULT_USER,
BackgroundActionService.prepareSwitchToDefaultUser(context),
PendingIntentIds.PENDING_INTENT_FLAGS
)
).build() ).build()
) )
} }

View file

@ -30,6 +30,7 @@ import io.timelimit.android.sync.SyncUtil
import io.timelimit.android.sync.network.api.ServerApi import io.timelimit.android.sync.network.api.ServerApi
import io.timelimit.android.sync.websocket.NetworkStatusInterface import io.timelimit.android.sync.websocket.NetworkStatusInterface
import io.timelimit.android.sync.websocket.WebsocketClientCreator import io.timelimit.android.sync.websocket.WebsocketClientCreator
import io.timelimit.android.ui.widget.TimesWidgetProvider
class AppLogic( class AppLogic(
val platformIntegration: PlatformIntegration, val platformIntegration: PlatformIntegration,
@ -98,6 +99,7 @@ class AppLogic(
init { init {
WatchdogLogic(this) WatchdogLogic(this)
TimesWidgetProvider.triggerUpdates(context)
} }
val suspendAppsLogic = SuspendAppsLogic(this) val suspendAppsLogic = SuspendAppsLogic(this)

View file

@ -0,0 +1,24 @@
/*
* 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.widget
import io.timelimit.android.data.model.WidgetCategory
data class TimesWidgetConfig (val categories: List<WidgetCategory>) {
val widgetCategoriesByWidgetId by lazy {
categories.groupBy { it.widgetId }.mapValues { it.value.map { it.categoryId }.toSet() }
}
}

View file

@ -0,0 +1,32 @@
/*
* 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.widget
sealed class TimesWidgetContent {
object UnconfiguredDevice: TimesWidgetContent()
data class NoChildUser(val canSwitchToDefaultUser: Boolean): TimesWidgetContent()
data class Categories(
val categories: List<Item>,
val canSwitchToDefaultUser: Boolean
): TimesWidgetContent() {
data class Item(
val categoryId: String,
val categoryName: String,
val level: Int,
val remainingTimeToday: Long?
)
}
}

View file

@ -0,0 +1,135 @@
/*
* 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.widget
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import io.timelimit.android.async.Threads
import io.timelimit.android.data.extensions.sortedCategories
import io.timelimit.android.data.model.UserType
import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.RealTime
import io.timelimit.android.logic.blockingreason.CategoryHandlingCache
object TimesWidgetContentLoader {
fun with(logic: AppLogic): LiveData<TimesWidgetContent> {
val database = logic.database
val realTimeLogic = logic.realTimeLogic
val realTime = RealTime.newInstance()
val categoryHandlingCache = CategoryHandlingCache()
val handler = Threads.mainThreadHandler
val deviceAndUserRelatedDataLive = database.derivedDataDao().getUserAndDeviceRelatedDataLive()
var deviceAndUserRelatedDataLiveLoaded = false
val batteryStatusLive = logic.platformIntegration.getBatteryStatusLive()
lateinit var timeModificationListener: () -> Unit
lateinit var updateByClockRunnable: Runnable
var isActive = false
val newResult = object: MediatorLiveData<TimesWidgetContent>() {
override fun onActive() {
super.onActive()
isActive = true
realTimeLogic.registerTimeModificationListener(timeModificationListener)
// ensure that the next update gets scheduled
updateByClockRunnable.run()
}
override fun onInactive() {
super.onInactive()
isActive = true
realTimeLogic.unregisterTimeModificationListener(timeModificationListener)
handler.removeCallbacks(updateByClockRunnable)
}
}
fun update() {
handler.removeCallbacks(updateByClockRunnable)
if (!deviceAndUserRelatedDataLiveLoaded) { return }
val deviceAndUserRelatedData = deviceAndUserRelatedDataLive.value
val userRelatedData = deviceAndUserRelatedData?.userRelatedData
val canSwitchToDefaultUser = deviceAndUserRelatedData?.deviceRelatedData?.canSwitchToDefaultUser ?: false
if (deviceAndUserRelatedData == null) {
newResult.value = TimesWidgetContent.UnconfiguredDevice
return
} else if (userRelatedData?.user?.type != UserType.Child) {
newResult.value = TimesWidgetContent.NoChildUser(
canSwitchToDefaultUser = canSwitchToDefaultUser
)
return
}
realTimeLogic.getRealTime(realTime)
categoryHandlingCache.reportStatus(
user = userRelatedData,
timeInMillis = realTime.timeInMillis,
batteryStatus = logic.platformIntegration.getBatteryStatus(),
shouldTrustTimeTemporarily = realTime.shouldTrustTimeTemporarily,
assumeCurrentDevice = true,
currentNetworkId = null, // not relevant here
hasPremiumOrLocalMode = false // not relevant here
)
var maxTime = Long.MAX_VALUE
val categories = userRelatedData.sortedCategories().map { (level, category) ->
val handling = categoryHandlingCache.get(categoryId = category.category.id)
maxTime = maxTime.coerceAtMost(handling.dependsOnMaxTime)
TimesWidgetContent.Categories.Item(
categoryId = category.category.id,
categoryName = category.category.title,
level = level,
remainingTimeToday = handling.remainingTime?.includingExtraTime
)
}
newResult.value = TimesWidgetContent.Categories(
categories = categories,
canSwitchToDefaultUser = canSwitchToDefaultUser
)
if (isActive && maxTime != Long.MAX_VALUE) {
val delay = maxTime - realTime.timeInMillis
handler.postDelayed(updateByClockRunnable, delay)
}
}
timeModificationListener = { update() }
updateByClockRunnable = Runnable { update() }
newResult.addSource(deviceAndUserRelatedDataLive) { deviceAndUserRelatedDataLiveLoaded = true; update() }
newResult.addSource(batteryStatusLive) { update() }
return newResult.ignoreUnchanged()
}
}

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 * 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 * it under the terms of the GNU General Public License as published by
@ -15,8 +15,8 @@
*/ */
package io.timelimit.android.ui.widget package io.timelimit.android.ui.widget
data class TimesWidgetItem( sealed class TimesWidgetItem {
val title: String, data class TextMessage(val textRessourceId: Int): TimesWidgetItem()
val level: Int, data class Category(val category: TimesWidgetContent.Categories.Item): TimesWidgetItem()
val remainingTimeToday: Long? object DefaultUserButton: TimesWidgetItem()
) }

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 * 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 * it under the terms of the GNU General Public License as published by
@ -15,107 +15,31 @@
*/ */
package io.timelimit.android.ui.widget package io.timelimit.android.ui.widget
import androidx.lifecycle.LiveData import io.timelimit.android.R
import androidx.lifecycle.MediatorLiveData
import io.timelimit.android.async.Threads
import io.timelimit.android.data.extensions.sortedCategories
import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.RealTime
import io.timelimit.android.logic.blockingreason.CategoryHandlingCache
object TimesWidgetItems { object TimesWidgetItems {
fun with(logic: AppLogic): LiveData<List<TimesWidgetItem>> { fun with(content: TimesWidgetContent, config: TimesWidgetConfig, appWidgetId: Int): List<TimesWidgetItem> = when (content) {
val database = logic.database is TimesWidgetContent.UnconfiguredDevice -> listOf(TimesWidgetItem.TextMessage(R.string.widget_msg_unconfigured))
val realTimeLogic = logic.realTimeLogic is TimesWidgetContent.NoChildUser -> {
val categoryHandlingCache = CategoryHandlingCache() val base = TimesWidgetItem.TextMessage(R.string.widget_msg_no_child)
val realTime = RealTime.newInstance()
val handler = Threads.mainThreadHandler
val deviceAndUserRelatedDataLive = database.derivedDataDao().getUserAndDeviceRelatedDataLive() if (content.canSwitchToDefaultUser) listOf(base, TimesWidgetItem.DefaultUserButton)
var deviceAndUserRelatedDataLiveLoaded = false else listOf(base)
val batteryStatusLive = logic.platformIntegration.getBatteryStatusLive()
lateinit var timeModificationListener: () -> Unit
lateinit var updateByClockRunnable: Runnable
var isActive = false
val newResult = object: MediatorLiveData<List<TimesWidgetItem>>() {
override fun onActive() {
super.onActive()
isActive = true
realTimeLogic.registerTimeModificationListener(timeModificationListener)
// ensure that the next update gets scheduled
updateByClockRunnable.run()
}
override fun onInactive() {
super.onInactive()
isActive = true
realTimeLogic.unregisterTimeModificationListener(timeModificationListener)
handler.removeCallbacks(updateByClockRunnable)
}
} }
is TimesWidgetContent.Categories -> {
val categoryFilter = config.widgetCategoriesByWidgetId[appWidgetId] ?: emptySet()
fun update() { val categoryItems = if (content.categories.isEmpty()) listOf(TimesWidgetItem.TextMessage(R.string.widget_msg_no_category))
handler.removeCallbacks(updateByClockRunnable) else if (categoryFilter.isEmpty()) content.categories.map { TimesWidgetItem.Category(it) }
else {
val filteredCategories = content.categories.filter { categoryFilter.contains(it.categoryId) }
if (!deviceAndUserRelatedDataLiveLoaded) { return } if (filteredCategories.isEmpty()) listOf(TimesWidgetItem.TextMessage(R.string.widget_msg_no_filtered_category))
else filteredCategories.map { TimesWidgetItem.Category(it) }
val deviceAndUserRelatedData = deviceAndUserRelatedDataLive.value
val userRelatedData = deviceAndUserRelatedData?.userRelatedData
if (userRelatedData == null) {
newResult.value = emptyList(); return
} }
realTimeLogic.getRealTime(realTime) if (content.canSwitchToDefaultUser) categoryItems + TimesWidgetItem.DefaultUserButton
else categoryItems
categoryHandlingCache.reportStatus(
user = userRelatedData,
timeInMillis = realTime.timeInMillis,
batteryStatus = logic.platformIntegration.getBatteryStatus(),
shouldTrustTimeTemporarily = realTime.shouldTrustTimeTemporarily,
assumeCurrentDevice = true,
currentNetworkId = null, // not relevant here
hasPremiumOrLocalMode = false // not relevant here
)
var maxTime = Long.MAX_VALUE
val list = userRelatedData.sortedCategories().map { (level, category) ->
val handling = categoryHandlingCache.get(categoryId = category.category.id)
maxTime = maxTime.coerceAtMost(handling.dependsOnMaxTime)
TimesWidgetItem(
title = category.category.title,
level = level,
remainingTimeToday = handling.remainingTime?.includingExtraTime
)
}
newResult.value = list
if (isActive && maxTime != Long.MAX_VALUE) {
val delay = maxTime - realTime.timeInMillis
handler.postDelayed(updateByClockRunnable, delay)
}
} }
timeModificationListener = { update() }
updateByClockRunnable = Runnable { update() }
newResult.addSource(deviceAndUserRelatedDataLive) { deviceAndUserRelatedDataLiveLoaded = true; update() }
newResult.addSource(batteryStatusLive) { update() }
return newResult.ignoreUnchanged()
} }
} }

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 * 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 * it under the terms of the GNU General Public License as published by
@ -17,13 +17,45 @@ package io.timelimit.android.ui.widget
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log
import android.widget.RemoteViews import android.widget.RemoteViews
import androidx.core.content.getSystemService
import io.timelimit.android.BuildConfig
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.integration.platform.android.BackgroundActionService
import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DefaultAppLogic
class TimesWidgetProvider: AppWidgetProvider() { class TimesWidgetProvider: AppWidgetProvider() {
companion object {
private const val LOG_TAG = "TimesWidgetProvider"
private fun handleUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
for (appWidgetId in appWidgetIds) {
val views = RemoteViews(context.packageName, R.layout.widget_times)
views.setRemoteAdapter(android.R.id.list, TimesWidgetService.intent(context, appWidgetId))
views.setPendingIntentTemplate(android.R.id.list, BackgroundActionService.getSwitchToDefaultUserIntent(context))
views.setEmptyView(android.R.id.list, android.R.id.empty)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
TimesWidgetService.notifyContentChanges(context)
}
fun triggerUpdates(context: Context) {
context.getSystemService<AppWidgetManager>()?.also { appWidgetManager ->
val appWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, TimesWidgetProvider::class.java))
handleUpdate(context, appWidgetManager, appWidgetIds)
}
}
}
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
super.onReceive(context, intent) super.onReceive(context, intent)
@ -34,13 +66,38 @@ class TimesWidgetProvider: AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds) super.onUpdate(context, appWidgetManager, appWidgetIds)
for (appWidgetId in appWidgetIds) { handleUpdate(context, appWidgetManager, appWidgetIds)
val views = RemoteViews(context.packageName, R.layout.widget_times) }
views.setRemoteAdapter(android.R.id.list, Intent(context, TimesWidgetService::class.java)) override fun onDeleted(context: Context, appWidgetIds: IntArray) {
views.setEmptyView(android.R.id.list, android.R.id.empty) super.onDeleted(context, appWidgetIds)
appWidgetManager.updateAppWidget(appWidgetId, views) val database = DefaultAppLogic.with(context).database
Threads.database.execute {
try {
database.widgetCategory().deleteByWidgetIds(appWidgetIds)
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "onDisabled", ex)
}
}
}
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
val database = DefaultAppLogic.with(context).database
Threads.database.execute {
try {
database.widgetCategory().deleteAll()
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "onDisabled", ex)
}
}
} }
} }
} }

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 * 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 * it under the terms of the GNU General Public License as published by
@ -17,101 +17,150 @@ package io.timelimit.android.ui.widget
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.view.View import android.view.View
import android.widget.RemoteViews import android.widget.RemoteViews
import android.widget.RemoteViewsService import android.widget.RemoteViewsService
import androidx.core.content.getSystemService
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.map
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.async.Threads import io.timelimit.android.async.Threads
import io.timelimit.android.livedata.mergeLiveDataWaitForValues
import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.ui.manage.child.category.CategoryItemLeftPadding import io.timelimit.android.ui.manage.child.category.CategoryItemLeftPadding
import io.timelimit.android.util.TimeTextUtil
class TimesWidgetService: RemoteViewsService() { class TimesWidgetService: RemoteViewsService() {
private val appWidgetManager: AppWidgetManager by lazy { AppWidgetManager.getInstance(this) } companion object {
private const val EXTRA_APP_WIDGET_ID = "appWidgetId"
private val categoriesLive: LiveData<List<TimesWidgetItem>> by lazy { fun intent(context: Context, appWidgetId: Int) = Intent(context, TimesWidgetService::class.java)
TimesWidgetItems.with(DefaultAppLogic.with(this)) .setData(Uri.parse("widget:$appWidgetId"))
.putExtra(EXTRA_APP_WIDGET_ID, appWidgetId)
fun notifyContentChanges(context: Context) {
context.getSystemService<AppWidgetManager>()?.also { appWidgetManager ->
val widgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, TimesWidgetProvider::class.java))
appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, android.R.id.list)
}
}
} }
private var categoriesInput: List<TimesWidgetItem> = emptyList() private val content: LiveData<Pair<TimesWidgetContent, TimesWidgetConfig>> by lazy {
private var categoriesCurrent: List<TimesWidgetItem> = categoriesInput val logic = DefaultAppLogic.with(this)
private val categoriesObserver = Observer<List<TimesWidgetItem>> { val content = TimesWidgetContentLoader.with(logic)
categoriesInput = it val config = logic.database.widgetCategory().queryLive().map { TimesWidgetConfig(it) }
val widgetIds = appWidgetManager.getAppWidgetIds(ComponentName(this, TimesWidgetProvider::class.java)) mergeLiveDataWaitForValues(content, config)
appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, android.R.id.list)
} }
private val factory = object : RemoteViewsFactory { private var observerCounter = 0
private var contentInput: Pair<TimesWidgetContent, TimesWidgetConfig>? = null
private val contentObserver = Observer<Pair<TimesWidgetContent, TimesWidgetConfig>> {
contentInput = it
notifyContentChanges(this)
}
private fun createFactory(appWidgetId: Int) = object : RemoteViewsFactory {
private var currentItems: List<TimesWidgetItem> = emptyList()
init { onDataSetChanged() }
override fun onCreate() { override fun onCreate() {
Threads.mainThreadHandler.post { categoriesLive.observeForever(categoriesObserver) } Threads.mainThreadHandler.post {
if (observerCounter < 0) throw IllegalStateException()
else if (observerCounter == 0) content.observeForever(contentObserver)
observerCounter++
}
} }
override fun onDestroy() { override fun onDestroy() {
Threads.mainThreadHandler.post { categoriesLive.removeObserver(categoriesObserver) } Threads.mainThreadHandler.post {
if (observerCounter <= 0) throw IllegalStateException()
else if (observerCounter == 1) content.removeObserver(contentObserver)
observerCounter--
}
} }
override fun onDataSetChanged() { override fun onDataSetChanged() {
categoriesCurrent = categoriesInput currentItems = contentInput?.let { TimesWidgetItems.with(it.first, it.second, appWidgetId) } ?: emptyList()
} }
override fun getCount(): Int = categoriesCurrent.size override fun getCount(): Int = currentItems.size
override fun getViewAt(position: Int): RemoteViews { override fun getViewAt(position: Int): RemoteViews {
if (position >= categoriesCurrent.size) { if (position >= currentItems.size) {
return RemoteViews(packageName, R.layout.widget_times_item) return RemoteViews(packageName, R.layout.widget_times_category_item)
} }
val category = categoriesCurrent[position] fun createCategoryItem(title: String?, subtitle: String, paddingLeft: Int) = RemoteViews(packageName, R.layout.widget_times_category_item).also { result ->
val result = RemoteViews(packageName, R.layout.widget_times_item) result.setTextViewText(R.id.title, title ?: "")
result.setTextViewText(R.id.subtitle, subtitle)
result.setTextViewText(R.id.title, category.title) result.setViewPadding(R.id.widgetInnerContainer, paddingLeft, 0, 0, 0)
result.setTextViewText( result.setViewVisibility(R.id.title, if (title != null) View.VISIBLE else View.GONE)
R.id.subtitle, result.setViewVisibility(R.id.topPadding, if (position == 0) View.VISIBLE else View.GONE)
if (category.remainingTimeToday == null) result.setViewVisibility(R.id.bottomPadding, if (position == count - 1) View.VISIBLE else View.GONE)
getString(R.string.manage_child_category_no_time_limits) }
else
TimeTextUtil.remaining(category.remainingTimeToday.toInt(), this@TimesWidgetService)
)
result.setViewPadding( val item = currentItems[position]
R.id.widgetInnerContainer,
// not much space here => / 2
CategoryItemLeftPadding.calculate(category.level, this@TimesWidgetService) / 2,
0, 0, 0
)
result.setViewVisibility(R.id.topPadding, if (position == 0) View.VISIBLE else View.GONE) return when (item) {
result.setViewVisibility(R.id.bottomPadding, if (position == count - 1) View.VISIBLE else View.GONE) is TimesWidgetItem.Category -> item.category.let { category ->
createCategoryItem(
title = if (category.remainingTimeToday == null)
getString(R.string.manage_child_category_no_time_limits_short)
else {
val remainingTimeToday = category.remainingTimeToday.coerceAtLeast(0) / (1000 * 60)
val minutes = remainingTimeToday % 60
val hours = remainingTimeToday / 60
return result if (hours == 0L) "$minutes m"
else "$hours h $minutes m"
},
subtitle = category.categoryName,
// not much space here => / 2
paddingLeft = CategoryItemLeftPadding.calculate(category.level, this@TimesWidgetService) / 2
)
}
is TimesWidgetItem.TextMessage -> createCategoryItem(null, getString(item.textRessourceId), 0)
is TimesWidgetItem.DefaultUserButton -> RemoteViews(packageName, R.layout.widget_times_button).also { result ->
result.setTextViewText(R.id.button, getString(R.string.manage_device_default_user_switch_btn))
result.setOnClickFillInIntent(R.id.button, Intent())
}
}
} }
override fun getLoadingView(): RemoteViews? { override fun getLoadingView(): RemoteViews? = null
return null
}
override fun getViewTypeCount(): Int { override fun getViewTypeCount(): Int = 2
return 1
}
override fun getItemId(position: Int): Long { override fun getItemId(position: Int): Long {
if (position >= categoriesCurrent.size) { if (position >= currentItems.size) {
return -(position.toLong()) return -(position.toLong())
} }
return categoriesCurrent[position].hashCode().toLong() val item = currentItems[position]
return when (item) {
is TimesWidgetItem.Category -> item.category.categoryId.hashCode()
else -> item.hashCode()
}.toLong()
} }
override fun hasStableIds(): Boolean { override fun hasStableIds(): Boolean = true
return true
}
} }
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = factory override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = createFactory(
intent.getIntExtra(EXTRA_APP_WIDGET_ID, 0)
)
} }

View file

@ -0,0 +1,51 @@
/*
* 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.widget.config
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import io.timelimit.android.R
class UnconfiguredDialogFragment: DialogFragment() {
companion object {
const val DIALOG_TAG = "UnconfiguredDialogFragment"
}
private val model: WidgetConfigModel by activityViewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
model.state.observe(this) {
if (!(it is WidgetConfigModel.State.Unconfigured)) dismissAllowingStateLoss()
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = AlertDialog.Builder(requireContext(), theme)
.setMessage(R.string.widget_msg_unconfigured)
.setPositiveButton(R.string.generic_ok) { _, _ -> model.userCancel() }
.create()
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
model.userCancel()
}
}

View file

@ -0,0 +1,78 @@
/*
* 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.widget.config
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.viewModels
import androidx.fragment.app.FragmentActivity
import io.timelimit.android.R
import io.timelimit.android.extensions.showSafe
class WidgetConfigActivity: FragmentActivity() {
private val model: WidgetConfigModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (model.state.value == WidgetConfigModel.State.WaitingForInit) {
model.init(
intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
?: AppWidgetManager.INVALID_APPWIDGET_ID
)
}
model.state.observe(this) { state ->
when (state) {
is WidgetConfigModel.State.WaitingForInit -> {/* ignore */}
is WidgetConfigModel.State.Working -> {/* ignore */}
is WidgetConfigModel.State.Unconfigured -> {
if (supportFragmentManager.findFragmentByTag(UnconfiguredDialogFragment.DIALOG_TAG) == null) {
UnconfiguredDialogFragment().showSafe(supportFragmentManager, UnconfiguredDialogFragment.DIALOG_TAG)
}
}
is WidgetConfigModel.State.ShowModeSelection -> {
if (supportFragmentManager.findFragmentByTag(WidgetConfigModeDialogFragment.DIALOG_TAG) == null) {
WidgetConfigModeDialogFragment().showSafe(supportFragmentManager, WidgetConfigModeDialogFragment.DIALOG_TAG)
}
}
is WidgetConfigModel.State.ShowCategorySelection -> {
if (supportFragmentManager.findFragmentByTag(WidgetConfigFilterDialogFragment.DIALOG_TAG) == null) {
WidgetConfigFilterDialogFragment().showSafe(supportFragmentManager, WidgetConfigFilterDialogFragment.DIALOG_TAG)
}
}
is WidgetConfigModel.State.Done -> {
setResult(
RESULT_OK,
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, state.appWidgetId)
)
finish()
}
is WidgetConfigModel.State.ErrorCancel -> {
Toast.makeText(this, R.string.error_general, Toast.LENGTH_SHORT).show()
finish()
}
is WidgetConfigModel.State.UserCancel -> finish()
}
}
setResult(RESULT_CANCELED)
}
}

View file

@ -0,0 +1,88 @@
/*
* 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.widget.config
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import io.timelimit.android.R
class WidgetConfigFilterDialogFragment: DialogFragment() {
companion object {
private const val STATE_CATEGORY_IDS = "categoryIds"
const val DIALOG_TAG = "WidgetConfigFilterDialogFragment"
}
private val model: WidgetConfigModel by activityViewModels()
private val selectedCategoryIds = mutableSetOf<String>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
model.state.value?.also { state ->
if (state is WidgetConfigModel.State.ShowCategorySelection) {
selectedCategoryIds.clear()
selectedCategoryIds.addAll(state.selectedFilterCategories)
}
}
savedInstanceState?.also {
selectedCategoryIds.clear()
selectedCategoryIds.addAll(it.getStringArray(STATE_CATEGORY_IDS) ?: emptyArray())
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putStringArray(STATE_CATEGORY_IDS, selectedCategoryIds.toTypedArray())
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val state = model.state.value
if (!(state is WidgetConfigModel.State.ShowCategorySelection)) return super.onCreateDialog(savedInstanceState)
return AlertDialog.Builder(requireContext(), theme)
.setMultiChoiceItems(
state.categories.map { it.title }.toTypedArray(),
state.categories.map { selectedCategoryIds.contains(it.id) }.toBooleanArray()
) { _, index, checked ->
val categoryId = state.categories[index].id
if (checked) selectedCategoryIds.add(categoryId) else selectedCategoryIds.remove(categoryId)
}
.setPositiveButton(R.string.wiazrd_next) { _, _ ->
if (selectedCategoryIds.isEmpty()) {
Toast.makeText(requireContext(), R.string.widget_config_error_filter_empty, Toast.LENGTH_SHORT).show()
model.userCancel()
} else model.selectFilterItems(selectedCategoryIds)
}
.create()
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
model.userCancel()
}
}

View file

@ -0,0 +1,77 @@
/*
* 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.widget.config
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import io.timelimit.android.R
class WidgetConfigModeDialogFragment: DialogFragment() {
companion object {
private const val STATE_SELECTION = "selection"
const val DIALOG_TAG = "WidgetConfigModeDialogFragment"
}
private val model: WidgetConfigModel by activityViewModels()
private var selection = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
model.state.value?.also {
if (it is WidgetConfigModel.State.ShowModeSelection) {
selection = if (it.selectedFilterCategories.isEmpty()) 0 else 1
}
}
savedInstanceState?.also { selection = it.getInt(STATE_SELECTION) }
model.state.observe(this) {
if (!(it is WidgetConfigModel.State.ShowModeSelection)) dismissAllowingStateLoss()
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(STATE_SELECTION, selection)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = AlertDialog.Builder(requireContext(), theme)
.setSingleChoiceItems(
arrayOf(
getString(R.string.widget_config_mode_all),
getString(R.string.widget_config_mode_filter)
),
selection
) { _, selectedItemIndex -> selection = selectedItemIndex }
.setPositiveButton(R.string.wiazrd_next) { _, _ ->
if (selection == 0) model.selectModeAll()
else model.selectModeFilter()
}
.create()
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
model.userCancel()
}
}

View file

@ -0,0 +1,165 @@
/*
* 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.widget.config
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.timelimit.android.BuildConfig
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.data.extensions.sortedCategories
import io.timelimit.android.data.model.Category
import io.timelimit.android.data.model.UserType
import io.timelimit.android.data.model.WidgetCategory
import io.timelimit.android.livedata.castDown
import io.timelimit.android.logic.DefaultAppLogic
import kotlinx.coroutines.launch
class WidgetConfigModel(application: Application): AndroidViewModel(application) {
companion object {
private const val LOG_TAG = "WidgetConfigModel"
}
private val stateInternal = MutableLiveData<State>().apply { value = State.WaitingForInit }
private val database = DefaultAppLogic.with(application).database
val state = stateInternal.castDown()
fun init(appWidgetId: Int) {
if (state.value != State.WaitingForInit) return
stateInternal.value = State.Working
viewModelScope.launch {
try {
val (deviceAndUserRelatedData, selectedFilterCategories) = Threads.database.executeAndWait {
database.runInTransaction {
val deviceAndUserRelatedData = database.derivedDataDao().getUserAndDeviceRelatedDataSync()
val selectedFilterCategories = database.widgetCategory().queryByWidgetIdSync(appWidgetId).toSet()
Pair(deviceAndUserRelatedData, selectedFilterCategories)
}
}
val isNoChildUser = deviceAndUserRelatedData?.userRelatedData?.user?.type != UserType.Child
val currentUserCategories = deviceAndUserRelatedData?.userRelatedData?.sortedCategories()?.map { it.second.category }
if (currentUserCategories == null || isNoChildUser) {
stateInternal.value = State.Unconfigured
return@launch
}
stateInternal.value = State.ShowModeSelection(appWidgetId, selectedFilterCategories, currentUserCategories)
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "selectModeAll", ex)
}
stateInternal.value = State.ErrorCancel
}
}
}
fun selectModeAll() {
val oldState = state.value
if (!(oldState is State.ShowModeSelection)) return
stateInternal.value = State.Working
viewModelScope.launch {
try {
Threads.database.executeAndWait {
database.widgetCategory().deleteByWidgetId(oldState.appWidgetId)
}
stateInternal.value = State.Done(appWidgetId = oldState.appWidgetId)
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "selectModeAll", ex)
}
stateInternal.value = State.ErrorCancel
}
}
}
fun selectModeFilter() {
val oldState = state.value
if (!(oldState is State.ShowModeSelection)) return
stateInternal.value = State.Working
stateInternal.value = State.ShowCategorySelection(
appWidgetId = oldState.appWidgetId,
selectedFilterCategories = oldState.selectedFilterCategories,
categories = oldState.categories
)
}
fun selectFilterItems(selectedCategoryIds: Set<String>) {
val oldState = state.value
if (!(oldState is State.ShowCategorySelection)) return
stateInternal.value = State.Working
viewModelScope.launch {
try {
Threads.database.executeAndWait {
val userAndDeviceRelatedData = database.derivedDataDao().getUserAndDeviceRelatedDataSync()
val currentCategoryIds = userAndDeviceRelatedData!!.userRelatedData!!.categoryById.keys
val categoriesToRemove = currentCategoryIds - selectedCategoryIds
val categoriesToAdd = selectedCategoryIds.filter { currentCategoryIds.contains(it) }
if (categoriesToRemove.isNotEmpty()) {
database.widgetCategory().deleteByWidgetIdAndCategoryIds(
oldState.appWidgetId, categoriesToRemove.toList()
)
}
if (categoriesToAdd.isNotEmpty()) {
database.widgetCategory().insert(
categoriesToAdd.toList().map { WidgetCategory(oldState.appWidgetId, it) }
)
}
}
stateInternal.value = State.Done(appWidgetId = oldState.appWidgetId)
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "selectModeAll", ex)
}
stateInternal.value = State.ErrorCancel
}
}
}
fun userCancel() {
stateInternal.value = State.UserCancel
}
sealed class State {
object WaitingForInit: State()
object Working: State()
data class ShowModeSelection(val appWidgetId: Int, val selectedFilterCategories: Set<String>, val categories: List<Category>): State()
data class ShowCategorySelection(val appWidgetId: Int, val selectedFilterCategories: Set<String>, val categories: List<Category>): State()
data class Done(val appWidgetId: Int): State()
object Unconfigured: State()
object UserCancel: State()
object ErrorCancel: State()
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

View file

@ -1,23 +1,23 @@
<?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.
This program is distributed in the hope that it will be useful, This program is free software: you can redistribute it and/or modify
but WITHOUT ANY WARRANTY; without even the implied warranty of it under the terms of the GNU General Public License as published by
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the the Free Software Foundation version 3 of the License.
GNU General Public License for more details.
You should have received a copy of the GNU General Public License This program is distributed in the hope that it will be useful,
along with this program. If not, see <https://www.gnu.org/licenses/>. 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 <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/widget_background"> android:background="@color/widgetBackground">
<ListView <ListView
android:divider="@color/transparent" android:divider="@color/transparent"
@ -26,13 +26,11 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:listSelector="@android:color/transparent"/> android:listSelector="@android:color/transparent"/>
<TextView <ProgressBar
android:id="@android:id/empty" android:id="@android:id/empty"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:gravity="center" style="?android:attr/progressBarStyleLarge"
android:text="@string/widget_empty_view" android:layout_gravity="center" />
android:textAppearance="?android:textAppearanceMedium"
android:textColor="@color/white"/>
</FrameLayout> </FrameLayout>

View file

@ -0,0 +1,33 @@
<?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/>.
-->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:background="@color/widget_background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="16dp"
android:paddingBottom="16dp"
android:paddingLeft="16dp"
android:paddingRight="16dp">
<Button
android:id="@+id/button"
tools:text="Action"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</FrameLayout>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -35,20 +35,21 @@
android:layout_height="12dp" /> android:layout_height="12dp" />
<TextView <TextView
tools:text="Erlaubte Apps" tools:text="3 m"
android:id="@+id/title" android:id="@+id/title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?android:textAppearanceMedium" android:textAppearance="?android:textAppearanceLarge"
android:textColor="@color/white"/> android:textStyle="bold"
android:textColor="@color/widgetText"/>
<TextView <TextView
tools:text="wenige Minuten verbleibend" tools:text="Erlaubte Apps"
android:id="@+id/subtitle" android:id="@+id/subtitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?android:textAppearanceSmall" android:textAppearance="?android:textAppearanceMedium"
android:textColor="@color/white"/> android:textColor="@color/widgetText"/>
<FrameLayout <FrameLayout
android:id="@+id/bottomPadding" android:id="@+id/bottomPadding"

View file

@ -0,0 +1,58 @@
<!--
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/>.
-->
<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="match_parent"
android:padding="16dp"
android:background="@color/widgetBackground">
<TextView
tools:ignore="HardcodedText"
android:text="no limit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:textAppearanceLarge"
android:textStyle="bold"
android:textColor="@color/widgetText"/>
<TextView
android:paddingBottom="8dp"
android:text="@string/setup_category_allowed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:textAppearanceMedium"
android:textColor="@color/widgetText"/>
<TextView
tools:ignore="HardcodedText"
android:text="30 m"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:textAppearanceLarge"
android:textStyle="bold"
android:textColor="@color/widgetText"/>
<TextView
android:text="@string/setup_category_games"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:textAppearanceMedium"
android:textColor="@color/widgetText"/>
</LinearLayout>

View file

@ -769,6 +769,7 @@
<string name="manage_child_tab_other">erweiterte Einstellungen</string> <string name="manage_child_tab_other">erweiterte Einstellungen</string>
<string name="manage_child_category_no_time_limits">Keine Zeitbegrenzung</string> <string name="manage_child_category_no_time_limits">Keine Zeitbegrenzung</string>
<string name="manage_child_category_no_time_limits_short">keine Begrenzung</string>
<string name="manage_child_categories_intro_title">Kategorien</string> <string name="manage_child_categories_intro_title">Kategorien</string>
<string name="manage_child_categories_intro_text"> <string name="manage_child_categories_intro_text">
@ -1608,10 +1609,13 @@
<string name="util_day_from_to">%s bis %s</string> <string name="util_day_from_to">%s bis %s</string>
<string name="widget_empty_view">Wenn dieses Gerät einem Kind zugeordnet <string name="widget_msg_unconfigured">Dieses Widget funktioniert nur auf beschränkten Geräten</string>
wird und dieses Kind Kategorien hat, dann sollten diese Kategorien <string name="widget_msg_no_child">Momentan hat dieses Gerät keinen Kind-Benutzer</string>
hier angezeigt werden. <string name="widget_msg_no_category">Das Kind hat keine Kategorien</string>
</string> <string name="widget_msg_no_filtered_category">Das Kind hat keine für das Widget ausgewählten Kategorien</string>
<string name="widget_config_mode_all">alle Kategorien anzeigen</string>
<string name="widget_config_mode_filter">sichtbare Kategorien filtern</string>
<string name="widget_config_error_filter_empty">Sie müssen mindestens eine Kategorie auswählen</string>
<string name="wiazrd_next">Weiter</string> <string name="wiazrd_next">Weiter</string>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -23,4 +23,7 @@
<color name="orange_text">#ff6f00</color> <color name="orange_text">#ff6f00</color>
<color name="text_green">#4caf50</color> <color name="text_green">#4caf50</color>
<color name="text_red">#f44</color> <color name="text_red">#f44</color>
<color name="widgetBackground">#000000</color>
<color name="widgetText">#ffffff</color>
</resources> </resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -17,7 +17,8 @@
<color name="colorPrimary">#009688</color> <color name="colorPrimary">#009688</color>
<color name="colorPrimaryDark">#00796B</color> <color name="colorPrimaryDark">#00796B</color>
<color name="colorAccent">#1976d2</color> <color name="colorAccent">#1976d2</color>
<color name="widget_background">#33000000</color> <color name="widgetBackground">#ffffff</color>
<color name="widgetText">#000000</color>
<color name="transparent">#00000000</color> <color name="transparent">#00000000</color>
<color name="gray">#9E9E9E</color> <color name="gray">#9E9E9E</color>

View file

@ -821,6 +821,7 @@
<string name="manage_child_tab_other">Advanced settings</string> <string name="manage_child_tab_other">Advanced settings</string>
<string name="manage_child_category_no_time_limits">no time limit</string> <string name="manage_child_category_no_time_limits">no time limit</string>
<string name="manage_child_category_no_time_limits_short">no limit</string>
<string name="manage_child_categories_intro_title">Categories</string> <string name="manage_child_categories_intro_title">Categories</string>
<string name="manage_child_categories_intro_text"> <string name="manage_child_categories_intro_text">
@ -1656,10 +1657,13 @@
<string name="util_day_from_to">%s to %s</string> <string name="util_day_from_to">%s to %s</string>
<string name="widget_empty_view">If this device is assigned to a child <string name="widget_msg_unconfigured">This Widget only works on limited devices</string>
and this child has got categories, then the categories should <string name="widget_msg_no_child">This device is not used by a child right now</string>
appear here. <string name="widget_msg_no_category">This child does not have any categories</string>
</string> <string name="widget_msg_no_filtered_category">For this child, no visible categories were chosen for this widget</string>
<string name="widget_config_mode_all">Show all Categories</string>
<string name="widget_config_mode_filter">Filter visible Categories</string>
<string name="widget_config_error_filter_empty">You must select at least one category</string>
<string name="wiazrd_next">Next</string> <string name="wiazrd_next">Next</string>

View file

@ -1,17 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?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.
This program is distributed in the hope that it will be useful, This program is free software: you can redistribute it and/or modify
but WITHOUT ANY WARRANTY; without even the implied warranty of it under the terms of the GNU General Public License as published by
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the the Free Software Foundation version 3 of the License.
GNU General Public License for more details.
You should have received a copy of the GNU General Public License This program is distributed in the hope that it will be useful,
along with this program. If not, see <https://www.gnu.org/licenses/>. 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/>.
--> -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="180dp" android:minWidth="180dp"
@ -19,4 +20,7 @@
android:updatePeriodMillis="0" android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:previewImage="@drawable/widget_preview" android:previewImage="@drawable/widget_preview"
android:configure="io.timelimit.android.ui.widget.config.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewLayout="@layout/widget_times_preview"
android:widgetCategory="home_screen" /> android:widgetCategory="home_screen" />