diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f712d80..139a5f8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -180,6 +180,28 @@ "one_tracker": "1 tracker", "n_tracker": "{n} trackers", "tracking": "Tracking", + "syncing": "Sync", + "sync_logged": "Login successful", + "syncing_subtitle": "Sync your progress across multiple devices via a self-hosted \nserver. Make sure to upload first if this is your first time \nsyncing or download before using (auto) sync on this device!", + "last_sync": "Last sync at: ", + "last_upload": "Last upload at: ", + "last_download": "Last download at: ", + "sync_server": "Sync Server Address", + "sync_login_invalid_creds": "Invalid email or password", + "sync_checking": "Checking for sync...", + "sync_uploading": "Upload started...", + "sync_downloading": "Download started...", + "sync_upload_finished": "Upload finished", + "sync_download_finished": "Download finished", + "sync_up_to_date": "Sync up to date", + "sync_upload_failed": "Upload failed", + "sync_download_failed": "Download failed", + "sync_button_sync": "Sync progress", + "sync_button_upload": "Full upload", + "sync_button_download": "Full download", + "sync_confirm_upload": "A full upload will completely replace the remote data with your current one!", + "sync_confirm_download": "A full download will completely replace your current data with the remote one!", + "dialog_confirm": "Confirm", "description": "Description", "episode_progress": "Progress: {n}", "n_episodes": "{n} episodes", @@ -274,6 +296,8 @@ "default_skip_intro_length": "Default Skip intro length", "default_playback_speed_length": "Default Playback speed length", "updateProgressAfterReading": "Update progress after reading", + "syncAfterReading": "Sync after reading or watching", + "syncOnAppLaunch": "Sync when opening the app", "no_sources_installed": "No sources installed!", "show_extensions": "Show extensions", "default_skip_forward_skip_length": "Default skip forward skip length", diff --git a/lib/main.dart b/lib/main.dart index 49effa0..9d3afa2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,8 @@ import 'package:mangayomi/modules/more/settings/appearance/providers/blend_level import 'package:mangayomi/modules/more/settings/appearance/providers/flex_scheme_color_state_provider.dart'; import 'package:mangayomi/modules/more/settings/appearance/providers/pure_black_dark_mode_state_provider.dart'; import 'package:mangayomi/modules/more/settings/appearance/providers/theme_mode_state_provider.dart'; +import 'package:mangayomi/modules/more/settings/sync/providers/sync_providers.dart'; +import 'package:mangayomi/services/sync_server.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:mangayomi/src/rust/frb_generated.dart'; import 'package:media_kit/media_kit.dart'; @@ -70,6 +72,10 @@ class _MyAppState extends ConsumerState { @override Widget build(BuildContext context) { + final syncOnAppLaunch = ref.watch(syncOnAppLaunchStateProvider); + if (syncOnAppLaunch) { + ref.read(syncServerProvider(syncId: 1).notifier).syncToServer(ref, true); + } final isDarkTheme = ref.watch(themeModeStateProvider); final blendLevel = ref.watch(blendLevelStateProvider); final appFontFamily = ref.watch(appFontFamilyProvider); diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 19e6cca..5713a54 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -137,6 +137,10 @@ class Settings { List? backupFrequencyOptions; + bool? syncOnAppLaunch; + + bool? syncAfterReading; + String? autoBackupLocation; bool? usePageTapZones; @@ -246,6 +250,8 @@ class Settings { this.personalPageModeList, this.backupFrequency, this.backupFrequencyOptions, + this.syncOnAppLaunch, + this.syncAfterReading, this.autoBackupLocation, this.startDatebackup, this.usePageTapZones = true, @@ -390,13 +396,15 @@ class Settings { userAgent = json['userAgent']; backupFrequency = json['backupFrequency']; backupFrequencyOptions = json['backupFrequencyOptions']?.cast(); + syncOnAppLaunch = json['syncOnAppLaunch']; + syncAfterReading = json['syncAfterReading']; autoBackupLocation = json['autoBackupLocation']; startDatebackup = json['startDatebackup']; usePageTapZones = json['usePageTapZones']; markEpisodeAsSeenType = json['markEpisodeAsSeenType']; defaultSkipIntroLength = json['defaultSkipIntroLength']; defaultDoubleTapToSkipLength = json['defaultDoubleTapToSkipLength']; - defaultPlayBackSpeed = json['defaultPlayBackSpeed']; + defaultPlayBackSpeed = json['defaultPlayBackSpeed'] is double ? json['defaultPlayBackSpeed'] : (json['defaultPlayBackSpeed'] as int).toDouble(); updateProgressAfterReading = json['updateProgressAfterReading']; enableAniSkip = json['enableAniSkip']; enableAutoSkip = json['enableAutoSkip']; @@ -410,7 +418,7 @@ class Settings { colorFilterBlendMode = ColorFilterBlendMode .values[json['colorFilterBlendMode'] ?? ColorFilterBlendMode.none]; playerSubtitleSettings = json['playerSubtitleSettings'] != null - ? PlayerSubtitleSettings.fromJson(json['customColorFilter']) + ? PlayerSubtitleSettings.fromJson(json['playerSubtitleSettings']) : null; mangaHomeDisplayType = DisplayType.values[ json['mangaHomeDisplayType'] ?? DisplayType.comfortableGrid.index]; @@ -503,6 +511,8 @@ class Settings { 'userAgent': userAgent, 'backupFrequency': backupFrequency, 'backupFrequencyOptions': backupFrequencyOptions, + 'syncOnAppLaunch': syncOnAppLaunch, + 'syncAfterReading': syncAfterReading, 'autoBackupLocation': autoBackupLocation, 'startDatebackup': startDatebackup, 'usePageTapZones': usePageTapZones, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index d1305e6..23ea71a 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -441,28 +441,38 @@ const SettingsSchema = CollectionSchema( name: r'startDatebackup', type: IsarType.long, ), - r'themeIsDark': PropertySchema( + r'syncAfterReading': PropertySchema( id: 80, + name: r'syncAfterReading', + type: IsarType.bool, + ), + r'syncOnAppLaunch': PropertySchema( + id: 81, + name: r'syncOnAppLaunch', + type: IsarType.bool, + ), + r'themeIsDark': PropertySchema( + id: 82, name: r'themeIsDark', type: IsarType.bool, ), r'updateProgressAfterReading': PropertySchema( - id: 81, + id: 83, name: r'updateProgressAfterReading', type: IsarType.bool, ), r'useLibass': PropertySchema( - id: 82, + id: 84, name: r'useLibass', type: IsarType.bool, ), r'usePageTapZones': PropertySchema( - id: 83, + id: 85, name: r'usePageTapZones', type: IsarType.bool, ), r'userAgent': PropertySchema( - id: 84, + id: 86, name: r'userAgent', type: IsarType.string, ) @@ -914,11 +924,13 @@ void _settingsSerialize( object.sortLibraryManga, ); writer.writeLong(offsets[79], object.startDatebackup); - writer.writeBool(offsets[80], object.themeIsDark); - writer.writeBool(offsets[81], object.updateProgressAfterReading); - writer.writeBool(offsets[82], object.useLibass); - writer.writeBool(offsets[83], object.usePageTapZones); - writer.writeString(offsets[84], object.userAgent); + writer.writeBool(offsets[80], object.syncAfterReading); + writer.writeBool(offsets[81], object.syncOnAppLaunch); + writer.writeBool(offsets[82], object.themeIsDark); + writer.writeBool(offsets[83], object.updateProgressAfterReading); + writer.writeBool(offsets[84], object.useLibass); + writer.writeBool(offsets[85], object.usePageTapZones); + writer.writeString(offsets[86], object.userAgent); } Settings _settingsDeserialize( @@ -1077,11 +1089,13 @@ Settings _settingsDeserialize( allOffsets, ), startDatebackup: reader.readLongOrNull(offsets[79]), - themeIsDark: reader.readBoolOrNull(offsets[80]), - updateProgressAfterReading: reader.readBoolOrNull(offsets[81]), - useLibass: reader.readBoolOrNull(offsets[82]), - usePageTapZones: reader.readBoolOrNull(offsets[83]), - userAgent: reader.readStringOrNull(offsets[84]), + syncAfterReading: reader.readBoolOrNull(offsets[80]), + syncOnAppLaunch: reader.readBoolOrNull(offsets[81]), + themeIsDark: reader.readBoolOrNull(offsets[82]), + updateProgressAfterReading: reader.readBoolOrNull(offsets[83]), + useLibass: reader.readBoolOrNull(offsets[84]), + usePageTapZones: reader.readBoolOrNull(offsets[85]), + userAgent: reader.readStringOrNull(offsets[86]), ); object.chapterFilterBookmarkedList = reader.readObjectList( @@ -1375,6 +1389,10 @@ P _settingsDeserializeProp

