From 24710aa21ed4dca5c99a063809b6bd3b04ca664d Mon Sep 17 00:00:00 2001 From: Jonas Lochmann Date: Mon, 19 Aug 2019 00:00:00 +0000 Subject: [PATCH] Add time lock for parent users This allows restricting the times at which a parent can sign in --- .../22.json | 803 ++++++++++++++++++ .../io/timelimit/android/data/Migrations.kt | 6 + .../io/timelimit/android/data/RoomDatabase.kt | 5 +- .../timelimit/android/data/model/Category.kt | 21 + .../io/timelimit/android/data/model/User.kt | 20 +- .../timelimit/android/logic/AppSetupLogic.kt | 6 +- .../timelimit/android/logic/BlockingReason.kt | 16 +- .../timelimit/android/sync/actions/Actions.kt | 42 + .../sync/actions/dispatch/ParentAction.kt | 29 +- .../ui/login/LoginDialogFragmentModel.kt | 29 +- .../android/ui/login/NewLoginFragment.kt | 20 + .../blocked_times/BlockedTimeAreasFragment.kt | 184 +--- .../BlockedTimeAreasHelpDialog.kt | 18 + .../blocked_times/BlockedTimeAreasLogic.kt | 196 +++++ .../CopyBlockedTimeAreasDialogFragment.kt | 55 +- .../ui/manage/parent/ManageParentFragment.kt | 11 + .../ManageParentBlockedTimesFragment.kt | 173 ++++ ...ryResetParentBlockedTimesDialogFragment.kt | 66 ++ ...opy_blocked_time_areas_dialog_fragment.xml | 2 +- .../layout/fragment_blocked_time_areas.xml | 1 - ...ragment_blocked_time_areas_help_dialog.xml | 1 + .../res/layout/fragment_manage_parent.xml | 33 + .../manage_parent_blocked_times_fragment.xml | 89 ++ .../main/res/layout/new_login_fragment.xml | 4 + .../new_login_fragment_blocked_time.xml | 26 + ...ew_login_fragment_missing_trusted_time.xml | 26 + app/src/main/res/navigation/nav_graph.xml | 16 + app/src/main/res/values-de/strings-login.xml | 2 + .../strings-manage-parent-blocked-times.xml | 31 + app/src/main/res/values/strings-login.xml | 2 + .../strings-manage-parent-blocked-times.xml | 31 + 31 files changed, 1747 insertions(+), 217 deletions(-) create mode 100644 app/schemas/io.timelimit.android.data.RoomDatabase/22.json create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasLogic.kt rename app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/{copy => }/CopyBlockedTimeAreasDialogFragment.kt (71%) create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/blockedtimes/ManageParentBlockedTimesFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/blockedtimes/TryResetParentBlockedTimesDialogFragment.kt create mode 100644 app/src/main/res/layout/manage_parent_blocked_times_fragment.xml create mode 100644 app/src/main/res/layout/new_login_fragment_blocked_time.xml create mode 100644 app/src/main/res/layout/new_login_fragment_missing_trusted_time.xml create mode 100644 app/src/main/res/values-de/strings-manage-parent-blocked-times.xml create mode 100644 app/src/main/res/values/strings-manage-parent-blocked-times.xml diff --git a/app/schemas/io.timelimit.android.data.RoomDatabase/22.json b/app/schemas/io.timelimit.android.data.RoomDatabase/22.json new file mode 100644 index 0000000..460ec31 --- /dev/null +++ b/app/schemas/io.timelimit.android.data.RoomDatabase/22.json @@ -0,0 +1,803 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "016e1fa3596a77d5e527c3b3999f9a66", + "entities": [ + { + "tableName": "user", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `password` TEXT NOT NULL, `second_password_salt` TEXT NOT NULL, `type` TEXT NOT NULL, `timezone` TEXT NOT NULL, `disable_limits_until` INTEGER NOT NULL, `mail` TEXT NOT NULL, `current_device` TEXT NOT NULL, `category_for_not_assigned_apps` TEXT NOT NULL, `relax_primary_device` INTEGER NOT NULL, `mail_notification_flags` INTEGER NOT NULL, `blocked_times` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondPasswordSalt", + "columnName": "second_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeZone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disableLimitsUntil", + "columnName": "disable_limits_until", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mail", + "columnName": "mail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentDevice", + "columnName": "current_device", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryForNotAssignedApps", + "columnName": "category_for_not_assigned_apps", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relaxPrimaryDevice", + "columnName": "relax_primary_device", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mailNotificationFlags", + "columnName": "mail_notification_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockedTimes", + "columnName": "blocked_times", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `model` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `current_user_id` TEXT NOT NULL, `apps_version` TEXT NOT NULL, `network_time` TEXT NOT NULL, `current_protection_level` TEXT NOT NULL, `highest_permission_level` TEXT NOT NULL, `current_usage_stats_permission` TEXT NOT NULL, `highest_usage_stats_permission` TEXT NOT NULL, `current_notification_access_permission` TEXT NOT NULL, `highest_notification_access_permission` TEXT NOT NULL, `current_app_version` INTEGER NOT NULL, `highest_app_version` INTEGER NOT NULL, `tried_disabling_device_admin` INTEGER NOT NULL, `did_reboot` INTEGER NOT NULL, `had_manipulation` INTEGER NOT NULL, `had_manipulation_flags` INTEGER NOT NULL, `did_report_uninstall` INTEGER NOT NULL, `is_user_kept_signed_in` INTEGER NOT NULL, `show_device_connected` INTEGER NOT NULL, `default_user` TEXT NOT NULL, `default_user_timeout` INTEGER NOT NULL, `consider_reboot_manipulation` INTEGER NOT NULL, `current_overlay_permission` TEXT NOT NULL, `highest_overlay_permission` TEXT NOT NULL, `current_accessibility_service_permission` INTEGER NOT NULL, `was_accessibility_service_permission` INTEGER NOT NULL, `enable_activity_level_blocking` INTEGER NOT NULL, `q_or_later` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentUserId", + "columnName": "current_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installedAppsVersion", + "columnName": "apps_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkTime", + "columnName": "network_time", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentProtectionLevel", + "columnName": "current_protection_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestProtectionLevel", + "columnName": "highest_permission_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentUsageStatsPermission", + "columnName": "current_usage_stats_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestUsageStatsPermission", + "columnName": "highest_usage_stats_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentNotificationAccessPermission", + "columnName": "current_notification_access_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestNotificationAccessPermission", + "columnName": "highest_notification_access_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentAppVersion", + "columnName": "current_app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "highestAppVersion", + "columnName": "highest_app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manipulationTriedDisablingDeviceAdmin", + "columnName": "tried_disabling_device_admin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manipulationDidReboot", + "columnName": "did_reboot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hadManipulation", + "columnName": "had_manipulation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hadManipulationFlags", + "columnName": "had_manipulation_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "didReportUninstall", + "columnName": "did_report_uninstall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUserKeptSignedIn", + "columnName": "is_user_kept_signed_in", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showDeviceConnected", + "columnName": "show_device_connected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultUser", + "columnName": "default_user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultUserTimeout", + "columnName": "default_user_timeout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "considerRebootManipulation", + "columnName": "consider_reboot_manipulation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentOverlayPermission", + "columnName": "current_overlay_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestOverlayPermission", + "columnName": "highest_overlay_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessibilityServiceEnabled", + "columnName": "current_accessibility_service_permission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wasAccessibilityServiceEnabled", + "columnName": "was_accessibility_service_permission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enableActivityLevelBlocking", + "columnName": "enable_activity_level_blocking", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "qOrLater", + "columnName": "q_or_later", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, `title` TEXT NOT NULL, `launchable` INTEGER NOT NULL, `recommendation` TEXT NOT NULL, PRIMARY KEY(`device_id`, `package_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLaunchable", + "columnName": "launchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_app_device_id", + "unique": false, + "columnNames": [ + "device_id" + ], + "createSql": "CREATE INDEX `index_app_device_id` ON `${TABLE_NAME}` (`device_id`)" + }, + { + "name": "index_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "createSql": "CREATE INDEX `index_app_package_name` ON `${TABLE_NAME}` (`package_name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "category_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`category_id`, `package_name`))", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_category_app_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "createSql": "CREATE INDEX `index_category_app_category_id` ON `${TABLE_NAME}` (`category_id`)" + }, + { + "name": "index_category_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "createSql": "CREATE INDEX `index_category_app_package_name` ON `${TABLE_NAME}` (`package_name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `child_id` TEXT NOT NULL, `title` TEXT NOT NULL, `blocked_times` TEXT NOT NULL, `extra_time` INTEGER NOT NULL, `temporarily_blocked` INTEGER NOT NULL, `base_version` TEXT NOT NULL, `apps_version` TEXT NOT NULL, `rules_version` TEXT NOT NULL, `usedtimes_version` TEXT NOT NULL, `parent_category_id` TEXT NOT NULL, `block_all_notifications` INTEGER NOT NULL, `time_warnings` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "childId", + "columnName": "child_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedMinutesInWeek", + "columnName": "blocked_times", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extraTimeInMillis", + "columnName": "extra_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "temporarilyBlocked", + "columnName": "temporarily_blocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseVersion", + "columnName": "base_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assignedAppsVersion", + "columnName": "apps_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeLimitRulesVersion", + "columnName": "rules_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usedTimesVersion", + "columnName": "usedtimes_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentCategoryId", + "columnName": "parent_category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockAllNotifications", + "columnName": "block_all_notifications", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeWarnings", + "columnName": "time_warnings", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "used_time", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_of_epoch` INTEGER NOT NULL, `used_time` INTEGER NOT NULL, `category_id` TEXT NOT NULL, PRIMARY KEY(`category_id`, `day_of_epoch`))", + "fields": [ + { + "fieldPath": "dayOfEpoch", + "columnName": "day_of_epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedMillis", + "columnName": "used_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "day_of_epoch" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "time_limit_rule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `apply_to_extra_time_usage` INTEGER NOT NULL, `day_mask` INTEGER NOT NULL, `max_time` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "applyToExtraTimeUsage", + "columnName": "apply_to_extra_time_usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dayMask", + "columnName": "day_mask", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maximumTimeInMillis", + "columnName": "max_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "temporarily_allowed_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`device_id`, `package_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pending_sync_action", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence_number` INTEGER NOT NULL, `action` TEXT NOT NULL, `integrity` TEXT NOT NULL, `scheduled_for_upload` INTEGER NOT NULL, `type` TEXT NOT NULL, `user_id` TEXT NOT NULL, PRIMARY KEY(`sequence_number`))", + "fields": [ + { + "fieldPath": "sequenceNumber", + "columnName": "sequence_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encodedAction", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "integrity", + "columnName": "integrity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scheduledForUpload", + "columnName": "scheduled_for_upload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sequence_number" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_pending_sync_action_scheduled_for_upload", + "unique": false, + "columnNames": [ + "scheduled_for_upload" + ], + "createSql": "CREATE INDEX `index_pending_sync_action_scheduled_for_upload` ON `${TABLE_NAME}` (`scheduled_for_upload`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "app_activity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `app_package_name` TEXT NOT NULL, `activity_class_name` TEXT NOT NULL, `activity_title` TEXT NOT NULL, PRIMARY KEY(`device_id`, `app_package_name`, `activity_class_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appPackageName", + "columnName": "app_package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityClassName", + "columnName": "activity_class_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "activity_title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "app_package_name", + "activity_class_name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL, `id` TEXT NOT NULL, `first_notify_time` INTEGER NOT NULL, `dismissed` INTEGER NOT NULL, PRIMARY KEY(`type`, `id`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstNotifyTime", + "columnName": "first_notify_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDismissed", + "columnName": "dismissed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "type", + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "allowed_contact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `phone` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone", + "columnName": "phone", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"016e1fa3596a77d5e527c3b3999f9a66\")" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/Migrations.kt b/app/src/main/java/io/timelimit/android/data/Migrations.kt index 0ff6408..c2d3ee7 100644 --- a/app/src/main/java/io/timelimit/android/data/Migrations.kt +++ b/app/src/main/java/io/timelimit/android/data/Migrations.kt @@ -153,4 +153,10 @@ object DatabaseMigrations { database.execSQL("ALTER TABLE `device` ADD COLUMN `had_manipulation_flags` INTEGER NOT NULL DEFAULT 0") } } + + val MIGRATE_TO_V22 = object: Migration(21, 22) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `user` ADD COLUMN `blocked_times` TEXT NOT NULL DEFAULT \"\"") + } + } } diff --git a/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt b/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt index edfdd92..c9b0ad6 100644 --- a/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt +++ b/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt @@ -35,7 +35,7 @@ import io.timelimit.android.data.model.* AppActivity::class, Notification::class, AllowedContact::class -], version = 21) +], version = 22) abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { companion object { private val lock = Object() @@ -90,7 +90,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database DatabaseMigrations.MIGRATE_TO_V18, DatabaseMigrations.MIGRATE_TO_V19, DatabaseMigrations.MIGRATE_TO_V20, - DatabaseMigrations.MIGRATE_TO_V21 + DatabaseMigrations.MIGRATE_TO_V21, + DatabaseMigrations.MIGRATE_TO_V22 ) .build() } diff --git a/app/src/main/java/io/timelimit/android/data/model/Category.kt b/app/src/main/java/io/timelimit/android/data/model/Category.kt index 84e66b5..7257f0b 100644 --- a/app/src/main/java/io/timelimit/android/data/model/Category.kt +++ b/app/src/main/java/io/timelimit/android/data/model/Category.kt @@ -26,6 +26,7 @@ import io.timelimit.android.data.JsonSerializable import io.timelimit.android.data.customtypes.ImmutableBitmask import io.timelimit.android.data.customtypes.ImmutableBitmaskAdapter import io.timelimit.android.data.customtypes.ImmutableBitmaskJson +import java.util.* @Entity(tableName = "category") @TypeConverters(ImmutableBitmaskAdapter::class) @@ -177,4 +178,24 @@ object CategoryTimeWarnings { ) val durations = durationToBitIndex.keys +} + +fun ImmutableBitmask.withConfigCopiedToOtherDates(sourceDay: Int, targetDays: Set): ImmutableBitmask { + val result = dataNotToModify.clone() as BitSet + + val configForSelectedDay = result.get( + sourceDay * Category.MINUTES_PER_DAY, + (sourceDay + 1) * Category.MINUTES_PER_DAY + ) + + // update all days + targetDays.forEach { day -> + val startWriteIndex = day * Category.MINUTES_PER_DAY + + for (i in 0..(Category.MINUTES_PER_DAY - 1)) { + result[startWriteIndex + i] = configForSelectedDay[i] + } + } + + return ImmutableBitmask(result) } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/User.kt b/app/src/main/java/io/timelimit/android/data/model/User.kt index 1a1a22a..e06e018 100644 --- a/app/src/main/java/io/timelimit/android/data/model/User.kt +++ b/app/src/main/java/io/timelimit/android/data/model/User.kt @@ -20,10 +20,17 @@ import android.util.JsonWriter import androidx.room.* import io.timelimit.android.data.IdGenerator import io.timelimit.android.data.JsonSerializable +import io.timelimit.android.data.customtypes.ImmutableBitmask +import io.timelimit.android.data.customtypes.ImmutableBitmaskAdapter +import io.timelimit.android.data.customtypes.ImmutableBitmaskJson import io.timelimit.android.util.parseJsonArray +import java.util.* @Entity(tableName = "user") -@TypeConverters(UserTypeConverter::class) +@TypeConverters( + UserTypeConverter::class, + ImmutableBitmaskAdapter::class +) data class User( @PrimaryKey @ColumnInfo(name = "id") @@ -53,7 +60,9 @@ data class User( @ColumnInfo(name = "relax_primary_device") val relaxPrimaryDevice: Boolean, @ColumnInfo(name = "mail_notification_flags") - val mailNotificationFlags: Int + val mailNotificationFlags: Int, + @ColumnInfo(name = "blocked_times") + val blockedTimes: ImmutableBitmask ): JsonSerializable { companion object { private const val ID = "id" @@ -68,6 +77,7 @@ data class User( private const val CATEGORY_FOR_NOT_ASSIGNED_APPS = "categoryForNotAssignedApps" private const val RELAX_PRIMARY_DEVICE = "relaxPrimaryDevice" private const val MAIL_NOTIFICATION_FLAGS = "mailNotificationFlags" + private const val BLOCKED_TIMES = "blockedTimes" fun parse(reader: JsonReader): User { var id: String? = null @@ -82,6 +92,7 @@ data class User( var categoryForNotAssignedApps = "" var relaxPrimaryDevice = false var mailNotificationFlags = 0 + var blockedTimes = ImmutableBitmask(BitSet()) reader.beginObject() while (reader.hasNext()) { @@ -98,6 +109,7 @@ data class User( CATEGORY_FOR_NOT_ASSIGNED_APPS -> categoryForNotAssignedApps = reader.nextString() RELAX_PRIMARY_DEVICE -> relaxPrimaryDevice = reader.nextBoolean() MAIL_NOTIFICATION_FLAGS -> mailNotificationFlags = reader.nextInt() + BLOCKED_TIMES -> blockedTimes = ImmutableBitmaskJson.parse(reader.nextString(), Category.BLOCKED_MINUTES_IN_WEEK_LENGTH) else -> reader.skipValue() } } @@ -115,7 +127,8 @@ data class User( currentDevice = currentDevice!!, categoryForNotAssignedApps = categoryForNotAssignedApps, relaxPrimaryDevice = relaxPrimaryDevice, - mailNotificationFlags = mailNotificationFlags + mailNotificationFlags = mailNotificationFlags, + blockedTimes = blockedTimes ) } @@ -161,6 +174,7 @@ data class User( writer.name(CATEGORY_FOR_NOT_ASSIGNED_APPS).value(categoryForNotAssignedApps) writer.name(RELAX_PRIMARY_DEVICE).value(relaxPrimaryDevice) writer.name(MAIL_NOTIFICATION_FLAGS).value(mailNotificationFlags) + writer.name(BLOCKED_TIMES).value(ImmutableBitmaskJson.serialize(blockedTimes)) writer.endObject() } diff --git a/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt b/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt index a022549..485e2b1 100644 --- a/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt @@ -127,7 +127,8 @@ class AppSetupLogic(private val appLogic: AppLogic) { currentDevice = "", categoryForNotAssignedApps = "", relaxPrimaryDevice = false, - mailNotificationFlags = 0 + mailNotificationFlags = 0, + blockedTimes = ImmutableBitmask(BitSet()) ) appLogic.database.user().addUserSync(child) @@ -148,7 +149,8 @@ class AppSetupLogic(private val appLogic: AppLogic) { currentDevice = "", categoryForNotAssignedApps = "", relaxPrimaryDevice = false, - mailNotificationFlags = 0 + mailNotificationFlags = 0, + blockedTimes = ImmutableBitmask(BitSet()) ) appLogic.database.user().addUserSync(parent) diff --git a/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt b/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt index 9fb1bf3..a69e76e 100644 --- a/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt +++ b/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt @@ -278,7 +278,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { Log.d(LOG_TAG, "step 5") } - return Transformations.switchMap(getTrustedMinuteOfWeekLive(appLogic.timeApi, timeZone)) { + return Transformations.switchMap(getTrustedMinuteOfWeekLive(timeZone)) { trustedMinuteOfWeek -> if (category.blockedMinutesInWeek.dataNotToModify.isEmpty) { @@ -298,7 +298,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { Log.d(LOG_TAG, "step 6") } - return getTrustedDateLive(appLogic.timeApi, timeZone).switchMap { + return getTrustedDateLive(timeZone).switchMap { nowTrustedDate -> appLogic.database.timeLimitRules().getTimeLimitRulesByCategory(category.id).switchMap { @@ -371,7 +371,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { } } - private fun getTrustedMinuteOfWeekLive(api: TimeApi, timeZone: TimeZone): LiveData { + fun getTrustedMinuteOfWeekLive(timeZone: TimeZone): LiveData { val realTime = RealTime.newInstance() return object: LiveData() { @@ -395,11 +395,11 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { } fun scheduleUpdate() { - api.runDelayed(scheduledUpdateRunnable, 1000L /* every second */) + appLogic.timeApi.runDelayed(scheduledUpdateRunnable, 1000L /* every second */) } fun cancelScheduledUpdate() { - api.cancelScheduledAction(scheduledUpdateRunnable) + appLogic.timeApi.cancelScheduledAction(scheduledUpdateRunnable) } override fun onActive() { @@ -417,7 +417,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { }.ignoreUnchanged() } - private fun getTrustedDateLive(api: TimeApi, timeZone: TimeZone): LiveData { + private fun getTrustedDateLive(timeZone: TimeZone): LiveData { val realTime = RealTime.newInstance() return object: LiveData() { @@ -441,11 +441,11 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { } fun scheduleUpdate() { - api.runDelayed(scheduledUpdateRunnable, 1000L /* every second */) + appLogic.timeApi.runDelayed(scheduledUpdateRunnable, 1000L /* every second */) } fun cancelScheduledUpdate() { - api.cancelScheduledAction(scheduledUpdateRunnable) + appLogic.timeApi.cancelScheduledAction(scheduledUpdateRunnable) } override fun onActive() { diff --git a/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt b/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt index d9d3d8a..528330b 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt @@ -1475,6 +1475,48 @@ data class RenameChildAction(val childId: String, val newName: String): ParentAc } } +data class UpdateParentBlockedTimesAction(val parentId: String, val blockedTimes: ImmutableBitmask): ParentAction() { + companion object { + const val TYPE_VALUE = "UPDATE_PARENT_BLOCKED_TIMES" + private const val PARENT_ID = "parentId" + private const val BLOCKED_TIMES = "times" + } + + init { + IdGenerator.assertIdValid(parentId) + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(PARENT_ID).value(parentId) + writer.name(BLOCKED_TIMES).value(ImmutableBitmaskJson.serialize(blockedTimes)) + + writer.endObject() + } +} + +data class ResetParentBlockedTimesAction(val parentId: String): ParentAction() { + companion object { + const val TYPE_VALUE = "RESET_PARENT_BLOCKED_TIMES" + private const val PARENT_ID = "parentId" + } + + init { + IdGenerator.assertIdValid(parentId) + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(PARENT_ID).value(parentId) + + writer.endObject() + } +} + // child actions object ChildSignInAction: ChildAction() { private const val TYPE_VALUE = "CHILD_SIGN_IN" diff --git a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt index fbb7594..ee095f8 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt @@ -153,7 +153,8 @@ object LocalDatabaseParentActionDispatcher { currentDevice = "", categoryForNotAssignedApps = "", relaxPrimaryDevice = false, - mailNotificationFlags = 0 + mailNotificationFlags = 0, + blockedTimes = ImmutableBitmask(BitSet()) )) } is UpdateCategoryBlockedTimesAction -> { @@ -512,6 +513,32 @@ object LocalDatabaseParentActionDispatcher { null } + is UpdateParentBlockedTimesAction -> { + val userEntry = database.user().getUserByIdSync(action.parentId) + + if (userEntry?.type != UserType.Parent) { + throw IllegalArgumentException("no valid parent id") + } + + database.user().updateUserSync( + userEntry.copy( + blockedTimes = action.blockedTimes + ) + ) + } + is ResetParentBlockedTimesAction -> { + val userEntry = database.user().getUserByIdSync(action.parentId) + + if (userEntry?.type != UserType.Parent) { + throw IllegalArgumentException("no valid parent id") + } + + database.user().updateUserSync( + userEntry.copy( + blockedTimes = ImmutableBitmask(BitSet()) + ) + ) + } }.let { } database.setTransactionSuccessful() diff --git a/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt b/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt index 6123eec..c288285 100644 --- a/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt @@ -28,6 +28,7 @@ import io.timelimit.android.crypto.PasswordHashing import io.timelimit.android.data.model.User import io.timelimit.android.data.model.UserType import io.timelimit.android.livedata.* +import io.timelimit.android.logic.BlockingReasonUtil import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.sync.actions.ChildSignInAction import io.timelimit.android.sync.actions.SetDeviceUserAction @@ -38,10 +39,12 @@ import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.AuthenticatedUser import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.util.* class LoginDialogFragmentModel(application: Application): AndroidViewModel(application) { val selectedUserId = MutableLiveData().apply { value = null } private val logic = DefaultAppLogic.with(application) + private val blockingReasonUtil = BlockingReasonUtil(logic) private val users = logic.database.user().getAllUsersLive() private val isConnectedMode = logic.fullVersion.isLocalMode.invert() private val selectedUser = users.switchMap { users -> @@ -49,6 +52,7 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli users.find { it.id == userId } } } + private val trustedTime = selectedUser.switchMap { blockingReasonUtil.getTrustedMinuteOfWeekLive(TimeZone.getTimeZone(it?.timeZone ?: "GMT")) } private val currentDeviceUser = logic.deviceUserId private val isCheckingPassword = MutableLiveData().apply { value = false } private val wasPasswordWrong = MutableLiveData().apply { value = false } @@ -63,8 +67,7 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli when (selectedUser?.type) { UserType.Parent -> { val isAlreadyCurrentUser = currentDeviceUser.map { it == selectedUser.id }.ignoreUnchanged() - - isConnectedMode.switchMap { isConnectedMode -> + val loginScreen = isConnectedMode.switchMap { isConnectedMode -> isAlreadyCurrentUser.switchMap { isAlreadyCurrentUser -> isCheckingPassword.switchMap { isCheckingPassword -> wasPasswordWrong.map { wasPasswordWrong -> @@ -78,6 +81,26 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli } } } + + if (selectedUser.blockedTimes.dataNotToModify.isEmpty) { + loginScreen + } else { + logic.fullVersion.shouldProvideFullVersionFunctions.switchMap { hasPremium -> + if (hasPremium) { + trustedTime.switchMap { time -> + if (time == null) { + liveDataFromValue(ParentUserLoginMissingTrustedTime as LoginDialogStatus) + } else if (selectedUser.blockedTimes.dataNotToModify[time]) { + liveDataFromValue(ParentUserLoginBlockedTime as LoginDialogStatus) + } else { + loginScreen + } + } + } else { + loginScreen + } + } + } } UserType.Child -> { logic.fullVersion.shouldProvideFullVersionFunctions.switchMap { fullversion -> @@ -265,6 +288,8 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli sealed class LoginDialogStatus data class UserListLoginDialogStatus(val usersToShow: List): LoginDialogStatus() +object ParentUserLoginMissingTrustedTime: LoginDialogStatus() +object ParentUserLoginBlockedTime: LoginDialogStatus() data class ParentUserLogin( val isConnectedMode: Boolean, val isAlreadyCurrentDeviceUser: Boolean, diff --git a/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt b/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt index af26a10..b2aaac9 100644 --- a/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt @@ -44,6 +44,8 @@ class NewLoginFragment: DialogFragment() { private const val CHILD_ALREADY_CURRENT_USER = 3 private const val CHILD_AUTH = 4 private const val CHILD_LOGIN_REQUIRES_PREMIUM = 5 + private const val BLOCKED_LOGIN_TIME = 6 + private const val UNVERIFIED_TIME = 7 } private val model: LoginDialogFragmentModel by lazy { @@ -200,6 +202,24 @@ class NewLoginFragment: DialogFragment() { null } + ParentUserLoginMissingTrustedTime -> { + if (binding.switcher.displayedChild != UNVERIFIED_TIME) { + binding.switcher.setInAnimation(context!!, R.anim.wizard_open_step_in) + binding.switcher.setOutAnimation(context!!, R.anim.wizard_open_step_out) + binding.switcher.displayedChild = UNVERIFIED_TIME + } + + null + } + ParentUserLoginBlockedTime -> { + if (binding.switcher.displayedChild != BLOCKED_LOGIN_TIME) { + binding.switcher.setInAnimation(context!!, R.anim.wizard_open_step_in) + binding.switcher.setOutAnimation(context!!, R.anim.wizard_open_step_out) + binding.switcher.displayedChild = BLOCKED_LOGIN_TIME + } + + null + } is CanNotSignInChildHasNoPassword -> { if (binding.switcher.displayedChild != CHILD_MISSING_PASSWORD) { binding.switcher.setInAnimation(context!!, R.anim.wizard_open_step_in) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasFragment.kt index 783b5b9..68714b7 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasFragment.kt @@ -19,29 +19,24 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import io.timelimit.android.R import io.timelimit.android.data.Database import io.timelimit.android.data.customtypes.ImmutableBitmask import io.timelimit.android.data.model.Category +import io.timelimit.android.data.model.withConfigCopiedToOtherDates +import io.timelimit.android.livedata.map import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.sync.actions.UpdateCategoryBlockedTimesAction import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.manage.category.ManageCategoryFragmentArgs -import io.timelimit.android.ui.manage.category.blocked_times.copy.CopyBlockedTimeAreasDialogFragment import kotlinx.android.synthetic.main.fragment_blocked_time_areas.* -import java.util.* -class BlockedTimeAreasFragment : Fragment() { +class BlockedTimeAreasFragment : Fragment(), CopyBlockedTimeAreasDialogFragmentListener { companion object { fun newInstance(params: ManageCategoryFragmentArgs): BlockedTimeAreasFragment { val result = BlockedTimeAreasFragment() @@ -66,12 +61,12 @@ class BlockedTimeAreasFragment : Fragment() { return inflater.inflate(R.layout.fragment_blocked_time_areas, container, false) } - fun updateBlockedTimes(oldMask: BitSet, newMask: BitSet) { + fun updateBlockedTimes(oldMask: ImmutableBitmask, newMask: ImmutableBitmask) { if ( auth.tryDispatchParentAction( UpdateCategoryBlockedTimesAction( categoryId = params.categoryId, - blockedTimes = ImmutableBitmask(newMask) + blockedTimes = newMask ) ) ) { @@ -80,7 +75,7 @@ class BlockedTimeAreasFragment : Fragment() { auth.tryDispatchParentAction( UpdateCategoryBlockedTimesAction( categoryId = params.categoryId, - blockedTimes = ImmutableBitmask(oldMask) + blockedTimes = oldMask ) ) } @@ -88,168 +83,33 @@ class BlockedTimeAreasFragment : Fragment() { } } + override fun onCopyBlockedTimeAreasConfirmed(sourceDay: Int, targetDays: Set) { + category.value?.blockedMinutesInWeek?.let { current -> + updateBlockedTimes(current, current.withConfigCopiedToOtherDates(sourceDay, targetDays)) + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val layoutManager = GridLayoutManager(context, items.value!!.recommendColumns) - layoutManager.spanSizeLookup = SpanSizeLookup(items.value!!) - - val adapter = Adapter(items.value!!) - - items.observe(this, Observer { - layoutManager.spanCount = it.recommendColumns - layoutManager.spanSizeLookup = SpanSizeLookup(it) - adapter.items = it - }) - - recycler.adapter = adapter - recycler.layoutManager = layoutManager - - category.observe(this, Observer { adapter.blockedTimeAreas = it?.blockedMinutesInWeek?.dataNotToModify }) - btn_help.setOnClickListener { BlockedTimeAreasHelpDialog().show(fragmentManager!!) } btn_copy_to_other_days.setOnClickListener { if (auth.requestAuthenticationOrReturnTrue()) { - CopyBlockedTimeAreasDialogFragment.newInstance(params).apply { - setTargetFragment(this@BlockedTimeAreasFragment, 0) - }.show(fragmentManager!!) + CopyBlockedTimeAreasDialogFragment.newInstance(this@BlockedTimeAreasFragment).show(fragmentManager!!) } } - adapter.handlers = object: Handlers { - override fun onMinuteTileClick(time: MinuteTile) { - if (auth.requestAuthenticationOrReturnTrue()) { - val selectedMinuteOfWeek = adapter.selectedMinuteOfWeek - val blockedTimeAreas = adapter.blockedTimeAreas - - if (blockedTimeAreas == null) { - // nothing to work with - } else if (selectedMinuteOfWeek == null) { - adapter.selectedMinuteOfWeek = time.minuteOfWeek - } else if (selectedMinuteOfWeek == time.minuteOfWeek) { - adapter.selectedMinuteOfWeek = null - - val newBlockMask = blockedTimeAreas.clone() as BitSet - newBlockMask.set( - selectedMinuteOfWeek, - selectedMinuteOfWeek + items.value!!.minutesPerTile, - !newBlockMask[selectedMinuteOfWeek] - ) - - updateBlockedTimes(blockedTimeAreas, newBlockMask) - } else { - var times = selectedMinuteOfWeek to time.minuteOfWeek - adapter.selectedMinuteOfWeek = null - - // sort selected times - if (times.first > times.second) { - times = times.second to times.first - } - - // mark until the end - times = times.first to (times.second + items.value!!.minutesPerTile - 1) - - // get majority of current value - var allowed = 0 - var blocked = 0 - - for (i in times.first..times.second) { - if (blockedTimeAreas[i]) { - blocked++ - } else { - allowed++ - } - } - - val isMajorityBlocked = blocked > allowed - val shouldBlock = !isMajorityBlocked - - val newBlockMask = blockedTimeAreas.clone() as BitSet - newBlockMask.set(times.first, times.second + 1, shouldBlock) - - updateBlockedTimes(blockedTimeAreas, newBlockMask) - } - } - } - } - - run { - val spinnerAdapter = ArrayAdapter.createFromResource(context!!, R.array.days_of_week_array, android.R.layout.simple_spinner_item) - spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - spinner_day.adapter = spinnerAdapter - spinner_day.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val selectedDay = items.value!!.getDayOfPosition( - layoutManager.findFirstVisibleItemPosition() - ) - - if (selectedDay != position) { - layoutManager.scrollToPositionWithOffset( - items.value!!.getPositionOfItem( - DayHeader(position) - ), - 0 - ) - } - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - // ignore - } - } - } - - recycler.addOnScrollListener(object: RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - super.onScrollStateChanged(recyclerView, newState) - - if (newState == RecyclerView.SCROLL_STATE_IDLE) { - items.value?.let { items -> - try { - val selectedDay = items.getDayOfPosition( - layoutManager.findFirstVisibleItemPosition() - ) - - if (selectedDay != spinner_day.selectedItemPosition) { - spinner_day.setSelection(selectedDay, true) - } - } catch (ex: IllegalStateException) { - // ignore - } - } - } - } - }) - - // bind detailed mode - items.value = when (detailed_mode.isChecked) { - true -> MinuteOfWeekItems - false -> FifteenMinutesOfWeekItems - } - - detailed_mode.setOnCheckedChangeListener { _, isChecked -> - val oldValue = items.value - val newValue = when (isChecked) { - true -> MinuteOfWeekItems - false -> FifteenMinutesOfWeekItems - } - - if (oldValue != newValue) { - val currentlyVisiblePosition = layoutManager.findFirstVisibleItemPosition() - - if (currentlyVisiblePosition == RecyclerView.NO_POSITION) { - items.value = newValue - } else { - val currentlyVisibleItem = oldValue!!.getItemAtPosition(currentlyVisiblePosition) - val newVisiblePosition = newValue.getPositionOfItem(currentlyVisibleItem) - - items.value = newValue - layoutManager.scrollToPositionWithOffset(newVisiblePosition, 0) - } - } - } + BlockedTimeAreasLogic.init( + recycler = recycler, + daySpinner = spinner_day, + detailedModeCheckbox = detailed_mode, + requestAuthenticationOrReturnTrue = { auth.requestAuthenticationOrReturnTrue() }, + updateBlockedTimes = { a, b -> updateBlockedTimes(a, b) }, + currentData = category.map { it?.blockedMinutesInWeek }, + lifecycleOwner = this + ) } } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasHelpDialog.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasHelpDialog.kt index ebcb3eb..28c03f9 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasHelpDialog.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasHelpDialog.kt @@ -24,16 +24,34 @@ import androidx.fragment.app.FragmentManager import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.timelimit.android.R import io.timelimit.android.extensions.showSafe +import kotlinx.android.synthetic.main.fragment_blocked_time_areas_help_dialog.* class BlockedTimeAreasHelpDialog : BottomSheetDialogFragment() { companion object { private const val DIALOG_TAG = "r" + private const val FOR_USER = "forUser" + + fun newInstance(forUser: Boolean) = BlockedTimeAreasHelpDialog().apply { + arguments = Bundle().apply { + putBoolean(FOR_USER, forUser) + } + } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_blocked_time_areas_help_dialog, container, false) } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val forUser = arguments?.getBoolean(FOR_USER, false) + + if (forUser == true) { + text1.setText(R.string.manage_parent_blocked_times_description) + } + } + fun show(manager: FragmentManager) { showSafe(manager, DIALOG_TAG) } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasLogic.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasLogic.kt new file mode 100644 index 0000000..10de6f8 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/BlockedTimeAreasLogic.kt @@ -0,0 +1,196 @@ +/* + * TimeLimit Copyright 2019 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.blocked_times + +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.CheckBox +import android.widget.Spinner +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.timelimit.android.R +import io.timelimit.android.data.customtypes.ImmutableBitmask +import java.util.* + +object BlockedTimeAreasLogic { + fun init( + recycler: RecyclerView, + daySpinner: Spinner, + detailedModeCheckbox: CheckBox, + requestAuthenticationOrReturnTrue: () -> Boolean, + updateBlockedTimes: (ImmutableBitmask, ImmutableBitmask) -> Unit, + currentData: LiveData, + lifecycleOwner: LifecycleOwner + ) { + val context = recycler.context!! + + val items = MutableLiveData().apply { value = FifteenMinutesOfWeekItems } + val layoutManager = GridLayoutManager(context, items.value!!.recommendColumns) + layoutManager.spanSizeLookup = SpanSizeLookup(items.value!!) + + val adapter = Adapter(items.value!!) + + items.observe(lifecycleOwner, Observer { + layoutManager.spanCount = it.recommendColumns + layoutManager.spanSizeLookup = SpanSizeLookup(it) + adapter.items = it + }) + + recycler.adapter = adapter + recycler.layoutManager = layoutManager + + adapter.handlers = object: Handlers { + override fun onMinuteTileClick(time: MinuteTile) { + if (requestAuthenticationOrReturnTrue()) { + val selectedMinuteOfWeek = adapter.selectedMinuteOfWeek + val blockedTimeAreas = adapter.blockedTimeAreas + + if (blockedTimeAreas == null) { + // nothing to work with + } else if (selectedMinuteOfWeek == null) { + adapter.selectedMinuteOfWeek = time.minuteOfWeek + } else if (selectedMinuteOfWeek == time.minuteOfWeek) { + adapter.selectedMinuteOfWeek = null + + val newBlockMask = blockedTimeAreas.clone() as BitSet + newBlockMask.set( + selectedMinuteOfWeek, + selectedMinuteOfWeek + items.value!!.minutesPerTile, + !newBlockMask[selectedMinuteOfWeek] + ) + + updateBlockedTimes(ImmutableBitmask(blockedTimeAreas), ImmutableBitmask(newBlockMask)) + } else { + var times = selectedMinuteOfWeek to time.minuteOfWeek + adapter.selectedMinuteOfWeek = null + + // sort selected times + if (times.first > times.second) { + times = times.second to times.first + } + + // mark until the end + times = times.first to (times.second + items.value!!.minutesPerTile - 1) + + // get majority of current value + var allowed = 0 + var blocked = 0 + + for (i in times.first..times.second) { + if (blockedTimeAreas[i]) { + blocked++ + } else { + allowed++ + } + } + + val isMajorityBlocked = blocked > allowed + val shouldBlock = !isMajorityBlocked + + val newBlockMask = blockedTimeAreas.clone() as BitSet + newBlockMask.set(times.first, times.second + 1, shouldBlock) + + updateBlockedTimes(ImmutableBitmask(blockedTimeAreas), ImmutableBitmask(newBlockMask)) + } + } + } + } + + run { + val spinnerAdapter = ArrayAdapter.createFromResource(context, R.array.days_of_week_array, android.R.layout.simple_spinner_item) + spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + daySpinner.adapter = spinnerAdapter + daySpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val selectedDay = items.value!!.getDayOfPosition( + layoutManager.findFirstVisibleItemPosition() + ) + + if (selectedDay != position) { + layoutManager.scrollToPositionWithOffset( + items.value!!.getPositionOfItem( + DayHeader(position) + ), + 0 + ) + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + // ignore + } + } + } + + recycler.addOnScrollListener(object: RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + items.value?.let { items -> + try { + val selectedDay = items.getDayOfPosition( + layoutManager.findFirstVisibleItemPosition() + ) + + if (selectedDay != daySpinner.selectedItemPosition) { + daySpinner.setSelection(selectedDay, true) + } + } catch (ex: IllegalStateException) { + // ignore + } + } + } + } + }) + + // bind detailed mode + items.value = when (detailedModeCheckbox.isChecked) { + true -> MinuteOfWeekItems + false -> FifteenMinutesOfWeekItems + } + + detailedModeCheckbox.setOnCheckedChangeListener { _, isChecked -> + val oldValue = items.value + val newValue = when (isChecked) { + true -> MinuteOfWeekItems + false -> FifteenMinutesOfWeekItems + } + + if (oldValue != newValue) { + val currentlyVisiblePosition = layoutManager.findFirstVisibleItemPosition() + + if (currentlyVisiblePosition == RecyclerView.NO_POSITION) { + items.value = newValue + } else { + val currentlyVisibleItem = oldValue!!.getItemAtPosition(currentlyVisiblePosition) + val newVisiblePosition = newValue.getPositionOfItem(currentlyVisibleItem) + + items.value = newValue + layoutManager.scrollToPositionWithOffset(newVisiblePosition, 0) + } + } + } + + // loading data + currentData.observe(lifecycleOwner, Observer { adapter.blockedTimeAreas = it?.dataNotToModify }) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/copy/CopyBlockedTimeAreasDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/CopyBlockedTimeAreasDialogFragment.kt similarity index 71% rename from app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/copy/CopyBlockedTimeAreasDialogFragment.kt rename to app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/CopyBlockedTimeAreasDialogFragment.kt index ee90c25..50a6a9e 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/copy/CopyBlockedTimeAreasDialogFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/blocked_times/CopyBlockedTimeAreasDialogFragment.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package io.timelimit.android.ui.manage.category.blocked_times.copy +package io.timelimit.android.ui.manage.category.blocked_times import android.os.Bundle @@ -21,38 +21,35 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.CheckedTextView +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Observer import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.timelimit.android.R -import io.timelimit.android.coroutines.runAsync -import io.timelimit.android.data.model.Category import io.timelimit.android.data.model.UserType import io.timelimit.android.databinding.CopyBlockedTimeAreasDialogFragmentBinding import io.timelimit.android.extensions.showSafe -import io.timelimit.android.livedata.waitForNullableValue -import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.manage.category.ManageCategoryFragmentArgs -import io.timelimit.android.ui.manage.category.blocked_times.BlockedTimeAreasFragment -import java.util.* class CopyBlockedTimeAreasDialogFragment : BottomSheetDialogFragment() { companion object { private const val TAG = "cbtadf" private const val SELECTED_START_DAY = "ssd" - fun newInstance(params: ManageCategoryFragmentArgs) = CopyBlockedTimeAreasDialogFragment().apply { - arguments = params.toBundle() + fun newInstance(target: Fragment) = CopyBlockedTimeAreasDialogFragment().apply { + setTargetFragment(target, 0) } } - val params: ManageCategoryFragmentArgs by lazy { ManageCategoryFragmentArgs.fromBundle(arguments!!) } var selectedStartDayIndex = -1 val auth: ActivityViewModel by lazy { (activity as ActivityViewModelHolder).getActivityViewModel() } + val target: CopyBlockedTimeAreasDialogFragmentListener by lazy { + targetFragment as CopyBlockedTimeAreasDialogFragmentListener + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -126,35 +123,19 @@ class CopyBlockedTimeAreasDialogFragment : BottomSheetDialogFragment() { bindSecondPage() binding.saveButton.setOnClickListener { - val logic = DefaultAppLogic.with(context!!) + val targetDays = mutableSetOf() - runAsync { - val current = logic.database.category().getCategoryByChildIdAndId(params.childId, params.categoryId).waitForNullableValue() - ?: return@runAsync - - val newBlockedTimes = current.blockedMinutesInWeek.dataNotToModify.clone() as BitSet - - val configForSelectedDay = newBlockedTimes.get( - selectedStartDayIndex * Category.MINUTES_PER_DAY, - (selectedStartDayIndex + 1) * Category.MINUTES_PER_DAY - ) - - // update all days - dayCheckboxes.forEachIndexed { day, checkBox -> - if (checkBox.isChecked) { - val startWriteIndex = day * Category.MINUTES_PER_DAY - - for (i in 0..(Category.MINUTES_PER_DAY - 1)) { - newBlockedTimes[startWriteIndex + i] = configForSelectedDay[i] - } - } + dayCheckboxes.forEachIndexed { day, checkBox -> + if (checkBox.isChecked && day != selectedStartDayIndex) { + targetDays.add(day) } - - // apply - val target = targetFragment as BlockedTimeAreasFragment - target.updateBlockedTimes(current.blockedMinutesInWeek.dataNotToModify, newBlockedTimes) } + target.onCopyBlockedTimeAreasConfirmed( + sourceDay = selectedStartDayIndex, + targetDays = targetDays + ) + dismissAllowingStateLoss() } @@ -168,3 +149,7 @@ class CopyBlockedTimeAreasDialogFragment : BottomSheetDialogFragment() { fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, TAG) } + +interface CopyBlockedTimeAreasDialogFragmentListener { + fun onCopyBlockedTimeAreasConfirmed(sourceDay: Int, targetDays: Set) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt index 502d5b5..831bbc3 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt @@ -135,6 +135,16 @@ class ManageParentFragment : Fragment(), FragmentWithCustomTitle { R.id.manageParentFragment ) } + + override fun onManageBlockedTimesClicked() { + navigation.safeNavigate( + ManageParentFragmentDirections. + actionManageParentFragmentToManageParentBlockedTimesFragment( + params.parentId + ), + R.id.manageParentFragment + ) + } } return binding.root @@ -147,4 +157,5 @@ interface ManageParentFragmentHandlers { fun onChangePasswordClicked() fun onRestorePasswordClicked() fun onLinkMailClicked() + fun onManageBlockedTimesClicked() } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/blockedtimes/ManageParentBlockedTimesFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/blockedtimes/ManageParentBlockedTimesFragment.kt new file mode 100644 index 0000000..bef0d7f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/blockedtimes/ManageParentBlockedTimesFragment.kt @@ -0,0 +1,173 @@ +/* + * TimeLimit Copyright 2019 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.blockedtimes + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.LiveData +import com.google.android.material.snackbar.Snackbar +import io.timelimit.android.R +import io.timelimit.android.data.customtypes.ImmutableBitmask +import io.timelimit.android.data.model.User +import io.timelimit.android.data.model.withConfigCopiedToOtherDates +import io.timelimit.android.databinding.ManageParentBlockedTimesFragmentBinding +import io.timelimit.android.livedata.liveDataFromValue +import io.timelimit.android.livedata.map +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.sync.actions.UpdateParentBlockedTimesAction +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.ui.main.ActivityViewModelHolder +import io.timelimit.android.ui.main.AuthenticationFab +import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.manage.category.blocked_times.* +import kotlinx.android.synthetic.main.fragment_blocked_time_areas.* +import java.util.* + +class ManageParentBlockedTimesFragment : Fragment(), FragmentWithCustomTitle, CopyBlockedTimeAreasDialogFragmentListener { + companion object { + private const val MINUTES_PER_DAY = 60 * 24 + private const val MAX_BLOCKED_MINUTES_PER_DAY = 60 * 18 + 1 + } + + private val params: ManageParentBlockedTimesFragmentArgs by lazy { + ManageParentBlockedTimesFragmentArgs.fromBundle(arguments!!) + } + + private val authActivity: ActivityViewModelHolder by lazy { + activity!! as ActivityViewModelHolder + } + + private val auth: ActivityViewModel by lazy { + authActivity.getActivityViewModel() + } + + private val parent: LiveData by lazy { + DefaultAppLogic.with(context!!).database.user().getParentUserByIdLive(params.parentUserId) + } + + override fun getCustomTitle(): LiveData = parent.map { it?.name } + + override fun onCopyBlockedTimeAreasConfirmed(sourceDay: Int, targetDays: Set) { + parent.value?.blockedTimes?.let { current -> + updateBlockedTimes(current, current.withConfigCopiedToOtherDates(sourceDay, targetDays)) + } + } + + private fun validateBlockedTimeAreas(newMask: BitSet): Boolean { + for (day in 0 until 7) { + var blocked = 0 + + for (minute in 0 until MINUTES_PER_DAY) { + if (newMask[day * MINUTES_PER_DAY + minute]) { + blocked++ + } + } + + if (blocked >= MAX_BLOCKED_MINUTES_PER_DAY) { + return false + } + } + + return true + } + + private fun updateBlockedTimes(oldMask: ImmutableBitmask, newMask: ImmutableBitmask) { + if (!validateBlockedTimeAreas(newMask.dataNotToModify)) { + Snackbar.make(coordinator, R.string.manage_parent_lockout_hour_rule, Snackbar.LENGTH_LONG).show() + + return + } + + if ( + auth.tryDispatchParentAction( + UpdateParentBlockedTimesAction( + parentId = params.parentUserId, + blockedTimes = newMask + ) + ) + ) { + Snackbar.make(coordinator, R.string.blocked_time_areas_snackbar_modified, Snackbar.LENGTH_SHORT) + .setAction(R.string.generic_undo) { + auth.tryDispatchParentAction( + UpdateParentBlockedTimesAction( + parentId = params.parentUserId, + blockedTimes = oldMask + ) + ) + } + .show() + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val binding = ManageParentBlockedTimesFragmentBinding.inflate(inflater, container, false) + + // auth button + AuthenticationFab.manageAuthenticationFab( + fab = binding.fab, + fragment = this, + shouldHighlight = auth.shouldHighlightAuthenticationButton, + authenticatedUser = auth.authenticatedUser, + doesSupportAuth = liveDataFromValue(true) + ) + + binding.fab.setOnClickListener { authActivity.showAuthenticationScreen() } + + // dispatching + fun requestAuthenticationOrReturnTrue(): Boolean { + if (!auth.requestAuthenticationOrReturnTrue()) { + return false + } + + val authenticatedUser = auth.authenticatedUser.value?.second?.id ?: return false + val targetUser = params.parentUserId + + if (authenticatedUser == targetUser) { + return true + } else { + TryResetParentBlockedTimesDialogFragment.newInstance(parentUserId = params.parentUserId).show(fragmentManager!!) + + return false + } + } + + // UI + binding.btnHelp.setOnClickListener { + BlockedTimeAreasHelpDialog.newInstance(forUser = true).show(fragmentManager!!) + } + + binding.btnCopyToOtherDays.setOnClickListener { + if (requestAuthenticationOrReturnTrue()) { + CopyBlockedTimeAreasDialogFragment.newInstance(this@ManageParentBlockedTimesFragment).show(fragmentManager!!) + } + } + + BlockedTimeAreasLogic.init( + recycler = binding.recycler, + daySpinner = binding.spinnerDay, + detailedModeCheckbox = binding.detailedMode, + requestAuthenticationOrReturnTrue = { requestAuthenticationOrReturnTrue() }, + updateBlockedTimes = { a, b -> updateBlockedTimes(a, b) }, + currentData = parent.map { it?.blockedTimes }, + lifecycleOwner = this + ) + + return binding.root + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/blockedtimes/TryResetParentBlockedTimesDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/blockedtimes/TryResetParentBlockedTimesDialogFragment.kt new file mode 100644 index 0000000..0a8d58e --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/blockedtimes/TryResetParentBlockedTimesDialogFragment.kt @@ -0,0 +1,66 @@ +/* + * TimeLimit Copyright 2019 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.blockedtimes + +import android.app.Dialog +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import io.timelimit.android.R +import io.timelimit.android.data.model.UserType +import io.timelimit.android.extensions.showSafe +import io.timelimit.android.sync.actions.ResetParentBlockedTimesAction +import io.timelimit.android.ui.main.ActivityViewModelHolder + +class TryResetParentBlockedTimesDialogFragment: DialogFragment() { + companion object { + private const val DIALOG_TAG = "TryResetParentBlockedTimesDialogFragment" + private const val PARENT_USER_ID = "parentUserId" + + fun newInstance(parentUserId: String) = TryResetParentBlockedTimesDialogFragment().apply { + arguments = Bundle().apply { + putString(PARENT_USER_ID, parentUserId) + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val parentUserId = arguments!!.getString(PARENT_USER_ID)!! + val auth = (activity!! as ActivityViewModelHolder).getActivityViewModel() + + auth.authenticatedUser.observe(this, Observer { + if (it?.second?.type != UserType.Parent) { + dismissAllowingStateLoss() + } + }) + + return AlertDialog.Builder(context!!, theme) + .setMessage(R.string.manage_parent_blocked_times_info) + .setPositiveButton(R.string.manage_parent_blocked_action_reset) { _, _ -> + auth.tryDispatchParentAction( + ResetParentBlockedTimesAction( + parentId = parentUserId + ) + ) + } + .setNegativeButton(R.string.generic_cancel, null) + .create() + } + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/res/layout/copy_blocked_time_areas_dialog_fragment.xml b/app/src/main/res/layout/copy_blocked_time_areas_dialog_fragment.xml index 12bfc7c..d348ba6 100644 --- a/app/src/main/res/layout/copy_blocked_time_areas_dialog_fragment.xml +++ b/app/src/main/res/layout/copy_blocked_time_areas_dialog_fragment.xml @@ -14,7 +14,7 @@ --> + tools:context="io.timelimit.android.ui.manage.category.blocked_times.CopyBlockedTimeAreasDialogFragment"> diff --git a/app/src/main/res/layout/fragment_blocked_time_areas_help_dialog.xml b/app/src/main/res/layout/fragment_blocked_time_areas_help_dialog.xml index 8916241..cec5f94 100644 --- a/app/src/main/res/layout/fragment_blocked_time_areas_help_dialog.xml +++ b/app/src/main/res/layout/fragment_blocked_time_areas_help_dialog.xml @@ -27,6 +27,7 @@ android:text="@string/generic_help" /> + + + + + + + + + + + + diff --git a/app/src/main/res/layout/manage_parent_blocked_times_fragment.xml b/app/src/main/res/layout/manage_parent_blocked_times_fragment.xml new file mode 100644 index 0000000..219dfc6 --- /dev/null +++ b/app/src/main/res/layout/manage_parent_blocked_times_fragment.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + +