feat(services): Service settings

This commit is contained in:
Inex Code 2024-07-23 02:47:53 +03:00
parent 7dff880147
commit ef51f27e91
18 changed files with 4061 additions and 18 deletions

View file

@ -349,7 +349,13 @@
"activating": "Activating", "activating": "Activating",
"deactivating": "Deactivating", "deactivating": "Deactivating",
"reloading": "Restarting" "reloading": "Restarting"
} },
"settings": "Service settings",
"modified": "Modified",
"invalid_input": "Invalid input",
"create_job": "Create job",
"update_job": "Update job",
"wait_for_jobs": "Server is busy with other jobs. Please wait until they are finished."
}, },
"mail": { "mail": {
"login_info": "Use username and password from users tab. IMAP port is 143 with STARTTLS, SMTP port is 587 with STARTTLS." "login_info": "Use username and password from users tab. IMAP port is 143 with STARTTLS, SMTP port is 587 with STARTTLS."
@ -602,7 +608,8 @@
"change_ssh_settings": "Change SSH settings", "change_ssh_settings": "Change SSH settings",
"update_dns_records": "Update DNS records", "update_dns_records": "Update DNS records",
"dns_records_did_not_change": "No changes needed", "dns_records_did_not_change": "No changes needed",
"dns_records_changed": "DNS records updated" "dns_records_changed": "DNS records updated",
"change_service_settings": "Change service settings for {}"
}, },
"validations": { "validations": {
"required": "Required", "required": "Required",

View file

@ -130,6 +130,22 @@ enum BackupReason {
PRE_RESTORE PRE_RESTORE
} }
type BoolConfigItem implements ConfigItem {
id: String!
description: String!
widget: String!
type: String!
value: Boolean!
defaultValue: Boolean!
}
interface ConfigItem {
id: String!
description: String!
widget: String!
type: String!
}
"""Date with time (isoformat)""" """Date with time (isoformat)"""
scalar DateTime scalar DateTime
@ -155,6 +171,16 @@ type DnsRecord {
displayName: String! displayName: String!
} }
type EnumConfigItem implements ConfigItem {
id: String!
description: String!
widget: String!
type: String!
value: String!
defaultValue: String!
options: [String!]!
}
type GenericBackupConfigReturn implements MutationReturnInterface { type GenericBackupConfigReturn implements MutationReturnInterface {
success: Boolean! success: Boolean!
message: String! message: String!
@ -183,6 +209,11 @@ input InitializeRepositoryInput {
password: String! password: String!
} }
"""
The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
"""
scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")
type Job { type Job {
getJobs: [ApiJob!]! getJobs: [ApiJob!]!
getJob(jobId: String!): ApiJob getJob(jobId: String!): ApiJob
@ -192,6 +223,24 @@ type JobMutations {
removeJob(jobId: String!): GenericMutationReturn! removeJob(jobId: String!): GenericMutationReturn!
} }
type LogEntry {
message: String!
timestamp: DateTime!
priority: Int
systemdUnit: String
systemdSlice: String
cursor: String!
}
type Logs {
paginated(limit: Int! = 20, upCursor: String = null, downCursor: String = null): PaginatedEntries!
}
type LogsPageMeta {
upCursor: String
downCursor: String
}
input MigrateToBindsInput { input MigrateToBindsInput {
emailBlockDevice: String! emailBlockDevice: String!
bitwardenBlockDevice: String! bitwardenBlockDevice: String!
@ -252,9 +301,18 @@ interface MutationReturnInterface {
code: Int! code: Int!
} }
type PaginatedEntries {
"""Metadata to aid in pagination."""
pageMeta: LogsPageMeta!
"""The list of log entries."""
entries: [LogEntry!]!
}
type Query { type Query {
api: Api! api: Api!
system: System! system: System!
logs: Logs!
users: Users! users: Users!
storage: Storage! storage: Storage!
jobs: Job! jobs: Job!
@ -305,6 +363,7 @@ type Service {
url: String url: String
dnsRecords: [DnsRecord!] dnsRecords: [DnsRecord!]
storageUsage: ServiceStorageUsage! storageUsage: ServiceStorageUsage!
configuration: [ConfigItem!]
backupSnapshots: [SnapshotInfo!] backupSnapshots: [SnapshotInfo!]
} }
@ -350,9 +409,15 @@ type ServicesMutations {
stopService(serviceId: String!): ServiceMutationReturn! stopService(serviceId: String!): ServiceMutationReturn!
startService(serviceId: String!): ServiceMutationReturn! startService(serviceId: String!): ServiceMutationReturn!
restartService(serviceId: String!): ServiceMutationReturn! restartService(serviceId: String!): ServiceMutationReturn!
setServiceConfiguration(input: SetServiceConfigurationInput!): ServiceMutationReturn!
moveService(input: MoveServiceInput!): ServiceJobMutationReturn! moveService(input: MoveServiceInput!): ServiceJobMutationReturn!
} }
input SetServiceConfigurationInput {
serviceId: String!
configuration: JSON!
}
enum Severity { enum Severity {
INFO INFO
WARNING WARNING
@ -408,9 +473,20 @@ type StorageVolume {
usages: [StorageUsageInterface!]! usages: [StorageUsageInterface!]!
} }
type StringConfigItem implements ConfigItem {
id: String!
description: String!
widget: String!
type: String!
value: String!
defaultValue: String!
regex: String
}
type Subscription { type Subscription {
jobUpdates: [ApiJob!]! jobUpdates: [ApiJob!]!
count: Int! count: Int!
logEntries: LogEntry!
} }
type System { type System {

View file

@ -1111,6 +1111,138 @@ class _CopyWithStubImpl$Input$SSHSettingsInput<TRes>
_res; _res;
} }
class Input$SetServiceConfigurationInput {
factory Input$SetServiceConfigurationInput({
required String serviceId,
required Map<String, dynamic> configuration,
}) =>
Input$SetServiceConfigurationInput._({
r'serviceId': serviceId,
r'configuration': configuration,
});
Input$SetServiceConfigurationInput._(this._$data);
factory Input$SetServiceConfigurationInput.fromJson(
Map<String, dynamic> data) {
final result$data = <String, dynamic>{};
final l$serviceId = data['serviceId'];
result$data['serviceId'] = (l$serviceId as String);
final l$configuration = data['configuration'];
result$data['configuration'] = (l$configuration as Map<String, dynamic>);
return Input$SetServiceConfigurationInput._(result$data);
}
Map<String, dynamic> _$data;
String get serviceId => (_$data['serviceId'] as String);
Map<String, dynamic> get configuration =>
(_$data['configuration'] as Map<String, dynamic>);
Map<String, dynamic> toJson() {
final result$data = <String, dynamic>{};
final l$serviceId = serviceId;
result$data['serviceId'] = l$serviceId;
final l$configuration = configuration;
result$data['configuration'] = l$configuration;
return result$data;
}
CopyWith$Input$SetServiceConfigurationInput<
Input$SetServiceConfigurationInput>
get copyWith => CopyWith$Input$SetServiceConfigurationInput(
this,
(i) => i,
);
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (!(other is Input$SetServiceConfigurationInput) ||
runtimeType != other.runtimeType) {
return false;
}
final l$serviceId = serviceId;
final lOther$serviceId = other.serviceId;
if (l$serviceId != lOther$serviceId) {
return false;
}
final l$configuration = configuration;
final lOther$configuration = other.configuration;
if (l$configuration != lOther$configuration) {
return false;
}
return true;
}
@override
int get hashCode {
final l$serviceId = serviceId;
final l$configuration = configuration;
return Object.hashAll([
l$serviceId,
l$configuration,
]);
}
}
abstract class CopyWith$Input$SetServiceConfigurationInput<TRes> {
factory CopyWith$Input$SetServiceConfigurationInput(
Input$SetServiceConfigurationInput instance,
TRes Function(Input$SetServiceConfigurationInput) then,
) = _CopyWithImpl$Input$SetServiceConfigurationInput;
factory CopyWith$Input$SetServiceConfigurationInput.stub(TRes res) =
_CopyWithStubImpl$Input$SetServiceConfigurationInput;
TRes call({
String? serviceId,
Map<String, dynamic>? configuration,
});
}
class _CopyWithImpl$Input$SetServiceConfigurationInput<TRes>
implements CopyWith$Input$SetServiceConfigurationInput<TRes> {
_CopyWithImpl$Input$SetServiceConfigurationInput(
this._instance,
this._then,
);
final Input$SetServiceConfigurationInput _instance;
final TRes Function(Input$SetServiceConfigurationInput) _then;
static const _undefined = <dynamic, dynamic>{};
TRes call({
Object? serviceId = _undefined,
Object? configuration = _undefined,
}) =>
_then(Input$SetServiceConfigurationInput._({
..._instance._$data,
if (serviceId != _undefined && serviceId != null)
'serviceId': (serviceId as String),
if (configuration != _undefined && configuration != null)
'configuration': (configuration as Map<String, dynamic>),
}));
}
class _CopyWithStubImpl$Input$SetServiceConfigurationInput<TRes>
implements CopyWith$Input$SetServiceConfigurationInput<TRes> {
_CopyWithStubImpl$Input$SetServiceConfigurationInput(this._res);
TRes _res;
call({
String? serviceId,
Map<String, dynamic>? configuration,
}) =>
_res;
}
class Input$SshMutationInput { class Input$SshMutationInput {
factory Input$SshMutationInput({ factory Input$SshMutationInput({
required String username, required String username,
@ -2152,5 +2284,10 @@ const possibleTypesMap = <String, Set<String>>{
'TimezoneMutationReturn', 'TimezoneMutationReturn',
'UserMutationReturn', 'UserMutationReturn',
}, },
'ConfigItem': {
'BoolConfigItem',
'EnumConfigItem',
'StringConfigItem',
},
'StorageUsageInterface': {'ServiceStorageUsage'}, 'StorageUsageInterface': {'ServiceStorageUsage'},
}; };

View file

@ -1,3 +1,32 @@
fragment BoolConfigItem on BoolConfigItem {
id
description
type
boolValue: value
defaultBoolValue: defaultValue
widget
}
fragment EnumConfigItem on EnumConfigItem {
id
description
type
stringValue: value
defaultStringValue: defaultValue
options
widget
}
fragment StringConfigItem on StringConfigItem {
id
description
type
stringValue: value
defaultStringValue: defaultValue
regex
widget
}
query AllServices { query AllServices {
services { services {
allServices { allServices {
@ -22,6 +51,14 @@ query AllServices {
} }
svgIcon svgIcon
url url
configuration {
id
description
type
... BoolConfigItem
... EnumConfigItem
... StringConfigItem
}
} }
} }
} }
@ -77,3 +114,10 @@ mutation MoveService($input: MoveServiceInput!) {
} }
} }
mutation SetServiceConfiguration($input: SetServiceConfigurationInput!) {
services {
setServiceConfiguration(input: $input) {
...basicMutationReturnFields
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -174,4 +174,38 @@ mixin ServicesApi on GraphQLApiMap {
); );
} }
} }
Future<GenericResult> setServiceConfiguration(
final String serviceId,
final Map<String, dynamic> settings,
) async {
try {
final GraphQLClient client = await getClient();
final variables = Variables$Mutation$SetServiceConfiguration(
input: Input$SetServiceConfigurationInput(
serviceId: serviceId,
configuration: settings,
),
);
final mutation =
Options$Mutation$SetServiceConfiguration(variables: variables);
final response = await client.mutate$SetServiceConfiguration(mutation);
return GenericResult(
data: null,
success:
response.parsedData?.services.setServiceConfiguration.success ??
false,
code: response.parsedData?.services.setServiceConfiguration.code ?? 0,
message: response.parsedData?.services.setServiceConfiguration.message,
);
} catch (e) {
print(e);
return GenericResult(
data: null,
success: false,
code: 0,
message: e.toString(),
);
}
}
} }

View file

@ -46,7 +46,11 @@ class JobsStateWithJobs extends JobsState {
JobsState addJob(final ClientJob job) { JobsState addJob(final ClientJob job) {
if (job is ReplaceableJob) { if (job is ReplaceableJob) {
final List<ClientJob> newJobsList = clientJobList final List<ClientJob> newJobsList = clientJobList
.where((final element) => element.runtimeType != job.runtimeType) .where(
(final element) => job.shouldReplaceOnlyIfSameId
? element.runtimeType != job.runtimeType || element.id != job.id
: element.runtimeType != job.runtimeType,
)
.toList(); .toList();
if (job.shouldRemoveInsteadOfAdd(clientJobList)) { if (job.shouldRemoveInsteadOfAdd(clientJobList)) {
getIt<NavigationService>().showSnackBar('jobs.job_removed'.tr()); getIt<NavigationService>().showSnackBar('jobs.job_removed'.tr());

View file

@ -242,6 +242,19 @@ class ApiConnectionRepository {
} }
} }
Future<(bool, String)> setServiceConfiguration(
final String serviceId,
final Map<String, dynamic> settings,
) async {
final GenericResult result =
await api.setServiceConfiguration(serviceId, settings);
if (result.success) {
return (true, result.message ?? 'basis.done'.tr());
} else {
return (false, result.message ?? 'jobs.generic_error'.tr());
}
}
void dispose() { void dispose() {
_dataStream.close(); _dataStream.close();
_connectionStatusStream.close(); _connectionStatusStream.close();

View file

@ -367,9 +367,12 @@ abstract class ReplaceableJob extends ClientJob {
super.id, super.id,
super.status, super.status,
super.message, super.message,
super.requiresRebuild,
super.requiresDnsUpdate,
}); });
bool shouldRemoveInsteadOfAdd(final List<ClientJob> jobs) => false; bool shouldRemoveInsteadOfAdd(final List<ClientJob> jobs) => false;
bool get shouldReplaceOnlyIfSameId => false;
} }
class ChangeAutoUpgradeSettingsJob extends ReplaceableJob { class ChangeAutoUpgradeSettingsJob extends ReplaceableJob {
@ -502,3 +505,45 @@ class ChangeSshSettingsJob extends ReplaceableJob {
id: id, id: id,
); );
} }
class ChangeServiceConfiguration extends ReplaceableJob {
ChangeServiceConfiguration({
required this.serviceId,
required this.serviceDisplayName,
required this.settings,
super.status,
super.message,
}) : super(
title: 'jobs.change_service_settings'.tr(args: [serviceDisplayName]),
id: 'change_settings_$serviceId',
requiresDnsUpdate: true,
requiresRebuild: true,
);
final String serviceId;
final String serviceDisplayName;
final Map<String, dynamic> settings;
@override
bool get shouldReplaceOnlyIfSameId => true;
@override
Future<(bool, String)> execute() async => getIt<ApiConnectionRepository>()
.setServiceConfiguration(serviceId, settings);
@override
List<Object> get props => [...super.props, serviceId, settings];
@override
ChangeServiceConfiguration copyWithNewStatus({
required final JobStatusEnum status,
final String? message,
}) =>
ChangeServiceConfiguration(
serviceId: serviceId,
serviceDisplayName: serviceDisplayName,
settings: settings,
status: status,
message: message,
);
}

View file

@ -36,6 +36,16 @@ class Service extends Equatable {
.toList() ?? .toList() ??
[], [],
url: service.url, url: service.url,
configuration: service.configuration
?.map(
(
final Query$AllServices$services$allServices$configuration
configItem,
) =>
ServiceConfigItem.fromGraphQL(configItem),
)
.toList() ??
[],
); );
const Service({ const Service({
required this.id, required this.id,
@ -50,6 +60,7 @@ class Service extends Equatable {
required this.storageUsage, required this.storageUsage,
required this.svgIcon, required this.svgIcon,
required this.dnsRecords, required this.dnsRecords,
required this.configuration,
this.url, this.url,
}); });
@ -89,6 +100,7 @@ class Service extends Equatable {
svgIcon: '', svgIcon: '',
dnsRecords: [], dnsRecords: [],
url: '', url: '',
configuration: [],
); );
final String id; final String id;
@ -104,6 +116,7 @@ class Service extends Equatable {
final String svgIcon; final String svgIcon;
final String? url; final String? url;
final List<DnsRecord> dnsRecords; final List<DnsRecord> dnsRecords;
final List<ServiceConfigItem> configuration;
@override @override
List<Object?> get props => [ List<Object?> get props => [
@ -120,6 +133,7 @@ class Service extends Equatable {
svgIcon, svgIcon,
dnsRecords, dnsRecords,
url, url,
configuration,
]; ];
} }
@ -166,3 +180,146 @@ enum ServiceStatus {
} }
} }
} }
sealed class ServiceConfigItem extends Equatable {
const ServiceConfigItem({
required this.id,
required this.description,
required this.widget,
required this.type,
});
factory ServiceConfigItem.fromGraphQL(
final Query$AllServices$services$allServices$configuration configItem,
) =>
configItem.when<ServiceConfigItem>(
boolConfigItem: (final boolConfigItem) => BoolServiceConfigItem(
id: boolConfigItem.id,
description: boolConfigItem.description,
widget: boolConfigItem.widget,
type: boolConfigItem.type,
value: boolConfigItem.boolValue,
defaultValue: boolConfigItem.defaultBoolValue,
),
enumConfigItem: (final enumConfigItem) => EnumServiceConfigItem(
id: enumConfigItem.id,
description: enumConfigItem.description,
widget: enumConfigItem.widget,
type: enumConfigItem.type,
value: enumConfigItem.stringValue,
defaultValue: enumConfigItem.defaultStringValue,
options: enumConfigItem.options,
),
stringConfigItem: (final stringConfigItem) => StringServiceConfigItem(
id: stringConfigItem.id,
description: stringConfigItem.description,
widget: stringConfigItem.widget,
type: stringConfigItem.type,
value: stringConfigItem.stringValue,
defaultValue: stringConfigItem.defaultStringValue,
regex: stringConfigItem.regex,
),
orElse: () => FallbackServiceConfigItem(
id: configItem.id,
description: configItem.description,
type: configItem.type,
),
);
final String id;
final String description;
final String widget;
final String type;
}
class StringServiceConfigItem extends ServiceConfigItem {
const StringServiceConfigItem({
required super.id,
required super.description,
required super.widget,
required super.type,
required this.value,
required this.defaultValue,
this.regex,
});
final String value;
final String defaultValue;
final String? regex;
@override
List<Object?> get props =>
[id, description, widget, type, value, defaultValue, regex];
}
class BoolServiceConfigItem extends ServiceConfigItem {
const BoolServiceConfigItem({
required super.id,
required super.description,
required super.widget,
required super.type,
required this.value,
required this.defaultValue,
});
final bool value;
final bool defaultValue;
@override
List<Object?> get props =>
[id, description, widget, type, value, defaultValue];
}
class EnumServiceConfigItem extends ServiceConfigItem {
const EnumServiceConfigItem({
required super.id,
required super.description,
required super.widget,
required super.type,
required this.value,
required this.defaultValue,
required this.options,
});
final String value;
final String defaultValue;
final List<String> options;
@override
List<Object?> get props =>
[id, description, widget, type, value, defaultValue, options];
}
class FallbackServiceConfigItem extends ServiceConfigItem {
const FallbackServiceConfigItem({
required super.id,
required super.description,
required super.type,
}) : super(widget: 'fallback');
@override
List<Object?> get props => [id, description, widget, type];
}
// TODO: Not used yet by the API
class IntServiceConfigItem extends ServiceConfigItem {
const IntServiceConfigItem({
required super.id,
required super.description,
required super.widget,
required super.type,
required this.value,
required this.defaultValue,
required this.min,
required this.max,
});
final int value;
final int defaultValue;
final int min;
final int max;
@override
List<Object?> get props =>
[id, description, widget, type, value, defaultValue, min, max];
}

