diff --git a/app/schemas/io.timelimit.android.data.RoomDatabase/34.json b/app/schemas/io.timelimit.android.data.RoomDatabase/34.json new file mode 100644 index 0000000..1791302 --- /dev/null +++ b/app/schemas/io.timelimit.android.data.RoomDatabase/34.json @@ -0,0 +1,1190 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "8315d5be946b4479b154515fdf0c2b4c", + "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, `flags` INTEGER 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": "obsoleteBlockedTimes", + "columnName": "blocked_times", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "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 IF NOT EXISTS `index_app_device_id` ON `${TABLE_NAME}` (`device_id`)" + }, + { + "name": "index_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `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 IF NOT EXISTS `index_category_app_category_id` ON `${TABLE_NAME}` (`category_id`)" + }, + { + "name": "index_category_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `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, `extra_time_day` INTEGER NOT NULL, `temporarily_blocked` INTEGER NOT NULL, `temporarily_blocked_end_time` INTEGER NOT NULL, `base_version` TEXT NOT NULL, `apps_version` TEXT NOT NULL, `rules_version` TEXT NOT NULL, `usedtimes_version` TEXT NOT NULL, `tasks_version` TEXT NOT NULL DEFAULT '', `parent_category_id` TEXT NOT NULL, `block_all_notifications` INTEGER NOT NULL, `time_warnings` INTEGER NOT NULL, `min_battery_charging` INTEGER NOT NULL, `min_battery_mobile` INTEGER NOT NULL, `sort` INTEGER NOT NULL, `disable_limits_until` 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": "extraTimeDay", + "columnName": "extra_time_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "temporarilyBlocked", + "columnName": "temporarily_blocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "temporarilyBlockedEndTime", + "columnName": "temporarily_blocked_end_time", + "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": "tasksVersion", + "columnName": "tasks_version", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "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 + }, + { + "fieldPath": "minBatteryLevelWhileCharging", + "columnName": "min_battery_charging", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minBatteryLevelMobile", + "columnName": "min_battery_mobile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableLimitsUntil", + "columnName": "disable_limits_until", + "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, `start_time_of_day` INTEGER NOT NULL, `end_time_of_day` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `day_of_epoch`, `start_time_of_day`, `end_time_of_day`))", + "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 + }, + { + "fieldPath": "startTimeOfDay", + "columnName": "start_time_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimeOfDay", + "columnName": "end_time_of_day", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "day_of_epoch", + "start_time_of_day", + "end_time_of_day" + ], + "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, `start_minute_of_day` INTEGER NOT NULL, `end_minute_of_day` INTEGER NOT NULL, `session_duration_milliseconds` INTEGER NOT NULL, `session_pause_milliseconds` 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 + }, + { + "fieldPath": "startMinuteOfDay", + "columnName": "start_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMinuteOfDay", + "columnName": "end_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionDurationMilliseconds", + "columnName": "session_duration_milliseconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionPauseMilliseconds", + "columnName": "session_pause_milliseconds", + "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 IF NOT EXISTS `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": [] + }, + { + "tableName": "user_key", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `key` BLOB NOT NULL, `last_use` INTEGER NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `user`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "key", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUse", + "columnName": "last_use", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_user_key_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_user_key_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "user", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "session_duration", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `max_session_duration` INTEGER NOT NULL, `session_pause_duration` INTEGER NOT NULL, `start_minute_of_day` INTEGER NOT NULL, `end_minute_of_day` INTEGER NOT NULL, `last_usage` INTEGER NOT NULL, `last_session_duration` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `max_session_duration`, `session_pause_duration`, `start_minute_of_day`, `end_minute_of_day`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSessionDuration", + "columnName": "max_session_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionPauseDuration", + "columnName": "session_pause_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startMinuteOfDay", + "columnName": "start_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMinuteOfDay", + "columnName": "end_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUsage", + "columnName": "last_usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSessionDuration", + "columnName": "last_session_duration", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "max_session_duration", + "session_pause_duration", + "start_minute_of_day", + "end_minute_of_day" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "session_duration_index_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `session_duration_index_category_id` ON `${TABLE_NAME}` (`category_id`)" + } + ], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "user_limit_login_category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `user`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "user_limit_login_category_index_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `user_limit_login_category_index_category_id` ON `${TABLE_NAME}` (`category_id`)" + } + ], + "foreignKeys": [ + { + "table": "user", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "category_network_id", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `network_item_id` TEXT NOT NULL, `hashed_network_id` TEXT NOT NULL, PRIMARY KEY(`category_id`, `network_item_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkItemId", + "columnName": "network_item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hashedNetworkId", + "columnName": "hashed_network_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "network_item_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "child_task", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`task_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `task_title` TEXT NOT NULL, `extra_time_duration` INTEGER NOT NULL, `pending_request` INTEGER NOT NULL, `last_grant_timestamp` INTEGER NOT NULL, PRIMARY KEY(`task_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "taskTitle", + "columnName": "task_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extraTimeDuration", + "columnName": "extra_time_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRequest", + "columnName": "pending_request", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastGrantTimestamp", + "columnName": "last_grant_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "task_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "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, '8315d5be946b4479b154515fdf0c2b4c')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/Database.kt b/app/src/main/java/io/timelimit/android/data/Database.kt index e698665..ac46dfe 100644 --- a/app/src/main/java/io/timelimit/android/data/Database.kt +++ b/app/src/main/java/io/timelimit/android/data/Database.kt @@ -39,6 +39,7 @@ interface Database { fun derivedDataDao(): DerivedDataDao fun userLimitLoginCategoryDao(): UserLimitLoginCategoryDao fun categoryNetworkId(): CategoryNetworkIdDao + fun childTasks(): ChildTaskDao fun runInTransaction(block: () -> T): T fun runInUnobservedTransaction(block: () -> T): T 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 e23c5a8..0830dc5 100644 --- a/app/src/main/java/io/timelimit/android/data/Migrations.kt +++ b/app/src/main/java/io/timelimit/android/data/Migrations.kt @@ -243,4 +243,11 @@ object DatabaseMigrations { database.execSQL("ALTER TABLE `category` ADD COLUMN `disable_limits_until` INTEGER NOT NULL DEFAULT 0") } } + + val MIGRATE_TO_V34 = object: Migration(33, 34) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `child_task` (`task_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `task_title` TEXT NOT NULL, `extra_time_duration` INTEGER NOT NULL, `pending_request` INTEGER NOT NULL, `last_grant_timestamp` INTEGER NOT NULL, PRIMARY KEY(`task_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )") + database.execSQL("ALTER TABLE `category` ADD COLUMN `tasks_version` TEXT NOT NULL DEFAULT ''") + } + } } 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 7600c98..2b14c13 100644 --- a/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt +++ b/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt @@ -48,8 +48,9 @@ import java.util.concurrent.TimeUnit UserKey::class, SessionDuration::class, UserLimitLoginCategory::class, - CategoryNetworkId::class -], version = 33) + CategoryNetworkId::class, + ChildTask::class +], version = 34) abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { companion object { private val lock = Object() @@ -116,7 +117,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database DatabaseMigrations.MIGRATE_TO_V30, DatabaseMigrations.MIGRATE_TO_V31, DatabaseMigrations.MIGRATE_TO_V32, - DatabaseMigrations.MIGRATE_TO_V33 + DatabaseMigrations.MIGRATE_TO_V33, + DatabaseMigrations.MIGRATE_TO_V34 ) .setQueryExecutor(Threads.database) .build() diff --git a/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt b/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt index ac65ad2..b0127a9 100644 --- a/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt +++ b/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt @@ -44,6 +44,7 @@ object DatabaseBackupLowlevel { private const val SESSION_DURATION = "sessionDuration" private const val USER_LIMIT_LOGIN_CATEGORY = "userLimitLoginCategory" private const val CATEGORY_NETWORK_ID = "categoryNetworkId" + private const val CHILD_TASK = "childTask" fun outputAsBackupJson(database: Database, outputStream: OutputStream) { val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)) @@ -92,6 +93,7 @@ object DatabaseBackupLowlevel { handleCollection(SESSION_DURATION) { offset, pageSize -> database.sessionDuration().getSessionDurationPageSync(offset, pageSize) } handleCollection(USER_LIMIT_LOGIN_CATEGORY) { offset, pageSize -> database.userLimitLoginCategoryDao().getAllowedContactPageSync(offset, pageSize) } handleCollection(CATEGORY_NETWORK_ID) { offset, pageSize -> database.categoryNetworkId().getPageSync(offset, pageSize) } + handleCollection(CHILD_TASK) { offset, pageSize -> database.childTasks().getPageSync(offset, pageSize) } writer.endObject().flush() } @@ -101,6 +103,7 @@ object DatabaseBackupLowlevel { var userLoginLimitCategories = emptyList() var categoryNetworkId = emptyList() + var childTasks = emptyList() database.runInTransaction { database.deleteAllData() @@ -267,6 +270,19 @@ object DatabaseBackupLowlevel { reader.endArray() } + CHILD_TASK -> { + reader.beginArray() + + mutableListOf().let { list -> + while (reader.hasNext()) { + list.add(ChildTask.parse(reader)) + } + + childTasks = list + } + + reader.endArray() + } else -> reader.skipValue() } } @@ -274,6 +290,7 @@ object DatabaseBackupLowlevel { if (userLoginLimitCategories.isNotEmpty()) { database.userLimitLoginCategoryDao().addItemsSync(userLoginLimitCategories) } if (categoryNetworkId.isNotEmpty()) { database.categoryNetworkId().insertItemsSync(categoryNetworkId) } + if (childTasks.isNotEmpty()) { database.childTasks().insertItemsSync(childTasks) } } } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt b/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt index 73ef063..fd9cc1b 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt @@ -65,7 +65,7 @@ abstract class CategoryDao { @Query("UPDATE category SET temporarily_blocked = :blocked, temporarily_blocked_end_time = :endTime WHERE id = :categoryId") abstract fun updateCategoryTemporarilyBlocked(categoryId: String, blocked: Boolean, endTime: Long) - @Query("SELECT id, base_version, apps_version, rules_version, usedtimes_version FROM category") + @Query("SELECT id, base_version, apps_version, rules_version, usedtimes_version, tasks_version FROM category") abstract fun getCategoriesWithVersionNumbers(): LiveData> @Query("UPDATE category SET apps_version = :assignedAppsVersion WHERE id = :categoryId") @@ -77,10 +77,13 @@ abstract class CategoryDao { @Query("UPDATE category SET usedtimes_version = :usedTimesVersion WHERE id = :categoryId") abstract fun updateCategoryUsedTimesVersion(categoryId: String, usedTimesVersion: String) + @Query("UPDATE category SET tasks_version = :tasksVersion WHERE id = :categoryId") + abstract fun updateCategoryTasksVersion(categoryId: String, tasksVersion: String) + @Update abstract fun updateCategorySync(category: Category) - @Query("UPDATE category SET apps_version = \"\", rules_version = \"\", usedtimes_version = \"\", base_version = \"\"") + @Query("UPDATE category SET apps_version = '', rules_version = '', usedtimes_version = '', base_version = '', tasks_version = ''") abstract fun deleteAllCategoriesVersionNumbers() @Query("SELECT * FROM category LIMIT :pageSize OFFSET :offset") @@ -114,7 +117,9 @@ data class CategoryWithVersionNumbers( @ColumnInfo(name = "rules_version") val timeLimitRulesVersion: String, @ColumnInfo(name = "usedtimes_version") - val usedTimeItemsVersion: String + val usedTimeItemsVersion: String, + @ColumnInfo(name = "tasks_version") + val taskListVersion: String ) data class CategoryShortInfo( diff --git a/app/src/main/java/io/timelimit/android/data/dao/ChildTaskDao.kt b/app/src/main/java/io/timelimit/android/data/dao/ChildTaskDao.kt new file mode 100644 index 0000000..a7b6d39 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/dao/ChildTaskDao.kt @@ -0,0 +1,65 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import io.timelimit.android.data.model.ChildTask +import io.timelimit.android.data.model.derived.ChildTaskWithCategoryTitle +import io.timelimit.android.data.model.derived.FullChildTask + +@Dao +interface ChildTaskDao { + @Query("SELECT * FROM child_task LIMIT :pageSize OFFSET :offset") + fun getPageSync(offset: Int, pageSize: Int): List + + @Insert + fun insertItemsSync(items: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun forceInsertItemsSync(items: List) + + @Insert + fun insertItemSync(item: ChildTask) + + @Update + fun updateItemSync(item: ChildTask) + + @Query("SELECT child_task.*, category.title as category_title FROM child_task JOIN category ON (child_task.category_id = category.id) WHERE category.child_id = :userId") + fun getTasksByUserIdWithCategoryTitlesLive(userId: String): LiveData> + + @Query("SELECT child_task.*, category.title as category_title, user.name as child_name FROM child_task JOIN category ON (child_task.category_id = category.id) JOIN user ON (category.child_id = user.id) WHERE child_task.pending_request = 1") + fun getPendingTasks(): LiveData> + + @Query("SELECT * FROM child_task WHERE category_id = :categoryId") + fun getTasksByCategoryId(categoryId: String): LiveData> + + @Query("SELECT * FROM child_task WHERE task_id = :taskId") + fun getTaskByTaskId(taskId: String): ChildTask? + + @Query("SELECT * FROM child_task WHERE task_id = :taskId") + fun getTaskByTaskIdLive(taskId: String): LiveData + + @Query("SELECT * FROM child_task WHERE task_id = :taskId") + suspend fun getTaskByTaskIdCoroutine(taskId: String): ChildTask? + + @Query("DELETE FROM child_task WHERE task_id = :taskId") + fun removeTaskById(taskId: String) + + @Query("DELETE FROM child_task WHERE category_id = :categoryId") + fun removeTasksByCategoryId(categoryId: String) +} \ No newline at end of file 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 2ab858b..b94aa4b 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 @@ -56,6 +56,8 @@ data class Category( val timeLimitRulesVersion: String, @ColumnInfo(name = "usedtimes_version") val usedTimesVersion: String, + @ColumnInfo(name = "tasks_version", defaultValue = "") + val tasksVersion: String, @ColumnInfo(name = "parent_category_id") val parentCategoryId: String, @ColumnInfo(name = "block_all_notifications") @@ -95,6 +97,7 @@ data class Category( private const val SORT = "sort" private const val EXTRA_TIME_DAY = "extraTimeDay" private const val DISABLE_LIMIITS_UNTIL = "dlu" + private const val TASKS_VERSION = "tv" fun parse(reader: JsonReader): Category { var id: String? = null @@ -117,6 +120,7 @@ data class Category( var sort = 0 var extraTimeDay = -1 var disableLimitsUntil = 0L + var tasksVersion = "" reader.beginObject() @@ -141,6 +145,7 @@ data class Category( SORT -> sort = reader.nextInt() EXTRA_TIME_DAY -> extraTimeDay = reader.nextInt() DISABLE_LIMIITS_UNTIL -> disableLimitsUntil = reader.nextLong() + TASKS_VERSION -> tasksVersion = reader.nextString() else -> reader.skipValue() } } @@ -159,6 +164,7 @@ data class Category( assignedAppsVersion = assignedAppsVersion!!, timeLimitRulesVersion = timeLimitRulesVersion!!, usedTimesVersion = usedTimesVersion!!, + tasksVersion = tasksVersion, parentCategoryId = parentCategoryId, blockAllNotifications = blockAllNotifications, timeWarnings = timeWarnings, @@ -214,6 +220,7 @@ data class Category( writer.name(ASSIGNED_APPS_VERSION).value(assignedAppsVersion) writer.name(RULES_VERSION).value(timeLimitRulesVersion) writer.name(USED_TIMES_VERSION).value(usedTimesVersion) + writer.name(TASKS_VERSION).value(tasksVersion) writer.name(PARENT_CATEGORY_ID).value(parentCategoryId) writer.name(BlOCK_ALL_NOTIFICATIONS).value(blockAllNotifications) writer.name(TIME_WARNINGS).value(timeWarnings) diff --git a/app/src/main/java/io/timelimit/android/data/model/ChildTask.kt b/app/src/main/java/io/timelimit/android/data/model/ChildTask.kt new file mode 100644 index 0000000..3596008 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/ChildTask.kt @@ -0,0 +1,121 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.data.model + +import android.util.JsonReader +import android.util.JsonWriter +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import io.timelimit.android.data.IdGenerator +import io.timelimit.android.data.JsonSerializable + +@Entity( + tableName = "child_task", + foreignKeys = [ + ForeignKey( + entity = Category::class, + childColumns = ["category_id"], + parentColumns = ["id"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + ) + ] +) +data class ChildTask( + @PrimaryKey + @ColumnInfo(name = "task_id") + val taskId: String, + @ColumnInfo(name = "category_id") + val categoryId: String, + @ColumnInfo(name = "task_title") + val taskTitle: String, + @ColumnInfo(name = "extra_time_duration") + val extraTimeDuration: Int, + @ColumnInfo(name = "pending_request") + val pendingRequest: Boolean, + // 0 = not yet granted + @ColumnInfo(name = "last_grant_timestamp") + val lastGrantTimestamp: Long +): JsonSerializable { + companion object { + private const val TASK_ID = "taskId" + private const val CATEGORY_ID = "categoryId" + private const val TASK_TITLE = "taskTitle" + private const val EXTRA_TIME_DURATION = "extraTimeDuration" + private const val PENDING_REQUEST = "pendingRequest" + private const val LAST_GRANT_TIMESTAMP = "lastGrantTimestamp" + + const val MAX_EXTRA_TIME = 1000 * 60 * 60 * 24 + const val MAX_TASK_TITLE_LENGTH = 50 + + fun parse(reader: JsonReader): ChildTask { + var taskId: String? = null + var categoryId: String? = null + var taskTitle: String? = null + var extraTimeDuration: Int? = null + var pendingRequest: Boolean? = null + var lastGrantTimestamp: Long? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + TASK_ID -> taskId = reader.nextString() + CATEGORY_ID -> categoryId = reader.nextString() + TASK_TITLE -> taskTitle = reader.nextString() + EXTRA_TIME_DURATION -> extraTimeDuration = reader.nextInt() + PENDING_REQUEST -> pendingRequest = reader.nextBoolean() + LAST_GRANT_TIMESTAMP -> lastGrantTimestamp = reader.nextLong() + else -> reader.skipValue() + } + } + reader.endObject() + + return ChildTask( + taskId = taskId!!, + categoryId = categoryId!!, + taskTitle = taskTitle!!, + extraTimeDuration = extraTimeDuration!!, + pendingRequest = pendingRequest!!, + lastGrantTimestamp = lastGrantTimestamp!! + ) + } + } + + init { + IdGenerator.assertIdValid(taskId) + IdGenerator.assertIdValid(categoryId) + + if (taskTitle.isEmpty() || taskTitle.length > MAX_TASK_TITLE_LENGTH) throw IllegalArgumentException() + if (extraTimeDuration <= 0 || extraTimeDuration > MAX_EXTRA_TIME) throw IllegalArgumentException() + if (lastGrantTimestamp < 0) throw IllegalArgumentException() + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TASK_ID).value(taskId) + writer.name(CATEGORY_ID).value(categoryId) + writer.name(TASK_TITLE).value(taskTitle) + writer.name(EXTRA_TIME_DURATION).value(extraTimeDuration) + writer.name(PENDING_REQUEST).value(pendingRequest) + writer.name(LAST_GRANT_TIMESTAMP).value(lastGrantTimestamp) + + writer.endObject() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/ConfigurationItem.kt b/app/src/main/java/io/timelimit/android/data/model/ConfigurationItem.kt index b60163e..42e687a 100644 --- a/app/src/main/java/io/timelimit/android/data/model/ConfigurationItem.kt +++ b/app/src/main/java/io/timelimit/android/data/model/ConfigurationItem.kt @@ -213,6 +213,7 @@ object HintsToShow { const val CONTACTS_INTRO = 16L const val TIMELIMIT_RULE_MUSTREAD = 32L const val BLOCKED_TIME_AREAS_OBSOLETE = 64L + const val TASKS_INTRODUCTION = 128L } object ExperimentalFlags { diff --git a/app/src/main/java/io/timelimit/android/data/model/derived/ChildTaskWithCategoryTitle.kt b/app/src/main/java/io/timelimit/android/data/model/derived/ChildTaskWithCategoryTitle.kt new file mode 100644 index 0000000..e724dd7 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/derived/ChildTaskWithCategoryTitle.kt @@ -0,0 +1,28 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.data.model.derived + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import io.timelimit.android.data.model.ChildTask + +data class ChildTaskWithCategoryTitle( + @Embedded + val childTask: ChildTask, + @ColumnInfo(name = "category_title") + val categoryTitle: String +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/derived/FullChildTask.kt b/app/src/main/java/io/timelimit/android/data/model/derived/FullChildTask.kt new file mode 100644 index 0000000..daba776 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/derived/FullChildTask.kt @@ -0,0 +1,30 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.data.model.derived + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import io.timelimit.android.data.model.ChildTask + +data class FullChildTask( + @Embedded + val childTask: ChildTask, + @ColumnInfo(name = "category_title") + val categoryTitle: String, + @ColumnInfo(name = "child_name") + val childName: String +) \ No newline at end of file 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 8a4aa71..e359165 100644 --- a/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt @@ -179,6 +179,7 @@ class AppSetupLogic(private val appLogic: AppLogic) { assignedAppsVersion = "", timeLimitRulesVersion = "", usedTimesVersion = "", + tasksVersion = "", parentCategoryId = "", blockAllNotifications = false, timeWarnings = 0, @@ -201,6 +202,7 @@ class AppSetupLogic(private val appLogic: AppLogic) { assignedAppsVersion = "", timeLimitRulesVersion = "", usedTimesVersion = "", + tasksVersion = "", parentCategoryId = "", blockAllNotifications = false, timeWarnings = 0, diff --git a/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryItselfHandling.kt b/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryItselfHandling.kt index 7eb40fd..057414d 100644 --- a/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryItselfHandling.kt +++ b/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryItselfHandling.kt @@ -105,8 +105,21 @@ data class CategoryItselfHandling ( else categoryRelatedData.networks.find { CategoryNetworkId.anonymizeNetworkId(itemId = it.networkItemId, networkId = currentNetworkId) == it.hashedNetworkId } != null + val allRelatedRules = if (areLimitsTemporarilyDisabled) + emptyList() + else + RemainingTime.getRulesRelatedToDay( + dayOfWeek = dayOfWeek, + minuteOfDay = minuteInWeek % MinuteOfDay.LENGTH, + rules = categoryRelatedData.rules + ) + + val regularRelatedRules = allRelatedRules.filter { it.maximumTimeInMillis != 0 } + val hasBlockedTimeAreaRelatedRule = allRelatedRules.find { it.maximumTimeInMillis == 0 } != null + val missingNetworkTimeForBlockedTimeAreas = !categoryRelatedData.category.blockedMinutesInWeek.dataNotToModify.isEmpty - val okByBlockedTimeAreas = areLimitsTemporarilyDisabled || !categoryRelatedData.category.blockedMinutesInWeek.read(minuteInWeek) + val okByBlockedTimeAreas = areLimitsTemporarilyDisabled || ( + (!categoryRelatedData.category.blockedMinutesInWeek.read(minuteInWeek)) && (!hasBlockedTimeAreaRelatedRule)) val dependsOnMaxMinuteOfWeekByBlockedTimeAreas = categoryRelatedData.category.blockedMinutesInWeek.let { blockedTimeAreas -> if (blockedTimeAreas.dataNotToModify[minuteInWeek]) { blockedTimeAreas.dataNotToModify.nextClearBit(minuteInWeek) @@ -121,27 +134,18 @@ data class CategoryItselfHandling ( else dependsOnMaxMinuteOfWeekByBlockedTimeAreas % MinuteOfDay.LENGTH - val relatedRules = if (areLimitsTemporarilyDisabled) - emptyList() - else - RemainingTime.getRulesRelatedToDay( - dayOfWeek = dayOfWeek, - minuteOfDay = minuteInWeek % MinuteOfDay.LENGTH, - rules = categoryRelatedData.rules - ) - val remainingTime = RemainingTime.getRemainingTime( usedTimes = categoryRelatedData.usedTimes, // dependsOnMaxTimeByRules always depends on the day so that this is invalidated correctly extraTime = categoryRelatedData.category.getExtraTime(dayOfEpoch = dayOfEpoch), - rules = relatedRules, + rules = regularRelatedRules, dayOfWeek = dayOfWeek, minuteOfDay = minuteInWeek % MinuteOfDay.LENGTH, firstDayOfWeekAsEpochDay = firstDayOfWeekAsEpochDay ) val remainingSessionDuration = RemainingSessionDuration.getRemainingSessionDuration( - rules = relatedRules, + rules = regularRelatedRules, minuteOfDay = minuteInWeek % MinuteOfDay.LENGTH, dayOfWeek = dayOfWeek, timestamp = timeInMillis, @@ -149,8 +153,8 @@ data class CategoryItselfHandling ( ) val missingNetworkTimeForRules = categoryRelatedData.rules.isNotEmpty() - val okByTimeLimitRules = relatedRules.isEmpty() || (remainingTime != null && remainingTime.hasRemainingTime) - val dependsOnMaxTimeByMinuteOfDay = (relatedRules.minBy { it.endMinuteOfDay }?.endMinuteOfDay ?: Int.MAX_VALUE).coerceAtMost( + val okByTimeLimitRules = regularRelatedRules.isEmpty() || (remainingTime != null && remainingTime.hasRemainingTime) + val dependsOnMaxTimeByMinuteOfDay = (allRelatedRules.minBy { it.endMinuteOfDay }?.endMinuteOfDay ?: Int.MAX_VALUE).coerceAtMost( categoryRelatedData.rules .filter { // related to today @@ -195,12 +199,12 @@ data class CategoryItselfHandling ( val missingNetworkTime = !shouldTrustTimeTemporarily && (missingNetworkTimeForDisableTempBlocking || missingNetworkTimeForBlockedTimeAreas || missingNetworkTimeForRules) - val shouldCountTime = relatedRules.isNotEmpty() + val shouldCountTime = regularRelatedRules.isNotEmpty() val shouldCountExtraTime = remainingTime?.usingExtraTime == true val sessionDurationSlotsToCount = if (remainingSessionDuration != null && remainingSessionDuration <= 0) emptySet() else - relatedRules.filter { it.sessionDurationLimitEnabled }.map { + regularRelatedRules.filter { it.sessionDurationLimitEnabled }.map { AddUsedTimeActionItemSessionDurationLimitSlot( startMinuteOfDay = it.startMinuteOfDay, endMinuteOfDay = it.endMinuteOfDay, @@ -219,7 +223,7 @@ data class CategoryItselfHandling ( val maxTimeToAdd = maxTimeToAddByRegularTime.coerceAtMost(maxTimeToAddBySessionDuration) val additionalTimeCountingSlots = if (shouldCountTime) - relatedRules + regularRelatedRules .filterNot { it.appliesToWholeDay } .map { AddUsedTimeActionItemAdditionalCountingSlot(it.startMinuteOfDay, it.endMinuteOfDay) } .toSet() diff --git a/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt b/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt index bfe738c..6fff88d 100644 --- a/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt +++ b/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt @@ -325,6 +325,7 @@ object ApplyServerDataStatus { assignedAppsVersion = "", timeLimitRulesVersion = "", usedTimesVersion = "", + tasksVersion = "", parentCategoryId = newCategory.parentCategoryId, timeWarnings = newCategory.timeWarnings, minBatteryLevelMobile = newCategory.minBatteryLevelMobile, @@ -495,6 +496,25 @@ object ApplyServerDataStatus { } } + status.newCategoryTasks.forEach { tasks -> + database.childTasks().removeTasksByCategoryId(tasks.categoryId) + + database.childTasks().forceInsertItemsSync( + tasks.tasks.map { task -> + ChildTask( + taskId = task.taskId, + categoryId = tasks.categoryId, + taskTitle = task.taskTitle, + extraTimeDuration = task.extraTimeDuration, + pendingRequest = task.pendingRequest, + lastGrantTimestamp = task.lastGrantTimestamp + ) + } + ) + + database.category().updateCategoryTasksVersion(categoryId = tasks.categoryId, tasksVersion = tasks.version) + } + status.newUserList?.data?.forEach { user -> if (user.limitLoginCategory == null) { database.userLimitLoginCategoryDao().removeItemSync(user.id) 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 0925a50..6156e64 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 @@ -492,6 +492,24 @@ object ForceSyncAction: AppLogicAction() { } +data class MarkTaskPendingAction(val taskId: String): AppLogicAction() { + companion object { + const val TYPE_VALUE = "MARK_TASK_PENDING" + private const val TASK_ID = "taskId" + } + + init { IdGenerator.assertIdValid(taskId) } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(TASK_ID).value(taskId) + + writer.endObject() + } +} + data class AddCategoryAppsAction(val categoryId: String, val packageNames: List): ParentAction() { companion object { const val TYPE_VALUE = "ADD_CATEGORY_APPS" @@ -928,6 +946,81 @@ data class UpdateCategoryDisableLimitsAction(val categoryId: String, val endTime } } +data class UpdateChildTaskAction(val isNew: Boolean, val taskId: String, val categoryId: String, val taskTitle: String, val extraTimeDuration: Int): ParentAction() { + companion object { + private const val TYPE_VALUE = "UPDATE_CHILD_TASK" + private const val IS_NEW = "isNew" + private const val TASK_ID = "taskId" + private const val CATEGORY_ID = "categoryId" + private const val TASK_TITLE = "taskTitle" + private const val EXTRA_TIME_DURATION = "extraTimeDuration" + } + + init { + IdGenerator.assertIdValid(taskId) + IdGenerator.assertIdValid(categoryId) + + if (taskTitle.isEmpty() || taskTitle.length > ChildTask.MAX_TASK_TITLE_LENGTH) throw IllegalArgumentException() + if (extraTimeDuration <= 0 || extraTimeDuration > ChildTask.MAX_EXTRA_TIME) throw IllegalArgumentException() + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(IS_NEW).value(isNew) + writer.name(TASK_ID).value(taskId) + writer.name(CATEGORY_ID).value(categoryId) + writer.name(TASK_TITLE).value(taskTitle) + writer.name(EXTRA_TIME_DURATION).value(extraTimeDuration) + + writer.endObject() + } +} + +data class DeleteChildTaskAction(val taskId: String): ParentAction() { + companion object { + private const val TYPE_VALUE = "DELETE_CHILD_TASK" + private const val TASK_ID = "taskId" + } + + init { IdGenerator.assertIdValid(taskId) } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(TASK_ID).value(taskId) + + writer.endObject() + } +} + +data class ReviewChildTaskAction(val taskId: String, val ok: Boolean, val time: Long): ParentAction() { + companion object { + private const val TYPE_VALUE = "REVIEW_CHILD_TASK" + private const val TASK_ID = "taskId" + private const val OK = "ok" + private const val TIME = "time" + } + + init { + if (time <= 0) throw IllegalArgumentException() + IdGenerator.assertIdValid(taskId) + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(TASK_ID).value(taskId) + writer.name(OK).value(ok) + writer.name(TIME).value(time) + + writer.endObject() + } +} + // DeviceDao data class UpdateDeviceStatusAction( diff --git a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt index d989b11..4086edd 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt @@ -381,6 +381,15 @@ object LocalDatabaseAppLogicActionDispatcher { null } + is MarkTaskPendingAction -> { + val task = database.childTasks().getTaskByTaskId(action.taskId) ?: throw RuntimeException() + val category = database.category().getCategoryByIdSync(task.categoryId)!! + val device = database.device().getDeviceByIdSync(deviceId)!! + + if (category.childId != device.currentUserId) throw IllegalStateException() + + database.childTasks().updateItemSync(task.copy(pendingRequest = true)) + } }.let { } } } 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 669e789..d8e0d3f 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 @@ -139,6 +139,7 @@ object LocalDatabaseParentActionDispatcher { assignedAppsVersion = "", timeLimitRulesVersion = "", usedTimesVersion = "", + tasksVersion = "", parentCategoryId = "", blockAllNotifications = false, timeWarnings = 0, @@ -767,6 +768,64 @@ object LocalDatabaseParentActionDispatcher { database.category().updateCategorySync(category.copy(disableLimitsUntil = action.endTime)) } + is UpdateChildTaskAction -> { + val task = database.childTasks().getTaskByTaskId(taskId = action.taskId) + val notFound = task == null + + if (notFound != action.isNew) { + if (action.isNew) { + throw IllegalArgumentException("task exists already") + } else { + throw IllegalArgumentException("task not found") + } + } + + if (task == null) { + database.childTasks().insertItemSync( + ChildTask( + taskId = action.taskId, + taskTitle = action.taskTitle, + categoryId = action.categoryId, + extraTimeDuration = action.extraTimeDuration, + lastGrantTimestamp = 0, + pendingRequest = false + ) + ) + } else { + database.childTasks().updateItemSync( + task.copy( + taskTitle = action.taskTitle, + categoryId = action.categoryId, + extraTimeDuration = action.extraTimeDuration, + ) + ) + } + } + is DeleteChildTaskAction -> { + val task = database.childTasks().getTaskByTaskId(taskId = action.taskId) ?: throw IllegalArgumentException("task not found") + + database.childTasks().removeTaskById(taskId = task.taskId) + } + is ReviewChildTaskAction -> { + val task = database.childTasks().getTaskByTaskId(taskId = action.taskId) ?: throw IllegalArgumentException("task not found") + + if (!task.pendingRequest) throw IllegalArgumentException("did review of a task which is not pending") + + if (action.ok) { + val category = database.category().getCategoryByIdSync(task.categoryId)!! + + if (category.extraTimeDay != 0 && category.extraTimeInMillis > 0) { + // if the current time is daily, then extend the daily time only + database.category().updateCategoryExtraTime(categoryId = category.id, extraTimeDay = category.extraTimeDay, newExtraTime = category.extraTimeInMillis + task.extraTimeDuration) + } else { + database.category().updateCategoryExtraTime(categoryId = category.id, extraTimeDay = -1, newExtraTime = category.extraTimeInMillis + task.extraTimeDuration) + } + + database.childTasks().updateItemSync(task.copy(pendingRequest = false, lastGrantTimestamp = action.time)) + } else { + database.childTasks().updateItemSync(task.copy(pendingRequest = false)) + } + } }.let { } } } diff --git a/app/src/main/java/io/timelimit/android/sync/network/ClientDataStatus.kt b/app/src/main/java/io/timelimit/android/sync/network/ClientDataStatus.kt index 96a8996..01b3aca 100644 --- a/app/src/main/java/io/timelimit/android/sync/network/ClientDataStatus.kt +++ b/app/src/main/java/io/timelimit/android/sync/network/ClientDataStatus.kt @@ -34,7 +34,7 @@ data class ClientDataStatus( private const val CATEGORIES = "categories" private const val USERS = "users" private const val CLIENT_LEVEL = "clientLevel" - private const val CLIENT_LEVEL_VALUE = 2 + private const val CLIENT_LEVEL_VALUE = 3 val empty = ClientDataStatus( deviceListVersion = "", @@ -63,7 +63,8 @@ data class ClientDataStatus( baseVersion = it.baseVersion, assignedAppsVersion = it.assignedAppsVersion, timeLimitRulesVersion = it.timeLimitRulesVersion, - usedTimeItemsVersion = it.usedTimeItemsVersion + usedTimeItemsVersion = it.usedTimeItemsVersion, + taskListVersion = it.taskListVersion ) } @@ -104,13 +105,15 @@ data class CategoryDataStatus( val baseVersion: String, val assignedAppsVersion: String, val timeLimitRulesVersion: String, - val usedTimeItemsVersion: String + val usedTimeItemsVersion: String, + val taskListVersion: String ) { companion object { private const val BASE_VERSION = "base" private const val ASSIGNED_APPS_VERSION = "apps" private const val TIME_LIMIT_RULES_VERSION = "rules" private const val USED_TIME_ITEMS_VERSION = "usedTime" + private const val TASK_LIST_VERSION = "tasks" } fun serialize(writer: JsonWriter) { @@ -121,6 +124,8 @@ data class CategoryDataStatus( writer.name(TIME_LIMIT_RULES_VERSION).value(timeLimitRulesVersion) writer.name(USED_TIME_ITEMS_VERSION).value(usedTimeItemsVersion) + if (taskListVersion.isNotEmpty()) writer.name(TASK_LIST_VERSION).value(taskListVersion) + writer.endObject() } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt b/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt index 9e3f92b..4cbc220 100644 --- a/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt +++ b/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt @@ -40,6 +40,7 @@ data class ServerDataStatus( val newCategoryAssignedApps: List, val newCategoryUsedTimes: List, val newCategoryTimeLimitRules: List, + val newCategoryTasks: List, val newUserList: ServerUserList?, val fullVersionUntil: Long, val message: String? @@ -52,6 +53,7 @@ data class ServerDataStatus( private const val NEW_CATEGORY_ASSIGNED_APPS = "categoryApp" private const val NEW_CATEGORY_USED_TIMES = "usedTimes" private const val NEW_CATEGORY_TIME_LIMIT_RULES = "rules" + private const val NEW_CATEGORY_TASKS = "tasks" private const val NEW_USER_LIST = "users" private const val FULL_VERSION_UNTIL = "fullVersion" private const val MESSAGE = "message" @@ -64,6 +66,7 @@ data class ServerDataStatus( var newCategoryAssignedApps: List = Collections.emptyList() var newCategoryUsedTimes: List = Collections.emptyList() var newCategoryTimeLimitRules: List = Collections.emptyList() + var newCategoryTasks: List = emptyList() var newUserList: ServerUserList? = null var fullVersionUntil: Long? = null var message: String? = null @@ -78,6 +81,7 @@ data class ServerDataStatus( NEW_CATEGORY_ASSIGNED_APPS -> newCategoryAssignedApps = ServerUpdatedCategoryAssignedApps.parseList(reader) NEW_CATEGORY_USED_TIMES -> newCategoryUsedTimes = ServerUpdatedCategoryUsedTimes.parseList(reader) NEW_CATEGORY_TIME_LIMIT_RULES -> newCategoryTimeLimitRules = ServerUpdatedTimeLimitRules.parseList(reader) + NEW_CATEGORY_TASKS -> newCategoryTasks = ServerUpdatedCategoryTasks.parseList(reader) NEW_USER_LIST -> newUserList = ServerUserList.parse(reader) FULL_VERSION_UNTIL -> fullVersionUntil = reader.nextLong() MESSAGE -> message = reader.nextString() @@ -94,6 +98,7 @@ data class ServerDataStatus( newCategoryAssignedApps = newCategoryAssignedApps, newCategoryUsedTimes = newCategoryUsedTimes, newCategoryTimeLimitRules = newCategoryTimeLimitRules, + newCategoryTasks = newCategoryTasks, newUserList = newUserList, fullVersionUntil = fullVersionUntil!!, message = message @@ -892,6 +897,90 @@ data class ServerTimeLimitRule( ) } +data class ServerUpdatedCategoryTasks ( + val categoryId: String, + val version: String, + val tasks: List +) { + companion object { + private const val CATEGORY_ID = "categoryId" + private const val VERSION = "version" + private const val TASKS = "tasks" + + fun parse(reader: JsonReader): ServerUpdatedCategoryTasks { + var categoryId: String? = null + var version: String? = null + var tasks: List? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + CATEGORY_ID -> categoryId = reader.nextString() + VERSION -> version = reader.nextString() + TASKS -> tasks = ServerUpdatedCategoryTask.parseList(reader) + else -> reader.skipValue() + } + } + reader.endObject() + + return ServerUpdatedCategoryTasks( + categoryId = categoryId!!, + version = version!!, + tasks = tasks!! + ) + } + + fun parseList(reader: JsonReader): List = parseJsonArray(reader) { parse(reader) } + } +} + +data class ServerUpdatedCategoryTask ( + val taskId: String, + val taskTitle: String, + val extraTimeDuration: Int, + val pendingRequest: Boolean, + val lastGrantTimestamp: Long +) { + companion object { + private const val TASK_ID = "i" + private const val TASK_TITLE = "t" + private const val EXTRA_TIME_DURATION = "d" + private const val PENDING_REQUEST = "p" + private const val LAST_GRANT_TIMESTAMP = "l" + + fun parse(reader: JsonReader): ServerUpdatedCategoryTask { + var taskId: String? = null + var taskTitle: String? = null + var extraTimeDuration: Int? = null + var pendingRequest: Boolean? = null + var lastGrantTimestamp: Long? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + TASK_ID -> taskId = reader.nextString() + TASK_TITLE -> taskTitle = reader.nextString() + EXTRA_TIME_DURATION -> extraTimeDuration = reader.nextInt() + PENDING_REQUEST -> pendingRequest = reader.nextBoolean() + LAST_GRANT_TIMESTAMP -> lastGrantTimestamp = reader.nextLong() + else -> reader.skipValue() + } + } + reader.endObject() + + return ServerUpdatedCategoryTask( + taskId = taskId!!, + taskTitle = taskTitle!!, + extraTimeDuration = extraTimeDuration!!, + pendingRequest = pendingRequest!!, + lastGrantTimestamp = lastGrantTimestamp!! + ) + } + + fun parseList(reader: JsonReader): List = parseJsonArray(reader) { parse(reader) } + } +} + data class ServerInstalledAppsData( val deviceId: String, val version: String, diff --git a/app/src/main/java/io/timelimit/android/ui/fragment/ChildFragmentWrappers.kt b/app/src/main/java/io/timelimit/android/ui/fragment/ChildFragmentWrappers.kt index 6f6d8b6..cfc52b7 100644 --- a/app/src/main/java/io/timelimit/android/ui/fragment/ChildFragmentWrappers.kt +++ b/app/src/main/java/io/timelimit/android/ui/fragment/ChildFragmentWrappers.kt @@ -25,6 +25,7 @@ import io.timelimit.android.ui.main.FragmentWithCustomTitle import io.timelimit.android.ui.manage.category.usagehistory.UsageHistoryFragment import io.timelimit.android.ui.manage.child.advanced.ManageChildAdvancedFragment import io.timelimit.android.ui.manage.child.apps.ChildAppsFragment +import io.timelimit.android.ui.manage.child.tasks.ManageChildTasksFragment abstract class ChildFragmentWrapper: SingleFragmentWrapper() { abstract val childId: String @@ -64,4 +65,13 @@ class ChildUsageHistoryFragmentWrapper: ChildFragmentWrapper(), FragmentWithCust override fun createChildFragment(): Fragment = UsageHistoryFragment.newInstance(userId = childId, categoryId = null) override fun getCustomTitle() = child.map { it?.let { "${it.name} - ${getString(R.string.usage_history_title)}" } } +} + +class ChildTasksFragmentWrapper: ChildFragmentWrapper(), FragmentWithCustomTitle { + private val params by lazy { ChildTasksFragmentWrapperArgs.fromBundle(requireArguments()) } + override val childId: String get() = params.childId + override val showAuthButton: Boolean = true + + override fun createChildFragment(): Fragment = ManageChildTasksFragment.newInstance(childId = childId) + override fun getCustomTitle() = child.map { it?.let { "${it.name} - ${getString(R.string.manage_child_tasks)}" } } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/lock/LockActivity.kt b/app/src/main/java/io/timelimit/android/ui/lock/LockActivity.kt index 5070f1b..793b82c 100644 --- a/app/src/main/java/io/timelimit/android/ui/lock/LockActivity.kt +++ b/app/src/main/java/io/timelimit/android/ui/lock/LockActivity.kt @@ -85,6 +85,8 @@ class LockActivity : AppCompatActivity(), ActivityViewModelHolder { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val adapter = LockActivityAdapter(supportFragmentManager, this) + setContentView(R.layout.lock_activity) if (savedInstanceState == null) { @@ -97,7 +99,7 @@ class LockActivity : AppCompatActivity(), ActivityViewModelHolder { model.init(blockedPackageName, blockedActivityName) - pager.adapter = LockActivityAdapter(supportFragmentManager, this) + pager.adapter = adapter model.content.observe(this) { if (isResumed && it is LockscreenContent.Blocked.BlockedCategory && it.reason == BlockingReason.RequiresCurrentDevice && !model.didOpenSetCurrentDeviceScreen) { @@ -123,11 +125,17 @@ class LockActivity : AppCompatActivity(), ActivityViewModelHolder { override fun onPageSelected(position: Int) { super.onPageSelected(position) - showAuth.value = position > 0 + showAuth.value = position == 1 } }) tabs.setupWithViewPager(pager) + + model.content.observe(this) { + val isTimeOver = it is LockscreenContent.Blocked.BlockedCategory && it.blockingHandling.activityBlockingReason == BlockingReason.TimeOver + + adapter.showTasksFragment = isTimeOver + } } override fun onDestroy() { diff --git a/app/src/main/java/io/timelimit/android/ui/lock/LockActivityAdapter.kt b/app/src/main/java/io/timelimit/android/ui/lock/LockActivityAdapter.kt index c3c0bea..04080e3 100644 --- a/app/src/main/java/io/timelimit/android/ui/lock/LockActivityAdapter.kt +++ b/app/src/main/java/io/timelimit/android/ui/lock/LockActivityAdapter.kt @@ -21,19 +21,24 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentPagerAdapter import io.timelimit.android.R +import kotlin.properties.Delegates class LockActivityAdapter(fragmentManager: FragmentManager, private val context: Context): FragmentPagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { - override fun getCount(): Int = 2 + var showTasksFragment: Boolean by Delegates.observable(false) { _, _, _ -> notifyDataSetChanged() } + + override fun getCount(): Int = if (showTasksFragment) 3 else 2 override fun getItem(position: Int): Fragment = when (position) { 0 -> LockReasonFragment() 1 -> LockActionFragment() + 2 -> LockTaskFragment() else -> throw IllegalArgumentException() } override fun getPageTitle(position: Int): CharSequence? = context.getString(when (position) { 0 -> R.string.lock_tab_reason 1 -> R.string.lock_tab_action + 2 -> R.string.lock_tab_task else -> throw IllegalArgumentException() }) } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/lock/LockModel.kt b/app/src/main/java/io/timelimit/android/ui/lock/LockModel.kt index 961839a..4e475b0 100644 --- a/app/src/main/java/io/timelimit/android/ui/lock/LockModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/lock/LockModel.kt @@ -171,6 +171,18 @@ class LockModel(application: Application): AndroidViewModel(application) { val osClockInMillis = liveDataFromFunction { logic.timeApi.getCurrentTimeInMillis() } + private val categoryIdForTasks = content.map { + if (it is LockscreenContent.Blocked.BlockedCategory && it.blockingHandling.activityBlockingReason == BlockingReason.TimeOver) + it.blockedCategoryId + else null + }.ignoreUnchanged() + + val blockedCategoryTasks = categoryIdForTasks.switchMap { categoryId -> + if (categoryId != null) + logic.database.childTasks().getTasksByCategoryId(categoryId) + else liveDataFromValue(emptyList()) + } + fun confirmLocalTime() { logic.realTimeLogic.confirmLocalTime() } diff --git a/app/src/main/java/io/timelimit/android/ui/lock/LockTaskAdapter.kt b/app/src/main/java/io/timelimit/android/ui/lock/LockTaskAdapter.kt new file mode 100644 index 0000000..5f96206 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/lock/LockTaskAdapter.kt @@ -0,0 +1,96 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.lock + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.timelimit.android.R +import io.timelimit.android.data.model.ChildTask +import io.timelimit.android.databinding.ChildTaskItemBinding +import io.timelimit.android.databinding.IntroCardBinding +import io.timelimit.android.util.TimeTextUtil +import kotlin.properties.Delegates + +class LockTaskAdapter: RecyclerView.Adapter() { + companion object { + private const val TYPE_INTRODUCTION = 1 + private const val TYPE_ITEM = 2 + } + + var content: List by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() } + var listener: Listener? = null + + init { setHasStableIds(true) } + + override fun getItemCount(): Int = content.size + + override fun getItemId(position: Int): Long = content[position].let { item -> + when (item) { + is LockTaskItem.Task -> item.task.taskId.hashCode() + else -> item.hashCode() + } + }.toLong() + + override fun getItemViewType(position: Int): Int = when (content[position]) { + is LockTaskItem.Task -> TYPE_ITEM + LockTaskItem.Introduction -> TYPE_INTRODUCTION + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = LayoutInflater.from(parent.context).let { inflater -> + LockTaskAdapter.Holder( + when (viewType) { + TYPE_INTRODUCTION -> IntroCardBinding.inflate(inflater, parent, false).also { + it.title = parent.context.getString(R.string.lock_tab_task) + it.text = parent.context.getString(R.string.lock_task_introduction) + it.noSwipe = true + }.root + TYPE_ITEM -> ChildTaskItemBinding.inflate(inflater, parent, false).also { + it.root.tag = it + }.root + else -> throw IllegalArgumentException() + } + ) + } + + override fun onBindViewHolder(holder: LockTaskAdapter.Holder, position: Int) { + val context = holder.itemView.context + val item = content[position] + + when (item) { + LockTaskItem.Introduction -> {/* nothing to do */} + is LockTaskItem.Task -> { + val binding = holder.itemView.tag as ChildTaskItemBinding + + binding.title = item.task.taskTitle + binding.duration = TimeTextUtil.time(item.task.extraTimeDuration, context) + binding.pendingReview = item.task.pendingRequest + + binding.executePendingBindings() + + binding.root.setOnClickListener { listener?.onTaskClicked(item.task) } + } + }.let {/* require handling all paths */} + } + + class Holder(view: View): RecyclerView.ViewHolder(view) + + interface Listener { + fun onTaskClicked(task: ChildTask) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/lock/LockTaskFragment.kt b/app/src/main/java/io/timelimit/android/ui/lock/LockTaskFragment.kt new file mode 100644 index 0000000..8174542 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/lock/LockTaskFragment.kt @@ -0,0 +1,59 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.lock + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import io.timelimit.android.R +import io.timelimit.android.data.model.ChildTask +import io.timelimit.android.ui.manage.child.tasks.ConfirmTaskDialogFragment +import kotlinx.android.synthetic.main.recycler_fragment.* + +class LockTaskFragment: Fragment() { + private val model: LockModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.recycler_fragment, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val adapter = LockTaskAdapter() + + recycler.layoutManager = LinearLayoutManager(requireContext()) + recycler.adapter = adapter + + model.blockedCategoryTasks.observe(viewLifecycleOwner) { tasks -> + adapter.content = listOf(LockTaskItem.Introduction) + tasks.map { LockTaskItem.Task(it) } + } + + adapter.listener = object: LockTaskAdapter.Listener { + override fun onTaskClicked(task: ChildTask) { + if (task.pendingRequest) + TaskReviewPendingDialogFragment.newInstance().show(parentFragmentManager) + else + ConfirmTaskDialogFragment.newInstance(taskId = task.taskId, taskTitle = task.taskTitle).show(parentFragmentManager) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/lock/LockTaskItem.kt b/app/src/main/java/io/timelimit/android/ui/lock/LockTaskItem.kt new file mode 100644 index 0000000..8df1d90 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/lock/LockTaskItem.kt @@ -0,0 +1,24 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.lock + +import io.timelimit.android.data.model.ChildTask + +sealed class LockTaskItem { + object Introduction: LockTaskItem() + data class Task(val task: ChildTask): LockTaskItem() +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/lock/TaskReviewPendingDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/lock/TaskReviewPendingDialogFragment.kt new file mode 100644 index 0000000..e807522 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/lock/TaskReviewPendingDialogFragment.kt @@ -0,0 +1,40 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.lock + +import android.app.Dialog +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import io.timelimit.android.R +import io.timelimit.android.extensions.showSafe + +class TaskReviewPendingDialogFragment: DialogFragment() { + companion object { + private const val DIALOG_TAG = "TaskReviewPendingDialogFragment" + + fun newInstance() = TaskReviewPendingDialogFragment() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = AlertDialog.Builder(requireContext(), theme) + .setMessage(R.string.lock_task_review_pending_dialog) + .setPositiveButton(R.string.generic_ok, null) + .create() + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt index 915794b..635d1ec 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt @@ -25,7 +25,6 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import com.google.android.material.snackbar.Snackbar import io.timelimit.android.R -import io.timelimit.android.async.Threads import io.timelimit.android.data.extensions.mapToTimezone import io.timelimit.android.databinding.FragmentCategorySettingsBinding import io.timelimit.android.date.DateInTimezone @@ -39,7 +38,7 @@ import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.manage.category.settings.addusedtime.AddUsedTimeDialogFragment import io.timelimit.android.ui.manage.category.settings.networks.ManageCategoryNetworksView import io.timelimit.android.ui.payment.RequiresPurchaseDialogFragment -import io.timelimit.android.ui.view.SelectTimeSpanViewListener +import io.timelimit.android.ui.util.bind class CategorySettingsFragment : Fragment() { companion object { @@ -210,16 +209,8 @@ class CategorySettingsFragment : Fragment() { binding.extraTimeSelection.enablePickerMode(it) }) - binding.extraTimeSelection.listener = object: SelectTimeSpanViewListener { - override fun onTimeSpanChanged(newTimeInMillis: Long) { - updateEditExtraTimeConfirmButtonVisibility() - } - - override fun setEnablePickerMode(enable: Boolean) { - Threads.database.execute { - appLogic.database.config().setEnableAlternativeDurationSelectionSync(enable) - } - } + binding.extraTimeSelection.bind(appLogic.database, viewLifecycleOwner) { + updateEditExtraTimeConfirmButtonVisibility() } binding.switchLimitExtraTimeToToday.setOnCheckedChangeListener { _, _ -> diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/timelimit_rules/edit/EditTimeLimitRuleDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/timelimit_rules/edit/EditTimeLimitRuleDialogFragment.kt index 92549c8..2a74d94 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/timelimit_rules/edit/EditTimeLimitRuleDialogFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/timelimit_rules/edit/EditTimeLimitRuleDialogFragment.kt @@ -123,10 +123,10 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment(), DurationPic } ) view.applyToExtraTime = newRule.applyToExtraTimeUsage - view.timeSpan.timeInMillis = newRule.maximumTimeInMillis.toLong() val affectedDays = Math.max(0, (0..6).map { (newRule.dayMask.toInt() shr it) and 1 }.sum()) view.timeSpan.maxDays = Math.max(0, affectedDays - 1) // max prevents crash + view.timeSpan.timeInMillis = newRule.maximumTimeInMillis.toLong() view.affectsMultipleDays = affectedDays >= 2 view.applyToWholeDay = newRule.appliesToWholeDay diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt index 6ba75be..d6de291 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt @@ -89,6 +89,14 @@ class ManageChildFragment : ChildFragmentWrapper(), FragmentWithCustomTitle { true } + R.id.menu_manage_child_tasks -> { + navigation.safeNavigate( + ManageChildFragmentDirections.actionManageChildFragmentToManageChildTasksFragment(childId = childId), + R.id.manageChildFragment + ) + + true + } else -> super.onOptionsItemSelected(item) } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/category/Adapter.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/category/Adapter.kt index 3d380cd..4a52856 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/child/category/Adapter.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/category/Adapter.kt @@ -24,6 +24,7 @@ import io.timelimit.android.R import io.timelimit.android.data.model.Category import io.timelimit.android.databinding.AddItemViewBinding import io.timelimit.android.databinding.CategoryRichCardBinding +import io.timelimit.android.databinding.IntroCardBinding import io.timelimit.android.ui.util.DateUtil import io.timelimit.android.util.TimeTextUtil import kotlin.properties.Delegates @@ -91,8 +92,10 @@ class Adapter: RecyclerView.Adapter() { TYPE_INTRO -> IntroViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.category_list_intro, parent, false) + IntroCardBinding.inflate(LayoutInflater.from(parent.context), parent, false).also { + it.title = parent.context.getString(R.string.manage_child_categories_intro_title) + it.text = parent.context.getString(R.string.manage_child_categories_intro_text) + }.root ) TYPE_MANIPULATION_WARNING -> diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt index db134d8..b7107bd 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt @@ -42,7 +42,7 @@ import io.timelimit.android.ui.manage.child.ManageChildFragmentArgs import io.timelimit.android.ui.manage.child.ManageChildFragmentDirections import io.timelimit.android.ui.manage.child.category.create.CreateCategoryDialogFragment import io.timelimit.android.ui.manage.child.category.specialmode.SetCategorySpecialModeFragment -import kotlinx.android.synthetic.main.fragment_manage_child_categories.* +import kotlinx.android.synthetic.main.recycler_fragment.* class ManageChildCategoriesFragment : Fragment() { companion object { @@ -57,7 +57,7 @@ class ManageChildCategoriesFragment : Fragment() { private val model: ManageChildCategoriesModel by viewModels() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_manage_child_categories, container, false) + return inflater.inflate(R.layout.recycler_fragment, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ChildTaskAdapter.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ChildTaskAdapter.kt new file mode 100644 index 0000000..6e7a1d8 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ChildTaskAdapter.kt @@ -0,0 +1,99 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.manage.child.tasks + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.timelimit.android.R +import io.timelimit.android.data.model.ChildTask +import io.timelimit.android.databinding.AddItemViewBinding +import io.timelimit.android.databinding.ChildTaskItemBinding +import io.timelimit.android.databinding.IntroCardBinding +import io.timelimit.android.ui.util.DateUtil +import io.timelimit.android.util.TimeTextUtil +import kotlin.properties.Delegates + +class ChildTaskAdapter: RecyclerView.Adapter() { + companion object { + private const val TYPE_ADD = 1 + private const val TYPE_INTRO = 2 + private const val TYPE_TASK = 3 + } + + var data: List by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() } + var listener: Listener? = null + + init { setHasStableIds(true) } + + override fun getItemCount(): Int = data.size + + override fun getItemId(position: Int): Long = data[position].let { item -> + if (item is ChildTaskItem.Task) item.taskItem.taskId.hashCode() else item.hashCode() + }.toLong() + + override fun getItemViewType(position: Int): Int = when (data[position]) { + ChildTaskItem.Add -> TYPE_ADD + ChildTaskItem.Intro -> TYPE_INTRO + is ChildTaskItem.Task -> TYPE_TASK + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder = LayoutInflater.from(parent.context).let { inflater -> + Holder(when (viewType) { + TYPE_ADD -> AddItemViewBinding.inflate(inflater, parent, false).also { + it.label = parent.context.getString(R.string.manage_child_tasks_add) + it.root.setOnClickListener { listener?.onAddClicked() } + }.root + TYPE_INTRO -> IntroCardBinding.inflate(inflater, parent, false).also { + it.title = parent.context.getString(R.string.manage_child_tasks) + it.text = parent.context.getString(R.string.manage_child_tasks_intro) + }.root + TYPE_TASK -> ChildTaskItemBinding.inflate(inflater, parent, false).also { + it.root.tag = it + }.root + else -> throw IllegalArgumentException() + }) + } + + override fun onBindViewHolder(holder: Holder, position: Int) { + val item = data[position] + + if (item is ChildTaskItem.Task) { + val context = holder.itemView.context + val binding = holder.itemView.tag as ChildTaskItemBinding + + binding.title = item.taskItem.taskTitle + binding.category = item.categoryTitle + binding.duration = TimeTextUtil.time(item.taskItem.extraTimeDuration, context) + binding.lastGrant = item.taskItem.lastGrantTimestamp.let { time -> + if (time == 0L) null else DateUtil.formatAbsoluteDate(context, time) + } + + binding.executePendingBindings() + + binding.root.setOnClickListener { listener?.onTaskClicked(item.taskItem) } + } + } + + class Holder(view: View): RecyclerView.ViewHolder(view) + + interface Listener { + fun onAddClicked() + fun onTaskClicked(task: ChildTask) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ChildTaskItem.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ChildTaskItem.kt new file mode 100644 index 0000000..2a24327 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ChildTaskItem.kt @@ -0,0 +1,25 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.manage.child.tasks + +import io.timelimit.android.data.model.ChildTask + +sealed class ChildTaskItem { + object Add: ChildTaskItem() + object Intro: ChildTaskItem() + class Task(val taskItem: ChildTask, val categoryTitle: String): ChildTaskItem() +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ChildTaskModel.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ChildTaskModel.kt new file mode 100644 index 0000000..73703fb --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ChildTaskModel.kt @@ -0,0 +1,57 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.manage.child.tasks + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.timelimit.android.async.Threads +import io.timelimit.android.data.model.HintsToShow +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.logic.DefaultAppLogic + +class ChildTaskModel (application: Application): AndroidViewModel(application) { + private val logic = DefaultAppLogic.with(application) + private val childIdLive = MutableLiveData() + private val data = childIdLive.switchMap { childId -> logic.database.childTasks().getTasksByUserIdWithCategoryTitlesLive(userId = childId) } + private val dataListItemsLive: LiveData> = data.map { items -> items.map { ChildTaskItem.Task(it.childTask, it.categoryTitle) } } + private val didHideIntroductionLive = logic.database.config().wereHintsShown(HintsToShow.TASKS_INTRODUCTION) + private var didInit = false + + val listContent = didHideIntroductionLive.switchMap { didHideIntroduction -> + dataListItemsLive.map { dataListItems -> + if (didHideIntroduction) + dataListItems + listOf(ChildTaskItem.Add) + else + listOf(ChildTaskItem.Intro) + dataListItems + listOf(ChildTaskItem.Add) + } + } + + fun init(childId: String) { + if (didInit) return + + didInit = true + + childIdLive.value = childId + } + + fun hideIntro() { + Threads.database.submit { logic.database.config().setHintsShownSync(HintsToShow.TASKS_INTRODUCTION) } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ConfirmTaskDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ConfirmTaskDialogFragment.kt new file mode 100644 index 0000000..80b8287 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ConfirmTaskDialogFragment.kt @@ -0,0 +1,66 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.manage.child.tasks + +import android.app.Dialog +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import io.timelimit.android.R +import io.timelimit.android.coroutines.runAsync +import io.timelimit.android.sync.actions.MarkTaskPendingAction +import io.timelimit.android.sync.actions.apply.ApplyActionUtil +import io.timelimit.android.ui.main.getActivityViewModel + +class ConfirmTaskDialogFragment: DialogFragment() { + companion object { + private const val DIALOG_TAG = "ConfirmTaskDialogFragment" + private const val TASK_TITLE = "taskTitle" + private const val TASK_ID = "taskId" + + fun newInstance(taskId: String, taskTitle: String) = ConfirmTaskDialogFragment().apply { + arguments = Bundle().apply { + putString(TASK_ID, taskId) + putString(TASK_TITLE, taskTitle) + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val taskId = requireArguments().getString(TASK_ID)!! + val taskTitle = requireArguments().getString(TASK_TITLE)!! + val logic = getActivityViewModel(requireActivity()).logic + + return AlertDialog.Builder(requireContext(), theme) + .setTitle(taskTitle) + .setMessage(R.string.lock_task_confirm_dialog) + .setNegativeButton(R.string.generic_no, null) + .setPositiveButton(R.string.generic_yes) { _, _ -> + runAsync { + ApplyActionUtil.applyAppLogicAction( + action = MarkTaskPendingAction(taskId = taskId), + appLogic = logic, + ignoreIfDeviceIsNotConfigured = true + ) + } + } + .create() + } + + fun show(fragmentManager: FragmentManager) = show(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/EditTaskCategoryDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/EditTaskCategoryDialogFragment.kt new file mode 100644 index 0000000..a8c6d0e --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/EditTaskCategoryDialogFragment.kt @@ -0,0 +1,85 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.manage.child.tasks + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import io.timelimit.android.extensions.showSafe +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.ui.fragment.BottomSheetSelectionListDialog +import io.timelimit.android.ui.main.getActivityViewModel + +class EditTaskCategoryDialogFragment: BottomSheetSelectionListDialog() { + companion object { + private const val DIALOG_TAG = "EditTaskCategoryDialogFragment" + private const val CHILD_ID = "childId" + private const val CATEGORY_ID = "categoryId" + + fun newInstance(childId: String, categoryId: String?, target: Fragment) = EditTaskCategoryDialogFragment().apply { + arguments = Bundle().apply { + putString(CHILD_ID, childId) + if (categoryId != null) putString(CATEGORY_ID, categoryId) + } + + setTargetFragment(target, 0) + } + } + + override val title: String? = null + private val listener: Listener get() = targetFragment as Listener + private val auth get() = getActivityViewModel(requireActivity()) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val database = DefaultAppLogic.with(requireContext()).database + val childId = requireArguments().getString(CHILD_ID)!! + val currentCategoryId = if (requireArguments().containsKey(CATEGORY_ID)) requireArguments().getString(CATEGORY_ID) else null + + database.user().getChildUserByIdLive(childId).observe(viewLifecycleOwner) { + if (it == null) dismissAllowingStateLoss() + } + + auth.authenticatedUser.observe(viewLifecycleOwner) { + if (it == null) dismissAllowingStateLoss() + } + + database.category().getCategoriesByChildId(childId).observe(viewLifecycleOwner) { categories -> + clearList() + + categories.forEach { category -> + addListItem( + label = category.title, + checked = category.id == currentCategoryId, + click = { + listener.onCategorySelected(category.id) + + dismissAllowingStateLoss() + } + ) + } + } + } + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) + + interface Listener { + fun onCategorySelected(categoryId: String) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/EditTaskDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/EditTaskDialogFragment.kt new file mode 100644 index 0000000..58c7e3f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/EditTaskDialogFragment.kt @@ -0,0 +1,122 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.manage.child.tasks + +import android.os.Bundle +import android.text.InputFilter +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import io.timelimit.android.R +import io.timelimit.android.data.model.ChildTask +import io.timelimit.android.databinding.EditTaskFragmentBinding +import io.timelimit.android.extensions.addOnTextChangedListener +import io.timelimit.android.ui.main.getActivityViewModel +import io.timelimit.android.ui.util.bind + +class EditTaskDialogFragment: BottomSheetDialogFragment(), EditTaskCategoryDialogFragment.Listener { + companion object { + private const val DIALOG_TAG = "EditTaskDialogFragment" + + private const val CHILD_ID = "childId" + private const val TASK_ID = "taskId" + + fun newInstance(childId: String, taskId: String?, listener: Fragment) = EditTaskDialogFragment().apply { + arguments = Bundle().apply { + putString(CHILD_ID, childId) + if (taskId != null) putString(TASK_ID, taskId) + + setTargetFragment(listener, 0) + } + } + } + + private val auth get() = getActivityViewModel(requireActivity()) + private val model by viewModels() + private val target get() = targetFragment as Listener + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val binding = EditTaskFragmentBinding.inflate(inflater, container, false) + val args = requireArguments() + val childId = args.getString(CHILD_ID)!! + val taskId = if (args.containsKey(TASK_ID)) args.getString(TASK_ID) else null + + model.init(childId = childId, taskId = taskId) + + binding.isNewTask = taskId == null + + binding.taskTitle.filters = arrayOf(InputFilter.LengthFilter(ChildTask.MAX_TASK_TITLE_LENGTH)) + + binding.taskTitle.addOnTextChangedListener { + val value = binding.taskTitle.text.toString() + + if (model.taskTitleLive.value != value) model.taskTitleLive.value = value + } + + model.taskTitleLive.observe(viewLifecycleOwner) { value -> + if (value != binding.taskTitle.text.toString()) binding.taskTitle.setText(value) + } + + model.selectedCategoryTitle.observe(viewLifecycleOwner) { categoryTitle -> + binding.categoryDropdown.text = categoryTitle ?: getString(R.string.manage_child_tasks_select_category) + } + + binding.categoryDropdown.setOnClickListener { + EditTaskCategoryDialogFragment.newInstance(childId = childId, categoryId = model.categoryIdLive.value, target = this).show(parentFragmentManager) + } + + binding.timespan.bind(model.logic.database, viewLifecycleOwner) { + if (model.durationLive.value != it) model.durationLive.value = it + } + + model.durationLive.observe(viewLifecycleOwner) { + if (it != binding.timespan.timeInMillis) binding.timespan.timeInMillis = it + } + + binding.confirmButton.isEnabled = false + model.valid.observe(viewLifecycleOwner) { binding.confirmButton.isEnabled = it } + + model.shouldClose.observe(viewLifecycleOwner) { if (it) dismissAllowingStateLoss() } + model.isBusy.observe(viewLifecycleOwner) { binding.flipper.displayedChild = if (it) 1 else 0 } + + binding.deleteButton.setOnClickListener { model.deleteRule(auth) { target.onTaskRemoved(it) } } + binding.confirmButton.setOnClickListener { model.saveRule(auth); target.onTaskSaved() } + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + auth.authenticatedUser.observe(this) { + if (it == null) dismissAllowingStateLoss() + } + } + + override fun onCategorySelected(categoryId: String) { model.categoryIdLive.value = categoryId } + + fun show(fragmentManager: FragmentManager) = show(fragmentManager, DIALOG_TAG) + + interface Listener { + fun onTaskRemoved(task: ChildTask) + fun onTaskSaved() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/EditTaskModel.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/EditTaskModel.kt new file mode 100644 index 0000000..d2d81b2 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/EditTaskModel.kt @@ -0,0 +1,150 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.manage.child.tasks + +import android.app.Application +import android.widget.Toast +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import io.timelimit.android.R +import io.timelimit.android.coroutines.runAsync +import io.timelimit.android.data.IdGenerator +import io.timelimit.android.data.model.ChildTask +import io.timelimit.android.livedata.* +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.sync.actions.DeleteChildTaskAction +import io.timelimit.android.sync.actions.UpdateChildTaskAction +import io.timelimit.android.ui.main.ActivityViewModel +import java.lang.IllegalArgumentException + +class EditTaskModel(application: Application): AndroidViewModel(application) { + val logic = DefaultAppLogic.with(application) + + private var didInit = false + private var originalTask: ChildTask? = null + private val childIdLive = MutableLiveData() + private val taskIdLive = MutableLiveData() + private val isBusyInternal = MutableLiveData().apply { value = true } + private val shouldCloseInternal = MutableLiveData().apply { value = false } + private val isMissingTask = taskIdLive.switchMap { taskId -> + if (taskId == null) liveDataFromValue(false) + else logic.database.childTasks().getTaskByTaskIdLive(taskId).map { it == null} + } + val categoryIdLive = MutableLiveData() + val taskTitleLive = MutableLiveData() + val durationLive = MutableLiveData() + val isBusy = isBusyInternal.or(isMissingTask) + val shouldClose = shouldCloseInternal.castDown() + + private val selectedCategory = childIdLive.switchMap { childId -> + categoryIdLive.switchMap { categoryId -> + if (categoryId != null) + logic.database.category().getCategoryByChildIdAndId(childId = childId, categoryId = categoryId) + else + liveDataFromValue(null) + } + } + + val selectedCategoryTitle = selectedCategory.map { it?.title } + + private val validCategory = selectedCategory.map { it != null } + private val validTitle = taskTitleLive.map { it.isNotBlank() && it.length <= ChildTask.MAX_TASK_TITLE_LENGTH } + private val durationValid = durationLive.map { it > 0 && it <= ChildTask.MAX_EXTRA_TIME } + val valid = validCategory.and(validTitle).and(durationValid) + + fun init(childId: String, taskId: String?) { + if (didInit) return; didInit = true + + childIdLive.value = childId + taskIdLive.value = taskId + categoryIdLive.value = null + taskTitleLive.value = "" + durationLive.value = 1000L * 60 * 15 + + runAsync { + if (taskId != null) { + val task = logic.database.childTasks().getTaskByTaskIdCoroutine(taskId) + + if (task != null) { + categoryIdLive.value = task.categoryId + taskTitleLive.value = task.taskTitle + durationLive.value = task.extraTimeDuration.toLong() + + originalTask = task + } else { + shouldCloseInternal.value = true + } + } + + isBusyInternal.value = false + } + } + + fun deleteRule(auth: ActivityViewModel, onTaskRemoved: (ChildTask) -> Unit) { + val taskId = taskIdLive.value + val oldTask = originalTask + + if (taskId != null && oldTask != null) { + isBusyInternal.value = true + + auth.tryDispatchParentAction( + DeleteChildTaskAction(taskId = taskId) + ) + + onTaskRemoved(oldTask) + + shouldCloseInternal.value = true + } + } + + fun saveRule(auth: ActivityViewModel) { + isBusyInternal.value = true + + val taskId = taskIdLive.value + val categoryId = categoryIdLive.value ?: return + val duration = durationLive.value ?: return + val taskTitle = taskTitleLive.value ?: return + + try { + if (taskId == null) { + auth.tryDispatchParentAction( + UpdateChildTaskAction( + taskId = IdGenerator.generateId(), + categoryId = categoryId, + extraTimeDuration = duration.toInt(), + isNew = true, + taskTitle = taskTitle + ) + ) + } else { + auth.tryDispatchParentAction( + UpdateChildTaskAction( + taskId = taskId, + categoryId = categoryId, + extraTimeDuration = duration.toInt(), + isNew = false, + taskTitle = taskTitle + ) + ) + } + } catch (ex: IllegalArgumentException) { + Toast.makeText(getApplication(), R.string.error_general, Toast.LENGTH_SHORT).show() + } + + shouldCloseInternal.value = true + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ManageChildTasksFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ManageChildTasksFragment.kt new file mode 100644 index 0000000..cbc37ab --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/tasks/ManageChildTasksFragment.kt @@ -0,0 +1,118 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.manage.child.tasks + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import io.timelimit.android.R +import io.timelimit.android.data.model.ChildTask +import io.timelimit.android.sync.actions.UpdateChildTaskAction +import io.timelimit.android.ui.main.getActivityViewModel +import kotlinx.android.synthetic.main.recycler_fragment.* + +class ManageChildTasksFragment: Fragment(), EditTaskDialogFragment.Listener { + companion object { + private const val CHILD_ID = "childId" + + fun newInstance(childId: String) = ManageChildTasksFragment().apply { + arguments = Bundle().apply { + putString(CHILD_ID, childId) + } + } + } + + private val childId get() = requireArguments().getString(CHILD_ID)!! + private val auth get() = getActivityViewModel(requireActivity()) + private val model: ChildTaskModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + model.init(childId = childId) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.recycler_fragment, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val adapter = ChildTaskAdapter() + + recycler.layoutManager = LinearLayoutManager(requireContext()) + recycler.adapter = adapter + + model.listContent.observe(viewLifecycleOwner) { adapter.data = it } + + adapter.listener = object: ChildTaskAdapter.Listener { + override fun onAddClicked() { + if (auth.requestAuthenticationOrReturnTrue()) { + EditTaskDialogFragment.newInstance(childId = childId, taskId = null, listener = this@ManageChildTasksFragment).show(parentFragmentManager) + } + } + + override fun onTaskClicked(task: ChildTask) { + if (auth.requestAuthenticationOrReturnTrue()) { + EditTaskDialogFragment.newInstance(childId = childId, taskId = task.taskId, listener = this@ManageChildTasksFragment).show(parentFragmentManager) + } + } + } + + ItemTouchHelper(object: ItemTouchHelper.SimpleCallback(0, 0) { + override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + val index = viewHolder.adapterPosition + val item = if (index == RecyclerView.NO_POSITION) null else adapter.data[index] + + return if (item == ChildTaskItem.Intro) { + ItemTouchHelper.START or ItemTouchHelper.END + } else 0 + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { model.hideIntro() } + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean = throw IllegalStateException() + }).attachToRecyclerView(recycler) + } + + override fun onTaskRemoved(task: ChildTask) { + Snackbar.make(requireView(), R.string.manage_child_tasks_toast_removed, Snackbar.LENGTH_SHORT) + .setAction(R.string.generic_undo) { + auth.tryDispatchParentAction( + UpdateChildTaskAction( + isNew = true, + taskId = task.taskId, + taskTitle = task.taskTitle, + extraTimeDuration = task.extraTimeDuration, + categoryId = task.categoryId + ) + ) + } + .show() + } + + override fun onTaskSaved() { + Snackbar.make(requireView(), R.string.manage_child_tasks_toast_saved, Snackbar.LENGTH_SHORT).show() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragment.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragment.kt index e764e1c..1892c1a 100644 --- a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragment.kt @@ -19,21 +19,18 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModelProviders +import androidx.fragment.app.viewModels import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.timelimit.android.R import io.timelimit.android.async.Threads import io.timelimit.android.coroutines.CoroutineFragment -import io.timelimit.android.data.model.Device -import io.timelimit.android.data.model.HintsToShow -import io.timelimit.android.data.model.User -import io.timelimit.android.data.model.UserType +import io.timelimit.android.data.model.* import io.timelimit.android.livedata.waitForNonNullValue import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.sync.actions.ReviewChildTaskAction import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel import kotlinx.android.synthetic.main.fragment_overview.* @@ -41,11 +38,9 @@ import kotlinx.coroutines.launch class OverviewFragment : CoroutineFragment() { private val handlers: OverviewFragmentParentHandlers by lazy { parentFragment as OverviewFragmentParentHandlers } - private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } - private val auth: ActivityViewModel by lazy { getActivityViewModel(activity!!) } - private val model: OverviewFragmentModel by lazy { - ViewModelProviders.of(this).get(OverviewFragmentModel::class.java) - } + private val logic: AppLogic by lazy { DefaultAppLogic.with(requireContext()) } + private val auth: ActivityViewModel by lazy { getActivityViewModel(requireActivity()) } + private val model: OverviewFragmentModel by viewModels() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_overview, container, false) @@ -57,7 +52,7 @@ class OverviewFragment : CoroutineFragment() { val adapter = OverviewFragmentAdapter() recycler.adapter = adapter - recycler.layoutManager = LinearLayoutManager(context!!) + recycler.layoutManager = LinearLayoutManager(requireContext()) adapter.handlers = object: OverviewFragmentHandlers { override fun onAddUserClicked() { @@ -106,9 +101,33 @@ class OverviewFragment : CoroutineFragment() { override fun onSetDeviceListVisibility(level: DeviceListItemVisibility) { model.showMoreDevices(level) } + + override fun onTaskConfirmed(task: ChildTask) { + auth.tryDispatchParentAction( + ReviewChildTaskAction( + taskId = task.taskId, + ok = true, + time = logic.timeApi.getCurrentTimeInMillis() + ) + ) + } + + override fun onTaskRejected(task: ChildTask) { + auth.tryDispatchParentAction( + ReviewChildTaskAction( + taskId = task.taskId, + ok = false, + time = logic.timeApi.getCurrentTimeInMillis() + ) + ) + } + + override fun onSkipTaskReviewClicked(task: ChildTask) { + if (auth.requestAuthenticationOrReturnTrue()) model.hideTask(task.taskId) + } } - model.listEntries.observe(this, Observer { adapter.data = it }) + model.listEntries.observe(viewLifecycleOwner) { adapter.data = it } ItemTouchHelper( object: ItemTouchHelper.Callback() { diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentAdapter.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentAdapter.kt index d2395e9..f40c90c 100644 --- a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentAdapter.kt +++ b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentAdapter.kt @@ -21,10 +21,13 @@ import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import io.timelimit.android.BuildConfig import io.timelimit.android.R +import io.timelimit.android.data.model.ChildTask import io.timelimit.android.data.model.Device import io.timelimit.android.data.model.User import io.timelimit.android.data.model.UserType import io.timelimit.android.databinding.* +import io.timelimit.android.ui.util.DateUtil +import io.timelimit.android.util.TimeTextUtil import kotlin.properties.Delegates class OverviewFragmentAdapter : RecyclerView.Adapter() { @@ -45,6 +48,7 @@ class OverviewFragmentAdapter : RecyclerView.Adapter return when (item) { is OverviewFragmentItemDevice -> "device ${item.device.id}".hashCode().toLong() is OverviewFragmentItemUser -> "user ${item.user.id}".hashCode().toLong() + is TaskReviewOverviewItem -> "task ${item.task.taskId}".hashCode().toLong() else -> item.hashCode().toLong() } } @@ -70,6 +74,7 @@ class OverviewFragmentAdapter : RecyclerView.Adapter is OverviewFragmentHeaderFinishSetup -> OverviewFragmentViewType.FinishSetup is OverviewFragmentItemMessage -> OverviewFragmentViewType.ServerMessage is ShowMoreOverviewFragmentItem -> OverviewFragmentViewType.ShowMoreButton + is TaskReviewOverviewItem -> OverviewFragmentViewType.TaskReview } override fun getItemViewType(position: Int) = getItemType(getItem(position)).ordinal @@ -155,6 +160,10 @@ class OverviewFragmentAdapter : RecyclerView.Adapter .inflate(R.layout.show_more_list_item, parent, false) ) + OverviewFragmentViewType.TaskReview.ordinal -> TaskReviewHolder( + FragmentOverviewTaskReviewBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + else -> throw IllegalStateException() } @@ -241,6 +250,23 @@ class OverviewFragmentAdapter : RecyclerView.Adapter } }.let { } } + is TaskReviewOverviewItem -> { + holder as TaskReviewHolder + + holder.binding.let { + it.categoryTitle = item.categoryTitle + it.childName = item.childTitle + it.duration = TimeTextUtil.time(item.task.extraTimeDuration, it.root.context) + it.lastGrant = if (item.task.lastGrantTimestamp == 0L) null else DateUtil.formatAbsoluteDate(it.root.context, item.task.lastGrantTimestamp) + it.taskTitle = item.task.taskTitle + + it.yesButton.setOnClickListener { handlers?.onTaskConfirmed(item.task) } + it.noButton.setOnClickListener { handlers?.onTaskRejected(item.task) } + it.skipButton.setOnClickListener { handlers?.onSkipTaskReviewClicked(item.task) } + } + + holder.binding.executePendingBindings() + } }.let { } } } @@ -254,7 +280,8 @@ enum class OverviewFragmentViewType { Introduction, FinishSetup, ServerMessage, - ShowMoreButton + ShowMoreButton, + TaskReview } sealed class OverviewFragmentViewHolder(view: View): RecyclerView.ViewHolder(view) @@ -267,6 +294,7 @@ class IntroViewHolder(view: View): OverviewFragmentViewHolder(view) class FinishSetupViewHolder(view: View): OverviewFragmentViewHolder(view) class ServerMessageViewHolder(val binding: FragmentOverviewServerMessageBinding): OverviewFragmentViewHolder(binding.root) class ShowMoreViewHolder(view: View): OverviewFragmentViewHolder(view) +class TaskReviewHolder(val binding: FragmentOverviewTaskReviewBinding): OverviewFragmentViewHolder(binding.root) interface OverviewFragmentHandlers { fun onAddUserClicked() @@ -276,4 +304,7 @@ interface OverviewFragmentHandlers { fun onFinishSetupClicked() fun onShowAllUsersClicked() fun onSetDeviceListVisibility(level: DeviceListItemVisibility) + fun onSkipTaskReviewClicked(task: ChildTask) + fun onTaskConfirmed(task: ChildTask) + fun onTaskRejected(task: ChildTask) } diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentItem.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentItem.kt index 8374ac0..de50bb6 100644 --- a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentItem.kt +++ b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentItem.kt @@ -15,6 +15,7 @@ */ package io.timelimit.android.ui.overview.overview +import io.timelimit.android.data.model.ChildTask import io.timelimit.android.data.model.Device import io.timelimit.android.data.model.User import io.timelimit.android.data.model.UserType @@ -39,4 +40,5 @@ data class OverviewFragmentItemMessage(val message: String): OverviewFragmentIte sealed class ShowMoreOverviewFragmentItem: OverviewFragmentItem() { object ShowAllUsers: ShowMoreOverviewFragmentItem() data class ShowMoreDevices(val level: DeviceListItemVisibility): ShowMoreOverviewFragmentItem() -} \ No newline at end of file +} +data class TaskReviewOverviewItem(val task: ChildTask, val childTitle: String, val categoryTitle: String): OverviewFragmentItem() \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentModel.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentModel.kt index 9573f02..47edd23 100644 --- a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentModel.kt @@ -115,42 +115,63 @@ class OverviewFragmentModel(application: Application): AndroidViewModel(applicat } } + private val hiddenTaskIdsLive = MutableLiveData>().apply { value = emptySet() } + private val tasksWithPendingReviewLive = logic.database.childTasks().getPendingTasks() + private val pendingTasksToShowLive = hiddenTaskIdsLive.switchMap { hiddenTaskIds -> + tasksWithPendingReviewLive.map { tasksWithPendingReview -> + tasksWithPendingReview.filterNot { hiddenTaskIds.contains(it.childTask.taskId) } + } + } + private val pendingTaskItemLive = pendingTasksToShowLive.map { tasks -> + tasks.firstOrNull()?.let { + TaskReviewOverviewItem(task = it.childTask, childTitle = it.childName, categoryTitle = it.categoryTitle) + } + } + + fun hideTask(taskId: String) { + hiddenTaskIdsLive.value = (hiddenTaskIdsLive.value ?: emptySet()) + setOf(taskId) + } + val listEntries = introEntries.switchMap { introEntries -> deviceEntries.switchMap { deviceEntries -> userEntries.switchMap { userEntries -> - itemVisibility.map { itemVisibility -> - mutableListOf().apply { - addAll(introEntries) + pendingTaskItemLive.switchMap { pendingTaskItem -> + itemVisibility.map { itemVisibility -> + mutableListOf().apply { + addAll(introEntries) - add(OverviewFragmentHeaderDevices) - val shownDevices = when (itemVisibility.devices) { - DeviceListItemVisibility.BareMinimum -> deviceEntries.filter { it.isCurrentDevice || it.isImportant } - DeviceListItemVisibility.AllChildDevices -> deviceEntries.filter { it.isCurrentDevice || it.isImportant || it.deviceUser?.type == UserType.Child } - DeviceListItemVisibility.AllDevices -> deviceEntries - } - addAll(shownDevices) - if (shownDevices.size == deviceEntries.size) { - add(OverviewFragmentActionAddDevice) - } else { - add(ShowMoreOverviewFragmentItem.ShowMoreDevices(when (itemVisibility.devices) { - DeviceListItemVisibility.BareMinimum -> if (deviceEntries.find { it.deviceUser?.type == UserType.Child } != null) - DeviceListItemVisibility.AllChildDevices else DeviceListItemVisibility.AllDevices - DeviceListItemVisibility.AllChildDevices -> DeviceListItemVisibility.AllDevices - DeviceListItemVisibility.AllDevices -> DeviceListItemVisibility.AllDevices - })) - } + if (pendingTaskItem != null) add(pendingTaskItem) - add(OverviewFragmentHeaderUsers) - if (itemVisibility.showParentUsers) { - addAll(userEntries) - add(OverviewFragmentActionAddUser) - } else { - userEntries.forEach { if (it.user.type != UserType.Parent) add(it) } - add(ShowMoreOverviewFragmentItem.ShowAllUsers) - } - }.toList() - } - } as LiveData> + add(OverviewFragmentHeaderDevices) + val shownDevices = when (itemVisibility.devices) { + DeviceListItemVisibility.BareMinimum -> deviceEntries.filter { it.isCurrentDevice || it.isImportant } + DeviceListItemVisibility.AllChildDevices -> deviceEntries.filter { it.isCurrentDevice || it.isImportant || it.deviceUser?.type == UserType.Child } + DeviceListItemVisibility.AllDevices -> deviceEntries + } + addAll(shownDevices) + if (shownDevices.size == deviceEntries.size) { + add(OverviewFragmentActionAddDevice) + } else { + add(ShowMoreOverviewFragmentItem.ShowMoreDevices(when (itemVisibility.devices) { + DeviceListItemVisibility.BareMinimum -> if (deviceEntries.find { it.deviceUser?.type == UserType.Child } != null) + DeviceListItemVisibility.AllChildDevices else DeviceListItemVisibility.AllDevices + DeviceListItemVisibility.AllChildDevices -> DeviceListItemVisibility.AllDevices + DeviceListItemVisibility.AllDevices -> DeviceListItemVisibility.AllDevices + })) + } + + add(OverviewFragmentHeaderUsers) + if (itemVisibility.showParentUsers) { + addAll(userEntries) + add(OverviewFragmentActionAddUser) + } else { + userEntries.forEach { if (it.user.type != UserType.Parent) add(it) } + add(ShowMoreOverviewFragmentItem.ShowAllUsers) + } + }.toList() + } + } as LiveData> + } } } diff --git a/app/src/main/java/io/timelimit/android/ui/util/DurationSelectionMode.kt b/app/src/main/java/io/timelimit/android/ui/util/DurationSelectionMode.kt new file mode 100644 index 0000000..4d587be --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/util/DurationSelectionMode.kt @@ -0,0 +1,39 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.util + +import androidx.lifecycle.LifecycleOwner +import io.timelimit.android.async.Threads +import io.timelimit.android.data.Database +import io.timelimit.android.ui.view.SelectTimeSpanView +import io.timelimit.android.ui.view.SelectTimeSpanViewListener + +fun SelectTimeSpanView.bind(database: Database, lifecycleOwner: LifecycleOwner, listener: (Long) -> Unit) { + database.config().getEnableAlternativeDurationSelectionAsync().observe(lifecycleOwner) { + enablePickerMode(it) + } + + this.listener = object: SelectTimeSpanViewListener { + override fun onTimeSpanChanged(newTimeInMillis: Long) { listener(timeInMillis) } + + override fun setEnablePickerMode(enable: Boolean) { + Threads.database.execute { + database.config().setEnableAlternativeDurationSelectionSync(enable) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/view/SelectTimeSpanView.kt b/app/src/main/java/io/timelimit/android/ui/view/SelectTimeSpanView.kt index 4c6c67f..c5a7c36 100644 --- a/app/src/main/java/io/timelimit/android/ui/view/SelectTimeSpanView.kt +++ b/app/src/main/java/io/timelimit/android/ui/view/SelectTimeSpanView.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,33 +19,44 @@ import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.View -import android.widget.FrameLayout -import android.widget.SeekBar +import android.widget.* import io.timelimit.android.R -import io.timelimit.android.databinding.ViewSelectTimeSpanBinding import io.timelimit.android.util.TimeTextUtil import kotlin.properties.Delegates class SelectTimeSpanView(context: Context, attributeSet: AttributeSet? = null): FrameLayout(context, attributeSet) { - private val binding = ViewSelectTimeSpanBinding.inflate(LayoutInflater.from(context), this, false) - init { - addView(binding.root) + LayoutInflater.from(context).inflate(R.layout.view_select_time_span, this, true) } + private val seekbarContainer = findViewById(R.id.seekbar_container) + private val pickerContainer = findViewById(R.id.picker_container) + + private val switchToPickerButton = findViewById(R.id.switch_to_picker_button) + private val switchToSeekbarButton = findViewById(R.id.switch_to_seekbar_button) + + private val daysText = findViewById(R.id.days_text) + private val dayPickerContainer = findViewById(R.id.day_picker_container) + private val dayPicker = findViewById(R.id.day_picker) + private val daySeekbar = findViewById(R.id.days_seek) + + private val hoursText = findViewById(R.id.hours_text) + private val hourPicker = findViewById(R.id.hour_picker) + private val hourSeekbar = findViewById(R.id.hours_seek) + + private val minutesText = findViewById(R.id.minutes_text) + private val minutePicker = findViewById(R.id.minute_picker) + private val minuteSeekbar = findViewById(R.id.minutes_seek) + var listener: SelectTimeSpanViewListener? = null - var timeInMillis: Long by Delegates.observable(0L) { _, _, _ -> - bindTime() + var timeInMillis: Long by Delegates.observable(0L) { _, oldValue, newValue -> + if (oldValue != newValue) { bindTime() } + listener?.onTimeSpanChanged(timeInMillis) } - var maxDays: Int by Delegates.observable(0) { _, _, _ -> - binding.maxDays = maxDays - - binding.dayPicker.maxValue = maxDays - binding.dayPickerContainer.visibility = if (maxDays > 0) View.VISIBLE else View.GONE - } + var maxDays: Int by Delegates.observable(0) { _, _, newValue -> bindMaxDays(newValue) } init { val attributes = context.obtainStyledAttributes(attributeSet, R.styleable.SelectTimeSpanView) @@ -56,77 +67,76 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet? = null): attributes.recycle() bindTime() + enablePickerMode(false) + } + + private fun bindMaxDays(newValue: Int) { + val multipleDays = newValue > 0 + val vis = if (multipleDays) View.VISIBLE else View.GONE + + dayPicker.maxValue = newValue + daySeekbar.max = newValue + + dayPickerContainer.visibility = vis + daysText.visibility = vis + daySeekbar.visibility = vis + } private fun bindTime() { - val totalMinutes = (timeInMillis / (1000 * 60)).toInt() - val totalHours = totalMinutes / 60 - val totalDays = totalHours / 24 - val minutes = totalMinutes % 60 - val hours = totalHours % 24 + val duration = Duration.decode(timeInMillis) - binding.days = totalDays - binding.minutes = minutes - binding.hours = hours + daysText.text = TimeTextUtil.days(duration.days, context!!) + minutesText.text = TimeTextUtil.minutes(duration.minutes, context!!) + hoursText.text = TimeTextUtil.hours(duration.hours, context!!) - binding.daysText = TimeTextUtil.days(totalDays, context!!) - binding.minutesText = TimeTextUtil.minutes(minutes, context!!) - binding.hoursText = TimeTextUtil.hours(hours, context!!) + minutePicker.value = duration.minutes + minuteSeekbar.progress = duration.minutes - binding.minutePicker.value = binding.minutes ?: 0 - binding.hourPicker.value = binding.hours ?: 0 - binding.dayPicker.value = binding.days ?: 0 - } + hourPicker.value = duration.hours + hourSeekbar.progress = duration.hours - private fun readStatusFromBinding() { - val days = binding.days!!.toLong() - val hours = binding.hours!!.toLong() - val minutes = binding.minutes!!.toLong() - - timeInMillis = (((days * 24) + hours) * 60 + minutes) * 1000 * 60 + dayPicker.value = duration.days + daySeekbar.progress = duration.days } fun clearNumberPickerFocus() { - binding.minutePicker.clearFocus() - binding.hourPicker.clearFocus() - binding.dayPicker.clearFocus() + minutePicker.clearFocus() + hourPicker.clearFocus() + dayPicker.clearFocus() } fun enablePickerMode(enable: Boolean) { - binding.seekbarContainer.visibility = if (enable) View.GONE else View.VISIBLE - binding.pickerContainer.visibility = if (enable) View.VISIBLE else View.GONE + seekbarContainer.visibility = if (enable) View.GONE else View.VISIBLE + pickerContainer.visibility = if (enable) View.VISIBLE else View.GONE } init { - binding.minutePicker.minValue = 0 - binding.minutePicker.maxValue = 59 + minutePicker.minValue = 0 + minutePicker.maxValue = 59 - binding.hourPicker.minValue = 0 - binding.hourPicker.maxValue = 23 + hourPicker.minValue = 0 + hourPicker.maxValue = 23 - binding.dayPicker.minValue = 0 - binding.dayPicker.maxValue = 1 - binding.dayPickerContainer.visibility = View.GONE + dayPicker.minValue = 0 + dayPicker.maxValue = 1 + dayPickerContainer.visibility = View.GONE - binding.minutePicker.setOnValueChangedListener { _, _, newValue -> - binding.minutes = newValue - readStatusFromBinding() + minutePicker.setOnValueChangedListener { _, _, newValue -> + timeInMillis = Duration.decode(timeInMillis).copy(minutes = newValue).timeInMillis } - binding.hourPicker.setOnValueChangedListener { _, _, newValue -> - binding.hours = newValue - readStatusFromBinding() + hourPicker.setOnValueChangedListener { _, _, newValue -> + timeInMillis = Duration.decode(timeInMillis).copy(hours = newValue).timeInMillis } - binding.dayPicker.setOnValueChangedListener { _, _, newValue -> - binding.days = newValue - readStatusFromBinding() + dayPicker.setOnValueChangedListener { _, _, newValue -> + timeInMillis = Duration.decode(timeInMillis).copy(days = newValue).timeInMillis } - binding.daysSeek.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener { + daySeekbar.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { - binding.days = progress - readStatusFromBinding() + timeInMillis = Duration.decode(timeInMillis).copy(days = progress).timeInMillis } override fun onStartTrackingTouch(seekBar: SeekBar?) { @@ -138,10 +148,9 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet? = null): } }) - binding.hoursSeek.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener { + hourSeekbar.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { - binding.hours = progress - readStatusFromBinding() + timeInMillis = Duration.decode(timeInMillis).copy(hours = progress).timeInMillis } override fun onStartTrackingTouch(seekBar: SeekBar?) { @@ -153,10 +162,9 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet? = null): } }) - binding.minutesSeek.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener { + minuteSeekbar.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { - binding.minutes = progress - readStatusFromBinding() + timeInMillis = Duration.decode(timeInMillis).copy(minutes = progress).timeInMillis } override fun onStartTrackingTouch(seekBar: SeekBar?) { @@ -168,10 +176,26 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet? = null): } }) - binding.pickerContainer.visibility = GONE + pickerContainer.visibility = GONE - binding.switchToPickerButton.setOnClickListener { listener?.setEnablePickerMode(true) } - binding.switchToSeekbarButton.setOnClickListener { listener?.setEnablePickerMode(false) } + switchToPickerButton.setOnClickListener { listener?.setEnablePickerMode(true) } + switchToSeekbarButton.setOnClickListener { listener?.setEnablePickerMode(false) } + } + + internal data class Duration (val days: Int, val hours: Int, val minutes: Int) { + companion object { + fun decode(timeInMillis: Long): Duration { + val totalMinutes = (timeInMillis / (1000 * 60)).toInt() + val totalHours = totalMinutes / 60 + val totalDays = totalHours / 24 + val minutes = totalMinutes % 60 + val hours = totalHours % 24 + + return Duration(days = totalDays, hours = hours, minutes = minutes) + } + } + + val timeInMillis = ((((days * 24L) + hours) * 60 + minutes) * 1000 * 60) } } diff --git a/app/src/main/res/drawable-de/ic_baseline_directions_bike_24.xml b/app/src/main/res/drawable-de/ic_baseline_directions_bike_24.xml new file mode 100644 index 0000000..b226cef --- /dev/null +++ b/app/src/main/res/drawable-de/ic_baseline_directions_bike_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/category_list_intro.xml b/app/src/main/res/layout/category_list_intro.xml deleted file mode 100644 index 5f6b47f..0000000 --- a/app/src/main/res/layout/category_list_intro.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/child_task_item.xml b/app/src/main/res/layout/child_task_item.xml new file mode 100644 index 0000000..0eb4e7a --- /dev/null +++ b/app/src/main/res/layout/child_task_item.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/edit_task_fragment.xml b/app/src/main/res/layout/edit_task_fragment.xml new file mode 100644 index 0000000..ac0a362 --- /dev/null +++ b/app/src/main/res/layout/edit_task_fragment.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + +