diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 00000000..fbad0237 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + keychain-access-groups + + + diff --git a/lib/common/consts.dart b/lib/common/consts.dart index 2b37e64d..25122218 100644 --- a/lib/common/consts.dart +++ b/lib/common/consts.dart @@ -17,6 +17,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../model/app_reminder_config.dart'; +import '../model/app_sync_options.dart'; import '../model/habit_display.dart'; import '../model/habit_form.dart'; import '../theme/color.dart'; @@ -47,6 +48,7 @@ const int appCalendarBarMaxOccupyPrt = 70; const int appCalendarBarMinOccupyPrt = 20; const int appCalendarBarDefualtOccupyPrt = 50; const int kHabitLargeScreenAdaptWidth = 600; +const int kHabitLargeScreenAdaptHeight = 400; //#endregion //#region l10n @@ -81,6 +83,10 @@ const defaultSortDirection = HabitDisplaySortDirection.asc; const defaultHabitsRecordScrollBehavior = HabitsRecordScrollBehavior.scrollable; const defaultFirstDay = DateTime.monday; const defaultAppReminder = AppReminderConfig.off; +const defaultAppSyncTimeout = Duration(seconds: 60); +const defaultAppSyncConnectTimeout = Duration(seconds: 10); +const int? defaultAppSyncConnectRetryCount = null; // null for infinity +const defaultAppSyncFetchInterval = AppSyncFetchInterval.minute5; //#endregion //#region habit-field diff --git a/lib/model/app_sync_options.dart b/lib/model/app_sync_options.dart new file mode 100644 index 00000000..31fccfb0 --- /dev/null +++ b/lib/model/app_sync_options.dart @@ -0,0 +1,38 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../common/enums.dart'; + +enum AppSyncFetchInterval implements EnumWithDBCode { + manual(null), + minute5(Duration(minutes: 5)), + minute15(Duration(minutes: 15)), + minute30(Duration(minutes: 30)), + hour1(Duration(hours: 1)); + + final Duration? t; + + const AppSyncFetchInterval(this.t); + + @override + int get dbCode => t?.inSeconds ?? -1; + + static AppSyncFetchInterval? getFromDBCode(int dbCode, + {AppSyncFetchInterval? withDefault = AppSyncFetchInterval.manual}) { + for (var value in AppSyncFetchInterval.values) { + if (value.dbCode == dbCode) return value; + } + return withDefault; + } +} diff --git a/lib/model/app_sync_server.dart b/lib/model/app_sync_server.dart new file mode 100644 index 00000000..09252795 --- /dev/null +++ b/lib/model/app_sync_server.dart @@ -0,0 +1,500 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:uuid/uuid.dart'; + +import '../common/enums.dart'; +import '../common/types.dart'; +import 'common.dart'; + +part 'app_sync_server.g.dart'; + +@JsonEnum(valueField: "code") +enum AppSyncServerType implements EnumWithDBCode { + unknown(code: 0), + webdav( + code: 1, + includePathField: true, + includeUsernameField: true, + includePasswordField: true, + includeIgnoreSSLField: true, + includeConnTimeoutField: true, + includeConnRetryCountField: true, + includeSyncNetworkField: true), + fake( + code: 99, + includePasswordField: true, + ); + + final int code; + final bool includePathField; + final bool includeUsernameField; + final bool includePasswordField; + final bool includeIgnoreSSLField; + final bool includeConnTimeoutField; + final bool includeConnRetryCountField; + final bool includeSyncNetworkField; + + const AppSyncServerType( + {required this.code, + this.includePathField = false, + this.includeUsernameField = false, + this.includePasswordField = false, + this.includeIgnoreSSLField = false, + this.includeConnTimeoutField = false, + this.includeConnRetryCountField = false, + this.includeSyncNetworkField = false}); + + @override + int get dbCode => code; + + static AppSyncServerType? getFromDBCode(int dbCode, + {AppSyncServerType? withDefault = AppSyncServerType.unknown}) { + for (var value in AppSyncServerType.values) { + if (value.dbCode == dbCode) return value; + } + return withDefault; + } +} + +@JsonEnum(valueField: "code") +enum AppSyncServerMobileNetwork + implements EnumWithDBCode { + none(code: 0), + wifi(code: 1), + mobile(code: 2); + + final int code; + + const AppSyncServerMobileNetwork({required this.code}); + + @override + int get dbCode => code; + + static List get allowed => const [ + AppSyncServerMobileNetwork.mobile, + AppSyncServerMobileNetwork.wifi, + ]; +} + +abstract interface class AppSyncServer implements JsonAdaptor { + static const typeJsonKey = 'type_'; + + static AppSyncServer? fromJson(JsonMap json) { + final type = AppSyncServerType.getFromDBCode( + (json[typeJsonKey] as int?) ?? -1, + withDefault: AppSyncServerType.unknown); + switch (type) { + case AppSyncServerType.webdav: + return AppWebDavSyncServer.fromJson(json); + case AppSyncServerType.fake: + return AppFakeSyncServer.fromJson(json); + default: + return null; + } + } + + static AppSyncServer? fromForm(AppSyncServerForm? form) { + if (form == null) return null; + switch (form.type) { + case AppSyncServerType.webdav: + return AppWebDavSyncServer.fromForm(form); + case AppSyncServerType.fake: + return AppFakeSyncServer.fromForm(form); + default: + return null; + } + } + + static AppSyncServer? newServer(AppSyncServerType type) { + final identity = const Uuid().v4(); + switch (type) { + case AppSyncServerType.webdav: + return AppWebDavSyncServer.newServer(identity: identity, path: ''); + case AppSyncServerType.fake: + return AppFakeSyncServer.newServer(identity: identity, path: ''); + default: + return null; + } + } + + String get name; + String get identity; + DateTime get createTime; + DateTime get modifyTime; + AppSyncServerType get type; + Duration? get timeout; + bool get verified; + bool get configed; + + String? get password; + + bool isSameConfig(AppSyncServer other, {bool withoutPassword = false}); + AppSyncServerForm toForm(); + String toDebugString(); +} + +@JsonSerializable() +@CopyWith(skipFields: true, copyWithNull: false, constructor: '_copyWith') +final class AppWebDavSyncServer implements AppSyncServer { + @override + final String identity; + @override + final DateTime createTime; + @override + final DateTime modifyTime; + @override + @JsonKey( + name: AppSyncServer.typeJsonKey, + includeToJson: true, + includeFromJson: false) + final AppSyncServerType type; + @override + final Duration? timeout; + @override + final bool verified; + @override + final bool configed; + + final List _syncMobileNetworks; + + final Uri path; + final String username; + final String password; + final bool ignoreSSL; + final bool syncInLowData; + final int? connectRetryCount; + final Duration? connectTimeout; + + const AppWebDavSyncServer({ + required this.identity, + required this.createTime, + required this.modifyTime, + required this.path, + required this.username, + required this.password, + this.timeout, + this.connectRetryCount, + this.connectTimeout, + required this.verified, + required this.configed, + required List syncMobileNetworks, + required this.ignoreSSL, + required this.syncInLowData, + }) : type = AppSyncServerType.webdav, + _syncMobileNetworks = syncMobileNetworks; + + factory AppWebDavSyncServer.newServer({ + required String identity, + required String path, + String username = '', + String password = '', + Iterable? syncMobileNetworks, + bool syncInLowData = true, + bool ignoreSSL = false, + Duration? timeout, + Duration? connectTimeout, + int? maxRetryCount, + }) { + final now = DateTime.now(); + return AppWebDavSyncServer( + identity: identity, + createTime: now, + modifyTime: now, + path: Uri.parse(path), + username: username, + password: password, + verified: false, + configed: false, + syncMobileNetworks: + syncMobileNetworks?.toList() ?? AppSyncServerMobileNetwork.allowed, + syncInLowData: syncInLowData, + ignoreSSL: ignoreSSL, + timeout: timeout, + connectRetryCount: maxRetryCount, + connectTimeout: connectTimeout); + } + + AppWebDavSyncServer._copyWith({ + required this.identity, + required this.createTime, + required this.modifyTime, + required this.path, + required this.username, + required this.password, + this.timeout, + this.connectRetryCount, + this.connectTimeout, + required this.verified, + required this.configed, + required Iterable syncMobileNetworks, + required this.syncInLowData, + required this.ignoreSSL, + }) : type = AppSyncServerType.webdav, + _syncMobileNetworks = [] { + _syncMobileNetworks.addAll(syncMobileNetworks); + } + + factory AppWebDavSyncServer.fromJson(Map json) => + _$AppWebDavSyncServerFromJson(json); + + factory AppWebDavSyncServer.fromForm(AppSyncServerForm form) => + AppWebDavSyncServer( + identity: form.uuid.uuid, + createTime: form.createTime, + modifyTime: form.modifyTime, + path: Uri.parse(form.path!), + username: form.username!, + password: form.password!, + timeout: form.timeout, + connectTimeout: form.connectTimeout, + connectRetryCount: form.connectRetryCount, + verified: form.verified, + configed: form.configed, + syncMobileNetworks: form.syncMobileNetworks!.toList(), + ignoreSSL: form.ignoreSSL!, + syncInLowData: form.syncInLowData!); + + Iterable get syncMobileNetworks => + _syncMobileNetworks; + + @override + @JsonKey() + String get name => path.toString(); + + @override + bool isSameConfig(AppSyncServer other, {bool withoutPassword = false}) { + if (identical(this, other)) return true; + if (other is! AppWebDavSyncServer) return false; + return (identity == other.identity && + path == other.path && + username == other.username && + (withoutPassword ? true : password == other.password)); + } + + @override + Map toJson() => _$AppWebDavSyncServerToJson(this); + + @override + AppSyncServerForm toForm() => AppSyncServerForm( + uuid: UuidValue.fromString(identity), + createTime: createTime, + modifyTime: modifyTime, + type: type, + path: path.toString(), + username: username, + password: password, + ignoreSSL: ignoreSSL, + timeout: timeout, + connectTimeout: connectTimeout, + connectRetryCount: connectRetryCount, + syncMobileNetworks: Set.of(syncMobileNetworks), + syncInLowData: syncInLowData, + verified: verified, + configed: configed); + + @override + String toDebugString() { + return """AppWebDavSyncServer( + identity: $identity, + createTime: $createTime, + modifyTime: $modifyTime, + type: $type, + syncInLowData: $syncInLowData, + timeout: $timeout, + verified: $verified, + configed: $configed, + syncMobileNetworks: $_syncMobileNetworks, + path: $path, + username: $username, + password: $password, + connectTimeout: $connectTimeout, + connectRetryCount: $connectRetryCount, +)"""; + } +} + +@JsonSerializable() +@CopyWith(skipFields: true, copyWithNull: false, constructor: '_copyWith') +final class AppFakeSyncServer implements AppSyncServer { + @override + final String identity; + @override + final String name; + @override + final DateTime createTime; + @override + final DateTime modifyTime; + @override + @JsonKey( + name: AppSyncServer.typeJsonKey, + includeToJson: true, + includeFromJson: false) + final AppSyncServerType type; + @override + final Duration? timeout; + @override + final bool verified; + @override + final bool configed; + @override + final String? password; + + const AppFakeSyncServer({ + required this.identity, + required this.name, + required this.createTime, + required this.modifyTime, + required this.timeout, + required this.verified, + required this.configed, + required this.password, + }) : type = AppSyncServerType.fake; + + factory AppFakeSyncServer.newServer({ + required String identity, + required String path, + String password = '', + Duration? timeout, + }) { + final now = DateTime.now(); + return AppFakeSyncServer( + identity: identity, + name: identity, + createTime: now, + modifyTime: now, + timeout: timeout, + verified: false, + configed: false, + password: password, + ); + } + + AppFakeSyncServer._copyWith({ + required this.identity, + required this.name, + required this.createTime, + required this.modifyTime, + this.password, + this.timeout, + required this.verified, + required this.configed, + }) : type = AppSyncServerType.fake; + + factory AppFakeSyncServer.fromJson(Map json) => + _$AppFakeSyncServerFromJson(json); + + factory AppFakeSyncServer.fromForm(AppSyncServerForm form) => + AppFakeSyncServer( + identity: form.uuid.uuid, + name: form.uuid.uuid, + createTime: form.createTime, + modifyTime: form.modifyTime, + timeout: form.timeout, + verified: form.verified, + configed: form.configed, + password: form.password); + + @override + String toDebugString() => """AppFakeSyncServer( + identity=$identity, + name=$name, + createTime=$createTime, + modifyTime=$modifyTime, + type=$type, + timeout=$timeout, + verified=$verified, + configed=$configed, +)"""; + + @override + bool isSameConfig(AppSyncServer other, {bool withoutPassword = true}) { + if (identical(this, other)) return true; + if (other is! AppFakeSyncServer) return false; + return identity == other.identity; + } + + @override + AppSyncServerForm toForm() => AppSyncServerForm( + uuid: UuidValue.fromString(identity), + createTime: createTime, + modifyTime: modifyTime, + type: type, + path: null, + username: null, + password: null, + ignoreSSL: null, + timeout: timeout, + connectTimeout: null, + connectRetryCount: null, + syncMobileNetworks: null, + syncInLowData: null, + verified: verified, + configed: configed); + + @override + Map toJson() => _$AppFakeSyncServerToJson(this); +} + +@CopyWith(skipFields: true, copyWithNull: false) +class AppSyncServerForm { + final UuidValue uuid; + final DateTime createTime; + final DateTime modifyTime; + + AppSyncServerType type; + bool verified; + bool configed; + + String? path; + String? username; + String? password; + bool? ignoreSSL; + Duration? timeout; + Duration? connectTimeout; + int? connectRetryCount; + Set? syncMobileNetworks; + bool? syncInLowData; + + AppSyncServerForm({ + required this.uuid, + required this.type, + required this.createTime, + required this.modifyTime, + required this.path, + required this.username, + required this.password, + required this.ignoreSSL, + required this.timeout, + required this.connectTimeout, + required this.connectRetryCount, + required this.syncMobileNetworks, + required this.syncInLowData, + required this.verified, + required this.configed, + }); + + String toDebugString() => """AppSyncServerForm( + uuid=$uuid,type=$type, + createTime=$createTime,modifyTime=$modifyTime, + path=$path,username=$username,password=$password, + ignoreSSL=$ignoreSSL,timeout=$timeout, + connectTimeout=$connectTimeout,connectRetryCount=$connectRetryCount, + syncMobileNetworks=$syncMobileNetworks, + syncInLowData=$syncInLowData, + vertified=$verified,configed=$configed, +)"""; +} diff --git a/lib/model/app_sync_server.g.dart b/lib/model/app_sync_server.g.dart new file mode 100644 index 00000000..43e261d0 --- /dev/null +++ b/lib/model/app_sync_server.g.dart @@ -0,0 +1,443 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_sync_server.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$AppWebDavSyncServerCWProxy { + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. + /// + /// Usage + /// ```dart + /// AppWebDavSyncServer(...).copyWith(id: 12, name: "My name") + /// ```` + AppWebDavSyncServer call({ + String? identity, + DateTime? createTime, + DateTime? modifyTime, + Uri? path, + String? username, + String? password, + Duration? timeout, + int? connectRetryCount, + Duration? connectTimeout, + bool? verified, + bool? configed, + Iterable? syncMobileNetworks, + bool? syncInLowData, + bool? ignoreSSL, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfAppWebDavSyncServer.copyWith(...)`. +class _$AppWebDavSyncServerCWProxyImpl implements _$AppWebDavSyncServerCWProxy { + const _$AppWebDavSyncServerCWProxyImpl(this._value); + + final AppWebDavSyncServer _value; + + @override + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. + /// + /// Usage + /// ```dart + /// AppWebDavSyncServer(...).copyWith(id: 12, name: "My name") + /// ```` + AppWebDavSyncServer call({ + Object? identity = const $CopyWithPlaceholder(), + Object? createTime = const $CopyWithPlaceholder(), + Object? modifyTime = const $CopyWithPlaceholder(), + Object? path = const $CopyWithPlaceholder(), + Object? username = const $CopyWithPlaceholder(), + Object? password = const $CopyWithPlaceholder(), + Object? timeout = const $CopyWithPlaceholder(), + Object? connectRetryCount = const $CopyWithPlaceholder(), + Object? connectTimeout = const $CopyWithPlaceholder(), + Object? verified = const $CopyWithPlaceholder(), + Object? configed = const $CopyWithPlaceholder(), + Object? syncMobileNetworks = const $CopyWithPlaceholder(), + Object? syncInLowData = const $CopyWithPlaceholder(), + Object? ignoreSSL = const $CopyWithPlaceholder(), + }) { + return AppWebDavSyncServer._copyWith( + identity: identity == const $CopyWithPlaceholder() || identity == null + ? _value.identity + // ignore: cast_nullable_to_non_nullable + : identity as String, + createTime: + createTime == const $CopyWithPlaceholder() || createTime == null + ? _value.createTime + // ignore: cast_nullable_to_non_nullable + : createTime as DateTime, + modifyTime: + modifyTime == const $CopyWithPlaceholder() || modifyTime == null + ? _value.modifyTime + // ignore: cast_nullable_to_non_nullable + : modifyTime as DateTime, + path: path == const $CopyWithPlaceholder() || path == null + ? _value.path + // ignore: cast_nullable_to_non_nullable + : path as Uri, + username: username == const $CopyWithPlaceholder() || username == null + ? _value.username + // ignore: cast_nullable_to_non_nullable + : username as String, + password: password == const $CopyWithPlaceholder() || password == null + ? _value.password + // ignore: cast_nullable_to_non_nullable + : password as String, + timeout: timeout == const $CopyWithPlaceholder() + ? _value.timeout + // ignore: cast_nullable_to_non_nullable + : timeout as Duration?, + connectRetryCount: connectRetryCount == const $CopyWithPlaceholder() + ? _value.connectRetryCount + // ignore: cast_nullable_to_non_nullable + : connectRetryCount as int?, + connectTimeout: connectTimeout == const $CopyWithPlaceholder() + ? _value.connectTimeout + // ignore: cast_nullable_to_non_nullable + : connectTimeout as Duration?, + verified: verified == const $CopyWithPlaceholder() || verified == null + ? _value.verified + // ignore: cast_nullable_to_non_nullable + : verified as bool, + configed: configed == const $CopyWithPlaceholder() || configed == null + ? _value.configed + // ignore: cast_nullable_to_non_nullable + : configed as bool, + syncMobileNetworks: syncMobileNetworks == const $CopyWithPlaceholder() || + syncMobileNetworks == null + ? _value.syncMobileNetworks + // ignore: cast_nullable_to_non_nullable + : syncMobileNetworks as Iterable, + syncInLowData: + syncInLowData == const $CopyWithPlaceholder() || syncInLowData == null + ? _value.syncInLowData + // ignore: cast_nullable_to_non_nullable + : syncInLowData as bool, + ignoreSSL: ignoreSSL == const $CopyWithPlaceholder() || ignoreSSL == null + ? _value.ignoreSSL + // ignore: cast_nullable_to_non_nullable + : ignoreSSL as bool, + ); + } +} + +extension $AppWebDavSyncServerCopyWith on AppWebDavSyncServer { + /// Returns a callable class that can be used as follows: `instanceOfAppWebDavSyncServer.copyWith(...)`. + // ignore: library_private_types_in_public_api + _$AppWebDavSyncServerCWProxy get copyWith => + _$AppWebDavSyncServerCWProxyImpl(this); +} + +abstract class _$AppFakeSyncServerCWProxy { + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. + /// + /// Usage + /// ```dart + /// AppFakeSyncServer(...).copyWith(id: 12, name: "My name") + /// ```` + AppFakeSyncServer call({ + String? identity, + String? name, + DateTime? createTime, + DateTime? modifyTime, + String? password, + Duration? timeout, + bool? verified, + bool? configed, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfAppFakeSyncServer.copyWith(...)`. +class _$AppFakeSyncServerCWProxyImpl implements _$AppFakeSyncServerCWProxy { + const _$AppFakeSyncServerCWProxyImpl(this._value); + + final AppFakeSyncServer _value; + + @override + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. + /// + /// Usage + /// ```dart + /// AppFakeSyncServer(...).copyWith(id: 12, name: "My name") + /// ```` + AppFakeSyncServer call({ + Object? identity = const $CopyWithPlaceholder(), + Object? name = const $CopyWithPlaceholder(), + Object? createTime = const $CopyWithPlaceholder(), + Object? modifyTime = const $CopyWithPlaceholder(), + Object? password = const $CopyWithPlaceholder(), + Object? timeout = const $CopyWithPlaceholder(), + Object? verified = const $CopyWithPlaceholder(), + Object? configed = const $CopyWithPlaceholder(), + }) { + return AppFakeSyncServer._copyWith( + identity: identity == const $CopyWithPlaceholder() || identity == null + ? _value.identity + // ignore: cast_nullable_to_non_nullable + : identity as String, + name: name == const $CopyWithPlaceholder() || name == null + ? _value.name + // ignore: cast_nullable_to_non_nullable + : name as String, + createTime: + createTime == const $CopyWithPlaceholder() || createTime == null + ? _value.createTime + // ignore: cast_nullable_to_non_nullable + : createTime as DateTime, + modifyTime: + modifyTime == const $CopyWithPlaceholder() || modifyTime == null + ? _value.modifyTime + // ignore: cast_nullable_to_non_nullable + : modifyTime as DateTime, + password: password == const $CopyWithPlaceholder() + ? _value.password + // ignore: cast_nullable_to_non_nullable + : password as String?, + timeout: timeout == const $CopyWithPlaceholder() + ? _value.timeout + // ignore: cast_nullable_to_non_nullable + : timeout as Duration?, + verified: verified == const $CopyWithPlaceholder() || verified == null + ? _value.verified + // ignore: cast_nullable_to_non_nullable + : verified as bool, + configed: configed == const $CopyWithPlaceholder() || configed == null + ? _value.configed + // ignore: cast_nullable_to_non_nullable + : configed as bool, + ); + } +} + +extension $AppFakeSyncServerCopyWith on AppFakeSyncServer { + /// Returns a callable class that can be used as follows: `instanceOfAppFakeSyncServer.copyWith(...)`. + // ignore: library_private_types_in_public_api + _$AppFakeSyncServerCWProxy get copyWith => + _$AppFakeSyncServerCWProxyImpl(this); +} + +abstract class _$AppSyncServerFormCWProxy { + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. + /// + /// Usage + /// ```dart + /// AppSyncServerForm(...).copyWith(id: 12, name: "My name") + /// ```` + AppSyncServerForm call({ + UuidValue? uuid, + AppSyncServerType? type, + DateTime? createTime, + DateTime? modifyTime, + String? path, + String? username, + String? password, + bool? ignoreSSL, + Duration? timeout, + Duration? connectTimeout, + int? connectRetryCount, + Set? syncMobileNetworks, + bool? syncInLowData, + bool? verified, + bool? configed, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfAppSyncServerForm.copyWith(...)`. +class _$AppSyncServerFormCWProxyImpl implements _$AppSyncServerFormCWProxy { + const _$AppSyncServerFormCWProxyImpl(this._value); + + final AppSyncServerForm _value; + + @override + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. + /// + /// Usage + /// ```dart + /// AppSyncServerForm(...).copyWith(id: 12, name: "My name") + /// ```` + AppSyncServerForm call({ + Object? uuid = const $CopyWithPlaceholder(), + Object? type = const $CopyWithPlaceholder(), + Object? createTime = const $CopyWithPlaceholder(), + Object? modifyTime = const $CopyWithPlaceholder(), + Object? path = const $CopyWithPlaceholder(), + Object? username = const $CopyWithPlaceholder(), + Object? password = const $CopyWithPlaceholder(), + Object? ignoreSSL = const $CopyWithPlaceholder(), + Object? timeout = const $CopyWithPlaceholder(), + Object? connectTimeout = const $CopyWithPlaceholder(), + Object? connectRetryCount = const $CopyWithPlaceholder(), + Object? syncMobileNetworks = const $CopyWithPlaceholder(), + Object? syncInLowData = const $CopyWithPlaceholder(), + Object? verified = const $CopyWithPlaceholder(), + Object? configed = const $CopyWithPlaceholder(), + }) { + return AppSyncServerForm( + uuid: uuid == const $CopyWithPlaceholder() || uuid == null + ? _value.uuid + // ignore: cast_nullable_to_non_nullable + : uuid as UuidValue, + type: type == const $CopyWithPlaceholder() || type == null + ? _value.type + // ignore: cast_nullable_to_non_nullable + : type as AppSyncServerType, + createTime: + createTime == const $CopyWithPlaceholder() || createTime == null + ? _value.createTime + // ignore: cast_nullable_to_non_nullable + : createTime as DateTime, + modifyTime: + modifyTime == const $CopyWithPlaceholder() || modifyTime == null + ? _value.modifyTime + // ignore: cast_nullable_to_non_nullable + : modifyTime as DateTime, + path: path == const $CopyWithPlaceholder() + ? _value.path + // ignore: cast_nullable_to_non_nullable + : path as String?, + username: username == const $CopyWithPlaceholder() + ? _value.username + // ignore: cast_nullable_to_non_nullable + : username as String?, + password: password == const $CopyWithPlaceholder() + ? _value.password + // ignore: cast_nullable_to_non_nullable + : password as String?, + ignoreSSL: ignoreSSL == const $CopyWithPlaceholder() + ? _value.ignoreSSL + // ignore: cast_nullable_to_non_nullable + : ignoreSSL as bool?, + timeout: timeout == const $CopyWithPlaceholder() + ? _value.timeout + // ignore: cast_nullable_to_non_nullable + : timeout as Duration?, + connectTimeout: connectTimeout == const $CopyWithPlaceholder() + ? _value.connectTimeout + // ignore: cast_nullable_to_non_nullable + : connectTimeout as Duration?, + connectRetryCount: connectRetryCount == const $CopyWithPlaceholder() + ? _value.connectRetryCount + // ignore: cast_nullable_to_non_nullable + : connectRetryCount as int?, + syncMobileNetworks: syncMobileNetworks == const $CopyWithPlaceholder() + ? _value.syncMobileNetworks + // ignore: cast_nullable_to_non_nullable + : syncMobileNetworks as Set?, + syncInLowData: syncInLowData == const $CopyWithPlaceholder() + ? _value.syncInLowData + // ignore: cast_nullable_to_non_nullable + : syncInLowData as bool?, + verified: verified == const $CopyWithPlaceholder() || verified == null + ? _value.verified + // ignore: cast_nullable_to_non_nullable + : verified as bool, + configed: configed == const $CopyWithPlaceholder() || configed == null + ? _value.configed + // ignore: cast_nullable_to_non_nullable + : configed as bool, + ); + } +} + +extension $AppSyncServerFormCopyWith on AppSyncServerForm { + /// Returns a callable class that can be used as follows: `instanceOfAppSyncServerForm.copyWith(...)`. + // ignore: library_private_types_in_public_api + _$AppSyncServerFormCWProxy get copyWith => + _$AppSyncServerFormCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AppWebDavSyncServer _$AppWebDavSyncServerFromJson(Map json) => + AppWebDavSyncServer( + identity: json['identity'] as String, + createTime: DateTime.parse(json['createTime'] as String), + modifyTime: DateTime.parse(json['modifyTime'] as String), + path: Uri.parse(json['path'] as String), + username: json['username'] as String, + password: json['password'] as String, + timeout: json['timeout'] == null + ? null + : Duration(microseconds: (json['timeout'] as num).toInt()), + connectRetryCount: (json['connectRetryCount'] as num?)?.toInt(), + connectTimeout: json['connectTimeout'] == null + ? null + : Duration(microseconds: (json['connectTimeout'] as num).toInt()), + verified: json['verified'] as bool, + configed: json['configed'] as bool, + syncMobileNetworks: (json['syncMobileNetworks'] as List) + .map((e) => $enumDecode(_$AppSyncServerMobileNetworkEnumMap, e)) + .toList(), + ignoreSSL: json['ignoreSSL'] as bool, + syncInLowData: json['syncInLowData'] as bool, + ); + +Map _$AppWebDavSyncServerToJson( + AppWebDavSyncServer instance) => + { + 'identity': instance.identity, + 'createTime': instance.createTime.toIso8601String(), + 'modifyTime': instance.modifyTime.toIso8601String(), + 'type_': _$AppSyncServerTypeEnumMap[instance.type]!, + 'timeout': instance.timeout?.inMicroseconds, + 'verified': instance.verified, + 'configed': instance.configed, + 'path': instance.path.toString(), + 'username': instance.username, + 'password': instance.password, + 'ignoreSSL': instance.ignoreSSL, + 'syncInLowData': instance.syncInLowData, + 'connectRetryCount': instance.connectRetryCount, + 'connectTimeout': instance.connectTimeout?.inMicroseconds, + 'syncMobileNetworks': instance.syncMobileNetworks + .map((e) => _$AppSyncServerMobileNetworkEnumMap[e]!) + .toList(), + }; + +const _$AppSyncServerMobileNetworkEnumMap = { + AppSyncServerMobileNetwork.none: 0, + AppSyncServerMobileNetwork.wifi: 1, + AppSyncServerMobileNetwork.mobile: 2, +}; + +const _$AppSyncServerTypeEnumMap = { + AppSyncServerType.unknown: 0, + AppSyncServerType.webdav: 1, + AppSyncServerType.fake: 99, +}; + +AppFakeSyncServer _$AppFakeSyncServerFromJson(Map json) => + AppFakeSyncServer( + identity: json['identity'] as String, + name: json['name'] as String, + createTime: DateTime.parse(json['createTime'] as String), + modifyTime: DateTime.parse(json['modifyTime'] as String), + timeout: json['timeout'] == null + ? null + : Duration(microseconds: (json['timeout'] as num).toInt()), + verified: json['verified'] as bool, + configed: json['configed'] as bool, + password: json['password'] as String?, + ); + +Map _$AppFakeSyncServerToJson(AppFakeSyncServer instance) => + { + 'identity': instance.identity, + 'name': instance.name, + 'createTime': instance.createTime.toIso8601String(), + 'modifyTime': instance.modifyTime.toIso8601String(), + 'type_': _$AppSyncServerTypeEnumMap[instance.type]!, + 'timeout': instance.timeout?.inMicroseconds, + 'verified': instance.verified, + 'configed': instance.configed, + 'password': instance.password, + }; diff --git a/lib/persistent/profile/handler/app_sync.dart b/lib/persistent/profile/handler/app_sync.dart new file mode 100644 index 00000000..942d05cb --- /dev/null +++ b/lib/persistent/profile/handler/app_sync.dart @@ -0,0 +1,105 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; + +import '../../../common/consts.dart'; +import '../../../common/types.dart'; +import '../../../model/app_sync_options.dart'; +import '../../../model/app_sync_server.dart'; +import '../converter.dart'; +import '../profile_helper.dart'; + +class AppSyncSwitchHandler extends ProfileHelperCovertToBoolHandler { + const AppSyncSwitchHandler(super.pref) : super(codec: const SameTypeCodec()); + + @override + String get key => 'enableSync'; +} + +class AppSyncFetchIntervalHandler + extends ProfileHelperCovertToIntHandler { + AppSyncFetchIntervalHandler(super.pref) + : super(codec: const AppSyncFetchIntervalCodec()); + + @override + String get key => 'syncFetchInterval'; +} + +final class AppSyncFetchIntervalCodec extends Codec { + const AppSyncFetchIntervalCodec(); + + @override + Converter get decoder => + const _AppSyncFetchIntervalDecoder(); + + @override + Converter get encoder => + const _AppSyncFetchIntervalEncoder(); +} + +final class _AppSyncFetchIntervalEncoder + extends Converter { + const _AppSyncFetchIntervalEncoder(); + + @override + int convert(AppSyncFetchInterval input) => input.dbCode; +} + +final class _AppSyncFetchIntervalDecoder + extends Converter { + const _AppSyncFetchIntervalDecoder(); + + @override + AppSyncFetchInterval convert(int input) => + AppSyncFetchInterval.getFromDBCode(input, + withDefault: defaultAppSyncFetchInterval)!; +} + +class AppSyncServerConfigHandler + extends ProfileHelperCovertToJsonHandler { + const AppSyncServerConfigHandler(super.pref, + {super.codec = const AppSyncServerConfigCodec()}); + + @override + String get key => 'syncServer'; +} + +final class AppSyncServerConfigCodec extends Codec { + const AppSyncServerConfigCodec(); + + @override + Converter get decoder => + const _AppSyncServerConfigDecoder(); + + @override + Converter get encoder => + const _AppSyncServerConfigEncoder(); +} + +final class _AppSyncServerConfigDecoder + extends Converter { + const _AppSyncServerConfigDecoder(); + + @override + AppSyncServer? convert(JsonMap input) => AppSyncServer.fromJson(input); +} + +final class _AppSyncServerConfigEncoder + extends Converter { + const _AppSyncServerConfigEncoder(); + + @override + JsonMap convert(AppSyncServer? input) => input?.toJson() ?? {}; +} diff --git a/lib/provider/app_sync.dart b/lib/provider/app_sync.dart new file mode 100644 index 00000000..a11e68c5 --- /dev/null +++ b/lib/provider/app_sync.dart @@ -0,0 +1,147 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../common/consts.dart'; +import '../model/app_sync_options.dart'; +import '../model/app_sync_server.dart'; +import '../persistent/profile/handler/app_sync.dart'; +import '../persistent/profile_provider.dart'; +import 'commons.dart'; + +class AppSyncViewModel + with ChangeNotifier, ProfileHandlerLoadedMixin + implements ProviderMounted { + bool _mounted = true; + AppSyncSwitchHandler? _switch; + AppSyncFetchIntervalHandler? _interval; + AppSyncServerConfigHandler? _serverConfig; + + @override + void dispose() { + _mounted = false; + super.dispose(); + } + + @override + bool get mounted => _mounted; + + @override + void updateProfile(ProfileViewModel newProfile) { + super.updateProfile(newProfile); + _switch = newProfile.getHandler(); + _interval = newProfile.getHandler(); + _serverConfig = newProfile.getHandler(); + } + + bool get enabled => _switch?.get() ?? false; + + Future setSyncSwitch(bool value, {bool listen = true}) async { + if (_switch?.get() != value) { + await _switch?.set(value); + if (listen) notifyListeners(); + } + } + + AppSyncFetchInterval get fetchInterval => + _interval?.get() ?? defaultAppSyncFetchInterval; + + Future setFetchInterval(AppSyncFetchInterval value, + {bool listen = true}) async { + if (_interval?.get() != value) { + await _interval?.set(value); + if (listen) notifyListeners(); + } + } + + AppSyncServer? get serverConfig => _serverConfig?.get(); + + Future getPassword({String? identity}) { + identity = identity ?? serverConfig?.identity; + if (identity == null) return Future.value(null); + return const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ).read(key: "sync-pwd-$identity").catchError((e, s) { + if (kDebugMode) Error.throwWithStackTrace(e, s); + return serverConfig?.password; + }); + } + + Future setPassword({String? identity, required String? value}) async { + identity = identity ?? serverConfig?.identity; + if (identity == null) return false; + try { + const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + mOptions: MacOsOptions(), + ).write(key: "sync-pwd-$identity", value: value); + } catch (e, s) { + if (kDebugMode) Error.throwWithStackTrace(e, s); + return false; + } + return true; + } + + Future saveWithConfigForm(AppSyncServerForm? form, + {bool forceSave = false, + bool resetStatus = true, + bool removable = false}) async { + final oldConfig = serverConfig; + final newConfig = AppSyncServer.fromForm(form); + if (newConfig == null && !removable) return false; + final isSameServer = switch ((oldConfig, newConfig)) { + (null, null) => true, + (null, _) || (_, null) => false, + (_, _) => oldConfig!.isSameConfig(newConfig!, withoutPassword: true), + }; + if (isSameServer && !forceSave) return false; + + AppSyncServer? buildConfig({bool withPwd = false}) => + (!isSameServer || resetStatus) + ? AppSyncServer.fromForm(form?.copyWith( + configed: false, + verified: false, + password: withPwd ? form.password : '')) + : newConfig; + + Future doSave() async { + Future? setOrRemoveConfig(AppSyncServer? config) => + config != null ? _serverConfig?.set(config) : _serverConfig?.remove(); + + // step1: set or remove new server config + if (await setOrRemoveConfig(buildConfig()) != true) return false; + // step2(optional): remove old password from sec-storage if necessary + if (oldConfig?.identity != newConfig?.identity && oldConfig != null) { + await setPassword(identity: oldConfig.identity, value: null); + } + // step3: set new password to sec-storage + final result = newConfig != null + ? await setPassword( + identity: newConfig.identity, value: newConfig.password) + : true; + // step4(optional): set server config with password (backup process) + if (result != true) { + return await setOrRemoveConfig(buildConfig(withPwd: true)) ?? false; + } + return true; + } + + return await doSave().then((value) { + if (mounted) notifyListeners(); + return value; + }); + } +} diff --git a/lib/provider/app_sync_server_form.dart b/lib/provider/app_sync_server_form.dart new file mode 100644 index 00000000..31785bd2 --- /dev/null +++ b/lib/provider/app_sync_server_form.dart @@ -0,0 +1,231 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../logging/helper.dart'; +import '../model/app_sync_server.dart'; +import 'app_sync.dart'; +import 'commons.dart'; + +class AppSyncServerFormViewModel extends ChangeNotifier + implements ProviderMounted { + final AppSyncServer? initServerConfig; + final TextEditingController pathInputController; + final TextEditingController usernameInputController; + final TextEditingController passwordInputController; + + bool _mounted = true; + bool _pwdLoaded = false; + bool _edited = false; + AppSyncViewModel? _parent; + Completer<(String, String?)>? _pwdCompleter; + + late AppSyncServerForm _form; + + AppSyncServerFormViewModel({ + this.initServerConfig, + required this.pathInputController, + required this.usernameInputController, + required this.passwordInputController, + }) { + _form = initServerConfig?.toForm() ?? getDefaultForm(); + refreshFormInputControllers(); + } + + @override + void dispose() { + if (!_mounted) return; + pathInputController.dispose(); + usernameInputController.dispose(); + passwordInputController.dispose(); + super.dispose(); + _mounted = false; + } + + @override + void notifyListeners() { + _edited = true; + super.notifyListeners(); + } + + @override + bool get mounted => _mounted; + + bool get pwdLoaded => _pwdLoaded; + + bool get edited => _edited; + + bool get canSave => switch (type) { + AppSyncServerType.webdav => pathInputController.text.isNotEmpty, + _ => true, + }; + + void refreshCanSaveStatus() => notifyListeners(); + + void refreshFormInputControllers() { + appLog.load.debug("$runtimeType.refreshFormInputControllers", ex: [_form]); + pathInputController.text = _form.path ?? ''; + usernameInputController.text = _form.username ?? ''; + passwordInputController.text = _form.password ?? ''; + } + + void flushInputControllersToForm() { + appLog.load.debug("$runtimeType.flushInputControllersToForm", ex: [_form]); + _form.path = pathInputController.text; + _form.username = usernameInputController.text; + _form.password = passwordInputController.text; + appLog.load + .debug("$runtimeType.flushInputControllersToForm", ex: ["DONE", _form]); + } + + AppSyncServerForm getDefaultForm() => + AppWebDavSyncServer.newServer(identity: const Uuid().v4(), path: '') + .toForm(); + + AppSyncServer? get crtServerConfig => + initServerConfig ?? _parent?.serverConfig; + + AppSyncServerForm get formSnapshot => _form.copyWith(); + + AppSyncServerForm getFinalForm() { + flushInputControllersToForm(); + return _form.copyWith(modifyTime: DateTime.now()); + } + + void updateParentViewModel(AppSyncViewModel? parent) { + if (parent != _parent) { + appLog.load.info("$runtimeType.updateCrtServerConfig", ex: [parent]); + _parent = parent; + } + } + + String get identity => _form.uuid.uuid; + + AppSyncServerType get type => _form.type; + set type(AppSyncServerType value) { + if (type == value) return; + final oldForm = _form; + _form = value != crtServerConfig?.type + ? AppSyncServer.newServer(value)?.toForm() ?? getDefaultForm() + : crtServerConfig!.toForm(); + refreshFormInputControllers(); + _pwdCompleter = null; + _pwdLoaded = false; + appLog.value.info('$runtimeType.type', beforeVal: oldForm, afterVal: _form); + notifyListeners(); + } + + bool? get ignoreSSL => _form.ignoreSSL; + set ignoreSSL(bool? value) { + if (value == ignoreSSL) return; + final oldValue = ignoreSSL; + _form.ignoreSSL = value; + appLog.value + .info('$runtimeType.ignoreSSL', beforeVal: oldValue, afterVal: value); + notifyListeners(); + } + + Duration? get timeout => _form.timeout; + set timeout(Duration? value) { + assert((value?.inSeconds ?? 0) >= 0); + if (value == timeout) return; + final oldValue = timeout; + _form.timeout = value; + appLog.value.info('$runtimeType.timeout', + beforeVal: oldValue?.inSeconds, afterVal: value?.inSeconds); + notifyListeners(); + } + + Duration? get connectTimeout => _form.connectTimeout; + set connectTimeout(Duration? value) { + assert((value?.inSeconds ?? 0) >= 0); + if (value == connectTimeout) return; + final oldValue = connectTimeout; + _form.connectTimeout = value; + appLog.value.info('$runtimeType.connectTimeout', + beforeVal: oldValue?.inSeconds, afterVal: value?.inSeconds); + notifyListeners(); + } + + int? get connectRetryCount => _form.connectRetryCount; + set connectRetryCount(int? value) { + assert((value ?? 0) >= 0); + if (value == connectRetryCount) return; + final oldValue = connectRetryCount; + _form.connectRetryCount = value; + appLog.value.info('$runtimeType.connectRetryCount', + beforeVal: oldValue, afterVal: value); + notifyListeners(); + } + + Iterable? get syncMobileNetworks => + _form.syncMobileNetworks; + set syncMobileNetworks(Iterable? value) { + final newValue = value?.toSet(); + final oldValue = _form.syncMobileNetworks; + if (newValue == oldValue) return; + _form.syncMobileNetworks = newValue; + appLog.value.info('$runtimeType.syncMobileNetworks', + beforeVal: oldValue, afterVal: newValue); + notifyListeners(); + } + + bool? get syncInLowData => _form.syncInLowData; + set syncInLowData(bool? value) { + if (value == syncInLowData) return; + final oldValue = syncInLowData; + _form.syncInLowData = value; + appLog.value.info('$runtimeType.syncInLowData', + beforeVal: oldValue, afterVal: value); + notifyListeners(); + } + + Future<(String, String?)> getPassword({ + Duration timeout = const Duration(seconds: 1), + bool changeController = true, + }) { + final crtCompleter = _pwdCompleter; + if (crtCompleter != null) return crtCompleter.future; + final completer = _pwdCompleter = Completer<(String, String?)>(); + final identity = this.identity; + (_parent?.getPassword(identity: identity) ?? Future.value(null)) + .timeout(timeout) + .then((value) { + value = value ?? _form.password; + if (!changeController || identity != this.identity) return null; + if (value == null) return value; + if (passwordInputController.text.isEmpty || + passwordInputController.text == _form.password) { + appLog.value.debug("passwordInputController.text", + beforeVal: passwordInputController.text, afterVal: value); + passwordInputController.text = value; + _pwdLoaded = true; + } + return value; + }) + .then((value) => completer.isCompleted + ? null + : completer.complete((identity, value))) + .catchError((e, s) => + completer.isCompleted ? null : completer.completeError(e, s)) + .whenComplete(() { + if (completer == _pwdCompleter) _pwdCompleter = null; + }); + return completer.future; + } +} diff --git a/lib/view/app.dart b/lib/view/app.dart index 50461694..c574b6d8 100644 --- a/lib/view/app.dart +++ b/lib/view/app.dart @@ -27,6 +27,7 @@ import '../l10n/localizations.dart'; import '../logging/helper.dart'; import '../persistent/db_helper_builder.dart'; import '../persistent/profile/handler/app_language.dart'; +import '../persistent/profile/handler/app_sync.dart'; import '../persistent/profile/handlers.dart'; import '../persistent/profile_builder.dart'; import '../persistent/profile_provider.dart'; @@ -61,6 +62,9 @@ class App extends StatelessWidget { CollectLogswitcherProfileHandler.new, LoggingLevelProfileHandler.new, AppLanguageProfileHanlder.new, + AppSyncSwitchHandler.new, + AppSyncServerConfigHandler.new, + AppSyncFetchIntervalHandler.new, ]; const App({super.key}); diff --git a/lib/view/common/app_ui_layout_builder.dart b/lib/view/common/app_ui_layout_builder.dart new file mode 100644 index 00000000..4a5018f2 --- /dev/null +++ b/lib/view/common/app_ui_layout_builder.dart @@ -0,0 +1,82 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; + +import '../../common/consts.dart'; + +enum UiLayoutType { + /// Small + s(0), + + /// Large + l(10); + + final int value; + + const UiLayoutType(this.value); +} + +class AppUiLayoutBuilder extends StatelessWidget { + final Widget? child; + final bool ignoreWidth; + final bool ignoreHeight; + final UiLayoutType defaultUiType; + final Widget Function( + BuildContext context, + UiLayoutType layoutType, + Widget? child, + ) builder; + + const AppUiLayoutBuilder({ + super.key, + this.ignoreHeight = true, + this.ignoreWidth = false, + this.defaultUiType = UiLayoutType.s, + this.child, + required this.builder, + }); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final isWidthLarger = + constraints.maxWidth >= kHabitLargeScreenAdaptWidth; + final isHeightLarger = + constraints.maxHeight >= kHabitLargeScreenAdaptHeight; + + if (ignoreWidth && ignoreHeight) { + return builder(context, defaultUiType, child); + } else if (ignoreWidth) { + return builder( + context, + isHeightLarger ? UiLayoutType.l : UiLayoutType.s, + child, + ); + } else if (ignoreHeight) { + return builder( + context, + isWidthLarger ? UiLayoutType.l : UiLayoutType.s, + child, + ); + } else { + return builder( + context, + isWidthLarger && isHeightLarger ? UiLayoutType.l : UiLayoutType.s, + child, + ); + } + }, + ); +} diff --git a/lib/view/for_app/app_providers.dart b/lib/view/for_app/app_providers.dart index ab801b86..481ad41c 100644 --- a/lib/view/for_app/app_providers.dart +++ b/lib/view/for_app/app_providers.dart @@ -26,6 +26,7 @@ import '../../provider/app_developer.dart'; import '../../provider/app_first_day.dart'; import '../../provider/app_language.dart'; import '../../provider/app_reminder.dart'; +import '../../provider/app_sync.dart'; import '../../provider/app_theme.dart'; import '../../provider/global.dart'; import '../../provider/habit_op_config.dart'; @@ -109,6 +110,11 @@ class AppProviders extends SingleChildStatelessWidget { update: (context, profile, previous) => previous!..updateProfile(profile), ), + ChangeNotifierProxyProvider( + create: (context) => AppSyncViewModel(), + update: (context, profile, previous) => + previous!..updateProfile(profile), + ), ChangeNotifierProxyProvider( create: (context) => HabitRecordOpConfigViewModel(), diff --git a/lib/view/for_app_sync/_dialog.dart b/lib/view/for_app_sync/_dialog.dart new file mode 100644 index 00000000..f550b83a --- /dev/null +++ b/lib/view/for_app_sync/_dialog.dart @@ -0,0 +1,16 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export './app_sync_fetch_interval.dart' + show AppSyncFetchIntervalSwitchDialog, showAppSyncFetchIntervalSwitchDialog; diff --git a/lib/view/for_app_sync/_widget.dart b/lib/view/for_app_sync/_widget.dart new file mode 100644 index 00000000..be7d21b3 --- /dev/null +++ b/lib/view/for_app_sync/_widget.dart @@ -0,0 +1,16 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export './app_sync_fetch_interval.dart' show AppSyncFetchIntervalTile; +export './app_sync_summary_tile.dart'; diff --git a/lib/view/for_app_sync/app_sync_fetch_interval.dart b/lib/view/for_app_sync/app_sync_fetch_interval.dart new file mode 100644 index 00000000..f8a1d8c5 --- /dev/null +++ b/lib/view/for_app_sync/app_sync_fetch_interval.dart @@ -0,0 +1,94 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/localizations.dart'; +import '../../model/app_sync_options.dart'; +import '../../provider/app_sync.dart'; + +Future showAppSyncFetchIntervalSwitchDialog( + {required BuildContext context, AppSyncFetchInterval? select}) => + showDialog( + context: context, + builder: (context) => AppSyncFetchIntervalSwitchDialog(select: select), + ); + +class AppSyncFetchIntervalSwitchDialog extends StatelessWidget { + final AppSyncFetchInterval? select; + + const AppSyncFetchIntervalSwitchDialog({super.key, required this.select}); + + Widget _buildOption(BuildContext context, AppSyncFetchInterval interval, + [L10n? l10n]) => + SimpleDialogOption( + key: ValueKey(interval.index), + onPressed: () => Navigator.of(context).pop(interval), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + switch (interval) { + AppSyncFetchInterval.manual => Text("Manual"), + AppSyncFetchInterval.minute5 => Text("5 Minutes"), + AppSyncFetchInterval.minute15 => Text("15 Minutes"), + AppSyncFetchInterval.minute30 => Text("30 Minutes"), + AppSyncFetchInterval.hour1 => Text("1 Hour"), + }, + if (select == interval) const Icon(Icons.check), + ], + ), + ); + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + return SimpleDialog( + title: const Text("Fetch Interval"), + children: AppSyncFetchInterval.values + .map((e) => _buildOption(context, e, l10n)) + .toList(), + ); + } +} + +class AppSyncFetchIntervalTile extends StatelessWidget { + final VoidCallback? onPressed; + + const AppSyncFetchIntervalTile({super.key, this.onPressed}); + + Widget buildSubtitle() => Builder( + builder: (context) { + final interval = + context.select( + (vm) => vm.fetchInterval); + return switch (interval) { + AppSyncFetchInterval.manual => Text("Manual"), + AppSyncFetchInterval.minute5 => Text("5 Minutes"), + AppSyncFetchInterval.minute15 => Text("15 Minutes"), + AppSyncFetchInterval.minute30 => Text("30 Minutes"), + AppSyncFetchInterval.hour1 => Text("1 Hour"), + }; + }, + ); + + @override + Widget build(BuildContext context) { + return ListTile( + title: const Text("Fetch Interval"), + subtitle: buildSubtitle(), + onTap: onPressed, + ); + } +} diff --git a/lib/view/for_app_sync/app_sync_summary_tile.dart b/lib/view/for_app_sync/app_sync_summary_tile.dart new file mode 100644 index 00000000..f24c6cc6 --- /dev/null +++ b/lib/view/for_app_sync/app_sync_summary_tile.dart @@ -0,0 +1,71 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import '../../model/app_sync_server.dart'; +import '../../provider/app_sync.dart'; + +class AppSyncSummaryTile extends StatelessWidget { + final VoidCallback? onPressed; + + const AppSyncSummaryTile({ + super.key, + this.onPressed, + }); + + IconData? getSubTitleLeading( + BuildContext context, AppSyncServer? serverConfig) => + switch (serverConfig?.type) { + AppSyncServerType.webdav => MdiIcons.cloudOutline, + AppSyncServerType.unknown => null, + _ => Icons.warning, + }; + + IconData? getTrailing(BuildContext context, AppSyncServer? serverConfig) => + switch (serverConfig?.type) { + != null => Icons.edit, + _ => Icons.add, + }; + + @override + Widget build(BuildContext context) { + final serverConfig = context + .select((vm) => vm.serverConfig); + final trailingData = getTrailing(context, serverConfig); + final subtileLeading = getSubTitleLeading(context, serverConfig); + return ListTile( + trailing: trailingData != null ? Icon(trailingData) : null, + title: Text("Sync Server"), + subtitle: serverConfig != null + ? Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4.0, + runSpacing: 6.0, + children: [ + if (subtileLeading != null) + Padding( + padding: const EdgeInsets.all(4.0), + child: Icon(subtileLeading), + ), + Text("Current: ${serverConfig.name}"), + ], + ) + : Text("Not Configured."), + onTap: onPressed, + ); + } +} diff --git a/lib/view/for_app_sync_server_editor/_widget.dart b/lib/view/for_app_sync_server_editor/_widget.dart new file mode 100644 index 00000000..a024dcce --- /dev/null +++ b/lib/view/for_app_sync_server_editor/_widget.dart @@ -0,0 +1,25 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export './app_sync_server_buttons.dart'; +export './app_sync_server_conn_timeout.dart'; +export './app_sync_server_delete_button.dart'; +export './app_sync_server_ignoressl.dart'; +export './app_sync_server_network_type.dart'; +export './app_sync_server_password.dart'; +export './app_sync_server_path.dart'; +export './app_sync_server_timeout.dart'; +export './app_sync_server_type.dart'; +export './app_sync_server_username.dart'; +export './page_providers.dart'; diff --git a/lib/view/for_app_sync_server_editor/app_sync_server_buttons.dart b/lib/view/for_app_sync_server_editor/app_sync_server_buttons.dart new file mode 100644 index 00000000..a616b7fd --- /dev/null +++ b/lib/view/for_app_sync_server_editor/app_sync_server_buttons.dart @@ -0,0 +1,38 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../provider/app_sync_server_form.dart'; + +class AppSyncServerSaveButton extends StatelessWidget { + final VoidCallback? onPressed; + + const AppSyncServerSaveButton({ + super.key, + this.onPressed, + }); + + @override + Widget build(BuildContext context) => + Selector( + selector: (context, vm) => vm.canSave, + shouldRebuild: (previous, next) => previous != next, + builder: (context, value, child) => TextButton( + onPressed: value ? onPressed : null, + child: Text("save"), + ), + ); +} diff --git a/lib/view/for_app_sync_server_editor/app_sync_server_conn_timeout.dart b/lib/view/for_app_sync_server_editor/app_sync_server_conn_timeout.dart new file mode 100644 index 00000000..55c53e6a --- /dev/null +++ b/lib/view/for_app_sync_server_editor/app_sync_server_conn_timeout.dart @@ -0,0 +1,192 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import '../../common/consts.dart'; +import '../../common/utils.dart'; +import '../../model/app_sync_server.dart'; +import '../../provider/app_sync_server_form.dart'; + +class AppSyncServerConnTimeoutTile extends StatefulWidget { + static const kAllowdMaxTimeoutSecond = 600; + + final EdgeInsetsGeometry? contentPadding; + + const AppSyncServerConnTimeoutTile({super.key, this.contentPadding}); + + @override + State createState() => + _AppSyncServerConnTimeoutTile(); +} + +class _AppSyncServerConnTimeoutTile + extends State { + late TextEditingController controller; + late AppSyncServerFormViewModel vm; + late AppSyncServerType crtType; + + String get crtText => vm.connectTimeout?.inSeconds.toString() ?? ''; + + @override + void initState() { + vm = context.read(); + crtType = vm.type; + controller = + TextEditingController.fromValue(TextEditingValue(text: crtText)); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + controller.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (crtType != vm.type) { + crtType = vm.type; + controller.text = crtText; + } + } + + void _onChange(String value) { + if (value.isNotEmpty) { + final realSecond = clampInt(num.parse(value).toInt(), + min: 0, max: AppSyncServerConnTimeoutTile.kAllowdMaxTimeoutSecond); + final realTimeout = Duration(seconds: realSecond).abs(); + vm.connectTimeout = realTimeout; + controller.text = realTimeout.inSeconds.toString(); + } else { + vm.connectTimeout = null; + controller.text = ''; + } + } + + @override + Widget build(BuildContext context) { + final type = context + .select((vm) => vm.type); + return Visibility( + visible: type.includeConnTimeoutField, + child: ListTile( + contentPadding: widget.contentPadding, + title: TextField( + controller: controller, + decoration: InputDecoration( + icon: const Icon(MdiIcons.lanPending), + labelText: 'Network Connection Timeout Seconds', + hintText: 'Default: ${defaultAppSyncConnectTimeout.inSeconds}s', + suffixText: "s", + ), + keyboardType: const TextInputType.numberWithOptions( + signed: false, decimal: false), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(AppSyncServerConnTimeoutTile + .kAllowdMaxTimeoutSecond + .toString() + .length) + ], + onChanged: _onChange, + ), + ), + ); + } +} + +class AppSyncServerConnRetryCountTile extends StatefulWidget { + final EdgeInsetsGeometry? contentPadding; + + const AppSyncServerConnRetryCountTile({super.key, this.contentPadding}); + + @override + State createState() => + _AppSyncServerConnRetryCountTile(); +} + +class _AppSyncServerConnRetryCountTile + extends State { + late TextEditingController controller; + late AppSyncServerFormViewModel vm; + late AppSyncServerType crtType; + + String get crtText => vm.connectRetryCount?.toString() ?? ''; + + @override + void initState() { + vm = context.read(); + crtType = vm.type; + controller = + TextEditingController.fromValue(TextEditingValue(text: crtText)); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + controller.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (crtType != vm.type) { + crtType = vm.type; + controller.text = crtText; + } + } + + void _onChange(String value) { + if (value.isNotEmpty) { + final count = clampInt(num.parse(value).toInt(), min: 0); + vm.connectRetryCount = count; + controller.text = count.toString(); + } else { + vm.connectRetryCount = null; + controller.text = ''; + } + } + + @override + Widget build(BuildContext context) { + final type = context + .select((vm) => vm.type); + return Visibility( + visible: type.includeConnRetryCountField, + child: ListTile( + contentPadding: widget.contentPadding, + title: TextField( + controller: controller, + decoration: const InputDecoration( + icon: Icon(MdiIcons.timelineClockOutline), + labelText: 'Network Connection Retry Count', + hintText: 'Default: Unlimited', + ), + keyboardType: const TextInputType.numberWithOptions( + signed: false, decimal: false), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + onChanged: _onChange, + ), + ), + ); + } +} diff --git a/lib/view/for_app_sync_server_editor/app_sync_server_delete_button.dart b/lib/view/for_app_sync_server_editor/app_sync_server_delete_button.dart new file mode 100644 index 00000000..be5d9ec6 --- /dev/null +++ b/lib/view/for_app_sync_server_editor/app_sync_server_delete_button.dart @@ -0,0 +1,100 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../provider/app_sync.dart'; + +enum AppSyncServerDeleteButtonStyle { normal, fullsreen } + +class AppSyncServerDeleteButton extends StatelessWidget { + final AppSyncServerDeleteButtonStyle style; + final VoidCallback? onPressed; + + const AppSyncServerDeleteButton({ + super.key, + required this.style, + this.onPressed, + }); + + const AppSyncServerDeleteButton.normal({super.key, this.onPressed}) + : style = AppSyncServerDeleteButtonStyle.normal; + + const AppSyncServerDeleteButton.fullscreen({super.key, this.onPressed}) + : style = AppSyncServerDeleteButtonStyle.fullsreen; + + TextButtonThemeData buildTextButtonTheme(BuildContext context) { + final theme = Theme.of(context); + final color = theme.colorScheme.error; + final iconColor = WidgetStatePropertyAll(color); + final buttonStyle = theme.textButtonTheme.style + ?.copyWith(iconColor: iconColor, foregroundColor: iconColor) ?? + ButtonStyle(iconColor: iconColor, foregroundColor: iconColor); + return TextButtonThemeData(style: buttonStyle); + } + + OutlinedButtonThemeData buildOutlineButtonTheme(BuildContext context) { + final theme = Theme.of(context); + final color = theme.colorScheme.error; + final iconColorStat = WidgetStatePropertyAll(color); + final sideStat = + WidgetStatePropertyAll(BorderSide(width: 0.8, color: color)); + final buttonStyle = theme.outlinedButtonTheme.style?.copyWith( + iconColor: iconColorStat, + foregroundColor: iconColorStat, + side: sideStat) ?? + ButtonStyle( + iconColor: iconColorStat, + foregroundColor: iconColorStat, + side: sideStat); + return OutlinedButtonThemeData(style: buttonStyle); + } + + Widget _buildNormlButton(BuildContext context, bool canDelete) => + TextButtonTheme( + data: buildTextButtonTheme(context), + child: TextButton( + onPressed: canDelete ? onPressed : null, + child: const Text('delete'), + ), + ); + + Widget _buildFullscreenButton(BuildContext context, bool canDelete) => + ListTile( + title: OutlinedButtonTheme( + data: buildOutlineButtonTheme(context), + child: OutlinedButton.icon( + onPressed: canDelete ? onPressed : null, + label: const Text('delete'), + icon: const Icon(Icons.delete_outline), + ), + ), + ); + + @override + Widget build(BuildContext context) { + final canDelete = + context.select((vm) => vm.serverConfig != null); + return Visibility( + visible: canDelete, + child: switch (style) { + AppSyncServerDeleteButtonStyle.normal => + _buildNormlButton(context, canDelete), + AppSyncServerDeleteButtonStyle.fullsreen => + _buildFullscreenButton(context, canDelete), + }, + ); + } +} diff --git a/lib/view/for_app_sync_server_editor/app_sync_server_ignoressl.dart b/lib/view/for_app_sync_server_editor/app_sync_server_ignoressl.dart new file mode 100644 index 00000000..69f84b56 --- /dev/null +++ b/lib/view/for_app_sync_server_editor/app_sync_server_ignoressl.dart @@ -0,0 +1,45 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import '../../model/app_sync_server.dart'; +import '../../provider/app_sync_server_form.dart'; + +class AppSyncServerIgnoreSSLTile extends StatelessWidget { + final EdgeInsetsGeometry? contentPadding; + + const AppSyncServerIgnoreSSLTile({super.key, this.contentPadding}); + + @override + Widget build(BuildContext context) { + final type = context + .select((vm) => vm.type); + final ignoreSSL = + context.select((vm) => vm.ignoreSSL); + return Visibility( + visible: type.includeIgnoreSSLField, + child: CheckboxListTile.adaptive( + secondary: const Icon(MdiIcons.lockOffOutline), + contentPadding: contentPadding, + title: const Text("Ignore SSL Certificate"), + value: ignoreSSL ?? false, + onChanged: (value) => + context.read().ignoreSSL = value, + ), + ); + } +} diff --git a/lib/view/for_app_sync_server_editor/app_sync_server_network_type.dart b/lib/view/for_app_sync_server_editor/app_sync_server_network_type.dart new file mode 100644 index 00000000..bc8bfd5b --- /dev/null +++ b/lib/view/for_app_sync_server_editor/app_sync_server_network_type.dart @@ -0,0 +1,128 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import '../../model/app_sync_server.dart'; +import '../../provider/app_sync_server_form.dart'; + +class AppSyncServerNetworkTypeTile extends StatelessWidget { + const AppSyncServerNetworkTypeTile({super.key}); + + Widget? _buildNetworkTypeChip( + BuildContext context, AppSyncServerMobileNetwork type) { + void onSelected(bool newValue) { + final vm = context.read(); + final syncMobileNetworks = (vm.syncMobileNetworks?.toSet() ?? const {}); + final result = newValue + ? syncMobileNetworks.add(type) + : syncMobileNetworks.remove(type); + if (result) vm.syncMobileNetworks = syncMobileNetworks; + } + + final syncMobileNetworks = context + .select>( + (vm) => vm.syncMobileNetworks?.toList() ?? const []); + final theme = Theme.of(context); + return switch (type) { + AppSyncServerMobileNetwork.mobile => FilterChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(MdiIcons.signal, size: 20.0), + Text('Mobile'), + ], + ), + selectedColor: theme.brightness == Brightness.dark + ? Colors.green + : Colors.lightGreen, + selected: syncMobileNetworks.contains(type), + onSelected: onSelected, + tooltip: "Sync on Cellular Network", + ), + AppSyncServerMobileNetwork.wifi => FilterChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(MdiIcons.signalVariant, size: 20.0), + Text("Wifi"), + ], + ), + selectedColor: theme.brightness == Brightness.dark + ? Colors.blue + : Colors.lightBlue, + selected: syncMobileNetworks.contains(type), + onSelected: onSelected, + tooltip: "Sync on Wifi", + ), + _ => null, + }; + } + + Widget _buildLowDataModeChip(BuildContext context) { + final syncInLowData = context + .select((vm) => vm.syncInLowData); + final theme = Theme.of(context); + return FilterChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(MdiIcons.swapHorizontal, size: 20.0), + Text("LowData"), + ], + ), + selectedColor: + theme.brightness == Brightness.dark ? Colors.grey : Colors.grey, + selected: syncInLowData == true, + onSelected: (newValue) { + context.read().syncInLowData = newValue; + }, + tooltip: "Sync in Low Data Mode", + ); + } + + @override + Widget build(BuildContext context) { + Widget buildNetworksSubtitle(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 6.0), + child: Wrap( + spacing: 8.0, + runSpacing: 12.0, + children: [ + ...AppSyncServerMobileNetwork.allowed + .map((type) => _buildNetworkTypeChip(context, type)) + .whereNotNull(), + _buildLowDataModeChip(context), + ], + ), + ); + } + + final type = context + .select((vm) => vm.type); + return Visibility( + visible: type.includeSyncNetworkField, + child: ListTile( + isThreeLine: true, + leading: Icon(MdiIcons.accessPointNetwork), + title: Text("Synchronous Networking"), + subtitle: buildNetworksSubtitle(context), + ), + ); + } +} diff --git a/lib/view/for_app_sync_server_editor/app_sync_server_password.dart b/lib/view/for_app_sync_server_editor/app_sync_server_password.dart new file mode 100644 index 00000000..2ab5680a --- /dev/null +++ b/lib/view/for_app_sync_server_editor/app_sync_server_password.dart @@ -0,0 +1,169 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import '../../component/widget.dart'; +import '../../extension/async_extensions.dart'; +import '../../logging/helper.dart'; +import '../../model/app_sync_server.dart'; +import '../../provider/app_sync_server_form.dart'; + +class AppSyncServerPasswordTile extends StatelessWidget { + const AppSyncServerPasswordTile({super.key}); + + Widget _buildSnapshotWidget(BuildContext context, + AsyncSnapshot<(String, String?)> snapshot, String identity) { + if (snapshot.hasError || (snapshot.isDone && !snapshot.hasData)) { + if (kDebugMode) { + throw Error.throwWithStackTrace(snapshot.error!, snapshot.stackTrace!); + } + return AppSyncServerPasswordField(enabled: false, loading: false); + } + if (snapshot.isDone) { + if (snapshot.data?.$1 != identity) { + return AppSyncServerPasswordField(loading: false, enabled: false); + } + return Selector( + selector: (context, vm) => vm.passwordInputController, + builder: (context, value, child) => + AppSyncServerPasswordField(controller: value)); + } + return AppSyncServerPasswordField( + enabled: false, loading: !snapshot.isDone); + } + + @override + Widget build(BuildContext context) { + final identity = + context.select((vm) => vm.identity); + final type = context + .select((vm) => vm.type); + final vm = context.read(); + return Visibility( + visible: type.includePasswordField, + child: FutureBuilder( + future: vm.getPassword(), + builder: (context, snapshot) => Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + AnimatedOpacity( + opacity: (snapshot.isDone || vm.pwdLoaded) ? 0 : 1, + duration: const Duration(milliseconds: 300), + child: const Padding( + padding: kListTileContentPadding, + child: LinearProgressIndicator()), + ), + _buildSnapshotWidget(context, snapshot, identity), + ], + ), + ), + ); + } +} + +class AppSyncServerPasswordField extends StatefulWidget { + final EdgeInsetsGeometry? contentPadding; + final bool loading; + final bool enabled; + final TextEditingController? controller; + + const AppSyncServerPasswordField({ + super.key, + this.contentPadding, + this.loading = false, + this.enabled = true, + this.controller, + }); + + @override + State createState() => + _AppSyncServerPasswordField(); +} + +class _AppSyncServerPasswordField extends State { + late bool showPassword; + late FocusNode _focusNode; + late final UniqueKey _debugId; + + _AppSyncServerPasswordField() { + _debugId = UniqueKey(); + } + + @override + void initState() { + appLog.build.debug(context, ex: [ + "init[$_debugId]", + widget.enabled, + widget.loading, + widget.controller, + widget.contentPadding + ]); + showPassword = false; + _focusNode = FocusNode(); + super.initState(); + + _focusNode.addListener(() { + if (_focusNode.hasFocus && !showPassword) { + final controller = + context.read().passwordInputController; + controller.selection = + TextSelection(baseOffset: 0, extentOffset: controller.text.length); + } + }); + } + + @override + void dispose() { + appLog.build.debug(context, ex: [ + "dispose[$_debugId]", + widget.enabled, + widget.loading, + widget.controller, + widget.contentPadding + ]); + super.dispose(); + _focusNode.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: widget.contentPadding, + trailing: IconButton( + onPressed: () => setState(() => showPassword = !showPassword), + icon: showPassword + ? const Icon(Icons.visibility_off) + : const Icon(Icons.visibility)), + title: TextField( + controller: widget.controller, + decoration: const InputDecoration( + icon: Icon(MdiIcons.formTextboxPassword), + labelText: 'Password', + ), + obscureText: !showPassword, + enableSuggestions: false, + autocorrect: false, + keyboardType: TextInputType.visiblePassword, + focusNode: _focusNode, + enabled: widget.enabled, + onChanged: (_) => + context.read().refreshCanSaveStatus(), + ), + ); + } +} diff --git a/lib/view/for_app_sync_server_editor/app_sync_server_path.dart b/lib/view/for_app_sync_server_editor/app_sync_server_path.dart new file mode 100644 index 00000000..f68cc1f3 --- /dev/null +++ b/lib/view/for_app_sync_server_editor/app_sync_server_path.dart @@ -0,0 +1,60 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import '../../model/app_sync_server.dart'; +import '../../provider/app_sync_server_form.dart'; + +class AppSyncServerPathTile extends StatelessWidget { + final EdgeInsetsGeometry? contentPadding; + + const AppSyncServerPathTile({ + super.key, + this.contentPadding, + }); + + @override + Widget build(BuildContext context) { + final type = context + .select((vm) => vm.type); + final controller = + context.select( + (vm) => vm.pathInputController); + final canSave = + context.select((vm) => vm.canSave); + return Visibility( + visible: type.includePathField, + child: ListTile( + contentPadding: contentPadding, + title: TextField( + controller: controller, + decoration: InputDecoration( + icon: const Icon(MdiIcons.networkOutline), + labelText: 'Path', + hintText: 'Enter a valid WebDAV path here.', + errorText: (!canSave && controller.text.isEmpty) + ? "Path shouldn't be empty!" + : null, + ), + keyboardType: TextInputType.url, + onChanged: (_) => + context.read().refreshCanSaveStatus(), + ), + ), + ); + } +} diff --git a/lib/view/for_app_sync_server_editor/app_sync_server_timeout.dart b/lib/view/for_app_sync_server_editor/app_sync_server_timeout.dart new file mode 100644 index 00000000..784c9f56 --- /dev/null +++ b/lib/view/for_app_sync_server_editor/app_sync_server_timeout.dart @@ -0,0 +1,108 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import '../../common/consts.dart'; +import '../../common/utils.dart'; +import '../../model/app_sync_server.dart'; +import '../../provider/app_sync_server_form.dart'; + +class AppSyncServerTimeoutTile extends StatefulWidget { + static const kAllowdMaxTimeoutSecond = 3600; + + final EdgeInsetsGeometry? contentPadding; + + const AppSyncServerTimeoutTile({super.key, this.contentPadding}); + + @override + State createState() => _AppSyncServerTimeoutTile(); +} + +class _AppSyncServerTimeoutTile extends State { + late TextEditingController controller; + late AppSyncServerFormViewModel vm; + late AppSyncServerType crtType; + + String get crtText => vm.timeout?.inSeconds.toString() ?? ''; + + @override + void initState() { + vm = context.read(); + crtType = vm.type; + controller = + TextEditingController.fromValue(TextEditingValue(text: crtText)); + super.initState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (crtType != vm.type) { + crtType = vm.type; + controller.text = crtText; + } + } + + @override + void dispose() { + super.dispose(); + controller.dispose(); + } + + void _onChange(String value) { + if (value.isNotEmpty) { + final realSecond = clampInt(int.parse(value), + min: 0, max: AppSyncServerTimeoutTile.kAllowdMaxTimeoutSecond); + final realTimeout = Duration(seconds: realSecond).abs(); + vm.timeout = realTimeout; + controller.text = realTimeout.inSeconds.toString(); + } else { + vm.timeout = null; + controller.text = ''; + } + } + + @override + Widget build(BuildContext context) { + context + .select((vm) => vm.type); + debugPrint(controller.text); + return ListTile( + contentPadding: widget.contentPadding, + title: TextField( + controller: controller, + decoration: InputDecoration( + icon: const Icon(MdiIcons.timerOutline), + labelText: 'Sync Timeout Seconds', + hintText: 'Default: ${defaultAppSyncTimeout.inSeconds}s', + suffixText: 's', + ), + keyboardType: const TextInputType.numberWithOptions( + signed: false, decimal: false), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(AppSyncServerTimeoutTile + .kAllowdMaxTimeoutSecond + .toString() + .length) + ], + onChanged: _onChange, + ), + ); + } +} diff --git a/lib/view/for_app_sync_server_editor/app_sync_server_type.dart b/lib/view/for_app_sync_server_editor/app_sync_server_type.dart new file mode 100644 index 00000000..2cc81de3 --- /dev/null +++ b/lib/view/for_app_sync_server_editor/app_sync_server_type.dart @@ -0,0 +1,81 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/localizations.g.dart'; +import '../../model/app_sync_server.dart'; +import '../../provider/app_sync_server_form.dart'; + +class AppSyncServerTypeMenu extends StatelessWidget { + final double? width; + final EdgeInsetsGeometry? contentPadding; + + const AppSyncServerTypeMenu({ + super.key, + this.width, + this.contentPadding, + }); + + String getName(AppSyncServerType type, [L10n? l10n]) { + switch (type) { + case AppSyncServerType.webdav: + return 'WebDAV'; + case AppSyncServerType.fake: + return "Fake (Only For Debugger)"; + default: + return 'Unknown'; + } + } + + @override + Widget build(BuildContext context) { + Widget buildMenu(BuildContext context, double? width) { + final l10n = L10n.of(context); + final vm = context.watch(); + return DropdownMenu( + width: width, + enableSearch: false, + requestFocusOnTap: false, + initialSelection: vm.type, + dropdownMenuEntries: AppSyncServerType.values + .where((e) => e != AppSyncServerType.unknown) + .where((e) => + e != AppSyncServerType.fake || + (kDebugMode || vm.type == AppSyncServerType.fake)) + .map((e) => DropdownMenuEntry(value: e, label: getName(e, l10n))) + .toList(), + onSelected: (value) => + context.read().type = value!, + ); + } + + if (width != null) { + return ListTile( + contentPadding: contentPadding, + title: buildMenu(context, width! > 0 ? width : null), + ); + } else { + return ListTile( + contentPadding: contentPadding, + title: LayoutBuilder( + builder: (context, constraints) => + buildMenu(context, constraints.maxWidth), + ), + ); + } + } +} diff --git a/lib/view/for_app_sync_server_editor/app_sync_server_username.dart b/lib/view/for_app_sync_server_editor/app_sync_server_username.dart new file mode 100644 index 00000000..4974a60c --- /dev/null +++ b/lib/view/for_app_sync_server_editor/app_sync_server_username.dart @@ -0,0 +1,55 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import '../../model/app_sync_server.dart'; +import '../../provider/app_sync_server_form.dart'; + +class AppSyncServerUsernameTile extends StatelessWidget { + final EdgeInsetsGeometry? contentPadding; + + const AppSyncServerUsernameTile({ + super.key, + this.contentPadding, + }); + + @override + Widget build(BuildContext context) { + final type = context + .select((vm) => vm.type); + final controller = + context.select( + (vm) => vm.usernameInputController); + return Visibility( + visible: type.includePathField, + child: ListTile( + contentPadding: contentPadding, + title: TextField( + controller: controller, + decoration: const InputDecoration( + icon: Icon(MdiIcons.accountCircleOutline), + labelText: 'Username', + hintText: 'Enter username here, leave empty if not required.', + ), + keyboardType: TextInputType.text, + onChanged: (_) => + context.read().refreshCanSaveStatus(), + ), + ), + ); + } +} diff --git a/lib/view/for_app_sync_server_editor/page_providers.dart b/lib/view/for_app_sync_server_editor/page_providers.dart new file mode 100644 index 00000000..e090a231 --- /dev/null +++ b/lib/view/for_app_sync_server_editor/page_providers.dart @@ -0,0 +1,48 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:nested/nested.dart'; +import 'package:provider/provider.dart'; + +import '../../model/app_sync_server.dart'; +import '../../provider/app_sync.dart'; +import '../../provider/app_sync_server_form.dart'; + +class PageProviders extends SingleChildStatelessWidget { + final AppSyncServer? initServerConfig; + + const PageProviders({super.key, super.child, this.initServerConfig}); + + @override + Widget buildWithChild(BuildContext context, Widget? child) => MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (context) => AppSyncServerFormViewModel( + initServerConfig: initServerConfig, + pathInputController: TextEditingController(), + usernameInputController: TextEditingController(), + passwordInputController: TextEditingController(), + ), + ), + ChangeNotifierProxyProvider( + create: (context) => context.read(), + update: (context, value, previous) => + previous!..updateParentViewModel(value), + ), + ], + child: child, + ); +} diff --git a/lib/view/page_app_setting.dart b/lib/view/page_app_setting.dart index f41afd4e..79561a02 100644 --- a/lib/view/page_app_setting.dart +++ b/lib/view/page_app_setting.dart @@ -54,8 +54,9 @@ import 'common/_mixin.dart'; import 'common/_widget.dart'; import 'for_app_setting/_dialog.dart'; import 'for_app_setting/_widget.dart'; -import 'page_app_about.dart' as app_about_view; +import 'page_app_about.dart' as app_about; import 'page_app_debugger.dart' as app_debugger; +import 'page_app_sync.dart' as app_sync; Future naviToAppSettingPage({ required BuildContext context, @@ -648,10 +649,19 @@ class _AppSettingView extends State with XShare { ? Text(l10n.appSetting_about_titleText) : const Text("About"), ), - onTap: () => app_about_view.naviToAppAboutPage(context: context), + onTap: () => app_about.naviToAppAboutPage(context: context), ), ]; + Iterable buildSyncSubGroup(BuildContext context) => [ + // TODO(Sync): L10n + GroupTitleListTile(title: Text("Sync")), + // TODO(Sync): L10n + ListTile( + title: const Text("Sync Option"), + onTap: () => app_sync.naviToAppSyncPage(context: context)) + ]; + Widget buildDevelopSubGroup(BuildContext context) => Selector>( selector: (context, vm) => @@ -679,6 +689,7 @@ class _AppSettingView extends State with XShare { body: EnhancedSafeArea.edgeToEdgeSafe( child: ListView( children: [ + ...buildSyncSubGroup(context), ...buildDisplaySubGroup(context), ...buildOperationSubGroup(context), ...buildReminderSubGroup(context), diff --git a/lib/view/page_app_sync.dart b/lib/view/page_app_sync.dart new file mode 100644 index 00000000..51d274c1 --- /dev/null +++ b/lib/view/page_app_sync.dart @@ -0,0 +1,184 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../component/widget.dart'; +import '../logging/helper.dart'; +import '../provider/app_developer.dart'; +import '../provider/app_sync.dart'; +import 'for_app_sync/_dialog.dart'; +import 'for_app_sync/_widget.dart'; +import 'page_app_sync_server_editor.dart'; + +Future naviToAppSyncPage({required BuildContext context}) async { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const PageAppSync(), + ), + ); +} + +final class PageAppSync extends StatelessWidget { + const PageAppSync({super.key}); + + @override + Widget build(BuildContext context) => const AppSyncView(); +} + +final class AppSyncView extends StatefulWidget { + const AppSyncView({super.key}); + + @override + State createState() => _AppSyncView(); +} + +final class _AppSyncView extends State { + @override + void initState() { + appLog.build.debug(context, ex: ["init"]); + super.initState(); + } + + @override + void dispose() { + appLog.build.debug(context, ex: ["dispose"], widget: widget); + super.dispose(); + } + + void _onServerConfigPressed() async { + final config = context.read().serverConfig; + appLog.build + .debug(context, ex: ["onServerConfigPressed", config?.toDebugString()]); + final result = await naviToAppSyncServerEditorDialog( + context: context, serverConfig: config); + if (!mounted) return; + appLog.build.debug(context, ex: ["onServerConfigPressed", "Done", result]); + if (result == null) return; + switch (result.op) { + case AppSyncServerEditorResultOp.update: + final saveResult = await context + .read() + .saveWithConfigForm(result.form); + if (!mounted) return; + appLog.build.info(context, + ex: ["onServerConfigPressed", "Saved[$saveResult]", result]); + case AppSyncServerEditorResultOp.delete: + final saveResult = await context + .read() + .saveWithConfigForm(null, forceSave: true, removable: true); + if (!mounted) return; + appLog.build.info(context, + ex: ["onServerConfigPressed", "Deleted[$saveResult]", result]); + } + } + + void _onServerFetchIntervalPressed() async { + final interval = context.read().fetchInterval; + appLog.build.debug(context, ex: ["onServerFetchIntervalPressed", interval]); + final result = await showAppSyncFetchIntervalSwitchDialog( + context: context, select: interval); + if (!mounted || result == null) return; + context.read().setFetchInterval(result); + appLog.build + .debug(context, ex: ["onServerFetchIntervalPressed", "Done", result]); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: const PageBackButton(reason: PageBackReason.back), + title: const Text("Sync"), + ), + body: ListView( + children: [ + Selector( + selector: (ctx, v) => v.enabled, + shouldRebuild: (previous, next) => previous != next, + builder: (context, value, child) => SwitchListTile.adaptive( + title: const Text("Enable"), + value: value, + onChanged: (value) => + context.read().setSyncSwitch(value), + ), + ), + const Divider(), + Selector( + selector: (ctx, v) => v.enabled, + shouldRebuild: (previous, next) => previous != next, + builder: (context, value, child) => _AppSyncConfigSubgroup( + enabled: value, + onConfigPressed: _onServerConfigPressed, + onFetchIntervalPressed: _onServerFetchIntervalPressed, + ), + ), + if (context.read().isInDevelopMode) ...[ + const Divider(), + const _DebugShowTile(), + ], + ], + ), + ); + } +} + +class _AppSyncConfigSubgroup extends StatelessWidget { + final bool enabled; + final VoidCallback? onConfigPressed; + final VoidCallback? onFetchIntervalPressed; + + const _AppSyncConfigSubgroup({ + required this.enabled, + this.onConfigPressed, + this.onFetchIntervalPressed, + }); + + @override + Widget build(BuildContext context) { + return ExpandedSection( + expand: enabled, + child: Column( + children: [ + AppSyncSummaryTile(onPressed: onConfigPressed), + AppSyncFetchIntervalTile(onPressed: onFetchIntervalPressed), + ], + ), + ); + } +} + +class _DebugShowTile extends StatelessWidget { + const _DebugShowTile(); + + @override + Widget build(BuildContext context) { + final appSync = context.watch(); + return ListTile( + leading: Icon(Icons.error, color: Theme.of(context).colorScheme.error), + isThreeLine: true, + title: const Text('DEBUG'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("LastRefresh: ${DateTime.now()}"), + Text("Enabled: ${appSync.enabled}"), + Text("FetchInterval: ${appSync.fetchInterval}"), + Text("ServerConfig: ${appSync.serverConfig?.toDebugString()}"), + ], + ), + ); + } +} diff --git a/lib/view/page_app_sync_server_editor.dart b/lib/view/page_app_sync_server_editor.dart new file mode 100644 index 00000000..77ec332f --- /dev/null +++ b/lib/view/page_app_sync_server_editor.dart @@ -0,0 +1,466 @@ +// Copyright 2025 Fries_I23 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import '../common/consts.dart'; +import '../component/widget.dart'; +import '../model/app_sync_server.dart'; +import '../provider/app_developer.dart'; +import '../provider/app_sync_server_form.dart'; +import 'common/_dialog.dart'; +import 'common/_widget.dart'; +import 'common/app_ui_layout_builder.dart'; +import 'for_app_sync_server_editor/_widget.dart'; + +enum AppSyncServerEditorResultOp { update, delete } + +class AppSyncServerEditorResult { + final AppSyncServerEditorResultOp op; + final AppSyncServerForm? form; + + const AppSyncServerEditorResult({required this.op, required this.form}); + + const AppSyncServerEditorResult.update(this.form) + : op = AppSyncServerEditorResultOp.update; + + const AppSyncServerEditorResult.delete() + : op = AppSyncServerEditorResultOp.delete, + form = null; + + @override + String toString() => "AppSyncServerEditorResult(op=$op,form=$form)"; +} + +Future naviToAppSyncServerEditorDialog({ + required BuildContext context, + AppSyncServer? serverConfig, + bool? naviWithFullscreenDialog, +}) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => PageAppSyncServerEditor( + serverConfig: serverConfig, + showInFullscreenDialog: naviWithFullscreenDialog, + ), + ); +} + +class PageAppSyncServerEditor extends StatelessWidget { + final AppSyncServer? serverConfig; + final bool? showInFullscreenDialog; + + const PageAppSyncServerEditor({ + super.key, + this.serverConfig, + this.showInFullscreenDialog, + }); + + @override + Widget build(BuildContext context) { + return PageProviders( + initServerConfig: serverConfig, + child: AppUiLayoutBuilder( + ignoreHeight: false, + ignoreWidth: false, + builder: (context, layoutType, child) => AppSyncServerEditorView( + serverConfig: serverConfig, + showInFullscreenDialog: showInFullscreenDialog ?? + (layoutType == UiLayoutType.s ? true : false), + ), + ), + ); + } +} + +class AppSyncServerEditorView extends StatefulWidget { + final AppSyncServer? serverConfig; + final bool showInFullscreenDialog; + + const AppSyncServerEditorView({ + super.key, + this.serverConfig, + required this.showInFullscreenDialog, + }); + + @override + State createState() => _AppSyncServerEditerView(); +} + +class _AppSyncServerEditerView extends State { + late bool showAdvanceConfig; + + _AppSyncServerEditerView(); + + @override + void initState() { + super.initState(); + showAdvanceConfig = false; + } + + void _onSaveButtonPressed() async { + final bool confirmed; + final vm = context.read(); + assert(vm.canSave, "Can't save current config, got ${vm.formSnapshot}"); + final form = vm.getFinalForm(); + if (vm.edited && vm.crtServerConfig != null) { + confirmed = await showConfirmDialog( + context: context, + title: const Text("Confirm Save Changes"), + subtitle: const Text( + "Saving will overwrite previous server configuration."), + cancelText: const Text("cancel"), + confirmText: const Text("confirm"), + ) ?? + false; + } else { + confirmed = true; + } + if (!mounted || !confirmed) return; + Navigator.of(context) + .pop(AppSyncServerEditorResult.update(form)); + } + + bool shouldShowCancelConfirmDialog(AppSyncServerFormViewModel vm) => + vm.edited; + + Future cancelConfirmProcess([AppSyncServerEditorResult? result]) async { + final bool confirmed; + final vm = context.read(); + if (shouldShowCancelConfirmDialog(vm)) { + confirmed = await showConfirmDialog( + context: context, + title: const Text("Unsaved Changes"), + subtitle: const Text("Exiting will discard all unsaved changes."), + cancelText: const Text("cancel"), + confirmText: const Text("exit"), + ) ?? + false; + } else { + confirmed = true; + } + if (!mounted || !confirmed) return; + Navigator.maybeOf(context)?.pop(result); + } + + void _onCancelButtonPressed() => cancelConfirmProcess(); + + void _onDeleteButtonPressed() async { + final confirmed = await showConfirmDialog( + context: context, + title: const Text("Confirm Delete"), + subtitle: const Text( + "Deleting will stop running sync and remove current server config"), + cancelText: const Text("cancel"), + confirmTextBuilder: (context) => Text("delete", + style: TextStyle(color: Theme.of(context).colorScheme.error)), + ); + if (!mounted || confirmed != true) return; + Navigator.of(context) + .pop(AppSyncServerEditorResult.delete()); + } + + void _onAdvanceConfigExpansionChanged(bool value) => + showAdvanceConfig = value; + + @override + Widget build(BuildContext context) => + Selector( + selector: (context, vm) => shouldShowCancelConfirmDialog(vm), + builder: (context, value, child) => PopScope( + canPop: !value, + child: child!, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + await cancelConfirmProcess(result); + }, + ), + child: AnimatedSwitcher( + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + duration: const Duration(milliseconds: 300), + child: widget.showInFullscreenDialog + ? _AppSyncServerEditorFsDialog( + key: const ValueKey("fullscreen"), + serverConfig: widget.serverConfig, + onSaveButtonPressed: _onSaveButtonPressed, + onCancelButtonPressed: _onCancelButtonPressed, + onDeleteButtonPressed: _onDeleteButtonPressed, + showAdvanceConfig: showAdvanceConfig, + onAdvConfigExpansionChanged: _onAdvanceConfigExpansionChanged, + ) + : _AppSyncServerEditorDialog( + key: const ValueKey("dialog"), + serverConfig: widget.serverConfig, + onSaveButtonPressed: _onSaveButtonPressed, + onCancelButtonPressed: _onCancelButtonPressed, + onDeleteButtonPressed: _onDeleteButtonPressed, + showAdvanceConfig: showAdvanceConfig, + onAdvConfigExpansionChanged: _onAdvanceConfigExpansionChanged, + ), + ), + ); +} + +class _AppSyncServerEditorFsDialog extends StatelessWidget { + final AppSyncServer? serverConfig; + final bool showAdvanceConfig; + final VoidCallback? onSaveButtonPressed; + final VoidCallback? onCancelButtonPressed; + final VoidCallback? onDeleteButtonPressed; + final ValueChanged? onAdvConfigExpansionChanged; + + const _AppSyncServerEditorFsDialog({ + super.key, + required this.serverConfig, + required this.showAdvanceConfig, + required this.onSaveButtonPressed, + required this.onCancelButtonPressed, + required this.onDeleteButtonPressed, + this.onAdvConfigExpansionChanged, + }); + + @override + Widget build(BuildContext context) => Dialog.fullscreen( + child: ColorfulNavibar( + child: Scaffold( + appBar: AppBar( + leading: PageBackButton( + reason: PageBackReason.close, + onPressed: onCancelButtonPressed), + actions: [ + AppSyncServerSaveButton(onPressed: onSaveButtonPressed), + ], + ), + body: ListView( + children: [ + const AppSyncServerTypeMenu(), + const AppSyncServerPathTile(), + const AppSyncServerUsernameTile(), + const AppSyncServerPasswordTile(), + _AppSyncServerEditorAdvConfigGroup( + type: UiLayoutType.s, + expanded: showAdvanceConfig, + onExpansionChanged: onAdvConfigExpansionChanged, + ), + AppSyncServerDeleteButton.fullscreen( + onPressed: onDeleteButtonPressed), + if (context.read().isInDevelopMode) + const _DebuggerTile(), + ], + ), + ), + ), + ); +} + +class _AppSyncServerEditorDialog extends StatelessWidget { + static const dialogMaxWidth = 1240.0; + + final AppSyncServer? serverConfig; + final bool showAdvanceConfig; + final VoidCallback? onSaveButtonPressed; + final VoidCallback? onCancelButtonPressed; + final VoidCallback? onDeleteButtonPressed; + final ValueChanged? onAdvConfigExpansionChanged; + + const _AppSyncServerEditorDialog({ + super.key, + required this.serverConfig, + required this.showAdvanceConfig, + required this.onSaveButtonPressed, + required this.onCancelButtonPressed, + required this.onDeleteButtonPressed, + this.onAdvConfigExpansionChanged, + }); + + Widget _buildUserTiles(BuildContext context) => Builder( + builder: (context) => MediaQuery.of(context).size.width >= + kHabitLargeScreenAdaptWidth * 1.5 + ? const Row( + key: ValueKey("large"), + mainAxisSize: MainAxisSize.min, + children: [ + Expanded(child: AppSyncServerUsernameTile()), + Expanded(child: AppSyncServerPasswordTile()), + ], + ) + : const Column( + key: ValueKey("small"), + mainAxisSize: MainAxisSize.min, + children: [ + AppSyncServerUsernameTile(), + AppSyncServerPasswordTile(), + ], + ), + ); + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: BoxConstraints.expand(width: dialogMaxWidth), + child: AlertDialog( + scrollable: true, + title: const Text("Modfiy Sync Server"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppSyncServerTypeMenu(width: -1), + const AppSyncServerPathTile(), + _buildUserTiles(context), + _AppSyncServerEditorAdvConfigGroup( + type: UiLayoutType.l, + expanded: showAdvanceConfig, + onExpansionChanged: onAdvConfigExpansionChanged, + ), + if (context.read().isInDevelopMode) + const _DebuggerTile(), + ], + ), + actions: [ + AppSyncServerDeleteButton.normal(onPressed: onDeleteButtonPressed), + TextButton( + onPressed: onCancelButtonPressed, + child: Text("cancel"), + ), + AppSyncServerSaveButton(onPressed: onSaveButtonPressed), + ], + ), + ); +} + +class _AppSyncServerEditorAdvConfigGroup extends StatelessWidget { + final UiLayoutType type; + final bool? expanded; + final ValueChanged? onExpansionChanged; + + const _AppSyncServerEditorAdvConfigGroup({ + required this.type, + this.expanded, + this.onExpansionChanged, + }); + + List _buildForSmallScreen() => [ + const AppSyncServerIgnoreSSLTile(), + const AppSyncServerTimeoutTile(), + const AppSyncServerConnTimeoutTile(), + const AppSyncServerConnRetryCountTile(), + const AppSyncServerNetworkTypeTile(), + ]; + + List _buildForLargeScreen() => [ + const AppSyncServerIgnoreSSLTile(), + const AppSyncServerTimeoutTile(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Expanded(child: AppSyncServerConnTimeoutTile()), + const Expanded(child: AppSyncServerConnRetryCountTile()), + ], + ), + const AppSyncServerNetworkTypeTile(), + ]; + + @override + Widget build(BuildContext context) => ExpansionTile( + title: const Text("Advance Configs"), + leading: Icon(MdiIcons.dotsHorizontal), + tilePadding: ExpansionTileTheme.of(context) + .tilePadding + ?.add(const EdgeInsets.symmetric(vertical: 4.0)), + shape: const Border(), + initiallyExpanded: expanded ?? false, + onExpansionChanged: onExpansionChanged, + children: switch (type) { + UiLayoutType.l => _buildForLargeScreen(), + _ => _buildForSmallScreen() + }, + ); +} + +class _DebuggerTile extends StatefulWidget { + const _DebuggerTile(); + + @override + State<_DebuggerTile> createState() => _DebuggerTileState(); +} + +class _DebuggerTileState extends State<_DebuggerTile> { + late AppSyncServerFormViewModel formVM; + late bool hided; + + void _changeListener() => setState(() {}); + + @override + void initState() { + super.initState(); + hided = false; + formVM = context.read(); + formVM.pathInputController.addListener(_changeListener); + formVM.usernameInputController.addListener(_changeListener); + formVM.passwordInputController.addListener(_changeListener); + } + + @override + void dispose() { + formVM.pathInputController.removeListener(_changeListener); + formVM.usernameInputController.removeListener(_changeListener); + formVM.passwordInputController.removeListener(_changeListener); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + formVM = context.read(); + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + return Visibility( + visible: !hided, + child: Column( + children: [ + const Divider(), + ListTile( + leading: IconButton( + onPressed: () => setState(() => hided = true), + icon: Icon(Icons.error, + color: Theme.of(context).colorScheme.error)), + title: const Text("DEBUG"), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Mounted: ${vm.mounted}"), + Text("Type: ${vm.type}"), + Text("Path: ${vm.pathInputController.text}"), + Text("Username: ${vm.usernameInputController.text}"), + Text("Password: ${vm.passwordInputController.text}"), + Text("IgnoreSSL: ${vm.ignoreSSL}"), + Text("Timeout: ${vm.timeout?.inSeconds}"), + Text("Conn Timeout: ${vm.connectTimeout?.inSeconds}"), + Text("Conn RetryCount: ${vm.connectRetryCount}"), + Text("Form: ${vm.formSnapshot.toDebugString()}"), + ], + ), + isThreeLine: true, + ), + ], + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index bed70b4f..9ec90f0e 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -18,6 +19,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_timezone_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin"); flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b7bddc5e..783cea02 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color file_selector_linux + flutter_secure_storage_linux flutter_timezone url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c2e7b610..7229734c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import device_info_plus import dynamic_color import file_selector_macos import flutter_local_notifications +import flutter_secure_storage_macos import flutter_timezone import package_info_plus import path_provider_foundation @@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index a4435483..2c6f127e 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -7,6 +7,8 @@ PODS: - FlutterMacOS - flutter_local_notifications (0.0.1): - FlutterMacOS + - flutter_secure_storage_macos (6.1.3): + - FlutterMacOS - flutter_timezone (0.1.0): - FlutterMacOS - FlutterMacOS (1.0.0) @@ -31,6 +33,7 @@ DEPENDENCIES: - dynamic_color (from `Flutter/ephemeral/.symlinks/plugins/dynamic_color/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) @@ -49,6 +52,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos flutter_local_notifications: :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos flutter_timezone: :path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos FlutterMacOS: @@ -71,6 +76,7 @@ SPEC CHECKSUMS: dynamic_color: 2eaa27267de1ca20d879fbd6e01259773fb1670f file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d flutter_local_notifications: 4b427ffabf278fc6ea9484c97505e231166927a5 + flutter_secure_storage_macos: c2754d3483d20bb207bb9e5a14f1b8e771abcdb9 flutter_timezone: 6b906d1740654acb16e50b639835628fea851037 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 3905e25e..2a2fe053 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -195,7 +195,6 @@ 8326D5A8B87DAA9120E5A6F8 /* Pods-RunnerTests.release.xcconfig */, E5F6967CFBBF82459E9BCAA2 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -568,8 +567,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = LRNWK7KUZ7; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -694,8 +695,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = LRNWK7KUZ7; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -714,8 +717,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = LRNWK7KUZ7; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index d138bd5b..6af3ac08 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.files.user-selected.read-write + keychain-access-groups + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 19afff14..7beaefd5 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.files.user-selected.read-write + keychain-access-groups + diff --git a/pubspec.lock b/pubspec.lock index 0e1490ad..18a1dd04 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -536,6 +536,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4+3" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: bf7404619d7ab5c0a1151d7c4e802edad8f33535abfbeff2f9e1fe1274e2d705 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_svg: dependency: "direct main" description: @@ -687,10 +735,10 @@ packages: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.6.7" json_annotation: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a65f4e04..cb30bc69 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,6 +86,7 @@ dependencies: # Data Persistence shared_preferences: ^2.0.15 + flutter_secure_storage: ^9.2.4 sqflite: ^2.2.2 sqflite_common_ffi: ^2.3.0+4 path: ^1.8.2 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b83003eb..f1875d8a 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); FlutterTimezonePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index af940c38..fe34847d 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color file_selector_windows + flutter_secure_storage_windows flutter_timezone share_plus url_launcher_windows