Files
repertory/web/repertory/lib/helpers.dart
Scott E. Graves 61e0ce4b97
All checks were successful
BlockStorage/repertory/pipeline/head This commit looks good
[ui] UI theme should match repertory blue #61
2025-09-04 18:26:22 -05:00

499 lines
13 KiB
Dart

// helpers.dart
import 'dart:ui';
import 'package:convert/convert.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:repertory/constants.dart' as constants;
import 'package:repertory/models/auth.dart';
import 'package:repertory/models/settings.dart';
import 'package:repertory/widgets/app_dropdown.dart';
import 'package:repertory/widgets/aurora_sweep.dart';
import 'package:sodium_libs/sodium_libs.dart' show SecureKey, StringX;
Future doShowDialog(BuildContext context, Widget child) => showDialog(
context: context,
builder: (context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Theme(
data: theme.copyWith(
dialogTheme: DialogThemeData(
backgroundColor: scheme.primary.withValues(
alpha: constants.primaryAlpha,
),
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(constants.borderRadius),
side: BorderSide(
color: scheme.outlineVariant.withValues(alpha: 0.08),
width: 1,
),
),
),
),
child: child,
);
},
);
typedef Validator = bool Function(String);
class NullPasswordException implements Exception {
String error() => 'password cannot be null';
}
class AuthenticationFailedException implements Exception {
String error() => 'failed to authenticate user';
}
// ignore: prefer_function_declarations_over_variables
final Validator noRestrictedChars = (value) {
return [
'!',
'"',
'\$',
'&',
"'",
'(',
')',
'*',
';',
'<',
'>',
'?',
'[',
']',
'`',
'{',
'}',
'|',
].firstWhereOrNull((char) => value.contains(char)) ==
null;
};
// ignore: prefer_function_declarations_over_variables
final Validator notEmptyValidator = (value) => value.isNotEmpty;
// ignore: prefer_function_declarations_over_variables
final Validator portIsValid = (value) {
int? intValue = int.tryParse(value);
if (intValue == null) {
return false;
}
return (intValue > 0 && intValue < 65536);
};
// ignore: prefer_function_declarations_over_variables
final Validator trimNotEmptyValidator = (value) => value.trim().isNotEmpty;
createUriValidator<Validator>({host, port}) {
return (value) =>
Uri.tryParse('http://${host ?? value}:${port ?? value}/') != null;
}
createHostNameOrIpValidators() => <Validator>[
trimNotEmptyValidator,
createUriValidator(port: 9000),
];
Map<String, dynamic> createDefaultSettings(String mountType) {
switch (mountType) {
case 'Encrypt':
return {
'EncryptConfig': {'EncryptionToken': '', 'Path': ''},
};
case 'Remote':
return {
'RemoteConfig': {
'ApiPort': 20000,
'EncryptionToken': '',
'HostNameOrIp': '',
},
};
case 'S3':
return {
'S3Config': {
'AccessKey': '',
'Bucket': '',
'Region': 'any',
'SecretKey': '',
'URL': '',
'UsePathStyle': false,
'UseRegionInURL': false,
},
};
case 'Sia':
return {
'HostConfig': {
'ApiPassword': '',
'ApiPort': 9980,
'HostNameOrIp': 'localhost',
},
'SiaConfig': {'Bucket': ''},
};
}
return {};
}
void displayAuthError(Auth auth) {
if (!auth.authenticated || constants.navigatorKey.currentContext == null) {
return;
}
displayErrorMessage(
constants.navigatorKey.currentContext!,
"Authentication failed",
clear: true,
);
}
void displayErrorMessage(context, String text, {bool clear = false}) {
if (!context.mounted) {
return;
}
final messenger = ScaffoldMessenger.of(context);
if (clear) {
messenger.removeCurrentSnackBar();
}
messenger.showSnackBar(
SnackBar(content: Text(text, textAlign: TextAlign.center)),
);
}
String formatMountName(String type, String name) {
if (type == 'remote') {
return name.replaceAll('_', ':');
}
return name;
}
String getBaseUri() {
if (kDebugMode || !kIsWeb) {
return 'http://127.0.0.1:30000';
}
return Uri.base.origin;
}
String? getSettingDescription(String settingPath) {
switch (settingPath) {
case 'ApiPassword':
return "HTTP authentication password";
case 'ApiUser':
return "HTTP authentication user";
case 'HostConfig.ApiPassword':
return "RENTERD_API_PASSWORD";
default:
return null;
}
}
List<Validator> getSettingValidators(String settingPath) {
switch (settingPath) {
case 'ApiPassword':
return [notEmptyValidator];
case 'DatabaseType':
return [(value) => constants.databaseTypeList.contains(value)];
case 'PreferredDownloadType':
return [(value) => constants.downloadTypeList.contains(value)];
case 'EventLevel':
return [(value) => constants.eventLevelList.contains(value)];
case 'EncryptConfig.EncryptionToken':
return [notEmptyValidator];
case 'EncryptConfig.Path':
return [trimNotEmptyValidator, noRestrictedChars];
case 'HostConfig.ApiPassword':
return [notEmptyValidator];
case 'HostConfig.ApiPort':
return [portIsValid];
case 'HostConfig.HostNameOrIp':
return createHostNameOrIpValidators();
case 'HostConfig.Protocol':
return [(value) => constants.protocolTypeList.contains(value)];
case 'Path':
return [trimNotEmptyValidator];
case 'RemoteConfig.ApiPort':
return [notEmptyValidator, portIsValid];
case 'RemoteConfig.EncryptionToken':
return [notEmptyValidator];
case 'RemoteConfig.HostNameOrIp':
return createHostNameOrIpValidators();
case 'RemoteMount.ApiPort':
return [notEmptyValidator, portIsValid];
case 'RemoteMount.EncryptionToken':
return [notEmptyValidator];
case 'RemoteMount.HostNameOrIp':
return createHostNameOrIpValidators();
case 'RingBufferFileSize':
return [(value) => constants.ringBufferSizeList.contains(value)];
case 'S3Config.AccessKey':
return [trimNotEmptyValidator];
case 'S3Config.Bucket':
return [trimNotEmptyValidator];
case 'S3Config.SecretKey':
return [trimNotEmptyValidator];
case 'S3Config.URL':
return [trimNotEmptyValidator, (value) => Uri.tryParse(value) != null];
case 'SiaConfig.Bucket':
return [trimNotEmptyValidator];
}
return [];
}
String initialCaps(String txt) {
if (txt.isEmpty) {
return txt;
}
if (txt.length == 1) {
return txt[0].toUpperCase();
}
return txt[0].toUpperCase() + txt.substring(1).toLowerCase();
}
bool validateSettings(
Map<String, dynamic> settings,
List<String> failed, {
String? rootKey,
}) {
settings.forEach((key, value) {
final settingKey = rootKey == null ? key : '$rootKey.$key';
if (value is Map<String, dynamic>) {
validateSettings(value, failed, rootKey: settingKey);
return;
}
for (var validator in getSettingValidators(settingKey)) {
if (validator(value.toString())) {
continue;
}
failed.add(settingKey);
}
});
return failed.isEmpty;
}
Future<Map<String, dynamic>> convertAllToString(
Map<String, dynamic> settings,
SecureKey key,
) async {
Future<Map<String, dynamic>> convert(Map<String, dynamic> settings) async {
for (var entry in settings.entries) {
if (entry.value is Map<String, dynamic>) {
await convert(entry.value);
continue;
}
if (entry.key == 'ApiPassword' ||
entry.key == 'EncryptionToken' ||
entry.key == 'SecretKey') {
if (entry.value.isEmpty) {
continue;
}
settings[entry.key] = encryptValue(entry.value, key);
continue;
}
if (entry.value is String) {
continue;
}
settings[entry.key] = entry.value.toString();
}
return settings;
}
return convert(settings);
}
String encryptValue(String value, SecureKey key) {
if (value.isEmpty) {
return value;
}
final sodium = constants.sodium;
final crypto = sodium.crypto.aeadXChaCha20Poly1305IETF;
final nonce = sodium.secureRandom(crypto.nonceBytes).extractBytes();
final data = crypto.encrypt(
additionalData: Uint8List.fromList('repertory'.toCharArray()),
key: key,
message: Uint8List.fromList(value.toCharArray()),
nonce: nonce,
);
return hex.encode(nonce + data);
}
Map<String, dynamic> getChanged(
Map<String, dynamic> original,
Map<String, dynamic> updated,
) {
if (DeepCollectionEquality().equals(original, updated)) {
return {};
}
Map<String, dynamic> changed = {};
original.forEach((key, value) {
if (DeepCollectionEquality().equals(value, updated[key])) {
return;
}
if (value is Map<String, dynamic>) {
changed[key] = <String, dynamic>{};
value.forEach((subKey, subValue) {
if (DeepCollectionEquality().equals(subValue, updated[key][subKey])) {
return;
}
changed[key][subKey] = updated[key][subKey];
});
return;
}
changed[key] = updated[key];
});
return changed;
}
Future<String?> editMountLocation(
BuildContext context,
List<String> available, {
bool allowEmpty = false,
String? location,
}) async {
if (!context.mounted) {
return location;
}
String? currentLocation = location;
final controller = TextEditingController(text: currentLocation);
return await doShowDialog(
context,
StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
actions: [
TextButton(
child: const Text('Cancel'),
onPressed: () => Navigator.of(context).pop(null),
),
TextButton(
child: const Text('OK'),
onPressed: () {
final result = getSettingValidators('Path').firstWhereOrNull(
(validator) => !validator(currentLocation ?? ''),
);
if (result != null) {
return displayErrorMessage(
context,
"Mount location is not valid",
);
}
Navigator.of(context).pop(currentLocation);
},
),
],
content: available.isEmpty
? TextField(
autofocus: true,
controller: controller,
onChanged: (value) => setState(() => currentLocation = value),
)
: AppDropdownFormField<String>(
labelOf: (s) => s,
labelText: "Select drive",
onChanged: (value) => setState(() => currentLocation = value),
prefixIcon: Icons.computer,
value: currentLocation,
values: available.toList(),
),
title: const Text('Mount Location', textAlign: TextAlign.center),
);
},
),
);
}
Scaffold createCommonScaffold(
List<Widget> children, {
Widget? floatingActionButton,
}) => Scaffold(
body: SafeArea(
child: Stack(
children: [
Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: constants.gradientColors,
),
),
),
Consumer<Settings>(
builder: (_, settings, _) =>
AuroraSweep(enabled: settings.enableAnimations),
),
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6),
child: Container(color: Colors.black.withValues(alpha: 0.06)),
),
),
...children,
],
),
),
floatingActionButton: floatingActionButton,
);
InputDecoration createCommonDecoration(
ColorScheme colorScheme,
String label, {
IconData? icon,
bool filled = true,
}) => InputDecoration(
labelText: label,
prefixIcon: icon == null ? null : Icon(icon),
filled: filled,
fillColor: colorScheme.primary.withValues(alpha: constants.primaryAlpha),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(constants.borderRadiusSmall),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(constants.borderRadiusSmall),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
contentPadding: const EdgeInsets.all(constants.paddingSmall),
);
IconData getProviderTypeIcon(String mountType) {
switch (mountType.toLowerCase()) {
case "encrypt":
return Icons.key_outlined;
case "remote":
return Icons.network_ping_outlined;
default:
return Icons.cloud_outlined;
}
}