View file

@ -0,0 +1,36 @@
part of '../service_settings_page.dart';
class BasicBoolConfigItem extends StatefulWidget {
const BasicBoolConfigItem({
required this.configItem,
required this.onChanged,
this.newValue,
super.key,
});
final BoolServiceConfigItem configItem;
final Function(bool) onChanged;
final bool? newValue;
@override
State<BasicBoolConfigItem> createState() => _BasicBoolConfigItemState();
}
class _BasicBoolConfigItemState extends State<BasicBoolConfigItem> {
@override
Widget build(final BuildContext context) => Column(
children: [
SwitchListTile.adaptive(
title: Text(widget.configItem.description),
subtitle: (widget.newValue != null &&
widget.newValue != widget.configItem.value)
? Text('service_page.modified'.tr())
: null,
value: widget.newValue ?? widget.configItem.value,
onChanged: (final bool value) {
widget.onChanged(value);
},
),
],
);
}

View file

@ -0,0 +1,46 @@
part of '../service_settings_page.dart';
class BasicEnumConfigItem extends StatefulWidget {
const BasicEnumConfigItem({
required this.configItem,
required this.onChanged,
this.newValue,
super.key,
});
final EnumServiceConfigItem configItem;
final Function(String) onChanged;
final String? newValue;
@override
State<BasicEnumConfigItem> createState() => _BasicEnumConfigItemState();
}
class _BasicEnumConfigItemState extends State<BasicEnumConfigItem> {
@override
Widget build(final BuildContext context) => Column(
children: [
ListTile(
title: Text(widget.configItem.description),
subtitle: (widget.newValue != null &&
widget.newValue != widget.configItem.value)
? Text('service_page.modified'.tr())
: null,
trailing: DropdownButton<String>(
value: widget.newValue ?? widget.configItem.value,
items: widget.configItem.options
.map<DropdownMenuItem<String>>(
(final String option) => DropdownMenuItem<String>(
value: option,
child: Text(option),
),
)
.toList(),
onChanged: (final String? value) {
widget.onChanged(value!);
},
),
),
],
);
}