( case 83: return (reader.readBoolOrNull(offset)) as P; case 84: + return (reader.readBoolOrNull(offset)) as P; + case 85: + return (reader.readBoolOrNull(offset)) as P; + case 86: return (reader.readStringOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -6759,6 +6777,62 @@ extension SettingsQueryFilter }); } + QueryBuilder + syncAfterReadingIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'syncAfterReading', + )); + }); + } + + QueryBuilder + syncAfterReadingIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'syncAfterReading', + )); + }); + } + + QueryBuilder + syncAfterReadingEqualTo(bool? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'syncAfterReading', + value: value, + )); + }); + } + + QueryBuilder + syncOnAppLaunchIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'syncOnAppLaunch', + )); + }); + } + + QueryBuilder + syncOnAppLaunchIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'syncOnAppLaunch', + )); + }); + } + + QueryBuilder + syncOnAppLaunchEqualTo(bool? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'syncOnAppLaunch', + value: value, + )); + }); + } + QueryBuilder themeIsDarkIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -8017,6 +8091,30 @@ extension SettingsQuerySortBy on QueryBuilder { }); } + QueryBuilder sortBySyncAfterReading() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'syncAfterReading', Sort.asc); + }); + } + + QueryBuilder sortBySyncAfterReadingDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'syncAfterReading', Sort.desc); + }); + } + + QueryBuilder sortBySyncOnAppLaunch() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'syncOnAppLaunch', Sort.asc); + }); + } + + QueryBuilder sortBySyncOnAppLaunchDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'syncOnAppLaunch', Sort.desc); + }); + } + QueryBuilder sortByThemeIsDark() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'themeIsDark', Sort.asc); @@ -8917,6 +9015,30 @@ extension SettingsQuerySortThenBy }); } + QueryBuilder thenBySyncAfterReading() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'syncAfterReading', Sort.asc); + }); + } + + QueryBuilder thenBySyncAfterReadingDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'syncAfterReading', Sort.desc); + }); + } + + QueryBuilder thenBySyncOnAppLaunch() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'syncOnAppLaunch', Sort.asc); + }); + } + + QueryBuilder thenBySyncOnAppLaunchDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'syncOnAppLaunch', Sort.desc); + }); + } + QueryBuilder thenByThemeIsDark() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'themeIsDark', Sort.asc); @@ -9404,6 +9526,18 @@ extension SettingsQueryWhereDistinct }); } + QueryBuilder distinctBySyncAfterReading() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'syncAfterReading'); + }); + } + + QueryBuilder distinctBySyncOnAppLaunch() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'syncOnAppLaunch'); + }); + } + QueryBuilder distinctByThemeIsDark() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'themeIsDark'); @@ -9980,6 +10114,18 @@ extension SettingsQueryProperty }); } + QueryBuilder syncAfterReadingProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'syncAfterReading'); + }); + } + + QueryBuilder syncOnAppLaunchProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'syncOnAppLaunch'); + }); + } + QueryBuilder themeIsDarkProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'themeIsDark'); diff --git a/lib/models/sync_preference.dart b/lib/models/sync_preference.dart new file mode 100644 index 0000000..30cb517 --- /dev/null +++ b/lib/models/sync_preference.dart @@ -0,0 +1,50 @@ +import 'package:isar/isar.dart'; +part 'sync_preference.g.dart'; + +@collection +@Name("Sync Preference") +class SyncPreference { + Id? syncId; + + String? email; + + String? authToken; + + int? lastSync; + + int? lastUpload; + + int? lastDownload; + + String? server; + + SyncPreference({ + this.syncId, + this.email, + this.authToken, + this.lastSync, + this.lastUpload, + this.lastDownload, + this.server, + }); + + SyncPreference.fromJson(Map json) { + syncId = json['syncId']; + email = json['email']; + authToken = json['authToken']; + lastSync = json['lastSync']; + lastUpload = json['lastUpload']; + lastDownload = json['lastDownload']; + server = json['server']; + } + + Map toJson() => { + 'syncId': syncId, + 'email': email, + 'authToken': authToken, + 'lastSync': lastSync, + 'lastUpload': lastUpload, + 'lastDownload': lastDownload, + 'server': server + }; +} diff --git a/lib/models/sync_preference.g.dart b/lib/models/sync_preference.g.dart new file mode 100644 index 0000000..c82f6bc --- /dev/null +++ b/lib/models/sync_preference.g.dart @@ -0,0 +1,1271 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync_preference.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetSyncPreferenceCollection on Isar { + IsarCollection get syncPreferences => this.collection(); +} + +const SyncPreferenceSchema = CollectionSchema( + name: r'Sync Preference', + id: 2788277548653279925, + properties: { + r'authToken': PropertySchema( + id: 0, + name: r'authToken', + type: IsarType.string, + ), + r'email': PropertySchema( + id: 1, + name: r'email', + type: IsarType.string, + ), + r'lastDownload': PropertySchema( + id: 2, + name: r'lastDownload', + type: IsarType.long, + ), + r'lastSync': PropertySchema( + id: 3, + name: r'lastSync', + type: IsarType.long, + ), + r'lastUpload': PropertySchema( + id: 4, + name: r'lastUpload', + type: IsarType.long, + ), + r'server': PropertySchema( + id: 5, + name: r'server', + type: IsarType.string, + ) + }, + estimateSize: _syncPreferenceEstimateSize, + serialize: _syncPreferenceSerialize, + deserialize: _syncPreferenceDeserialize, + deserializeProp: _syncPreferenceDeserializeProp, + idName: r'syncId', + indexes: {}, + links: {}, + embeddedSchemas: {}, + getId: _syncPreferenceGetId, + getLinks: _syncPreferenceGetLinks, + attach: _syncPreferenceAttach, + version: '3.1.0+1', +); + +int _syncPreferenceEstimateSize( + SyncPreference object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final value = object.authToken; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.email; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.server; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + return bytesCount; +} + +void _syncPreferenceSerialize( + SyncPreference object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.authToken); + writer.writeString(offsets[1], object.email); + writer.writeLong(offsets[2], object.lastDownload); + writer.writeLong(offsets[3], object.lastSync); + writer.writeLong(offsets[4], object.lastUpload); + writer.writeString(offsets[5], object.server); +} + +SyncPreference _syncPreferenceDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = SyncPreference( + authToken: reader.readStringOrNull(offsets[0]), + email: reader.readStringOrNull(offsets[1]), + lastDownload: reader.readLongOrNull(offsets[2]), + lastSync: reader.readLongOrNull(offsets[3]), + lastUpload: reader.readLongOrNull(offsets[4]), + server: reader.readStringOrNull(offsets[5]), + syncId: id, + ); + return object; +} + +P _syncPreferenceDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringOrNull(offset)) as P; + case 1: + return (reader.readStringOrNull(offset)) as P; + case 2: + return (reader.readLongOrNull(offset)) as P; + case 3: + return (reader.readLongOrNull(offset)) as P; + case 4: + return (reader.readLongOrNull(offset)) as P; + case 5: + return (reader.readStringOrNull(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _syncPreferenceGetId(SyncPreference object) { + return object.syncId ?? Isar.autoIncrement; +} + +List> _syncPreferenceGetLinks(SyncPreference object) { + return []; +} + +void _syncPreferenceAttach( + IsarCollection col, Id id, SyncPreference object) { + object.syncId = id; +} + +extension SyncPreferenceQueryWhereSort + on QueryBuilder { + QueryBuilder anySyncId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension SyncPreferenceQueryWhere + on QueryBuilder { + QueryBuilder syncIdEqualTo( + Id syncId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: syncId, + upper: syncId, + )); + }); + } + + QueryBuilder + syncIdNotEqualTo(Id syncId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: syncId, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: syncId, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: syncId, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: syncId, includeUpper: false), + ); + } + }); + } + + QueryBuilder + syncIdGreaterThan(Id syncId, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: syncId, includeLower: include), + ); + }); + } + + QueryBuilder + syncIdLessThan(Id syncId, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: syncId, includeUpper: include), + ); + }); + } + + QueryBuilder syncIdBetween( + Id lowerSyncId, + Id upperSyncId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerSyncId, + includeLower: includeLower, + upper: upperSyncId, + includeUpper: includeUpper, + )); + }); + } +} + +extension SyncPreferenceQueryFilter + on QueryBuilder { + QueryBuilder + authTokenIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'authToken', + )); + }); + } + + QueryBuilder + authTokenIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'authToken', + )); + }); + } + + QueryBuilder + authTokenEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'authToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + authTokenGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'authToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + authTokenLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'authToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + authTokenBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'authToken', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + authTokenStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'authToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + authTokenEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'authToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + authTokenContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'authToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + authTokenMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'authToken', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + authTokenIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'authToken', + value: '', + )); + }); + } + + QueryBuilder + authTokenIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'authToken', + value: '', + )); + }); + } + + QueryBuilder + emailIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'email', + )); + }); + } + + QueryBuilder + emailIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'email', + )); + }); + } + + QueryBuilder + emailEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'email', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + emailGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'email', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + emailLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'email', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + emailBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'email', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + emailStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'email', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + emailEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'email', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + emailContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'email', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + emailMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'email', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + emailIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'email', + value: '', + )); + }); + } + + QueryBuilder + emailIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'email', + value: '', + )); + }); + } + + QueryBuilder + lastDownloadIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'lastDownload', + )); + }); + } + + QueryBuilder + lastDownloadIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'lastDownload', + )); + }); + } + + QueryBuilder + lastDownloadEqualTo(int? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'lastDownload', + value: value, + )); + }); + } + + QueryBuilder + lastDownloadGreaterThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'lastDownload', + value: value, + )); + }); + } + + QueryBuilder + lastDownloadLessThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'lastDownload', + value: value, + )); + }); + } + + QueryBuilder + lastDownloadBetween( + int? lower, + int? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'lastDownload', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + lastSyncIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'lastSync', + )); + }); + } + + QueryBuilder + lastSyncIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'lastSync', + )); + }); + } + + QueryBuilder + lastSyncEqualTo(int? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'lastSync', + value: value, + )); + }); + } + + QueryBuilder + lastSyncGreaterThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'lastSync', + value: value, + )); + }); + } + + QueryBuilder + lastSyncLessThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'lastSync', + value: value, + )); + }); + } + + QueryBuilder + lastSyncBetween( + int? lower, + int? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'lastSync', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + lastUploadIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'lastUpload', + )); + }); + } + + QueryBuilder + lastUploadIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'lastUpload', + )); + }); + } + + QueryBuilder + lastUploadEqualTo(int? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'lastUpload', + value: value, + )); + }); + } + + QueryBuilder + lastUploadGreaterThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'lastUpload', + value: value, + )); + }); + } + + QueryBuilder + lastUploadLessThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'lastUpload', + value: value, + )); + }); + } + + QueryBuilder + lastUploadBetween( + int? lower, + int? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'lastUpload', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + serverIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'server', + )); + }); + } + + QueryBuilder + serverIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'server', + )); + }); + } + + QueryBuilder + serverEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'server', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + serverGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'server', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + serverLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'server', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + serverBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'server', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + serverStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'server', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + serverEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'server', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + serverContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'server', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + serverMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'server', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + serverIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'server', + value: '', + )); + }); + } + + QueryBuilder + serverIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'server', + value: '', + )); + }); + } + + QueryBuilder + syncIdIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'syncId', + )); + }); + } + + QueryBuilder + syncIdIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'syncId', + )); + }); + } + + QueryBuilder + syncIdEqualTo(Id? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'syncId', + value: value, + )); + }); + } + + QueryBuilder + syncIdGreaterThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'syncId', + value: value, + )); + }); + } + + QueryBuilder + syncIdLessThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'syncId', + value: value, + )); + }); + } + + QueryBuilder + syncIdBetween( + Id? lower, + Id? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'syncId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } +} + +extension SyncPreferenceQueryObject + on QueryBuilder {} + +extension SyncPreferenceQueryLinks + on QueryBuilder {} + +extension SyncPreferenceQuerySortBy + on QueryBuilder { + QueryBuilder sortByAuthToken() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'authToken', Sort.asc); + }); + } + + QueryBuilder + sortByAuthTokenDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'authToken', Sort.desc); + }); + } + + QueryBuilder sortByEmail() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'email', Sort.asc); + }); + } + + QueryBuilder sortByEmailDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'email', Sort.desc); + }); + } + + QueryBuilder + sortByLastDownload() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastDownload', Sort.asc); + }); + } + + QueryBuilder + sortByLastDownloadDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastDownload', Sort.desc); + }); + } + + QueryBuilder sortByLastSync() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastSync', Sort.asc); + }); + } + + QueryBuilder + sortByLastSyncDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastSync', Sort.desc); + }); + } + + QueryBuilder + sortByLastUpload() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastUpload', Sort.asc); + }); + } + + QueryBuilder + sortByLastUploadDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastUpload', Sort.desc); + }); + } + + QueryBuilder sortByServer() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'server', Sort.asc); + }); + } + + QueryBuilder + sortByServerDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'server', Sort.desc); + }); + } +} + +extension SyncPreferenceQuerySortThenBy + on QueryBuilder { + QueryBuilder thenByAuthToken() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'authToken', Sort.asc); + }); + } + + QueryBuilder + thenByAuthTokenDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'authToken', Sort.desc); + }); + } + + QueryBuilder thenByEmail() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'email', Sort.asc); + }); + } + + QueryBuilder thenByEmailDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'email', Sort.desc); + }); + } + + QueryBuilder + thenByLastDownload() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastDownload', Sort.asc); + }); + } + + QueryBuilder + thenByLastDownloadDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastDownload', Sort.desc); + }); + } + + QueryBuilder thenByLastSync() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastSync', Sort.asc); + }); + } + + QueryBuilder + thenByLastSyncDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastSync', Sort.desc); + }); + } + + QueryBuilder + thenByLastUpload() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastUpload', Sort.asc); + }); + } + + QueryBuilder + thenByLastUploadDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastUpload', Sort.desc); + }); + } + + QueryBuilder thenByServer() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'server', Sort.asc); + }); + } + + QueryBuilder + thenByServerDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'server', Sort.desc); + }); + } + + QueryBuilder thenBySyncId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'syncId', Sort.asc); + }); + } + + QueryBuilder + thenBySyncIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'syncId', Sort.desc); + }); + } +} + +extension SyncPreferenceQueryWhereDistinct + on QueryBuilder { + QueryBuilder distinctByAuthToken( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'authToken', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByEmail( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'email', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByLastDownload() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'lastDownload'); + }); + } + + QueryBuilder distinctByLastSync() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'lastSync'); + }); + } + + QueryBuilder + distinctByLastUpload() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'lastUpload'); + }); + } + + QueryBuilder distinctByServer( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'server', caseSensitive: caseSensitive); + }); + } +} + +extension SyncPreferenceQueryProperty + on QueryBuilder { + QueryBuilder syncIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'syncId'); + }); + } + + QueryBuilder authTokenProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'authToken'); + }); + } + + QueryBuilder emailProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'email'); + }); + } + + QueryBuilder lastDownloadProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'lastDownload'); + }); + } + + QueryBuilder lastSyncProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'lastSync'); + }); + } + + QueryBuilder lastUploadProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'lastUpload'); + }); + } + + QueryBuilder serverProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'server'); + }); + } +} diff --git a/lib/modules/anime/providers/anime_player_controller_provider.dart b/lib/modules/anime/providers/anime_player_controller_provider.dart index 059366a..d719fd7 100644 --- a/lib/modules/anime/providers/anime_player_controller_provider.dart +++ b/lib/modules/anime/providers/anime_player_controller_provider.dart @@ -168,6 +168,7 @@ class AnimeStreamController extends _$AnimeStreamController { }); if (isWatch) { episode.updateTrackChapterRead(ref); + episode.syncProgressAfterChapterRead(ref); } } } diff --git a/lib/modules/anime/providers/anime_player_controller_provider.g.dart b/lib/modules/anime/providers/anime_player_controller_provider.g.dart index 94eb07c..20958d9 100644 --- a/lib/modules/anime/providers/anime_player_controller_provider.g.dart +++ b/lib/modules/anime/providers/anime_player_controller_provider.g.dart @@ -7,7 +7,7 @@ part of 'anime_player_controller_provider.dart'; // ************************************************************************** String _$animeStreamControllerHash() => - r'24639a8644ea9820458658807035e4cffb1b1644'; + r'4e0ee0f3f8b778d5bc236fd5ca928659d2769c62'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/modules/library/providers/library_state_provider.dart b/lib/modules/library/providers/library_state_provider.dart index 06ef506..08a7543 100644 --- a/lib/modules/library/providers/library_state_provider.dart +++ b/lib/modules/library/providers/library_state_provider.dart @@ -646,6 +646,7 @@ class MangasSetIsReadState extends _$MangasSetIsReadState { final chapters = manga.chapters; if (chapters.isNotEmpty) { chapters.last.updateTrackChapterRead(ref); + chapters.last.syncProgressAfterChapterRead(ref); isar.writeTxnSync(() { for (var chapter in chapters) { chapter.isRead = true; diff --git a/lib/modules/library/providers/library_state_provider.g.dart b/lib/modules/library/providers/library_state_provider.g.dart index 15834da..15a4730 100644 --- a/lib/modules/library/providers/library_state_provider.g.dart +++ b/lib/modules/library/providers/library_state_provider.g.dart @@ -2520,7 +2520,7 @@ final isLongPressedMangaStateProvider = typedef _$IsLongPressedMangaState = AutoDisposeNotifier; String _$mangasSetIsReadStateHash() => - r'8f86296f588a48747de625e0471048978ee9bdeb'; + r'dce8293fa6b8338791c76ddad07efa4339ca8628'; abstract class _$MangasSetIsReadState extends BuildlessAutoDisposeNotifier { diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index 224a944..80e1b15 100644 --- a/lib/modules/manga/detail/manga_detail_view.dart +++ b/lib/modules/manga/detail/manga_detail_view.dart @@ -753,6 +753,7 @@ class _MangaDetailViewState extends ConsumerState chapter.manga.saveSync(); if (chapter.isRead!) { chapter.updateTrackChapterRead(ref); + chapter.syncProgressAfterChapterRead(ref); } } }); @@ -786,6 +787,7 @@ class _MangaDetailViewState extends ConsumerState onPressed: () { int index = chapters.indexOf(chap.first); chapters[index + 1].updateTrackChapterRead(ref); + chapters[index + 1].syncProgressAfterChapterRead(ref); isar.writeTxnSync(() { for (var i = index + 1; i < chapters.length; diff --git a/lib/modules/manga/download/providers/download_provider.g.dart b/lib/modules/manga/download/providers/download_provider.g.dart index 8900d39..ff43d1c 100644 --- a/lib/modules/manga/download/providers/download_provider.g.dart +++ b/lib/modules/manga/download/providers/download_provider.g.dart @@ -6,7 +6,7 @@ part of 'download_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$downloadChapterHash() => r'eca8ccbe5f93f07c3471af81355fe9b3a8ec11e8'; +String _$downloadChapterHash() => r'6bf91d6683ebaacb5b822d5e4e7926e44362a98b'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/modules/manga/reader/providers/reader_controller_provider.dart b/lib/modules/manga/reader/providers/reader_controller_provider.dart index 509471b..d9dd956 100644 --- a/lib/modules/manga/reader/providers/reader_controller_provider.dart +++ b/lib/modules/manga/reader/providers/reader_controller_provider.dart @@ -11,7 +11,9 @@ import 'package:mangayomi/models/track.dart'; import 'package:mangayomi/models/track_preference.dart'; import 'package:mangayomi/modules/manga/detail/providers/track_state_providers.dart'; import 'package:mangayomi/modules/more/providers/incognito_mode_state_provider.dart'; +import 'package:mangayomi/modules/more/settings/sync/providers/sync_providers.dart'; import 'package:mangayomi/modules/more/settings/track/providers/track_providers.dart'; +import 'package:mangayomi/services/sync_server.dart'; import 'package:mangayomi/utils/chapter_recognition.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'reader_controller_provider.g.dart'; @@ -396,6 +398,13 @@ extension ChapterExtensions on Chapter { } } } + + void syncProgressAfterChapterRead(dynamic ref) { + if (!(ref is WidgetRef || ref is AutoDisposeNotifierProviderRef)) return; + final syncAfterReading = ref.watch(syncAfterReadingStateProvider); + if (!syncAfterReading) return; + ref.read(syncServerProvider(syncId: 1).notifier).syncToServer(ref, true); + } } extension MangaExtensions on Manga { diff --git a/lib/modules/more/settings/settings_screen.dart b/lib/modules/more/settings/settings_screen.dart index fbf4a00..4bf95c0 100644 --- a/lib/modules/more/settings/settings_screen.dart +++ b/lib/modules/more/settings/settings_screen.dart @@ -41,6 +41,11 @@ class SettingsScreen extends StatelessWidget { subtitle: "", icon: Icons.sync_outlined, onTap: () => context.push('/track')), + ListTileWidget( + title: l10n.syncing, + subtitle: l10n.syncing_subtitle, + icon: Icons.cloud_sync_outlined, + onTap: () => context.push('/sync')), ListTileWidget( title: l10n.browse, subtitle: l10n.browse_subtitle, diff --git a/lib/modules/more/settings/sync/models/jwt.dart b/lib/modules/more/settings/sync/models/jwt.dart new file mode 100644 index 0000000..c6e0c97 --- /dev/null +++ b/lib/modules/more/settings/sync/models/jwt.dart @@ -0,0 +1,24 @@ +class JWToken { + String? uuid; + String? email; + int? iat; + int? exp; + + JWToken({this.uuid, this.email, this.iat, this.exp}); + + JWToken.fromJson(Map json) { + email = json['email']; + uuid = json['uuid']; + iat = (json['iat'] as int) * 1000; + exp = (json['exp'] as int) * 1000; + } + + Map toJson() { + final Map data = {}; + data['uuid'] = uuid; + data['email'] = email; + data['iat'] = iat; + data['exp'] = exp; + return data; + } +} diff --git a/lib/modules/more/settings/sync/providers/sync_providers.dart b/lib/modules/more/settings/sync/providers/sync_providers.dart new file mode 100644 index 0000000..aa12f84 --- /dev/null +++ b/lib/modules/more/settings/sync/providers/sync_providers.dart @@ -0,0 +1,79 @@ +import 'package:mangayomi/main.dart'; +import 'package:mangayomi/models/settings.dart'; +import 'package:mangayomi/models/sync_preference.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +part 'sync_providers.g.dart'; + +@riverpod +class Synching extends _$Synching { + @override + SyncPreference? build({required int? syncId}) { + return isar.syncPreferences.getSync(syncId!); + } + + void login(SyncPreference syncPreference) { + isar.writeTxnSync(() { + isar.syncPreferences.putSync(syncPreference); + }); + } + + void logout() { + isar.writeTxnSync(() { + isar.syncPreferences.deleteSync(syncId!); + }); + } + + void setLastSync(int timestamp) { + isar.writeTxnSync(() { + isar.syncPreferences.putSync(isar.syncPreferences.getSync(syncId!)!..lastSync = timestamp); + }); + } + + void setLastUpload(int timestamp) { + isar.writeTxnSync(() { + isar.syncPreferences.putSync(isar.syncPreferences.getSync(syncId!)!..lastUpload = timestamp); + }); + } + + void setLastDownload(int timestamp) { + isar.writeTxnSync(() { + isar.syncPreferences.putSync(isar.syncPreferences.getSync(syncId!)!..lastDownload = timestamp); + }); + } + + void setServer(String? server) { + isar.writeTxnSync(() { + isar.syncPreferences.putSync(isar.syncPreferences.getSync(syncId!)!..server = server); + }); + } +} + +@riverpod +class SyncOnAppLaunchState extends _$SyncOnAppLaunchState { + @override + bool build() { + return isar.settings.getSync(227)!.syncOnAppLaunch ?? false; + } + + void set(bool value) { + final settings = isar.settings.getSync(227); + state = value; + isar.writeTxnSync( + () => isar.settings.putSync(settings!..syncOnAppLaunch = value)); + } +} + +@riverpod +class SyncAfterReadingState extends _$SyncAfterReadingState { + @override + bool build() { + return isar.settings.getSync(227)!.syncAfterReading ?? false; + } + + void set(bool value) { + final settings = isar.settings.getSync(227); + state = value; + isar.writeTxnSync( + () => isar.settings.putSync(settings!..syncAfterReading = value)); + } +} diff --git a/lib/modules/more/settings/sync/providers/sync_providers.g.dart b/lib/modules/more/settings/sync/providers/sync_providers.g.dart new file mode 100644 index 0000000..1488458 --- /dev/null +++ b/lib/modules/more/settings/sync/providers/sync_providers.g.dart @@ -0,0 +1,208 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$synchingHash() => r'2ef7fd99da4292ed236252d2b727cff9a69f43a9'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$Synching + extends BuildlessAutoDisposeNotifier { + late final int? syncId; + + SyncPreference? build({ + required int? syncId, + }); +} + +/// See also [Synching]. +@ProviderFor(Synching) +const synchingProvider = SynchingFamily(); + +/// See also [Synching]. +class SynchingFamily extends Family { + /// See also [Synching]. + const SynchingFamily(); + + /// See also [Synching]. + SynchingProvider call({ + required int? syncId, + }) { + return SynchingProvider( + syncId: syncId, + ); + } + + @override + SynchingProvider getProviderOverride( + covariant SynchingProvider provider, + ) { + return call( + syncId: provider.syncId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'synchingProvider'; +} + +/// See also [Synching]. +class SynchingProvider + extends AutoDisposeNotifierProviderImpl { + /// See also [Synching]. + SynchingProvider({ + required int? syncId, + }) : this._internal( + () => Synching()..syncId = syncId, + from: synchingProvider, + name: r'synchingProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$synchingHash, + dependencies: SynchingFamily._dependencies, + allTransitiveDependencies: SynchingFamily._allTransitiveDependencies, + syncId: syncId, + ); + + SynchingProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.syncId, + }) : super.internal(); + + final int? syncId; + + @override + SyncPreference? runNotifierBuild( + covariant Synching notifier, + ) { + return notifier.build( + syncId: syncId, + ); + } + + @override + Override overrideWith(Synching Function() create) { + return ProviderOverride( + origin: this, + override: SynchingProvider._internal( + () => create()..syncId = syncId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + syncId: syncId, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement + createElement() { + return _SynchingProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SynchingProvider && other.syncId == syncId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, syncId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin SynchingRef on AutoDisposeNotifierProviderRef { + /// The parameter `syncId` of this provider. + int? get syncId; +} + +class _SynchingProviderElement + extends AutoDisposeNotifierProviderElement + with SynchingRef { + _SynchingProviderElement(super.provider); + + @override + int? get syncId => (origin as SynchingProvider).syncId; +} + +String _$syncOnAppLaunchStateHash() => + r'dc7f3243e38a748462628229066c8fc0653c908b'; + +/// See also [SyncOnAppLaunchState]. +@ProviderFor(SyncOnAppLaunchState) +final syncOnAppLaunchStateProvider = + AutoDisposeNotifierProvider.internal( + SyncOnAppLaunchState.new, + name: r'syncOnAppLaunchStateProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$syncOnAppLaunchStateHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SyncOnAppLaunchState = AutoDisposeNotifier; +String _$syncAfterReadingStateHash() => + r'e507acd490b5aea7fc1a8fd7a369ec01f4c47192'; + +/// See also [SyncAfterReadingState]. +@ProviderFor(SyncAfterReadingState) +final syncAfterReadingStateProvider = + AutoDisposeNotifierProvider.internal( + SyncAfterReadingState.new, + name: r'syncAfterReadingStateProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$syncAfterReadingStateHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SyncAfterReadingState = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/modules/more/settings/sync/sync.dart b/lib/modules/more/settings/sync/sync.dart new file mode 100644 index 0000000..fdd1e30 --- /dev/null +++ b/lib/modules/more/settings/sync/sync.dart @@ -0,0 +1,470 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar/isar.dart'; +import 'package:mangayomi/main.dart'; +import 'package:mangayomi/utils/date.dart'; +import 'package:mangayomi/models/sync_preference.dart'; +import 'package:mangayomi/modules/more/settings/sync/providers/sync_providers.dart'; +import 'package:mangayomi/modules/more/settings/sync/widgets/sync_listile.dart'; +import 'package:mangayomi/providers/l10n_providers.dart'; +import 'package:mangayomi/services/sync_server.dart'; +import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; + +class SyncScreen extends ConsumerWidget { + const SyncScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final syncAfterReading = ref.watch(syncAfterReadingStateProvider); + final syncOnAppLaunch = ref.watch(syncOnAppLaunchStateProvider); + final l10n = l10nLocalizations(context)!; + return Scaffold( + appBar: AppBar( + title: Text(l10nLocalizations(context)!.syncing), + ), + body: SingleChildScrollView( + child: StreamBuilder( + stream: isar.syncPreferences + .filter() + .syncIdIsNotNull() + .watch(fireImmediately: true), + builder: (context, snapshot) { + SyncPreference syncPreference = snapshot.data?.isNotEmpty ?? false + ? snapshot.data?.first ?? SyncPreference() + : SyncPreference(); + final bool isLogged = + syncPreference.authToken?.isNotEmpty ?? false; + return Column( + children: [ + SwitchListTile( + value: syncAfterReading, + title: Text(context.l10n.syncAfterReading), + onChanged: !isLogged + ? null + : (value) { + ref + .read(syncAfterReadingStateProvider.notifier) + .set(value); + }), + SwitchListTile( + value: syncOnAppLaunch, + title: Text(context.l10n.syncOnAppLaunch), + onChanged: !isLogged + ? null + : (value) { + ref + .read(syncOnAppLaunchStateProvider.notifier) + .set(value); + }), + Padding( + padding: const EdgeInsets.only( + left: 15, right: 15, bottom: 10, top: 5), + child: Row( + children: [ + Text(l10n.services, + style: TextStyle( + fontSize: 13, color: context.primaryColor)), + ], + ), + ), + SyncListile( + onTap: () async { + _showDialogLogin(context, ref); + }, + id: 1, + preference: syncPreference, + ), + ListTile( + title: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + color: context.secondaryColor, + ), + const SizedBox(width: 10), + Text(l10n.syncing_subtitle, + style: TextStyle( + fontSize: 11, color: context.secondaryColor)) + ], + ), + ), + ), + ListTile( + title: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon( + Icons.sync, + color: context.secondaryColor, + ), + const SizedBox(width: 10), + Column(children: [ + const SizedBox(width: 20), + Text( + "${l10n.last_sync}: ${dateFormat((syncPreference.lastSync ?? 0).toString(), ref: ref, context: context)} ${dateFormatHour((syncPreference.lastSync ?? 0).toString(), context)}", + style: TextStyle( + fontSize: 11, + color: context.secondaryColor)), + const SizedBox(width: 20), + Text( + "${l10n.last_upload}: ${dateFormat((syncPreference.lastUpload ?? 0).toString(), ref: ref, context: context)} ${dateFormatHour((syncPreference.lastUpload ?? 0).toString(), context)}", + style: TextStyle( + fontSize: 11, + color: context.secondaryColor)), + const SizedBox(width: 20), + Text( + "${l10n.last_download}: ${dateFormat((syncPreference.lastDownload ?? 0).toString(), ref: ref, context: context)} ${dateFormatHour((syncPreference.lastDownload ?? 0).toString(), context)}", + style: TextStyle( + fontSize: 11, + color: context.secondaryColor)), + ]), + ], + ), + ), + ), + Row( + children: [ + const SizedBox(width: 20), + Column( + children: [ + IconButton( + onPressed: !isLogged + ? null + : () { + ref + .read(syncServerProvider(syncId: 1) + .notifier) + .syncToServer(ref, false); + }, + icon: Icon( + Icons.sync, + color: !isLogged ? context.secondaryColor : context.primaryColor, + )), + Text(l10n.sync_button_sync), + ], + ), + const SizedBox(width: 20), + Column( + children: [ + IconButton( + onPressed: !isLogged + ? null + : () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text( + l10n.sync_confirm_upload), + actions: [ + Row( + mainAxisAlignment: + MainAxisAlignment.end, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + Colors + .transparent, + shadowColor: Colors + .transparent, + surfaceTintColor: + Colors + .transparent, + shape: RoundedRectangleBorder( + side: BorderSide( + color: context + .secondaryColor), + borderRadius: + BorderRadius + .circular( + 20))), + onPressed: () { + Navigator.pop( + context); + }, + child: Text( + l10n.cancel, + style: TextStyle( + color: context + .secondaryColor), + )), + const SizedBox(width: 15), + ElevatedButton( + style: ElevatedButton + .styleFrom( + backgroundColor: + Colors.red + .withOpacity( + 0.7)), + onPressed: () { + ref + .read( + syncServerProvider( + syncId: + 1) + .notifier) + .uploadToServer( + l10n); + Navigator.pop( + context); + }, + child: Text( + l10n.dialog_confirm, + style: TextStyle( + color: context + .secondaryColor), + )), + ], + ) + ], + ); + }); + }, + icon: Icon( + Icons.cloud_upload_outlined, + color: !isLogged ? context.secondaryColor : context.primaryColor, + )), + Text(l10n.sync_button_upload), + ], + ), + const SizedBox(width: 20), + Column( + children: [ + IconButton( + onPressed: !isLogged + ? null + : () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text( + l10n.sync_confirm_download), + actions: [ + Row( + mainAxisAlignment: + MainAxisAlignment.end, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + Colors + .transparent, + shadowColor: Colors + .transparent, + surfaceTintColor: + Colors + .transparent, + shape: RoundedRectangleBorder( + side: BorderSide( + color: context + .secondaryColor), + borderRadius: + BorderRadius + .circular( + 20))), + onPressed: () { + Navigator.pop( + context); + }, + child: Text( + l10n.cancel, + style: TextStyle( + color: context + .secondaryColor), + )), + const SizedBox(width: 15), + ElevatedButton( + style: ElevatedButton + .styleFrom( + backgroundColor: + Colors.red + .withOpacity( + 0.7)), + onPressed: () { + ref + .read( + syncServerProvider( + syncId: + 1) + .notifier) + .downloadFromServer( + l10n, ref); + Navigator.pop( + context); + }, + child: Text( + l10n.dialog_confirm, + style: TextStyle( + color: context + .secondaryColor), + )), + ], + ) + ], + ); + }); + }, + icon: Icon( + Icons.cloud_download_outlined, + color: !isLogged ? context.secondaryColor : context.primaryColor, + )), + Text(l10n.sync_button_download), + ], + ), + ], + ) + ], + ); + }), + ), + ); + } +} + +void _showDialogLogin(BuildContext context, WidgetRef ref) { + final serverController = TextEditingController(); + final emailController = TextEditingController(); + final passwordController = TextEditingController(); + String server = ""; + String email = ""; + String password = ""; + String errorMessage = ""; + bool isLoading = false; + bool obscureText = true; + final l10n = l10nLocalizations(context)!; + showDialog( + context: context, + builder: (context) => StatefulBuilder(builder: (context, setState) { + return AlertDialog( + title: Text( + l10n.login_into("SyncServer"), + style: const TextStyle(fontSize: 30), + ), + content: SizedBox( + height: 400, + width: MediaQuery.of(context).size.width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: TextFormField( + controller: serverController, + autofocus: true, + onChanged: (value) => setState(() { + server = value; + }), + decoration: InputDecoration( + hintText: l10n.sync_server, + filled: false, + contentPadding: const EdgeInsets.all(12), + enabledBorder: OutlineInputBorder( + borderSide: const BorderSide(width: 0.4), + borderRadius: BorderRadius.circular(5)), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: const BorderSide()))), + ), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: TextFormField( + controller: emailController, + autofocus: true, + onChanged: (value) => setState(() { + email = value; + }), + decoration: InputDecoration( + hintText: l10n.email_adress, + filled: false, + contentPadding: const EdgeInsets.all(12), + enabledBorder: OutlineInputBorder( + borderSide: const BorderSide(width: 0.4), + borderRadius: BorderRadius.circular(5)), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: const BorderSide()))), + ), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: TextFormField( + controller: passwordController, + obscureText: obscureText, + onChanged: (value) => setState(() { + password = value; + }), + decoration: InputDecoration( + hintText: l10n.password, + suffixIcon: IconButton( + onPressed: () => setState(() { + obscureText = !obscureText; + }), + icon: Icon(obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined)), + filled: false, + contentPadding: const EdgeInsets.all(12), + enabledBorder: OutlineInputBorder( + borderSide: const BorderSide(width: 0.4), + borderRadius: BorderRadius.circular(5)), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: const BorderSide()))), + ), + const SizedBox(height: 10), + Text(errorMessage, style: const TextStyle(color: Colors.red)), + const SizedBox(height: 30), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: SizedBox( + width: context.width(1), + height: 50, + child: ElevatedButton( + onPressed: isLoading + ? null + : () async { + setState(() { + isLoading = true; + }); + final res = await ref + .read( + syncServerProvider(syncId: 1).notifier) + .login(l10n, server, email, password); + if (!res.$1) { + setState(() { + isLoading = false; + errorMessage = res.$2; + }); + } else { + if (context.mounted) { + Navigator.pop(context); + } + } + }, + child: isLoading + ? const CircularProgressIndicator() + : Text(l10n.login))), + ) + ], + ), + ), + ); + }), + ); +} diff --git a/lib/modules/more/settings/sync/widgets/sync_listile.dart b/lib/modules/more/settings/sync/widgets/sync_listile.dart new file mode 100644 index 0000000..74418ed --- /dev/null +++ b/lib/modules/more/settings/sync/widgets/sync_listile.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mangayomi/models/sync_preference.dart'; +import 'package:mangayomi/modules/more/settings/sync/providers/sync_providers.dart'; +import 'package:mangayomi/providers/l10n_providers.dart'; +import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; + +class SyncListile extends ConsumerWidget { + final VoidCallback onTap; + final int id; + final SyncPreference preference; + final String? text; + const SyncListile( + {super.key, + required this.onTap, + required this.id, + required this.preference, + this.text}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bool isLogged = preference.authToken?.isNotEmpty ?? false; + final l10n = l10nLocalizations(context)!; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + leading: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: const Color.fromRGBO(18, 25, 35, 1)), + width: 60, + height: 70, + child: const Icon( + Icons.dns_outlined, + size: 30, + color: Colors.grey, + ), + ), + trailing: (isLogged + ? const Icon( + Icons.check, + size: 30, + color: Colors.green, + ) + : null), + onTap: isLogged + ? () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(l10n.log_out_from(l10n.sync_server)), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + side: BorderSide( + color: context.secondaryColor), + borderRadius: + BorderRadius.circular(20))), + onPressed: () { + Navigator.pop(context); + }, + child: Text( + l10n.cancel, + style: TextStyle( + color: context.secondaryColor), + )), + const SizedBox(width: 15), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + Colors.red.withOpacity(0.7)), + onPressed: () { + ref + .read(synchingProvider(syncId: id) + .notifier) + .logout(); + Navigator.pop(context); + }, + child: Text( + l10n.log_out, + style: TextStyle( + color: context.secondaryColor), + )), + ], + ) + ], + ); + }); + } + : onTap, + title: Text( + text ?? l10n.sync_server, + style: TextStyle(fontSize: text != null ? 13 : null), + ), + ), + ); + } +} diff --git a/lib/providers/storage_provider.dart b/lib/providers/storage_provider.dart index 51b28c3..f116e21 100644 --- a/lib/providers/storage_provider.dart +++ b/lib/providers/storage_provider.dart @@ -10,6 +10,7 @@ import 'package:mangayomi/models/history.dart'; import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/models/source.dart'; +import 'package:mangayomi/models/sync_preference.dart'; import 'package:mangayomi/models/track.dart'; import 'package:mangayomi/models/track_preference.dart'; import 'package:path_provider/path_provider.dart'; @@ -136,6 +137,7 @@ class StorageProvider { SettingsSchema, TrackPreferenceSchema, TrackSchema, + SyncPreferenceSchema, SourcePreferenceSchema, SourcePreferenceStringValueSchema ], directory: dir!.path, name: "mangayomiDb", inspector: inspector!); diff --git a/lib/router/router.dart b/lib/router/router.dart index 48aed1c..328ddd2 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -14,6 +14,7 @@ import 'package:mangayomi/modules/more/backup_and_restore/backup_and_restore.dar import 'package:mangayomi/modules/more/categories/categories_screen.dart'; import 'package:mangayomi/modules/more/settings/downloads/downloads_screen.dart'; import 'package:mangayomi/modules/more/settings/player/player_screen.dart'; +import 'package:mangayomi/modules/more/settings/sync/sync.dart'; import 'package:mangayomi/modules/more/settings/track/track.dart'; import 'package:mangayomi/modules/more/settings/track/manage_trackers/manage_trackers.dart'; import 'package:mangayomi/modules/more/settings/track/manage_trackers/tracking_detail.dart'; @@ -327,6 +328,19 @@ class RouterNotifier extends ChangeNotifier { ); }, ), + GoRoute( + path: "/sync", + name: "sync", + builder: (context, state) { + return const SyncScreen(); + }, + pageBuilder: (context, state) { + return transitionPage( + key: state.pageKey, + child: const SyncScreen(), + ); + }, + ), GoRoute( path: "/sourceFilter", name: "sourceFilter", diff --git a/lib/services/sync_server.dart b/lib/services/sync_server.dart new file mode 100644 index 0000000..c5ed7eb --- /dev/null +++ b/lib/services/sync_server.dart @@ -0,0 +1,434 @@ +import 'dart:developer'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar/isar.dart'; +import 'package:mangayomi/eval/dart/model/m_bridge.dart'; +import 'package:mangayomi/eval/dart/model/source_preference.dart'; +import 'package:mangayomi/main.dart'; +import 'package:mangayomi/models/sync_preference.dart'; +import 'package:mangayomi/models/track.dart'; +import 'package:mangayomi/models/manga.dart'; +import 'package:mangayomi/models/category.dart'; +import 'package:mangayomi/models/chapter.dart'; +import 'package:mangayomi/models/history.dart'; +import 'package:mangayomi/models/settings.dart'; +import 'package:mangayomi/models/source.dart'; +import 'package:mangayomi/modules/more/settings/sync/models/jwt.dart'; +import 'package:mangayomi/modules/more/settings/sync/providers/sync_providers.dart'; +import 'package:mangayomi/modules/more/settings/appearance/providers/blend_level_state_provider.dart'; +import 'package:mangayomi/modules/more/settings/appearance/providers/flex_scheme_color_state_provider.dart'; +import 'package:mangayomi/modules/more/settings/appearance/providers/pure_black_dark_mode_state_provider.dart'; +import 'package:mangayomi/modules/more/settings/appearance/providers/theme_mode_state_provider.dart'; +import 'package:mangayomi/providers/l10n_providers.dart'; +import 'dart:convert'; +import 'package:mangayomi/services/http/m_client.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +part 'sync_server.g.dart'; + +@riverpod +class SyncServer extends _$SyncServer { + final http = MClient.init(reqcopyWith: {'useDartHttpClient': true}); + final String _loginUrl = '/login'; + final String _syncUrl = '/sync'; + final String _uploadUrl = '/upload/full'; + final String _downloadUrl = '/download'; + + @override + void build({required int syncId}) {} + + Future<(bool, String)> login(AppLocalizations l10n, String server, + String username, String password) async { + try { + var response = await http.post( + Uri.parse('$server$_loginUrl'), + headers: { + 'Content-Type': 'application/json', + }, + body: jsonEncode({'email': username, 'password': password}), + ); + var jsonData = jsonDecode(response.body) as Map; + if (response.statusCode != 200) { + return (false, jsonData["error"] as String); + } + ref.read(synchingProvider(syncId: syncId).notifier).login(SyncPreference( + syncId: syncId, + email: username, + server: server, + authToken: jsonData["token"])); + botToast(l10n.sync_logged); + return (true, ""); + } catch (e) { + return (false, e.toString()); + } + } + + Future syncToServer(WidgetRef ref, bool silent) async { + if (!silent) { + botToast("Sync started...", second: 2); + } + try { + final datas = _getData(); + final accessToken = _getAccessToken(); + final localHash = _getDataHash(datas); + + var response = await http.post( + Uri.parse('${_getServer()}$_syncUrl'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $accessToken' + }, + body: jsonEncode({'backupData': datas}), + ); + if (response.statusCode != 200) { + botToast("Sync failed", second: 5); + return; + } + var jsonData = jsonDecode(response.body) as Map; + final decodedBackupData = jsonData["backupData"] is String + ? jsonDecode(jsonData["backupData"]) + : jsonData["backupData"]; + final remoteHash = _getDataHash(decodedBackupData); + if (localHash != remoteHash) { + _restoreMerge(decodedBackupData, ref); + ref + .read(synchingProvider(syncId: syncId).notifier) + .setLastSync(DateTime.now().millisecondsSinceEpoch); + if (!silent) { + botToast("Sync finished", second: 2); + } + } else if (!silent) { + botToast("Sync up to date", second: 2); + } + } catch (error) { + botToast(error.toString(), second: 5); + } + } + + Future uploadToServer(AppLocalizations l10n) async { + botToast(l10n.sync_uploading, second: 2); + try { + final datas = _getData(); + final accessToken = _getAccessToken(); + + var response = await http.post( + Uri.parse('${_getServer()}$_uploadUrl'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $accessToken' + }, + body: jsonEncode({'backupData': datas}), + ); + if (response.statusCode != 200) { + botToast(l10n.sync_upload_failed, second: 5); + return; + } + ref + .read(synchingProvider(syncId: syncId).notifier) + .setLastUpload(DateTime.now().millisecondsSinceEpoch); + botToast(l10n.sync_upload_finished, second: 2); + } catch (error) { + botToast(error.toString(), second: 5); + } + } + + Future downloadFromServer(AppLocalizations l10n, WidgetRef ref) async { + botToast(l10n.sync_downloading, second: 2); + try { + final accessToken = _getAccessToken(); + + var response = await http.get( + Uri.parse('${_getServer()}$_downloadUrl'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $accessToken' + }, + ); + if (response.statusCode != 200) { + botToast(l10n.sync_download_failed, second: 5); + return; + } + var jsonData = jsonDecode(response.body) as Map; + _restore( + jsonData["backupData"] is String + ? jsonDecode(jsonData["backupData"]) + : jsonData["backupData"], + ref); + ref + .read(synchingProvider(syncId: syncId).notifier) + .setLastDownload(DateTime.now().millisecondsSinceEpoch); + botToast(l10n.sync_download_finished, second: 2); + } catch (error) { + botToast(error.toString(), second: 5); + } + } + + String _getDataHash(Map data) { + Map datas = {}; + datas["version"] = data["version"]; + datas["manga"] = data["manga"]; + datas["categories"] = data["categories"]; + datas["chapters"] = data["chapters"]; + datas["tracks"] = data["tracks"]; + datas["history"] = data["history"]; + var encodedJson = jsonEncode(datas); + return sha256.convert(utf8.encode(encodedJson)).toString(); + } + + Map _getData() { + Map datas = {}; + datas.addAll({"version": "1"}); + final mangas = isar.mangas + .filter() + .idIsNotNull() + .favoriteEqualTo(true) + .isLocalArchiveEqualTo(false) + .findAllSync() + .map((e) => e.toJson()) + .toList(); + datas.addAll({"manga": mangas}); + final categorys = isar.categorys + .filter() + .idIsNotNull() + .findAllSync() + .map((e) => e.toJson()) + .toList(); + datas.addAll({"categories": categorys}); + final chapters = isar.chapters + .filter() + .idIsNotNull() + .findAllSync() + .map((e) => e.toJson()) + .toList(); + datas.addAll({"chapters": chapters}); + datas.addAll({"downloads": []}); + final tracks = isar.tracks + .filter() + .idIsNotNull() + .findAllSync() + .map((e) => e.toJson()) + .toList(); + datas.addAll({"tracks": tracks}); + datas.addAll({"trackPreferences": []}); + final historys = isar.historys + .filter() + .idIsNotNull() + .findAllSync() + .map((e) => e.toJson()) + .toList(); + datas.addAll({"history": historys}); + final settings = isar.settings + .filter() + .idIsNotNull() + .findAllSync() + .map((e) => e.toJson()) + .toList(); + datas.addAll({"settings": settings}); + final sources = isar.sources + .filter() + .idIsNotNull() + .findAllSync() + .map((e) => e.toJson()) + .toList(); + datas.addAll({"extensions": sources}); + final sourcePreferences = isar.sourcePreferences + .filter() + .idIsNotNull() + .keyIsNotNull() + .findAllSync() + .map((e) => e.toJson()) + .toList(); + datas.addAll({"extensions_preferences": sourcePreferences}); + return datas; + } + + void _restoreMerge(Map backup, WidgetRef ref) { + if (backup['version'] == "1") { + try { + final manga = + (backup["manga"] as List?)?.map((e) => Manga.fromJson(e)).toList(); + final chapters = (backup["chapters"] as List?) + ?.map((e) => Chapter.fromJson(e)) + .toList(); + final categories = (backup["categories"] as List?) + ?.map((e) => Category.fromJson(e)) + .toList(); + final track = + (backup["tracks"] as List?)?.map((e) => Track.fromJson(e)).toList(); + final history = (backup["history"] as List?) + ?.map((e) => History.fromJson(e)) + .toList(); + + isar.writeTxnSync(() { + isar.mangas.clearSync(); + if (manga != null) { + isar.mangas.putAllSync(manga); + if (chapters != null) { + isar.chapters.clearSync(); + for (var chapter in chapters) { + final manga = isar.mangas.getSync(chapter.mangaId!); + if (manga != null) { + isar.chapters.putSync(chapter..manga.value = manga); + chapter.manga.saveSync(); + } + } + + isar.historys.clearSync(); + if (history != null) { + for (var element in history) { + final chapter = isar.chapters.getSync(element.chapterId!); + if (chapter != null) { + isar.historys.putSync(element..chapter.value = chapter); + element.chapter.saveSync(); + } + } + } + } + + isar.categorys.clearSync(); + if (categories != null) { + isar.categorys.putAllSync(categories); + } + } + + isar.tracks.clearSync(); + if (track != null) { + isar.tracks.putAllSync(track); + } + + ref.invalidate(themeModeStateProvider); + ref.invalidate(blendLevelStateProvider); + ref.invalidate(flexSchemeColorStateProvider); + ref.invalidate(pureBlackDarkModeStateProvider); + ref.invalidate(l10nLocaleStateProvider); + }); + } catch (e) { + botToast(e.toString()); + } + } + } + + void _restore(Map backup, WidgetRef ref) { + if (backup['version'] == "1") { + try { + log("DEBUG: ${jsonEncode(backup["version"])}"); + final manga = + (backup["manga"] as List?)?.map((e) => Manga.fromJson(e)).toList(); + final chapters = (backup["chapters"] as List?) + ?.map((e) => Chapter.fromJson(e)) + .toList(); + final categories = (backup["categories"] as List?) + ?.map((e) => Category.fromJson(e)) + .toList(); + final track = + (backup["tracks"] as List?)?.map((e) => Track.fromJson(e)).toList(); + final history = (backup["history"] as List?) + ?.map((e) => History.fromJson(e)) + .toList(); + final settings = (backup["settings"] as List?) + ?.map((e) => Settings.fromJson(e)) + .toList(); + final extensions = (backup["extensions"] as List?) + ?.map((e) => Source.fromJson(e)) + .toList(); + final extensionsPref = (backup["extensions_preferences"] as List?) + ?.map((e) => SourcePreference.fromJson(e)) + .toList(); + log("DEBUG 1: ${jsonEncode(backup["manga"])}"); + log("DEBUG 2: ${jsonEncode(manga)}"); + + isar.writeTxnSync(() { + isar.mangas.clearSync(); + if (manga != null) { + isar.mangas.putAllSync(manga); + if (chapters != null) { + isar.chapters.clearSync(); + for (var chapter in chapters) { + final manga = isar.mangas.getSync(chapter.mangaId!); + if (manga != null) { + isar.chapters.putSync(chapter..manga.value = manga); + chapter.manga.saveSync(); + } + } + + isar.historys.clearSync(); + if (history != null) { + for (var element in history) { + final chapter = isar.chapters.getSync(element.chapterId!); + if (chapter != null) { + isar.historys.putSync(element..chapter.value = chapter); + element.chapter.saveSync(); + } + } + } + } + + isar.categorys.clearSync(); + if (categories != null) { + isar.categorys.putAllSync(categories); + } + } + + isar.tracks.clearSync(); + if (track != null) { + isar.tracks.putAllSync(track); + } + + isar.sources.clearSync(); + if (extensions != null) { + isar.sources.putAllSync(extensions); + } + + isar.sourcePreferences.clearSync(); + if (extensionsPref != null) { + isar.sourcePreferences.putAllSync(extensionsPref); + } + final syncAfterReading = isar.settings.getSync(227)!.syncAfterReading; + final syncOnAppLaunch = isar.settings.getSync(227)!.syncOnAppLaunch; + isar.settings.clearSync(); + if (settings != null) { + isar.settings.putAllSync(settings); + } + isar.settings.putSync( + isar.settings.getSync(227)!..syncAfterReading = syncAfterReading); + isar.settings.putSync( + isar.settings.getSync(227)!..syncOnAppLaunch = syncOnAppLaunch); + ref.invalidate(themeModeStateProvider); + ref.invalidate(blendLevelStateProvider); + ref.invalidate(flexSchemeColorStateProvider); + ref.invalidate(pureBlackDarkModeStateProvider); + ref.invalidate(l10nLocaleStateProvider); + }); + } catch (e) { + botToast(e.toString(), second: 5); + } + } + } + + String _getAccessToken() { + final syncPrefs = ref.watch(synchingProvider(syncId: syncId)); + if (syncPrefs == null || syncPrefs.authToken == null) { + return ""; + } + var paddedPayload = syncPrefs.authToken!.split(".")[1]; + if (paddedPayload.length % 4 > 0) { + paddedPayload += '=' * (4 - paddedPayload.length % 4); + } + final decodedJwt = jsonDecode(utf8.decode(base64Decode(paddedPayload))) + as Map; + final auth = JWToken.fromJson(decodedJwt); + final expiresIn = DateTime.fromMillisecondsSinceEpoch(auth.exp!); + if (DateTime.now().isAfter(expiresIn)) { + ref.read(synchingProvider(syncId: syncId).notifier).logout(); + botToast("SyncServer Token expired"); + throw Exception("Token expired"); + } + return syncPrefs.authToken!; + } + + String _getServer() { + final syncPrefs = ref.watch(synchingProvider(syncId: syncId)); + return syncPrefs?.server ?? ""; + } +} diff --git a/lib/services/sync_server.g.dart b/lib/services/sync_server.g.dart new file mode 100644 index 0000000..a64fafc --- /dev/null +++ b/lib/services/sync_server.g.dart @@ -0,0 +1,172 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync_server.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$syncServerHash() => r'79a57c46772a4ca28681162896742c1fd24955da'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$SyncServer extends BuildlessAutoDisposeNotifier { + late final int syncId; + + void build({ + required int syncId, + }); +} + +/// See also [SyncServer]. +@ProviderFor(SyncServer) +const syncServerProvider = SyncServerFamily(); + +/// See also [SyncServer]. +class SyncServerFamily extends Family { + /// See also [SyncServer]. + const SyncServerFamily(); + + /// See also [SyncServer]. + SyncServerProvider call({ + required int syncId, + }) { + return SyncServerProvider( + syncId: syncId, + ); + } + + @override + SyncServerProvider getProviderOverride( + covariant SyncServerProvider provider, + ) { + return call( + syncId: provider.syncId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'syncServerProvider'; +} + +/// See also [SyncServer]. +class SyncServerProvider + extends AutoDisposeNotifierProviderImpl { + /// See also [SyncServer]. + SyncServerProvider({ + required int syncId, + }) : this._internal( + () => SyncServer()..syncId = syncId, + from: syncServerProvider, + name: r'syncServerProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$syncServerHash, + dependencies: SyncServerFamily._dependencies, + allTransitiveDependencies: + SyncServerFamily._allTransitiveDependencies, + syncId: syncId, + ); + + SyncServerProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.syncId, + }) : super.internal(); + + final int syncId; + + @override + void runNotifierBuild( + covariant SyncServer notifier, + ) { + return notifier.build( + syncId: syncId, + ); + } + + @override + Override overrideWith(SyncServer Function() create) { + return ProviderOverride( + origin: this, + override: SyncServerProvider._internal( + () => create()..syncId = syncId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + syncId: syncId, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement createElement() { + return _SyncServerProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SyncServerProvider && other.syncId == syncId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, syncId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin SyncServerRef on AutoDisposeNotifierProviderRef { + /// The parameter `syncId` of this provider. + int get syncId; +} + +class _SyncServerProviderElement + extends AutoDisposeNotifierProviderElement + with SyncServerRef { + _SyncServerProviderElement(super.provider); + + @override + int get syncId => (origin as SyncServerProvider).syncId; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member