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