View file

@ -0,0 +1,105 @@
part of '../service_settings_page.dart';
class BasicStringConfigItem extends StatefulWidget {
const BasicStringConfigItem({
required this.configItem,
required this.onChanged,
this.newValue,
super.key,
});
final StringServiceConfigItem configItem;
final Function(String, bool) onChanged;
final String? newValue;
@override
State<BasicStringConfigItem> createState() => _BasicStringConfigItemState();
}
class _BasicStringConfigItemState extends State<BasicStringConfigItem> {
final TextEditingController _controller = TextEditingController();
bool _isValid = true;
@override
void initState() {
super.initState();
_controller.text = widget.newValue ?? widget.configItem.value;
_controller.addListener(() {
final String value = _controller.text;
final bool isValid = _validateInput(value);
if (isValid) {
widget.onChanged(value, isValid);
} else {
setState(() {
widget.onChanged(widget.newValue ?? widget.configItem.value, isValid);
_isValid = isValid;
});
}
});
}
bool _validateInput(final String value) {
if (value == '') {
return false;
}
final regexPattern = widget.configItem.regex;
if (regexPattern == null) {
return true;
}
final regex = RegExp(regexPattern);
return regex.hasMatch(value);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(final BuildContext context) => Column(
children: [
TextField(
controller: _controller,
decoration: InputDecoration(
labelText: widget.configItem.description,
hintText: widget.configItem.value,
counter: _controller.text != widget.configItem.value
? InkWell(
onTap: () {
_controller.text = widget.configItem.value;
},
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'service_page.modified'.tr(),
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const Gap(8.0),
Icon(
Icons.undo_outlined,
size: 16.0,
color: Theme.of(context).colorScheme.primary,
),
],
),
)
: Text(
' ',
style: Theme.of(context).textTheme.labelSmall,
),
border: const OutlineInputBorder(),
errorText: _isValid ? null : 'service_page.invalid_input'.tr(),
),
),
],
);
}

View file

@ -0,0 +1,114 @@
part of '../service_settings_page.dart';
class DomainStringConfigItem extends StatefulWidget {
const DomainStringConfigItem({
required this.configItem,
required this.onChanged,
this.newValue,
super.key,
});
final StringServiceConfigItem configItem;
final Function(String, bool) onChanged;
final String? newValue;
@override
State<DomainStringConfigItem> createState() => _DomainStringConfigItemState();
}
class _DomainStringConfigItemState extends State<DomainStringConfigItem> {
final TextEditingController _controller = TextEditingController();
bool _isValid = true;
@override
void initState() {
super.initState();
_controller.text = widget.newValue ?? widget.configItem.value;
_controller.addListener(() {
final String value = _controller.text;
final bool isValid = _validateInput(value);
if (isValid) {
setState(() {
widget.onChanged(value, isValid);
_isValid = isValid;
});
} else {
setState(() {
widget.onChanged(widget.newValue ?? widget.configItem.value, isValid);
_isValid = isValid;
});
}
});
}
bool _validateInput(final String value) {
if (value == '') {
return false;
}
final regexPattern = widget.configItem.regex;
if (regexPattern == null) {
return true;
}
final regex = RegExp(regexPattern);
return regex.hasMatch(value);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(final BuildContext context) {
final String domain =
getIt<ResourcesModel>().serverDomain?.domainName ?? '';
return Column(
children: [
TextField(
controller: _controller,
decoration: InputDecoration(
labelText: widget.configItem.description,
hintText: widget.configItem.value,
suffixText: '.$domain',
counter: _controller.text != widget.configItem.value
? InkWell(
onTap: () {
_controller.text = widget.configItem.value;
},
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'service_page.modified'.tr(),
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const Gap(8.0),
Icon(
Icons.undo_outlined,
size: 16.0,
color: Theme.of(context).colorScheme.primary,
),
],
),
)
: Text(
' ',
style: Theme.of(context).textTheme.labelSmall,
),
border: const OutlineInputBorder(),
errorText: _isValid ? null : 'service_page.invalid_input'.tr(),
),
),
],
);
}
}

View file

@ -96,23 +96,36 @@ class _ServicePageState extends State<ServicePage> {
), ),
enabled: !serviceDisabled && !serviceLocked, enabled: !serviceDisabled && !serviceLocked,
), ),
ListTile( if (!service.isRequired)
iconColor: Theme.of(context).colorScheme.onBackground, ListTile(
onTap: () => context.read<JobsCubit>().addJob( iconColor: Theme.of(context).colorScheme.onBackground,
ServiceToggleJob( onTap: () => context.read<JobsCubit>().addJob(
service: service, ServiceToggleJob(
needToTurnOn: serviceDisabled, service: service,
needToTurnOn: serviceDisabled,
),
), ),
), leading: const Icon(Icons.power_settings_new),
leading: const Icon(Icons.power_settings_new), title: Text(
title: Text( serviceDisabled
serviceDisabled ? 'service_page.enable'.tr()
? 'service_page.enable'.tr() : 'service_page.disable'.tr(),
: 'service_page.disable'.tr(), style: Theme.of(context).textTheme.titleMedium,
style: Theme.of(context).textTheme.titleMedium, ),
enabled: !serviceLocked,
),
if (service.configuration.isNotEmpty)
ListTile(
iconColor: Theme.of(context).colorScheme.onBackground,
onTap: () => context.pushRoute(
ServiceSettingsRoute(serviceId: service.id),
),
leading: const Icon(Icons.settings_outlined),
title: Text(
'service_page.settings'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
), ),
enabled: !serviceLocked,
),
if (service.isMovable) if (service.isMovable)
ListTile( ListTile(
iconColor: Theme.of(context).colorScheme.onBackground, iconColor: Theme.of(context).colorScheme.onBackground,

View file

@ -0,0 +1,238 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:gap/gap.dart';
import 'package:selfprivacy/config/get_it_config.dart';
import 'package:selfprivacy/logic/bloc/services/services_bloc.dart';
import 'package:selfprivacy/logic/cubit/client_jobs/client_jobs_cubit.dart';
import 'package:selfprivacy/logic/get_it/resources_model.dart';
import 'package:selfprivacy/logic/models/job.dart';
import 'package:selfprivacy/logic/models/service.dart';
import 'package:selfprivacy/ui/layouts/brand_hero_screen.dart';
part 'config_item_fields/basic_string_config_item.dart';
part 'config_item_fields/basic_bool_config_item.dart';
part 'config_item_fields/basic_enum_config_item.dart';
part 'config_item_fields/domain_string_config_item.dart';
@RoutePage()
class ServiceSettingsPage extends StatefulWidget {
const ServiceSettingsPage({required this.serviceId, super.key});
final String serviceId;
@override
State<ServiceSettingsPage> createState() => _ServiceSettingsPageState();
}
class _ServiceSettingsPageState extends State<ServiceSettingsPage> {
Map<String, dynamic> settings = {};
bool isFormValid = true;
bool isJobAlreadyExists = false;
Widget configurationItemToWidget(
final BuildContext context,
final ServiceConfigItem configItem,
final Map<String, dynamic> settings,
) {
switch (configItem) {
case StringServiceConfigItem():
void onChanged(final String value, final bool isFieldValid) {
if (isFieldValid) {
setState(() {
if (value == configItem.value) {
settings.remove(configItem.id);
} else {
settings[configItem.id] = value;
}
isFormValid = true;
});
} else {
setState(() {
isFormValid = false;
});
}
}
if (configItem.widget == 'subdomain') {
return DomainStringConfigItem(
configItem: configItem,
newValue: settings[configItem.id],
onChanged: onChanged,
);
}
return BasicStringConfigItem(
configItem: configItem,
newValue: settings[configItem.id],
onChanged: onChanged,
);
case BoolServiceConfigItem():
void onChanged(final bool value) {
setState(() {
if (value == configItem.value) {
settings.remove(configItem.id);
} else {
settings[configItem.id] = value;
}
});
}
return BasicBoolConfigItem(
configItem: configItem,
newValue: settings[configItem.id],
onChanged: onChanged,
);
case EnumServiceConfigItem():
void onChanged(final String value) {
setState(() {
if (value == configItem.value) {
settings.remove(configItem.id);
} else {
settings[configItem.id] = value;
}
});
}
return BasicEnumConfigItem(
configItem: configItem,
newValue: settings[configItem.id],
onChanged: onChanged,
);
case FallbackServiceConfigItem():
return ListTile(
title: Text(configItem.description),
subtitle: Text(configItem.id),
trailing: Text(configItem.type),
leading: const Icon(Icons.error),
);
case IntServiceConfigItem():
return ListTile(
title: Text(configItem.description),
subtitle: Text(configItem.id),
trailing: Text(configItem.value.toString()),
leading: const Icon(Icons.error),
);
}
}
@override
void initState() {
super.initState();
final JobsState state = context.read<JobsCubit>().state;
if (state is JobsStateWithJobs) {
final ChangeServiceConfiguration? existingJob =
state.clientJobList.firstWhereOrNull(
(final ClientJob job) =>
job is ChangeServiceConfiguration &&
job.serviceId == widget.serviceId,
) as ChangeServiceConfiguration?;
if (existingJob != null) {
setState(() {
settings = existingJob.settings;
isJobAlreadyExists = true;
});
}
}
}
@override
Widget build(final BuildContext context) {
final Service? service =
context.watch<ServicesBloc>().state.getServiceById(widget.serviceId);
if (service == null) {
return const BrandHeroScreen(
hasBackButton: true,
children: [
Center(
child: CircularProgressIndicator.adaptive(),
),
],
);
}
final JobsState state = context.watch<JobsCubit>().state;
if (state is JobsStateLoading) {
return BrandHeroScreen(
hasBackButton: true,
hasFlashButton: true,
heroIconWidget: SvgPicture.string(
service.svgIcon,
width: 48.0,
height: 48.0,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onBackground,
BlendMode.srcIn,
),
),
heroTitle: service.displayName,
heroSubtitle: 'service_page.settings'.tr(),
children: [
Center(
child: Column(
children: [
Text(
'service_page.wait_for_jobs'.tr(),
textAlign: TextAlign.center,
),
const Gap(16.0),
const CircularProgressIndicator.adaptive(),
],
),
),
],
);
}
final bool isModified = settings.isNotEmpty;
return BrandHeroScreen(
hasBackButton: true,
hasFlashButton: true,
heroIconWidget: SvgPicture.string(
service.svgIcon,
width: 48.0,
height: 48.0,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onBackground,
BlendMode.srcIn,
),
),
heroTitle: service.displayName,
heroSubtitle: 'service_page.settings'.tr(),
children: [
...service.configuration.map(
(final ServiceConfigItem configItem) => Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: configurationItemToWidget(context, configItem, settings),
),
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: FilledButton(
onPressed: (isModified && isFormValid)
? () {
context.read<JobsCubit>().addJob(
ChangeServiceConfiguration(
serviceId: service.id,
serviceDisplayName: service.displayName,
settings: settings,
),
);
context.router.maybePop();
}
: null,
child: Text(
isJobAlreadyExists
? 'service_page.update_job'.tr()
: 'service_page.create_job'.tr(),
),
),
),
],
);
}
}

View file

@ -22,6 +22,7 @@ import 'package:selfprivacy/ui/pages/server_storage/binds_migration/services_mig
import 'package:selfprivacy/ui/pages/server_storage/extending_volume.dart'; import 'package:selfprivacy/ui/pages/server_storage/extending_volume.dart';
import 'package:selfprivacy/ui/pages/server_storage/server_storage.dart'; import 'package:selfprivacy/ui/pages/server_storage/server_storage.dart';
import 'package:selfprivacy/ui/pages/services/service_page.dart'; import 'package:selfprivacy/ui/pages/services/service_page.dart';
import 'package:selfprivacy/ui/pages/services/service_settings_page.dart';
import 'package:selfprivacy/ui/pages/services/services.dart'; import 'package:selfprivacy/ui/pages/services/services.dart';
import 'package:selfprivacy/ui/pages/setup/initializing/initializing.dart'; import 'package:selfprivacy/ui/pages/setup/initializing/initializing.dart';
import 'package:selfprivacy/ui/pages/setup/recovering/recovery_routing.dart'; import 'package:selfprivacy/ui/pages/setup/recovering/recovery_routing.dart';
@ -96,6 +97,7 @@ class RootRouter extends _$RootRouter {
AutoRoute(page: AboutApplicationRoute.page), AutoRoute(page: AboutApplicationRoute.page),
AutoRoute(page: DeveloperSettingsRoute.page), AutoRoute(page: DeveloperSettingsRoute.page),
AutoRoute(page: ServiceRoute.page), AutoRoute(page: ServiceRoute.page),
AutoRoute(page: ServiceSettingsRoute.page),
AutoRoute(page: ServerDetailsRoute.page), AutoRoute(page: ServerDetailsRoute.page),
AutoRoute(page: DnsDetailsRoute.page), AutoRoute(page: DnsDetailsRoute.page),
AutoRoute(page: BackupDetailsRoute.page), AutoRoute(page: BackupDetailsRoute.page),
@ -120,6 +122,8 @@ String getRouteTitle(final String routeName) {
case 'ServicesRoute': case 'ServicesRoute':
case 'ServiceRoute': case 'ServiceRoute':
return 'basis.services'; return 'basis.services';
case 'ServiceSettingsRoute':
return 'service_page.settings';
case 'UsersRoute': case 'UsersRoute':
return 'basis.users'; return 'basis.users';
case 'MoreRoute': case 'MoreRoute':

View file

@ -158,6 +158,16 @@ abstract class _$RootRouter extends RootStackRouter {
), ),
); );
}, },
ServiceSettingsRoute.name: (routeData) {
final args = routeData.argsAs<ServiceSettingsRouteArgs>();
return AutoRoutePage<dynamic>(
routeData: routeData,
child: ServiceSettingsPage(
serviceId: args.serviceId,
key: args.key,
),
);
},
ServicesMigrationRoute.name: (routeData) { ServicesMigrationRoute.name: (routeData) {
final args = routeData.argsAs<ServicesMigrationRouteArgs>(); final args = routeData.argsAs<ServicesMigrationRouteArgs>();
return AutoRoutePage<dynamic>( return AutoRoutePage<dynamic>(
@ -590,6 +600,44 @@ class ServiceRouteArgs {
} }
} }
/// generated route for
/// [ServiceSettingsPage]
class ServiceSettingsRoute extends PageRouteInfo<ServiceSettingsRouteArgs> {
ServiceSettingsRoute({
required String serviceId,
Key? key,
List<PageRouteInfo>? children,
}) : super(
ServiceSettingsRoute.name,
args: ServiceSettingsRouteArgs(
serviceId: serviceId,
key: key,
),
initialChildren: children,
);
static const String name = 'ServiceSettingsRoute';
static const PageInfo<ServiceSettingsRouteArgs> page =
PageInfo<ServiceSettingsRouteArgs>(name);
}
class ServiceSettingsRouteArgs {
const ServiceSettingsRouteArgs({
required this.serviceId,
this.key,
});
final String serviceId;
final Key? key;
@override
String toString() {
return 'ServiceSettingsRouteArgs{serviceId: $serviceId, key: $key}';
}
}
/// generated route for /// generated route for
/// [ServicesMigrationPage] /// [ServicesMigrationPage]
class ServicesMigrationRoute extends PageRouteInfo<ServicesMigrationRouteArgs> { class ServicesMigrationRoute extends PageRouteInfo<ServicesMigrationRouteArgs> {